diff --git a/package.json b/package.json index 7534f84ed..560a73eec 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "12.1.5", "@testing-library/user-event": "^14.4.3", - "@trivago/prettier-plugin-sort-imports": "^4.0.0", + "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^20.8.2", "@types/react": "17.0.50", "@types/react-dom": "17.0.17", diff --git a/src/components/Trendline/Trendline.tsx b/src/components/Trendline/Trendline.tsx index e7f7cae03..cd156eb8f 100644 --- a/src/components/Trendline/Trendline.tsx +++ b/src/components/Trendline/Trendline.tsx @@ -19,12 +19,13 @@ import { TrendlineProps } from '../../types'; const Trendline: FC = ({ children, color, + dimensionExtent, dimensionRange = [null, null], + displayOnHover = false, + highlightRawPoint = false, lineType = 'dashed', lineWidth = 'M', method = 'linear', - displayOnHover = false, - highlightRawPoint = false, opacity = 1, }) => { return null; diff --git a/src/specBuilder/line/lineSpecBuilder.test.ts b/src/specBuilder/line/lineSpecBuilder.test.ts index 52f6b52dd..df7843748 100644 --- a/src/specBuilder/line/lineSpecBuilder.test.ts +++ b/src/specBuilder/line/lineSpecBuilder.test.ts @@ -371,8 +371,8 @@ describe('lineSpecBuilder', () => { }); test('scaleTypes "point" and "linear" should return the original data', () => { - expect(addData([], { ...defaultLineProps, scaleType: 'point' })).toEqual([]); - expect(addData([], { ...defaultLineProps, scaleType: 'linear' })).toEqual([]); + expect(addData(baseData, { ...defaultLineProps, scaleType: 'point' })).toEqual(baseData); + expect(addData(baseData, { ...defaultLineProps, scaleType: 'linear' })).toEqual(baseData); }); test('should add trendline transform', () => { @@ -380,21 +380,20 @@ describe('lineSpecBuilder', () => { addData(baseData, { ...defaultLineProps, children: [createElement(Trendline, { method: 'average' })], - })[2].transform + })[2].transform, ).toStrictEqual([ { - type: 'collect', - sort: { - field: DEFAULT_TRANSFORMED_TIME_DIMENSION, - }, - }, - { - type: 'joinaggregate', - groupby: ['series'], - fields: ['value'], - ops: ['mean'], - as: [TRENDLINE_VALUE], + as: [ + TRENDLINE_VALUE, + `${DEFAULT_TRANSFORMED_TIME_DIMENSION}Min`, + `${DEFAULT_TRANSFORMED_TIME_DIMENSION}Max`, + ], + fields: [DEFAULT_METRIC, DEFAULT_TRANSFORMED_TIME_DIMENSION, DEFAULT_TRANSFORMED_TIME_DIMENSION], + groupby: [DEFAULT_COLOR], + ops: ['mean', 'min', 'max'], + type: 'aggregate', }, + { as: SERIES_ID, expr: `datum.${DEFAULT_COLOR}`, type: 'formula' }, ]); }); @@ -403,7 +402,7 @@ describe('lineSpecBuilder', () => { addData(baseData, { ...defaultLineProps, children: [createElement(Trendline, { method: 'movingAverage-7' })], - })[0].transform + })[0].transform, ).toHaveLength(2); }); @@ -430,7 +429,7 @@ describe('lineSpecBuilder', () => { setScales(startingSpec.scales ?? [], { ...defaultLineProps, scaleType: 'linear', - }) + }), ).toStrictEqual([defaultSpec.scales?.[0], defaultLinearScale, defaultSpec.scales?.[2]]); }); @@ -439,7 +438,7 @@ describe('lineSpecBuilder', () => { setScales(startingSpec.scales ?? [], { ...defaultLineProps, scaleType: 'point', - }) + }), ).toStrictEqual([defaultSpec.scales?.[0], defaultPointScale, defaultSpec.scales?.[2]]); }); @@ -456,7 +455,7 @@ describe('lineSpecBuilder', () => { setScales(startingSpec.scales ?? [], { ...defaultLineProps, children: [createElement(MetricRange, { scaleAxisToFit: true, metricEnd, metricStart })], - }) + }), ).toStrictEqual([defaultSpec.scales?.[0], defaultSpec.scales?.[1], metricRangeMetricScale]); }); }); @@ -498,13 +497,13 @@ describe('lineSpecBuilder', () => { test('with metric range', () => { expect(addLineMarks([], { ...defaultLineProps, children: [getMetricRangeElement()] })).toStrictEqual( - metricRangeMarks + metricRangeMarks, ); }); test('with displayPointMark', () => { expect(addLineMarks([], { ...defaultLineProps, staticPoint: 'staticPoint' })).toStrictEqual( - displayPointMarks + displayPointMarks, ); }); @@ -514,7 +513,7 @@ describe('lineSpecBuilder', () => { ...defaultLineProps, staticPoint: 'staticPoint', children: [getMetricRangeElement()], - }) + }), ).toStrictEqual(metricRangeWithDisplayPointMarks); }); }); @@ -534,8 +533,8 @@ describe('lineSpecBuilder', () => { value: null, }, ], - defaultLineProps - ) + defaultLineProps, + ), ).toStrictEqual([ { name: 'line0_selectedSeries', @@ -556,7 +555,7 @@ describe('lineSpecBuilder', () => { value: null, }, ], - { ...defaultLineProps, children: [createElement(ChartPopover)] } + { ...defaultLineProps, children: [createElement(ChartPopover)] }, ); expect(getGenericSignalSpy).toHaveBeenCalledTimes(1); @@ -565,7 +564,7 @@ describe('lineSpecBuilder', () => { test('hover signals with metric range', () => { expect( - addSignals([], { ...defaultLineProps, children: [getMetricRangeElement({ displayOnHover: true })] }) + addSignals([], { ...defaultLineProps, children: [getMetricRangeElement({ displayOnHover: true })] }), ).toStrictEqual([ { name: 'line0_hoveredSeries', @@ -616,7 +615,7 @@ describe('lineSpecBuilder', () => { ...defaultLineProps, staticPoint: 'staticPoint', children: [getMetricRangeElement({ displayOnHover: true })], - }) + }), ).toStrictEqual([ { name: 'line0_hoveredSeries', diff --git a/src/specBuilder/line/lineSpecBuilder.ts b/src/specBuilder/line/lineSpecBuilder.ts index 3381dc584..5576ea26e 100644 --- a/src/specBuilder/line/lineSpecBuilder.ts +++ b/src/specBuilder/line/lineSpecBuilder.ts @@ -17,12 +17,7 @@ import { getMetricRanges, } from '@specBuilder/metricRange/metricRangeUtils'; import { getFacetsFromProps } from '@specBuilder/specUtils'; -import { - addTrendlineData, - getTrendlineMarks, - getTrendlineScales, - getTrendlineSignals, -} from '@specBuilder/trendline/trendlineUtils'; +import { addTrendlineData, getTrendlineMarks, getTrendlineScales, getTrendlineSignals } from '@specBuilder/trendline'; import { sanitizeMarkChildren, toCamelCase } from '@utils'; import { produce } from 'immer'; import { ColorScheme, LineProps, LineSpecProps, MarkChildElement } from 'types'; @@ -56,7 +51,7 @@ export const addLine = produce { const sanitizedChildren = sanitizeMarkChildren(children); const lineName = toCamelCase(name || `line${index}`); @@ -83,7 +78,7 @@ export const addLine = produce((data, props) => { diff --git a/src/specBuilder/marks/markUtils.ts b/src/specBuilder/marks/markUtils.ts index a4a9faa53..59e8ab968 100644 --- a/src/specBuilder/marks/markUtils.ts +++ b/src/specBuilder/marks/markUtils.ts @@ -43,6 +43,7 @@ import { ScaledValueRef, SignalRef, } from 'vega'; +import { getScaleName } from '@specBuilder/scale/scaleSpecBuilder'; /** * If a popover exists on the mark, then set the cursor to a pointer. @@ -96,7 +97,7 @@ export const hasInteractiveChildren = (children: ReactElement[]): boolean => { child.type === ChartTooltip || child.type === ChartPopover || (child.type === Trendline && child.props.displayOnHover) || - (child.type === MetricRange && child.props.displayOnHover) + (child.type === MetricRange && child.props.displayOnHover), ); }; export const hasMetricRange = (children: ReactElement[]): boolean => @@ -119,7 +120,7 @@ export const getColorProductionRule = (color: ColorFacet | DualFacet, colorSchem }; export const getLineWidthProductionRule = ( - lineWidth: LineWidthFacet | DualFacet | undefined + lineWidth: LineWidthFacet | DualFacet | undefined, ): NumericValueRef | undefined => { if (!lineWidth) return; if (Array.isArray(lineWidth)) { @@ -183,10 +184,9 @@ export const getHighlightOpacityValue = (opacityValue: { signal: string } | { va * @returns x encoding */ export const getXProductionRule = (scaleType: ScaleType, dimension: string): ProductionRule => { + const scale = getScaleName('x', scaleType); if (scaleType === 'time') { - return { scale: 'xTime', field: DEFAULT_TRANSFORMED_TIME_DIMENSION }; - } else if (scaleType === 'linear') { - return { scale: 'xLinear', field: dimension }; + return { scale, field: DEFAULT_TRANSFORMED_TIME_DIMENSION }; } - return { scale: 'xPoint', field: dimension }; + return { scale, field: dimension }; }; diff --git a/src/specBuilder/scale/scaleSpecBuilder.test.ts b/src/specBuilder/scale/scaleSpecBuilder.test.ts index 46c40e3f1..ae4ffd06d 100644 --- a/src/specBuilder/scale/scaleSpecBuilder.test.ts +++ b/src/specBuilder/scale/scaleSpecBuilder.test.ts @@ -17,6 +17,7 @@ import { addDomainFields, addFieldToFacetScaleDomain, getPadding, + getScaleName, } from './scaleSpecBuilder'; const defaultColorScale: OrdinalScale = { @@ -25,77 +26,82 @@ const defaultColorScale: OrdinalScale = { type: 'ordinal', }; -describe('scaleSpecBuilder', () => { - describe('addDomainFields()', () => { - test('no domain fields', () => { - expect(addDomainFields({ name: 'color', type: 'ordinal' }, [DEFAULT_COLOR])).toStrictEqual({ - domain: { data: 'table', fields: [DEFAULT_COLOR] }, - name: 'color', - type: 'ordinal', - }); +describe('addDomainFields()', () => { + test('no domain fields', () => { + expect(addDomainFields({ name: 'color', type: 'ordinal' }, [DEFAULT_COLOR])).toStrictEqual({ + domain: { data: 'table', fields: [DEFAULT_COLOR] }, + name: 'color', + type: 'ordinal', }); + }); - test('field matches existing one, nothing should change', () => { - expect(addDomainFields(defaultColorScale, [DEFAULT_COLOR])).toStrictEqual(defaultColorScale); - }); + test('field matches existing one, nothing should change', () => { + expect(addDomainFields(defaultColorScale, [DEFAULT_COLOR])).toStrictEqual(defaultColorScale); + }); - test('new field should be added to existing one', () => { - expect( - addDomainFields({ ...defaultColorScale, domain: { data: 'table', fields: ['test'] } }, [DEFAULT_COLOR]) - ).toStrictEqual({ ...defaultColorScale, domain: { data: 'table', fields: ['test', DEFAULT_COLOR] } }); - }); + test('new field should be added to existing one', () => { + expect( + addDomainFields({ ...defaultColorScale, domain: { data: 'table', fields: ['test'] } }, [DEFAULT_COLOR]), + ).toStrictEqual({ ...defaultColorScale, domain: { data: 'table', fields: ['test', DEFAULT_COLOR] } }); }); +}); - describe('getPadding()', () => { - test('time', () => { - expect(getPadding('time')).toStrictEqual({ - padding: 32, - }); +describe('getPadding()', () => { + test('time', () => { + expect(getPadding('time')).toStrictEqual({ + padding: 32, }); - test('linear', () => { - expect(getPadding('time')).toStrictEqual({ - padding: 32, - }); + }); + test('linear', () => { + expect(getPadding('time')).toStrictEqual({ + padding: 32, }); - test('point', () => { - expect(getPadding('point')).toStrictEqual({ - paddingOuter: 0.5, - }); + }); + test('point', () => { + expect(getPadding('point')).toStrictEqual({ + paddingOuter: 0.5, }); - test('band', () => { - expect(getPadding('band')).toStrictEqual({ - paddingInner: 0.4, - paddingOuter: 0.2, - }); + }); + test('band', () => { + expect(getPadding('band')).toStrictEqual({ + paddingInner: 0.4, + paddingOuter: 0.2, }); }); +}); - describe('addFieldToFacetScaleDomain()', () => { - test('should add fields to correct scale', () => { - const scales: Scale[] = [{ name: 'color', type: 'ordinal' }]; - addFieldToFacetScaleDomain(scales, 'color', DEFAULT_COLOR); - expect(scales).toStrictEqual([ - { name: 'color', type: 'ordinal', domain: { data: 'table', fields: [DEFAULT_COLOR] } }, - ]); - }); +describe('addFieldToFacetScaleDomain()', () => { + test('should add fields to correct scale', () => { + const scales: Scale[] = [{ name: 'color', type: 'ordinal' }]; + addFieldToFacetScaleDomain(scales, 'color', DEFAULT_COLOR); + expect(scales).toStrictEqual([ + { name: 'color', type: 'ordinal', domain: { data: 'table', fields: [DEFAULT_COLOR] } }, + ]); + }); - test('should not add any fields to the domain if the facet value is static', () => { - const scales: Scale[] = [{ name: 'color', type: 'ordinal' }]; - addFieldToFacetScaleDomain(scales, 'color', { value: 'red-500' }); - expect(scales).toStrictEqual([{ name: 'color', type: 'ordinal' }]); - }); + test('should not add any fields to the domain if the facet value is static', () => { + const scales: Scale[] = [{ name: 'color', type: 'ordinal' }]; + addFieldToFacetScaleDomain(scales, 'color', { value: 'red-500' }); + expect(scales).toStrictEqual([{ name: 'color', type: 'ordinal' }]); }); +}); - describe('addContinuousDimensionScale()', () => { - test('should override padding if it exists', () => { - const scales = []; - addContinuousDimensionScale(scales, { scaleType: 'linear', dimension: 'x', padding: 24 }); - expect(scales[0]).toHaveProperty('padding', 24); - }); - test('should override paddingOuter if padding exists', () => { - const scales: Scale[] = [{ type: 'band', name: 'xBand', paddingInner: 0.3, paddingOuter: 0.7 }]; - addContinuousDimensionScale(scales, { scaleType: 'band', dimension: 'x', padding: 0 }); - expect(scales[0]).toHaveProperty('paddingOuter', 0); - }); +describe('addContinuousDimensionScale()', () => { + test('should override padding if it exists', () => { + const scales = []; + addContinuousDimensionScale(scales, { scaleType: 'linear', dimension: 'x', padding: 24 }); + expect(scales[0]).toHaveProperty('padding', 24); + }); + test('should override paddingOuter if padding exists', () => { + const scales: Scale[] = [{ type: 'band', name: 'xBand', paddingInner: 0.3, paddingOuter: 0.7 }]; + addContinuousDimensionScale(scales, { scaleType: 'band', dimension: 'x', padding: 0 }); + expect(scales[0]).toHaveProperty('paddingOuter', 0); + }); +}); + +describe('getScaleName()', () => { + test('should return correct scale name', () => { + expect(getScaleName('x', 'linear')).toBe('xLinear'); + expect(getScaleName('y', 'band')).toBe('yBand'); }); }); diff --git a/src/specBuilder/scale/scaleSpecBuilder.ts b/src/specBuilder/scale/scaleSpecBuilder.ts index 3aa1d13d8..c9dfae516 100644 --- a/src/specBuilder/scale/scaleSpecBuilder.ts +++ b/src/specBuilder/scale/scaleSpecBuilder.ts @@ -73,7 +73,7 @@ export const addDomainFields = produce((scale, values) => { export const addContinuousDimensionScale = ( scales: Scale[], - { scaleType, dimension, padding }: { scaleType: SupportedScaleType; dimension: string; padding?: number } + { scaleType, dimension, padding }: { scaleType: SupportedScaleType; dimension: string; padding?: number }, ) => { const index = getScaleIndexByType(scales, scaleType, 'x'); const fields = [getDimensionField(dimension, scaleType)]; @@ -127,7 +127,7 @@ export const getMetricScale = (metricKeys: string[], metricAxis: AxisType, chart export const addFieldToFacetScaleDomain = ( scales: Scale[], facetType: FacetType, - facetValue: FacetRef | DualFacet | undefined + facetValue: FacetRef | DualFacet | undefined, ) => { // if facetValue is a string or an array of strings, it is a field reference and should be added the facet scale domain if (typeof facetValue === 'string' || (Array.isArray(facetValue) && facetValue.length)) { @@ -147,7 +147,7 @@ export const generateScale = (type: SupportedScaleType, axis: AxisType, props?: export const getDefaultScale = ( scaleType: SupportedScaleType, axis: AxisType, - chartOrientation: Orientation = 'vertical' + chartOrientation: Orientation = 'vertical', ): Scale => { const orientationToAxis: { [key in Orientation]: AxisType } = { vertical: 'x', @@ -195,8 +195,16 @@ export const getPadding = (type: SupportedScaleType | 'band') => { } }; +/** + * Gets the name of the scale based on the axis and type + * @param axis + * @param type + * @returns scale name + */ +export const getScaleName = (axis: AxisType, type: SupportedScaleType) => toCamelCase(`${axis} ${type}`); + const isScaleMultiFieldsRef = ( - domain: (null | string | number | boolean | SignalRef)[] | ScaleData | SignalRef | undefined + domain: (null | string | number | boolean | SignalRef)[] | ScaleData | SignalRef | undefined, ): domain is ScaleMultiFieldsRef => { return Boolean(domain && !Array.isArray(domain) && 'data' in domain && 'fields' in domain); }; diff --git a/src/specBuilder/scatter/scatterSpecBuilder.ts b/src/specBuilder/scatter/scatterSpecBuilder.ts index 68451c215..2f5c5d9fc 100644 --- a/src/specBuilder/scatter/scatterSpecBuilder.ts +++ b/src/specBuilder/scatter/scatterSpecBuilder.ts @@ -31,12 +31,7 @@ import { addFieldToFacetScaleDomain, addMetricScale, } from '@specBuilder/scale/scaleSpecBuilder'; -import { - addTrendlineData, - getTrendlineMarks, - getTrendlineScales, - getTrendlineSignals, -} from '@specBuilder/trendline/trendlineUtils'; +import { addTrendlineData, getTrendlineMarks, getTrendlineScales, getTrendlineSignals } from '@specBuilder/trendline'; import { sanitizeMarkChildren, toCamelCase } from '@utils'; import { produce } from 'immer'; import { ColorScheme, ScatterProps, ScatterSpecProps } from 'types'; @@ -60,7 +55,7 @@ export const addScatter = produce { const sanitizedChildren = sanitizeMarkChildren(children); const scatterName = toCamelCase(name || `scatter${index}`); @@ -87,7 +82,7 @@ export const addScatter = produce((data, props) => { diff --git a/src/specBuilder/trendline/index.ts b/src/specBuilder/trendline/index.ts new file mode 100644 index 000000000..d104ec699 --- /dev/null +++ b/src/specBuilder/trendline/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './trendlineDataUtils'; +export * from './trendlineMarkUtils'; +export * from './trendlineScaleUtils'; +export * from './trendlineSignalUtils'; diff --git a/src/specBuilder/trendline/trendlineDataTransformUtils.test.ts b/src/specBuilder/trendline/trendlineDataTransformUtils.test.ts new file mode 100644 index 000000000..44fa79a06 --- /dev/null +++ b/src/specBuilder/trendline/trendlineDataTransformUtils.test.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { DEFAULT_TIME_DIMENSION, TRENDLINE_VALUE } from '@constants'; +import { + getAggregateTransform, + getRegressionTransform, + getTrendlineDimensionRangeTransforms, + getTrendlineParamFormulaTransforms, + getWindowTransform, +} from './trendlineDataTransformUtils'; +import { defaultLineProps, defaultTrendlineProps } from './trendlineTestUtils'; + +describe('getAggregateTransform()', () => { + test('should return the correct method', () => { + expect(getAggregateTransform(defaultLineProps, 'average', false, DEFAULT_TIME_DIMENSION)).toHaveProperty( + 'ops', + ['mean'], + ); + expect(getAggregateTransform(defaultLineProps, 'median', false, DEFAULT_TIME_DIMENSION)).toHaveProperty('ops', [ + 'median', + ]); + }); +}); + +describe('getRegressionTransform()', () => { + test('should return the correct regression method', () => { + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'exponential' }, false), + ).toHaveProperty('method', 'exp'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'logarithmic' }, false), + ).toHaveProperty('method', 'log'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'power' }, false), + ).toHaveProperty('method', 'pow'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'linear' }, false), + ).toHaveProperty('method', 'poly'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'quadratic' }, false), + ).toHaveProperty('method', 'poly'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'polynomial-4' }, false), + ).toHaveProperty('method', 'poly'); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'polynomial-25' }, false), + ).toHaveProperty('method', 'poly'); + }); + test('should return the correct order for polynomials', () => { + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'linear' }, false), + ).toHaveProperty('order', 1); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'quadratic' }, false), + ).toHaveProperty('order', 2); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'polynomial-4' }, false), + ).toHaveProperty('order', 4); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'polynomial-25' }, false), + ).toHaveProperty('order', 25); + expect( + getRegressionTransform(defaultLineProps, { ...defaultTrendlineProps, method: 'power' }, false).order, + ).toBeUndefined(); + }); + test('should use ${dimension}Normalized as ouput dimension if scaleType is time', () => { + const transform = getRegressionTransform( + { ...defaultLineProps, dimension: 'x', scaleType: 'time' }, + { ...defaultTrendlineProps, method: 'linear' }, + true, + ); + expect(transform.as).toHaveLength(2); + expect(transform.as).toEqual(['xNormalized', TRENDLINE_VALUE]); + }); + test('should have params on transform and no `as` property when isHighResolutionData is false', () => { + const transform = getRegressionTransform( + defaultLineProps, + { ...defaultTrendlineProps, method: 'linear' }, + false, + ); + expect(transform.as).toBeUndefined(); + expect(transform).toHaveProperty('params', true); + }); +}); + +describe('getWindowTransform()', () => { + test('should return a window transform with the correct frame', () => { + const transform = getWindowTransform(defaultLineProps, 'movingAverage-7'); + expect(transform).toHaveProperty('type', 'window'); + expect(transform).toHaveProperty('frame', [6, 0]); + }); + test('should throw error if the method is not of form "moveingAverage-${number}"', () => { + expect(() => getWindowTransform(defaultLineProps, 'linear')).toThrow( + 'Invalid moving average frame width: NaN, frame width must be an integer greater than 0', + ); + expect(() => getWindowTransform(defaultLineProps, 'movingAverage-0')).toThrow( + 'Invalid moving average frame width: 0, frame width must be an integer greater than 0', + ); + }); +}); + +describe('getTrendlineDimensionRangeTransforms()', () => { + test('should add filters if the dimensionRange has non-null values', () => { + const transforms = getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [1, 2]); + expect(transforms).toHaveLength(1); + expect(transforms[0].expr.split(' && ')).toHaveLength(2); + expect(transforms).toStrictEqual([ + { + type: 'filter', + expr: `datum.${DEFAULT_TIME_DIMENSION} >= 1 && datum.${DEFAULT_TIME_DIMENSION} <= 2`, + }, + ]); + expect( + getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [null, 2])[0].expr.split(' && '), + ).toHaveLength(1); + expect( + getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [1, null])[0].expr.split(' && '), + ).toHaveLength(1); + expect(getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [null, null])).toHaveLength(0); + }); +}); + +describe('getTrendlineParamFormulaTransforms()', () => { + test('should return the correct formula for each polynomial method', () => { + expect(getTrendlineParamFormulaTransforms('x', 'linear', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'quadratic', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'polynomial-1', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'polynomial-2', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'polynomial-3', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2) + datum.coef[3] * pow(datum.x, 3)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'polynomial-8', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2) + datum.coef[3] * pow(datum.x, 3) + datum.coef[4] * pow(datum.x, 4) + datum.coef[5] * pow(datum.x, 5) + datum.coef[6] * pow(datum.x, 6) + datum.coef[7] * pow(datum.x, 7) + datum.coef[8] * pow(datum.x, 8)', + ); + }); + test('should return the correct formula for other non-polynomial regression methods', () => { + expect(getTrendlineParamFormulaTransforms('x', 'exponential', 'linear')[0].expr).toEqual( + 'datum.coef[0] + exp(datum.coef[1] * datum.x)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'logarithmic', 'linear')[0].expr).toEqual( + 'datum.coef[0] + datum.coef[1] * log(datum.x)', + ); + expect(getTrendlineParamFormulaTransforms('x', 'power', 'linear')[0].expr).toEqual( + 'datum.coef[0] * pow(datum.x, datum.coef[1])', + ); + }); + test('should use normalized dimension for time scaleType', () => { + expect(getTrendlineParamFormulaTransforms('x', 'exponential', 'time')[0].expr).toEqual( + 'datum.coef[0] + exp(datum.coef[1] * datum.xNormalized)', + ); + }); +}); diff --git a/src/specBuilder/trendline/trendlineDataTransformUtils.ts b/src/specBuilder/trendline/trendlineDataTransformUtils.ts new file mode 100644 index 000000000..2056f42a6 --- /dev/null +++ b/src/specBuilder/trendline/trendlineDataTransformUtils.ts @@ -0,0 +1,289 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { MS_PER_DAY, TRENDLINE_VALUE } from '@constants'; +import { AggregateMethod, TrendlineMethod, TrendlineSpecProps } from 'types'; +import { + AggregateOp, + AggregateTransform, + CollectTransform, + ExtentTransform, + FilterTransform, + FormulaTransform, + JoinAggregateTransform, + LookupTransform, + RegressionMethod, + RegressionTransform, + ScaleType, + Transforms, + WindowTransform, +} from 'vega'; +import { + TrendlineParentProps, + getPolynomialOrder, + getRegressionExtent, + getTrendlineScaleType, + isPolynomialMethod, + trendlineUsesNormalizedDimension, +} from './trendlineUtils'; +import { getFacetsFromProps } from '@specBuilder/specUtils'; + +/** + * Gets the aggreagate transform used for calculating the average trendline + * @param facets data facets + * @param metric data y key + * @returns transform + */ +export const getAggregateTransform = ( + { color, lineType, metric }: TrendlineParentProps, + method: AggregateMethod, + isHighResolutionData: boolean, + dimension: string, +): AggregateTransform | JoinAggregateTransform => { + const { facets } = getFacetsFromProps({ color, lineType }); + const operations: Record = { + average: 'mean', + median: 'median', + }; + if (isHighResolutionData) { + return { + type: 'aggregate', + groupby: facets, + ops: [operations[method], 'min', 'max'], + fields: [metric, dimension, dimension], + as: [TRENDLINE_VALUE, `${dimension}Min`, `${dimension}Max`], + }; + } + return { + type: 'joinaggregate', + groupby: facets, + ops: [operations[method]], + fields: [metric], + as: [TRENDLINE_VALUE], + }; +}; + +/** + * Gets the regression transform used for calculating the regression trendline. + * Regression trendlines are ones that use the x value as a parameter + * @see https://vega.github.io/vega/docs/transforms/regression/ + * @param markProps + * @param method + * @param isHighResolutionData + * @returns + */ +export const getRegressionTransform = ( + markProps: TrendlineParentProps, + trendlineProps: TrendlineSpecProps, + isHighResolutionData: boolean, +): RegressionTransform => { + const { color, dimension, lineType, metric } = markProps; + const { dimensionExtent, method, name } = trendlineProps; + const { facets } = getFacetsFromProps({ color, lineType }); + + let regressionMethod: RegressionMethod | undefined; + let order: number | undefined; + + switch (method) { + case 'exponential': + regressionMethod = 'exp'; + break; + case 'logarithmic': + regressionMethod = 'log'; + break; + case 'power': + regressionMethod = 'pow'; + break; + default: + order = getPolynomialOrder(method); + regressionMethod = 'poly'; + break; + } + + const isNormalized = getTrendlineScaleType(markProps) === 'time'; + const trendlineDimension = isNormalized ? `${dimension}Normalized` : dimension; + + return { + type: 'regression', + method: regressionMethod, + order, + groupby: facets, + x: trendlineDimension, + y: metric, + as: isHighResolutionData ? [trendlineDimension, TRENDLINE_VALUE] : undefined, + params: !isHighResolutionData, + extent: isHighResolutionData ? getRegressionExtent(dimensionExtent, name, isNormalized) : undefined, + }; +}; + +/** + * Gets the window transform used for calculating the moving average trendline. + * @param markProps + * @param method + * @returns + */ +export const getWindowTransform = (markProps: TrendlineParentProps, method: TrendlineMethod): WindowTransform => { + const frameWidth = parseInt(method.split('-')[1]); + + const { color, lineType, metric } = markProps; + const { facets } = getFacetsFromProps({ color, lineType }); + + if (isNaN(frameWidth) || frameWidth < 1) { + throw new Error( + `Invalid moving average frame width: ${frameWidth}, frame width must be an integer greater than 0`, + ); + } + + return { + type: 'window', + ops: ['mean'], + groupby: facets, + fields: [metric], + as: [TRENDLINE_VALUE], + frame: [frameWidth - 1, 0], + }; +}; + +/** + * Gets the transforms that will normalize the dimension. + * The dimension gets normalized for time scales on regression methods. This makes the regression calculations far more accurate than using the raw time values + * @param dimension + * @returns + */ +export const getNormalizedDimensionTransform = (dimension: string): Transforms[] => [ + { + type: 'joinaggregate', + fields: [dimension], + as: [`${dimension}Min`], + ops: ['min'], + }, + { + type: 'formula', + expr: `(datum.${dimension} - datum.${dimension}Min + ${MS_PER_DAY}) / ${MS_PER_DAY}`, + as: `${dimension}Normalized`, + }, +]; + +/** + * Gets an extent transform. + * This is used to calculate the min and max of the dimension so that it can be used to set the extent of the regression trendline + * @param dimension + * @param name + * @returns + */ +export const getRegressionExtentTransform = (dimension: string, name: string): ExtentTransform => ({ + type: 'extent', + field: dimension, + signal: `${name}_extent`, +}); + +/** + * Gets the sort transform for the provided dimension. + * This is used to sort window methods so they are calculated and drawn in the correct order + * @param dimension + * @returns CollectTransform + */ +export const getSortTransform = (dimension: string): CollectTransform => ({ + type: 'collect', + sort: { + field: dimension, + }, +}); + +/** + * gets the filter transforms that will restrict the data to the dimension range + * @param dimension + * @param dimensionRange + * @returns filterTansforms + */ +export const getTrendlineDimensionRangeTransforms = ( + dimension: string, + dimensionRange: [number | null, number | null], +): FilterTransform[] => { + const filterExpressions: string[] = []; + if (dimensionRange[0] !== null) { + filterExpressions.push(`datum.${dimension} >= ${dimensionRange[0]}`); + } + if (dimensionRange[1] !== null) { + filterExpressions.push(`datum.${dimension} <= ${dimensionRange[1]}`); + } + if (filterExpressions.length) { + return [ + { + type: 'filter', + expr: filterExpressions.join(' && '), + }, + ]; + } + return []; +}; + +/** + * This transform is used to calculate the value of the trendline using the coef and the dimension + * @param dimension mark dimension + * @param method trenline method + * @returns formula transorfm + */ +export const getTrendlineParamFormulaTransforms = ( + dimension: string, + method: TrendlineMethod, + scaleType: ScaleType | undefined, +): FormulaTransform[] => { + let expr = ''; + const trendlineDimension = trendlineUsesNormalizedDimension(method, scaleType) + ? `${dimension}Normalized` + : dimension; + if (isPolynomialMethod(method)) { + const order = getPolynomialOrder(method); + expr = [ + 'datum.coef[0]', + ...Array(order) + .fill(0) + .map((_e, i) => `datum.coef[${i + 1}] * pow(datum.${trendlineDimension}, ${i + 1})`), + ].join(' + '); + } else if (method === 'exponential') { + expr = `datum.coef[0] + exp(datum.coef[1] * datum.${trendlineDimension})`; + } else if (method === 'logarithmic') { + expr = `datum.coef[0] + datum.coef[1] * log(datum.${trendlineDimension})`; + } else if (method === 'power') { + expr = `datum.coef[0] * pow(datum.${trendlineDimension}, datum.coef[1])`; + } + + if (!expr) return []; + return [ + { + type: 'formula', + expr, + as: TRENDLINE_VALUE, + }, + ]; +}; + +/** + * Gets the lookup transform that will be used to lookup the coef for regression trendlines + * @param markProps + * @param trendlineProps + * @returns LookupTransform + */ +export const getTrendlineParamLookupTransform = ( + { color, lineType }: TrendlineParentProps, + { name }: TrendlineSpecProps, +): LookupTransform => { + const { facets } = getFacetsFromProps({ color, lineType }); + return { + type: 'lookup', + from: `${name}_params`, + key: 'keys', + fields: facets, + values: ['coef'], + }; +}; diff --git a/src/specBuilder/trendline/trendlineDataUtils.test.ts b/src/specBuilder/trendline/trendlineDataUtils.test.ts new file mode 100644 index 000000000..4c382316d --- /dev/null +++ b/src/specBuilder/trendline/trendlineDataUtils.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ChartTooltip } from '@components/ChartTooltip'; +import { Trendline } from '@components/Trendline'; +import { + DEFAULT_COLOR, + DEFAULT_TRANSFORMED_TIME_DIMENSION, + FILTERED_TABLE, + MS_PER_DAY, + SERIES_ID, + TRENDLINE_VALUE, +} from '@constants'; +import { baseData } from '@specBuilder/specUtils'; +import { createElement } from 'react'; +import { Data } from 'vega'; +import { + addTableDataTransforms, + addTrendlineData, + getAggregateTrendlineData, + getRegressionTrendlineData, + getTrendlineStatisticalTransforms, +} from './trendlineDataUtils'; +import { defaultLineProps, defaultTrendlineProps } from './trendlineTestUtils'; + +const getDefaultData = (): Data[] => JSON.parse(JSON.stringify(baseData)); + +describe('addTrendlineData()', () => { + test('should add normalized dimension for regression trendline', () => { + const trendlineData = getDefaultData(); + addTrendlineData(trendlineData, { + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' })], + }); + expect(trendlineData[0].transform).toHaveLength(4); + expect(trendlineData[0].transform?.[1]).toStrictEqual({ + as: ['datetimeMin'], + fields: ['datetime'], + ops: ['min'], + type: 'joinaggregate', + }); + expect(trendlineData[0].transform?.[2]).toStrictEqual({ + as: 'datetimeNormalized', + expr: `(datum.datetime - datum.datetimeMin + ${MS_PER_DAY}) / ${MS_PER_DAY}`, + type: 'formula', + }); + }); + + test('should not add normalized dimension in not regression trendline', () => { + const trendlineData = getDefaultData(); + addTrendlineData(trendlineData, defaultLineProps); + expect(trendlineData[0].transform).toHaveLength(1); + }); + + test('should add datasource for trendline', () => { + const trendlineData = getDefaultData(); + expect(trendlineData).toHaveLength(2); + addTrendlineData(trendlineData, defaultLineProps); + expect(trendlineData).toHaveLength(3); + expect(trendlineData[2]).toStrictEqual({ + name: 'line0Trendline0_highResolutionData', + source: FILTERED_TABLE, + transform: [ + { + as: [ + TRENDLINE_VALUE, + `${DEFAULT_TRANSFORMED_TIME_DIMENSION}Min`, + `${DEFAULT_TRANSFORMED_TIME_DIMENSION}Max`, + ], + fields: ['value', DEFAULT_TRANSFORMED_TIME_DIMENSION, DEFAULT_TRANSFORMED_TIME_DIMENSION], + groupby: ['series'], + ops: ['mean', 'min', 'max'], + type: 'aggregate', + }, + { + type: 'formula', + expr: `datum.${DEFAULT_COLOR}`, + as: SERIES_ID, + }, + ], + }); + }); + + test('should add data sources for hover interactiontions if ChartTooltip exists', () => { + const trendlineData = getDefaultData(); + addTrendlineData(trendlineData, { + ...defaultLineProps, + children: [createElement(Trendline, {}, createElement(ChartTooltip))], + }); + expect(trendlineData).toHaveLength(7); + expect(trendlineData[5]).toHaveProperty('name', 'line0_allTrendlineData'); + expect(trendlineData[6]).toHaveProperty('name', 'line0Trendline_highlightedData'); + }); + + test('should add _highResolutionData if doing a regression method', () => { + const trendlineData = getDefaultData(); + + addTrendlineData(trendlineData, { + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' })], + }); + expect(trendlineData).toHaveLength(3); + expect(trendlineData[2]).toHaveProperty('name', 'line0Trendline0_highResolutionData'); + }); + + test('should add _params and _data if doing a regression method and there is a tooltip on the trendline', () => { + const trendlineData = getDefaultData(); + + addTrendlineData(trendlineData, { + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' }, createElement(ChartTooltip))], + }); + expect(trendlineData).toHaveLength(7); + expect(trendlineData[3]).toHaveProperty('name', 'line0Trendline0_params'); + expect(trendlineData[4]).toHaveProperty('name', 'line0Trendline0_data'); + }); + + test('should add sort transform, then window trandform, and then dimension range filter transform for movingAverage', () => { + const trendlineData = getDefaultData(); + addTrendlineData(trendlineData, { + ...defaultLineProps, + children: [createElement(Trendline, { method: 'movingAverage-3', dimensionRange: [1, 2] })], + }); + expect(trendlineData).toHaveLength(3); + expect(trendlineData[2]).toHaveProperty('name', 'line0Trendline0_data'); + expect(trendlineData[2].transform).toHaveLength(3); + expect(trendlineData[2].transform?.[0]).toHaveProperty('type', 'collect'); + expect(trendlineData[2].transform?.[1]).toHaveProperty('type', 'window'); + expect(trendlineData[2].transform?.[2]).toHaveProperty('type', 'filter'); + }); +}); + +describe('getAggregateTrendlineData()', () => { + test('should return one data source if there are not any interactive children', () => { + const data = getAggregateTrendlineData(defaultLineProps, defaultTrendlineProps, [DEFAULT_COLOR]); + expect(data).toHaveLength(1); + expect(data[0]).toHaveProperty('name', 'line0Trendline0_highResolutionData'); + }); + test('should return two data sources if there are interactive children', () => { + const data = getAggregateTrendlineData( + defaultLineProps, + { ...defaultTrendlineProps, children: [createElement(ChartTooltip)] }, + [DEFAULT_COLOR], + ); + expect(data).toHaveLength(2); + expect(data[1]).toHaveProperty('name', 'line0Trendline0_data'); + }); +}); + +describe('getRegressionTrendlineData()', () => { + test('should return one data source if there are not any interactive children', () => { + const data = getRegressionTrendlineData(defaultLineProps, defaultTrendlineProps, [DEFAULT_COLOR]); + expect(data).toHaveLength(1); + expect(data[0]).toHaveProperty('name', 'line0Trendline0_highResolutionData'); + }); + test('should return three data sources if there are interactive children', () => { + const data = getRegressionTrendlineData( + defaultLineProps, + { ...defaultTrendlineProps, children: [createElement(ChartTooltip)] }, + [DEFAULT_COLOR], + ); + expect(data).toHaveLength(3); + expect(data[1]).toHaveProperty('name', 'line0Trendline0_params'); + expect(data[2]).toHaveProperty('name', 'line0Trendline0_data'); + }); +}); + +describe('getTrendlineStatisticalTransforms()', () => { + test('should return the aggregate transform for aggregate methods', () => { + const aggregateTransforms = getTrendlineStatisticalTransforms( + defaultLineProps, + { ...defaultTrendlineProps, method: 'average' }, + true, + ); + expect(aggregateTransforms).toHaveLength(1); + expect(aggregateTransforms[0]).toHaveProperty('type', 'aggregate'); + }); + test('should return the regression transform for regression methods', () => { + const aggregateTransforms = getTrendlineStatisticalTransforms( + defaultLineProps, + { ...defaultTrendlineProps, method: 'linear' }, + true, + ); + expect(aggregateTransforms).toHaveLength(1); + expect(aggregateTransforms[0]).toHaveProperty('type', 'regression'); + }); + test('should return the window transform for window methods', () => { + const aggregateTransforms = getTrendlineStatisticalTransforms( + defaultLineProps, + { ...defaultTrendlineProps, method: 'movingAverage-2' }, + true, + ); + expect(aggregateTransforms).toHaveLength(2); + expect(aggregateTransforms[0]).toHaveProperty('type', 'collect'); + expect(aggregateTransforms[1]).toHaveProperty('type', 'window'); + }); +}); + +describe('addTableDataTransforms()', () => { + test('should add normalized dimension transform if regression method and time scale type', () => { + const transforms = addTableDataTransforms([], { + ...defaultLineProps, + scaleType: 'time', + children: [createElement(Trendline, { method: 'linear' })], + }); + expect(transforms).toHaveLength(3); + expect(transforms[0]).toHaveProperty('type', 'joinaggregate'); + expect(transforms[1]).toHaveProperty('type', 'formula'); + expect(transforms[2]).toHaveProperty('type', 'extent'); + }); + test('should add extent transforms if there are trendlines with regression methods', () => { + const transforms = addTableDataTransforms([], { + ...defaultLineProps, + scaleType: 'linear', + children: [createElement(Trendline, { method: 'linear' })], + }); + expect(transforms).toHaveLength(1); + expect(transforms[0]).toHaveProperty('type', 'extent'); + }); + test('should not add any transforms if there are not any regression trendlines', () => { + expect( + addTableDataTransforms([], { + ...defaultLineProps, + scaleType: 'linear', + children: [createElement(Trendline, { method: 'average' })], + }), + ).toHaveLength(0); + expect( + addTableDataTransforms([], { + ...defaultLineProps, + scaleType: 'linear', + children: [createElement(Trendline, { method: 'movingAverage-2' })], + }), + ).toHaveLength(0); + }); +}); diff --git a/src/specBuilder/trendline/trendlineDataUtils.ts b/src/specBuilder/trendline/trendlineDataUtils.ts new file mode 100644 index 000000000..06dc597fd --- /dev/null +++ b/src/specBuilder/trendline/trendlineDataUtils.ts @@ -0,0 +1,281 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { FILTERED_TABLE, MARK_ID } from '@constants'; +import { getSeriesIdTransform, getTableData } from '@specBuilder/data/dataUtils'; +import { hasInteractiveChildren, hasPopover } from '@specBuilder/marks/markUtils'; +import { getDimensionField, getFacetsFromProps } from '@specBuilder/specUtils'; +import { produce } from 'immer'; +import { TrendlineSpecProps } from 'types'; +import { Data, SourceData, Transforms } from 'vega'; +import { + TrendlineParentProps, + getTrendlineScaleType, + getTrendlines, + hasTrendlineWithNormailizedDimension, + isAggregateMethod, + isRegressionMethod, + isWindowMethod, +} from './trendlineUtils'; +import { + getAggregateTransform, + getNormalizedDimensionTransform, + getRegressionExtentTransform, + getRegressionTransform, + getSortTransform, + getTrendlineDimensionRangeTransforms, + getTrendlineParamFormulaTransforms, + getTrendlineParamLookupTransform, + getWindowTransform, +} from './trendlineDataTransformUtils'; + +/** + * Adds the necessary data sources and transforms for the trendlines + * NOTE: this function mutates the data array because it gets called from within a data produce function + * @param data + * @param markProps + */ +export const addTrendlineData = (data: Data[], markProps: TrendlineParentProps) => { + data.push(...getTrendlineData(markProps)); + + const tableData = getTableData(data); + tableData.transform = addTableDataTransforms(tableData.transform ?? [], markProps); +}; + +/** + * Gets all the data sources and transforms for all trendlines + * @param data + * @param markProps + * @returns Data[] + */ +export const getTrendlineData = (markProps: TrendlineParentProps): SourceData[] => { + const data: SourceData[] = []; + const { children, color, lineType, name: markName } = markProps; + const trendlines = getTrendlines(children, markName); + + const concatenatedTrendlineData: { name: string; source: string[] } = { + name: `${markName}_allTrendlineData`, + source: [], + }; + + for (const trendlineProps of trendlines) { + const { children: trendlineChildren, method, name } = trendlineProps; + const { facets } = getFacetsFromProps({ color, lineType }); + + if (isRegressionMethod(method)) { + data.push(...getRegressionTrendlineData(markProps, trendlineProps, facets)); + } else if (isAggregateMethod(method)) { + data.push(...getAggregateTrendlineData(markProps, trendlineProps, facets)); + } else if (isWindowMethod(method)) { + data.push(getWindowTrendlineData(markProps, trendlineProps)); + } + if (hasInteractiveChildren(trendlineChildren)) { + concatenatedTrendlineData.source.push(`${name}_data`); + } + } + + if (trendlines.some((trendline) => hasInteractiveChildren(trendline.children))) { + data.push(concatenatedTrendlineData); + data.push(getHighlightTrendlineData(markName, trendlines)); + } + + return data; +}; + +/** + * Gets the data sources and transforms for aggregate trendlines (average, median) + * @param markProps + * @param trendlineProps + * @param facets + * @returns Data[] + */ +export const getAggregateTrendlineData = ( + markProps: TrendlineParentProps, + trendlineProps: TrendlineSpecProps, + facets: string[], +) => { + const data: SourceData[] = []; + const { dimension } = markProps; + const { dimensionRange, name, children: trendlineChildren } = trendlineProps; + const dimensionRangeTransforms = getTrendlineDimensionRangeTransforms(dimension, dimensionRange); + // high resolution data used for drawing the rule marks + data.push({ + name: `${name}_highResolutionData`, + source: FILTERED_TABLE, + transform: [ + ...dimensionRangeTransforms, + ...getTrendlineStatisticalTransforms(markProps, trendlineProps, true), + getSeriesIdTransform(facets), + ], + }); + if (hasInteractiveChildren(trendlineChildren)) { + // data used for each of the trendline points + data.push({ + name: `${name}_data`, + source: FILTERED_TABLE, + transform: [ + ...dimensionRangeTransforms, + ...getTrendlineStatisticalTransforms(markProps, trendlineProps, false), + ], + }); + } + return data; +}; + +/** + * Gets the data sources and transforms for regression trendlines (linear, power, polynomial-x, etc.) + * @param markProps + * @param trendlineProps + * @param facets + * @returns Data[] + */ +export const getRegressionTrendlineData = ( + markProps: TrendlineParentProps, + trendlineProps: TrendlineSpecProps, + facets: string[], +) => { + const data: SourceData[] = []; + const { dimension } = markProps; + const { dimensionRange, method, name, children: trendlineChildren } = trendlineProps; + const scaleType = getTrendlineScaleType(markProps); + const dimensionRangeTransforms = getTrendlineDimensionRangeTransforms(dimension, dimensionRange); + // high resolution data used for drawing the smooth trendline + data.push({ + name: `${name}_highResolutionData`, + source: FILTERED_TABLE, + transform: [ + ...dimensionRangeTransforms, + ...getTrendlineStatisticalTransforms(markProps, trendlineProps, true), + getSeriesIdTransform(facets), + ], + }); + if (hasInteractiveChildren(trendlineChildren)) { + // params and data used for each of the trendline data points + // the high resolution data has too much detail and we don't want a tooltip at each high resolution point + data.push( + { + name: `${name}_params`, + source: FILTERED_TABLE, + transform: [ + ...dimensionRangeTransforms, + ...getTrendlineStatisticalTransforms(markProps, trendlineProps, false), + ], + }, + { + name: `${name}_data`, + source: FILTERED_TABLE, + transform: [ + ...dimensionRangeTransforms, + getTrendlineParamLookupTransform(markProps, trendlineProps), + ...getTrendlineParamFormulaTransforms(dimension, method, scaleType), + ], + }, + ); + } + return data; +}; + +/** + * Gets the data source and transforms for window trendlines (movingAverage-x) + * @param markProps + * @param trendlineProps + * @returns Data + */ +const getWindowTrendlineData = (markProps: TrendlineParentProps, trendlineProps: TrendlineSpecProps): SourceData => ({ + name: `${trendlineProps.name}_data`, + source: FILTERED_TABLE, + transform: [ + ...getTrendlineStatisticalTransforms(markProps, trendlineProps, false), + ...getTrendlineDimensionRangeTransforms(markProps.dimension, trendlineProps.dimensionRange), + ], +}); + +/** + * gets the data source and transforms for highlighting trendlines + * @param markName + * @param trendlines + * @returns Data + */ +const getHighlightTrendlineData = (markName: string, trendlines: TrendlineSpecProps[]): SourceData => { + const selectSignal = `${markName}Trendline_selectedId`; + const hoverSignal = `${markName}Trendline_hoveredId`; + const trendlineHasPopover = trendlines.some((trendline) => hasPopover(trendline.children)); + const expr = trendlineHasPopover + ? `${selectSignal} === datum.${MARK_ID} || !${selectSignal} && ${hoverSignal} === datum.${MARK_ID}` + : `${hoverSignal} === datum.${MARK_ID}`; + + return { + name: `${markName}Trendline_highlightedData`, + source: `${markName}_allTrendlineData`, + transform: [ + { + type: 'filter', + expr, + }, + ], + }; +}; + +/** + * Gets the statistical transforms that will calculate the trendline values + * @param markProps + * @param trendlineProps + * @returns dataTransforms + */ +export const getTrendlineStatisticalTransforms = ( + markProps: TrendlineParentProps, + trendlineProps: TrendlineSpecProps, + isHighResolutionData: boolean, +): Transforms[] => { + const { method } = trendlineProps; + const scaleType = getTrendlineScaleType(markProps); + const dimension = getDimensionField(markProps.dimension, scaleType); + + if (isAggregateMethod(method)) { + return [getAggregateTransform(markProps, method, isHighResolutionData, dimension)]; + } + if (isRegressionMethod(method)) { + return [getRegressionTransform(markProps, trendlineProps, isHighResolutionData)]; + } + if (isWindowMethod(method)) { + return [getSortTransform(dimension), getWindowTransform(markProps, method)]; + } + + return []; +}; + +/** + * Adds the table data transforms needed for trendlines + * @param transforms + * @param markProps + */ +export const addTableDataTransforms = produce((transforms, markProps) => { + const { children, dimension, name } = markProps; + const scaleType = getTrendlineScaleType(markProps); + + const normalizedDimensionTransformExists = transforms.some( + (transform) => 'as' in transform && transform.as === `${dimension}Normalized`, + ); + // if a normalized dimension transform is needed and doesn't exist, add it + if (hasTrendlineWithNormailizedDimension(markProps) && !normalizedDimensionTransformExists) { + transforms.push(...getNormalizedDimensionTransform(dimension)); + } + + // add the extent transform for each regression trendline + const trendlines = getTrendlines(children, name); + for (const trendlineProps of trendlines) { + if (isRegressionMethod(trendlineProps.method)) { + const trendlineDimension = scaleType === 'time' ? `${dimension}Normalized` : dimension; + transforms.push(getRegressionExtentTransform(trendlineDimension, trendlineProps.name)); + } + } +}); diff --git a/src/specBuilder/trendline/trendlineMarkUtils.test.ts b/src/specBuilder/trendline/trendlineMarkUtils.test.ts new file mode 100644 index 000000000..5058a64f2 --- /dev/null +++ b/src/specBuilder/trendline/trendlineMarkUtils.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ChartTooltip } from '@components/ChartTooltip'; +import { Trendline } from '@components/Trendline'; +import { DEFAULT_TIME_DIMENSION } from '@constants'; +import { spectrumColors } from '@themes'; +import { createElement } from 'react'; +import { Facet, From, GroupMark, Mark } from 'vega'; +import { + getRuleX2ProductionRule, + getRuleXProductionRule, + getTrendlineLineMark, + getTrendlineMarks, + getTrendlineRuleMark, +} from './trendlineMarkUtils'; +import { defaultLineProps, defaultTrendlineProps } from './trendlineTestUtils'; + +describe('getTrendlineMarks()', () => { + test('should return rule mark for aggregate methods', () => { + const marks = getTrendlineMarks({ + ...defaultLineProps, + children: [createElement(Trendline, { method: 'median' })], + }); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveProperty('type', 'rule'); + }); + test('should return group and line mark for non-aggregate methods', () => { + const marks = getTrendlineMarks({ + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' })], + }); + // group mark + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveProperty('type', 'group'); + const groupMark = marks[0] as GroupMark; + // line mark + expect(groupMark.marks).toHaveLength(1); + expect(groupMark.marks?.[0]).toHaveProperty('type', 'line'); + }); + test('should add hover marks if ChartTooltip exists on Trendline', () => { + const marks = getTrendlineMarks({ + ...defaultLineProps, + children: [createElement(Trendline, {}, createElement(ChartTooltip))], + }); + expect(marks).toHaveLength(2); + expect(marks[1]).toHaveProperty('type', 'group'); + const trendlineMarks = (marks[1] as GroupMark).marks as Mark[]; + // line mark + expect(trendlineMarks).toHaveLength(5); + expect(trendlineMarks[0]).toHaveProperty('type', 'rule'); + expect(trendlineMarks[1]).toHaveProperty('type', 'symbol'); + expect(trendlineMarks[2]).toHaveProperty('type', 'symbol'); + expect(trendlineMarks[3]).toHaveProperty('type', 'symbol'); // highlight point background + expect(trendlineMarks[4]).toHaveProperty('type', 'path'); + }); + test('should reference _data for window method', () => { + const marks = getTrendlineMarks({ + ...defaultLineProps, + children: [createElement(Trendline, { method: 'movingAverage-2' })], + }); + expect( + ( + marks[0].from as From & { + facet: Facet; + } + ).facet.data, + ).toEqual('line0Trendline0_data'); + }); + test('should reference _highResolutionData for linear method', () => { + const marks = getTrendlineMarks({ + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' })], + }); + expect( + ( + marks[0].from as From & { + facet: Facet; + } + ).facet.data, + ).toEqual('line0Trendline0_highResolutionData'); + }); +}); + +describe('getTrendlineRuleMark()', () => { + test('should use series color if static color is not provided', () => { + const mark = getTrendlineRuleMark(defaultLineProps, { ...defaultTrendlineProps, method: 'median' }); + expect(mark.encode?.enter?.stroke).toEqual({ field: 'series', scale: 'color' }); + }); + test('should use static color if provided', () => { + const mark = getTrendlineRuleMark(defaultLineProps, { + ...defaultTrendlineProps, + color: 'gray-500', + method: 'median', + }); + expect(mark.encode?.enter?.stroke).toEqual({ value: spectrumColors.light['gray-500'] }); + }); +}); + +describe('getRuleXProductionRule()', () => { + test('should return the correct production rule for a number value extent', () => { + expect(getRuleXProductionRule(0, 'count', 'linear')).toEqual({ scale: 'xLinear', value: 0 }); + expect(getRuleXProductionRule(10, 'count', 'linear')).toEqual({ scale: 'xLinear', value: 10 }); + }); + test('should return the correct production rule for "domain" extent', () => { + expect(getRuleXProductionRule('domain', 'count', 'linear')).toEqual({ value: 0 }); + }); + test('should return the correct production rule for null extent', () => { + expect(getRuleXProductionRule(null, 'count', 'linear')).toEqual({ scale: 'xLinear', field: 'countMin' }); + }); +}); + +describe('getRuleX2ProductionRule()', () => { + test('should return the correct production rule for a number value extent', () => { + expect(getRuleX2ProductionRule(0, 'count', 'linear')).toEqual({ scale: 'xLinear', value: 0 }); + expect(getRuleX2ProductionRule(10, 'count', 'linear')).toEqual({ scale: 'xLinear', value: 10 }); + }); + test('should return the correct production rule for "domain" extent', () => { + expect(getRuleX2ProductionRule('domain', 'count', 'linear')).toEqual({ signal: 'width' }); + }); + test('should return the correct production rule for null extent', () => { + expect(getRuleX2ProductionRule(null, 'count', 'linear')).toEqual({ scale: 'xLinear', field: 'countMax' }); + }); +}); + +describe('getTrendlineLineMark()', () => { + test('should use normalized values for x if it is a regression method and scale is time', () => { + expect( + getTrendlineLineMark(defaultLineProps, { ...defaultTrendlineProps, method: 'linear' }).encode?.update?.x, + ).toEqual({ + scale: 'xTrendline', + field: `${DEFAULT_TIME_DIMENSION}Normalized`, + }); + }); + test('should use regular x rule if the x dimension is not normalized', () => { + expect( + getTrendlineLineMark(defaultLineProps, { ...defaultTrendlineProps, method: 'median' }).encode?.update?.x, + ).toEqual({ field: 'datetime0', scale: 'xTime' }); + expect( + getTrendlineLineMark(defaultLineProps, { ...defaultTrendlineProps, method: 'movingAverage-12' }).encode + ?.update?.x, + ).toEqual({ field: 'datetime0', scale: 'xTime' }); + expect( + getTrendlineLineMark( + { ...defaultLineProps, scaleType: 'linear', dimension: 'count' }, + defaultTrendlineProps, + ).encode?.update?.x, + ).toEqual({ field: 'count', scale: 'xLinear' }); + }); + test('should use series color if static color is not provided', () => { + const mark = getTrendlineLineMark(defaultLineProps, defaultTrendlineProps); + expect(mark.encode?.enter?.stroke).toEqual({ field: 'series', scale: 'color' }); + }); + test('should use static color if provided', () => { + const mark = getTrendlineLineMark(defaultLineProps, { ...defaultTrendlineProps, color: 'gray-500' }); + expect(mark.encode?.enter?.stroke).toEqual({ value: spectrumColors.light['gray-500'] }); + }); +}); diff --git a/src/specBuilder/trendline/trendlineMarkUtils.ts b/src/specBuilder/trendline/trendlineMarkUtils.ts new file mode 100644 index 000000000..d0af50382 --- /dev/null +++ b/src/specBuilder/trendline/trendlineMarkUtils.ts @@ -0,0 +1,241 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { ScaleType, TrendlineSpecProps } from 'types'; +import { + TrendlineParentProps, + getTrendlineScaleType, + getTrendlines, + isAggregateMethod, + isRegressionMethod, + trendlineUsesNormalizedDimension, +} from './trendlineUtils'; +import { GroupMark, LineMark, NumericValueRef, RuleMark } from 'vega'; +import { getDimensionField, getFacetsFromProps } from '@specBuilder/specUtils'; +import { + getColorProductionRule, + getLineWidthProductionRule, + getStrokeDashProductionRule, + getXProductionRule, + hasTooltip, +} from '@specBuilder/marks/markUtils'; +import { getScaleName } from '@specBuilder/scale/scaleSpecBuilder'; +import { getLineHoverMarks, getLineStrokeOpacity } from '@specBuilder/line/lineMarkUtils'; +import { LineMarkProps } from '@specBuilder/line/lineUtils'; +import { TRENDLINE_VALUE } from '@constants'; + +export const getTrendlineMarks = (markProps: TrendlineParentProps): (GroupMark | RuleMark)[] => { + const { children, color, lineType, name } = markProps; + const { facets } = getFacetsFromProps({ color, lineType }); + + const marks: (GroupMark | RuleMark)[] = []; + const trendlines = getTrendlines(children, name); + for (const trendlineProps of trendlines) { + if (isAggregateMethod(trendlineProps.method)) { + marks.push(getTrendlineRuleMark(markProps, trendlineProps)); + } else { + const dataSuffix = isRegressionMethod(trendlineProps.method) ? '_highResolutionData' : '_data'; + marks.push({ + name: `${trendlineProps.name}_group`, + type: 'group', + clip: true, + from: { + facet: { + name: `${trendlineProps.name}_facet`, + data: trendlineProps.name + dataSuffix, + groupby: facets, + }, + }, + marks: [getTrendlineLineMark(markProps, trendlineProps)], + }); + } + } + + if (trendlines.some((trendline) => hasTooltip(trendline.children))) { + marks.push( + getTrendlineHoverMarks( + markProps, + trendlines.some((trendlineProps) => trendlineProps.highlightRawPoint), + ), + ); + } + + return marks; +}; + +/** + * gets the trendline rule mark used for aggregate methods (mean, median) + * @param markProps + * @param trendlineProps + * @returns rule mark + */ +export const getTrendlineRuleMark = (markProps: TrendlineParentProps, trendlineProps: TrendlineSpecProps): RuleMark => { + const { dimension, colorScheme } = markProps; + const { dimensionExtent, lineType, lineWidth, metric, name } = trendlineProps; + const color = trendlineProps.color ? { value: trendlineProps.color } : markProps.color; + const scaleType = getTrendlineScaleType(markProps); + const dimensionField = getDimensionField(dimension, scaleType); + + return { + name, + type: 'rule', + clip: true, + from: { + data: `${name}_highResolutionData`, + }, + interactive: false, + encode: { + enter: { + y: { scale: 'yLinear', field: metric }, + stroke: getColorProductionRule(color, colorScheme), + strokeDash: getStrokeDashProductionRule({ value: lineType }), + strokeWidth: getLineWidthProductionRule({ value: lineWidth }), + }, + update: { + x: getRuleXProductionRule(dimensionExtent[0], dimensionField, scaleType), + x2: getRuleX2ProductionRule(dimensionExtent[1], dimensionField, scaleType), + strokeOpacity: getLineStrokeOpacity(getLineMarkProps(markProps, trendlineProps)), + }, + }, + }; +}; + +/** + * gets the production rule for the x encoding of a rule mark + * @param startDimensionExtent + * @param dimension + * @param scaleType + * @returns x production rule + */ +export const getRuleXProductionRule = ( + startDimensionExtent: number | 'domain' | null, + dimension: string, + scaleType: ScaleType, +): NumericValueRef => { + const scale = getScaleName('x', scaleType); + switch (startDimensionExtent) { + case null: + return { scale, field: `${dimension}Min` }; + case 'domain': + return { value: 0 }; + default: + return { scale, value: startDimensionExtent }; + } +}; + +/** + * gets the production rule for the x2 encoding of a rule mark + * @param endDimensionExtent + * @param dimension + * @param scaleType + * @returns x2 production rule + */ +export const getRuleX2ProductionRule = ( + endDimensionExtent: number | 'domain' | null, + dimension: string, + scaleType: ScaleType, +): NumericValueRef => { + const scale = getScaleName('x', scaleType); + switch (endDimensionExtent) { + case null: + return { scale, field: `${dimension}Max` }; + case 'domain': + return { signal: 'width' }; + default: + return { scale, value: endDimensionExtent }; + } +}; + +/** + * gets the trendline line mark used for regression and window methods + * @param markProps + * @param trendlineProps + * @returns + */ +export const getTrendlineLineMark = (markProps: TrendlineParentProps, trendlineProps: TrendlineSpecProps): LineMark => { + const { colorScheme, dimension } = markProps; + const scaleType = getTrendlineScaleType(markProps); + const { lineType, lineWidth, metric, name } = trendlineProps; + + const x = trendlineUsesNormalizedDimension(trendlineProps.method, scaleType) + ? { scale: 'xTrendline', field: `${dimension}Normalized` } + : getXProductionRule(scaleType, dimension); + const color = trendlineProps.color ? { value: trendlineProps.color } : markProps.color; + + return { + name, + type: 'line', + from: { data: `${name}_facet` }, + interactive: false, + encode: { + enter: { + y: { scale: 'yLinear', field: metric }, + stroke: getColorProductionRule(color, colorScheme), + strokeDash: getStrokeDashProductionRule({ value: lineType }), + strokeWidth: getLineWidthProductionRule({ value: lineWidth }), + }, + update: { + x, + strokeOpacity: getLineStrokeOpacity(getLineMarkProps(markProps, trendlineProps)), + }, + }, + }; +}; + +const getTrendlineHoverMarks = (lineProps: TrendlineParentProps, highlightRawPoint: boolean): GroupMark => { + const { children, metric, name } = lineProps; + const trendlines = getTrendlines(children, name); + const trendlineHoverProps: LineMarkProps = getLineMarkProps(lineProps, trendlines[0], { + name: `${name}Trendline`, + children: trendlines.map((trendline) => trendline.children).flat(), + metric: TRENDLINE_VALUE, + }); + + return { + name: `${name}Trendline_hoverGroup`, + type: 'group', + clip: true, + marks: getLineHoverMarks( + trendlineHoverProps, + `${name}_allTrendlineData`, + highlightRawPoint ? metric : undefined, + ), + }; +}; + +const getLineMarkProps = ( + markProps: TrendlineParentProps, + { displayOnHover, lineWidth, metric, name, opacity }: TrendlineSpecProps, + override?: Partial, +): LineMarkProps => { + const { children, color, colorScheme, dimension, interactiveMarkName, lineType } = markProps; + const popoverMarkName = 'popoverMarkName' in markProps ? markProps.popoverMarkName : undefined; + const scaleType = getTrendlineScaleType(markProps); + const staticPoint = 'staticPoint' in markProps ? markProps.staticPoint : undefined; + return { + children, + color, + colorScheme, + dimension, + displayOnHover, + interactiveMarkName, + lineType, + lineWidth: { value: lineWidth }, + metric, + name, + opacity: { value: opacity }, + popoverMarkName, + scaleType, + staticPoint, + ...override, + }; +}; diff --git a/src/specBuilder/trendline/trendlineScaleUtils.test.ts b/src/specBuilder/trendline/trendlineScaleUtils.test.ts new file mode 100644 index 000000000..27ace213e --- /dev/null +++ b/src/specBuilder/trendline/trendlineScaleUtils.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { createElement } from 'react'; +import { getTrendlineScales } from './trendlineScaleUtils'; +import { defaultLineProps } from './trendlineTestUtils'; +import { Trendline } from '@components/Trendline'; + +describe('getTrendlineScales()', () => { + test('should return the xTrendline scale if the scaleType is time and there is a regression trendline', () => { + const scales = getTrendlineScales({ + ...defaultLineProps, + children: [createElement(Trendline, { method: 'linear' })], + }); + expect(scales).toHaveLength(1); + expect(scales[0]).toHaveProperty('name', 'xTrendline'); + }); +}); diff --git a/src/specBuilder/trendline/trendlineScaleUtils.ts b/src/specBuilder/trendline/trendlineScaleUtils.ts new file mode 100644 index 000000000..2245d78ba --- /dev/null +++ b/src/specBuilder/trendline/trendlineScaleUtils.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Scale } from 'vega'; +import { TrendlineParentProps, hasTrendlineWithNormailizedDimension } from './trendlineUtils'; +import { FILTERED_TABLE, LINEAR_PADDING } from '@constants'; + +/** + * Gets all the scales used for trendlines + * @param props + * @returns Scale[] + */ +export const getTrendlineScales = (props: TrendlineParentProps): Scale[] => { + const { dimension } = props; + + // if there is a trendline that requires a normalized dimension, add the scale + if (hasTrendlineWithNormailizedDimension(props)) { + return [ + { + name: 'xTrendline', + type: 'linear', + range: 'width', + domain: { data: FILTERED_TABLE, fields: [`${dimension}Normalized`] }, + padding: LINEAR_PADDING, + zero: false, + nice: false, + }, + ]; + } + return []; +}; diff --git a/src/specBuilder/trendline/trendlineSignalUtils.test.ts b/src/specBuilder/trendline/trendlineSignalUtils.test.ts new file mode 100644 index 000000000..faf607a5c --- /dev/null +++ b/src/specBuilder/trendline/trendlineSignalUtils.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { getTrendlineSignals } from './trendlineSignalUtils'; +import { createElement } from 'react'; +import { ChartPopover } from '@components/ChartPopover'; +import { ChartTooltip } from '@components/ChartTooltip'; +import { Trendline } from '@components/Trendline'; +import { defaultLineProps } from './trendlineTestUtils'; + +describe('getTrendlineSignals()', () => { + test('should return voronoi hover signal if ChartTooltip exists', () => { + const signals = getTrendlineSignals({ + ...defaultLineProps, + children: [createElement(Trendline, {}, createElement(ChartTooltip))], + }); + expect(signals).toHaveLength(2); + expect(signals[0]).toHaveProperty('name', 'line0Trendline_hoveredId'); + expect(signals[1]).toHaveProperty('name', 'line0Trendline_hoveredSeries'); + }); + + test('should not return any signals if there is not a ChartTooltip', () => { + const signals = getTrendlineSignals(defaultLineProps); + expect(signals).toHaveLength(0); + }); + + test('should return voronoi selected signal if ChartPopover exists', () => { + const signals = getTrendlineSignals({ + ...defaultLineProps, + children: [ + createElement(Trendline, {}, createElement(ChartTooltip)), + createElement(Trendline, {}, createElement(ChartPopover)), + ], + }); + expect(signals).toHaveLength(4); + expect(signals[2]).toHaveProperty('name', 'line0Trendline_selectedId'); + expect(signals[3]).toHaveProperty('name', 'line0Trendline_selectedSeries'); + }); + + test('should not return selected signal if there is not a ChartPopover', () => { + const signals = getTrendlineSignals({ + ...defaultLineProps, + children: [createElement(Trendline, {}, createElement(ChartTooltip))], + }); + expect(signals).toHaveLength(2); + expect(signals[0].name).not.toContain('selected'); + }); +}); diff --git a/src/specBuilder/trendline/trendlineSignalUtils.ts b/src/specBuilder/trendline/trendlineSignalUtils.ts new file mode 100644 index 000000000..938e351e3 --- /dev/null +++ b/src/specBuilder/trendline/trendlineSignalUtils.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Signal } from 'vega'; +import { TrendlineParentProps, getTrendlines } from './trendlineUtils'; +import { hasPopover, hasTooltip } from '@specBuilder/marks/markUtils'; +import { + getGenericSignal, + getSeriesHoveredSignal, + getUncontrolledHoverSignal, +} from '@specBuilder/signal/signalSpecBuilder'; + +export const getTrendlineSignals = (markProps: TrendlineParentProps): Signal[] => { + const signals: Signal[] = []; + const { children, name: markName } = markProps; + const trendlines = getTrendlines(children, markName); + + if (trendlines.some((trendline) => hasTooltip(trendline.children))) { + signals.push(getUncontrolledHoverSignal(`${markName}Trendline`, true, `${markName}Trendline_voronoi`)); + signals.push(getSeriesHoveredSignal(`${markName}Trendline`, true, `${markName}Trendline_voronoi`)); + } + + if (trendlines.some((trendline) => trendline.displayOnHover)) { + signals.push(getSeriesHoveredSignal(markName, true, `${markName}_voronoi`)); + } + + if (trendlines.some((trendline) => hasPopover(trendline.children))) { + signals.push(getGenericSignal(`${markName}Trendline_selectedId`)); + signals.push(getGenericSignal(`${markName}Trendline_selectedSeries`)); + } + + return signals; +}; diff --git a/src/specBuilder/trendline/trendlineTestUtils.ts b/src/specBuilder/trendline/trendlineTestUtils.ts new file mode 100644 index 000000000..4f43239a0 --- /dev/null +++ b/src/specBuilder/trendline/trendlineTestUtils.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Trendline } from '@components/Trendline'; +import { DEFAULT_COLOR, DEFAULT_COLOR_SCHEME, DEFAULT_METRIC, DEFAULT_TIME_DIMENSION } from '@constants'; +import { createElement } from 'react'; +import { LineSpecProps, TrendlineSpecProps } from 'types'; + +export const defaultLineProps: LineSpecProps = { + children: [createElement(Trendline, { method: 'average' })], + color: DEFAULT_COLOR, + colorScheme: DEFAULT_COLOR_SCHEME, + dimension: DEFAULT_TIME_DIMENSION, + index: 0, + lineType: { value: 'solid' }, + metric: DEFAULT_METRIC, + name: 'line0', + opacity: { value: 1 }, + scaleType: 'time', + interactiveMarkName: undefined, + popoverMarkName: undefined, +}; + +export const defaultTrendlineProps: TrendlineSpecProps = { + children: [], + dimensionExtent: [null, null], + dimensionRange: [null, null], + displayOnHover: false, + highlightRawPoint: false, + lineType: 'dashed', + lineWidth: 'M', + method: 'average', + metric: DEFAULT_METRIC, + name: 'line0Trendline0', + opacity: 1, +}; diff --git a/src/specBuilder/trendline/trendlineUtils.test.ts b/src/specBuilder/trendline/trendlineUtils.test.ts index 84e2cb762..d8da002b9 100644 --- a/src/specBuilder/trendline/trendlineUtils.test.ts +++ b/src/specBuilder/trendline/trendlineUtils.test.ts @@ -12,53 +12,10 @@ import { createElement } from 'react'; import { Annotation } from '@components/Annotation'; -import { ChartPopover } from '@components/ChartPopover'; -import { ChartTooltip } from '@components/ChartTooltip'; import { Trendline } from '@components/Trendline'; -import { - DEFAULT_COLOR, - DEFAULT_COLOR_SCHEME, - DEFAULT_METRIC, - DEFAULT_TIME_DIMENSION, - DEFAULT_TRANSFORMED_TIME_DIMENSION, - FILTERED_TABLE, - MS_PER_DAY, - TRENDLINE_VALUE, -} from '@constants'; -import { baseData } from '@specBuilder/specUtils'; -import { LineSpecProps } from 'types'; -import { Data, Facet, From } from 'vega'; +import { FILTERED_TABLE, MS_PER_DAY, TRENDLINE_VALUE } from '@constants'; -import { - addTrendlineData, - applyTrendlinePropDefaults, - getAggregateTransform, - getMovingAverageTransform, - getPolynomialOrder, - getRegressionTransform, - getTrendlineDimensionRangeTransforms, - getTrendlineMarks, - getTrendlineParamFormulaTransforms, - getTrendlineSignals, - getTrendlines, -} from './trendlineUtils'; - -const defaultLineProps: LineSpecProps = { - children: [createElement(Trendline, { method: 'average' })], - color: DEFAULT_COLOR, - colorScheme: DEFAULT_COLOR_SCHEME, - dimension: DEFAULT_TIME_DIMENSION, - index: 0, - lineType: { value: 'solid' }, - metric: DEFAULT_METRIC, - name: 'line0', - opacity: { value: 1 }, - scaleType: 'time', - interactiveMarkName: undefined, - popoverMarkName: undefined, -}; - -const getDefaultData = (): Data[] => JSON.parse(JSON.stringify(baseData)); +import { applyTrendlinePropDefaults, getPolynomialOrder, getRegressionExtent, getTrendlines } from './trendlineUtils'; describe('getTrendlines()', () => { test('should return an array of trendline props', () => { @@ -91,322 +48,27 @@ describe('applyTrendlinePropDefaults()', () => { }); }); -describe('getTrendlineMarks()', () => { - test('should return line mark', () => { - const marks = getTrendlineMarks(defaultLineProps); - // group mark - expect(marks).toHaveLength(1); - expect(marks[0]).toHaveProperty('type', 'group'); - // line mark - expect(marks[0].marks).toHaveLength(1); - expect(marks[0].marks?.[0]).toHaveProperty('type', 'line'); - }); - test('should use series color if static color is not provided', () => { - const marks = getTrendlineMarks(defaultLineProps); - expect(marks[0].marks?.[0].encode?.enter?.stroke).toEqual({ field: 'series', scale: 'color' }); - }); - test('should use static color if provided', () => { - const marks = getTrendlineMarks({ - ...defaultLineProps, - children: [createElement(Trendline, { method: 'average', color: 'gray-500' })], - }); - expect(marks[0].marks?.[0].encode?.enter?.stroke).toEqual({ value: 'rgb(144, 144, 144)' }); - }); - test('should add hover marks if ChartTooltip exists on Trendline', () => { - const marks = getTrendlineMarks({ - ...defaultLineProps, - children: [createElement(Trendline, {}, createElement(ChartTooltip))], - }); - expect(marks).toHaveLength(2); - expect(marks[1]).toHaveProperty('type', 'group'); - // line mark - expect(marks[1].marks).toHaveLength(5); - expect(marks[1].marks?.[0]).toHaveProperty('type', 'rule'); - expect(marks[1].marks?.[1]).toHaveProperty('type', 'symbol'); - expect(marks[1].marks?.[2]).toHaveProperty('type', 'symbol'); - expect(marks[1].marks?.[3]).toHaveProperty('type', 'symbol'); // highlight point background - expect(marks[1].marks?.[4]).toHaveProperty('type', 'path'); - }); - test('should reference _data for average method', () => { - const marks = getTrendlineMarks(defaultLineProps); - expect( - ( - marks[0].from as From & { - facet: Facet; - } - ).facet.data - ).toEqual('line0Trendline0_data'); - }); - test('should reference _highResolutionData for average method', () => { - const marks = getTrendlineMarks({ - ...defaultLineProps, - children: [createElement(Trendline, { method: 'linear' })], - }); - expect( - ( - marks[0].from as From & { - facet: Facet; - } - ).facet.data - ).toEqual('line0Trendline0_highResolutionData'); - }); -}); - -describe('addTrendlineData()', () => { - test('should add normalized dimension for regression trendline', () => { - const trendlineData = getDefaultData(); - addTrendlineData(trendlineData, { - ...defaultLineProps, - children: [createElement(Trendline, { method: 'linear' })], - }); - expect(trendlineData[0].transform).toHaveLength(3); - expect(trendlineData[0].transform?.[1]).toStrictEqual({ - as: ['datetimeMin'], - fields: ['datetime'], - ops: ['min'], - type: 'joinaggregate', - }); - expect(trendlineData[0].transform?.[2]).toStrictEqual({ - as: 'datetimeNormalized', - expr: `(datum.datetime - datum.datetimeMin + ${MS_PER_DAY}) / ${MS_PER_DAY}`, - type: 'formula', - }); - }); - - test('should not add normalized dimension in not regression trendline', () => { - const trendlineData = getDefaultData(); - addTrendlineData(trendlineData, defaultLineProps); - expect(trendlineData[0].transform).toHaveLength(1); - }); - - test('should add datasource for trendline', () => { - const trendlineData = getDefaultData(); - expect(trendlineData).toHaveLength(2); - addTrendlineData(trendlineData, defaultLineProps); - expect(trendlineData).toHaveLength(3); - expect(trendlineData[2]).toStrictEqual({ - name: 'line0Trendline0_data', - source: FILTERED_TABLE, - transform: [ - { - type: 'collect', - sort: { - field: DEFAULT_TRANSFORMED_TIME_DIMENSION, - }, - }, - { - as: [TRENDLINE_VALUE], - fields: ['value'], - groupby: ['series'], - ops: ['mean'], - type: 'joinaggregate', - }, - ], - }); - }); - - test('should add data sources for hover interactiontions if ChartTooltip exists', () => { - const trendlineData = getDefaultData(); - addTrendlineData(trendlineData, { - ...defaultLineProps, - children: [createElement(Trendline, {}, createElement(ChartTooltip))], - }); - expect(trendlineData).toHaveLength(7); - expect(trendlineData[5]).toHaveProperty('name', 'line0_allTrendlineData'); - expect(trendlineData[6]).toHaveProperty('name', 'line0Trendline_highlightedData'); - }); - - test('should add _highResolutionData if doing a regression method', () => { - const trendlineData = getDefaultData(); - - addTrendlineData(trendlineData, { - ...defaultLineProps, - children: [createElement(Trendline, { method: 'linear' })], - }); - expect(trendlineData).toHaveLength(3); - expect(trendlineData[2]).toHaveProperty('name', 'line0Trendline0_highResolutionData'); - }); - - test('should add _params and _data if doing a regression method and there is a tooltip on the trendline', () => { - const trendlineData = getDefaultData(); - - addTrendlineData(trendlineData, { - ...defaultLineProps, - children: [createElement(Trendline, { method: 'linear' }, createElement(ChartTooltip))], - }); - expect(trendlineData).toHaveLength(7); - expect(trendlineData[3]).toHaveProperty('name', 'line0Trendline0_params'); - expect(trendlineData[4]).toHaveProperty('name', 'line0Trendline0_data'); - }); - - test('should add sort transform, then window trandform, and then dimension range filter transform for movingAverage', () => { - const trendlineData = getDefaultData(); - addTrendlineData(trendlineData, { - ...defaultLineProps, - children: [createElement(Trendline, { method: 'movingAverage-3', dimensionRange: [1, 2] })], - }); - expect(trendlineData).toHaveLength(3); - expect(trendlineData[2]).toHaveProperty('name', 'line0Trendline0_data'); - expect(trendlineData[2].transform).toHaveLength(3); - expect(trendlineData[2].transform?.[0]).toHaveProperty('type', 'collect'); - expect(trendlineData[2].transform?.[1]).toHaveProperty('type', 'window'); - expect(trendlineData[2].transform?.[2]).toHaveProperty('type', 'filter'); - }); -}); - -describe('getTrendlineDimensionRangeTransforms()', () => { - test('should add filters if the dimensionRange has non-null values', () => { - const transforms = getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [1, 2]); - expect(transforms).toHaveLength(1); - expect(transforms[0].expr.split(' && ')).toHaveLength(2); - expect(transforms).toStrictEqual([ - { - type: 'filter', - expr: `datum.${DEFAULT_TIME_DIMENSION} >= 1 && datum.${DEFAULT_TIME_DIMENSION} <= 2`, - }, - ]); - expect( - getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [null, 2])[0].expr.split(' && ') - ).toHaveLength(1); - expect( - getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [1, null])[0].expr.split(' && ') - ).toHaveLength(1); - expect(getTrendlineDimensionRangeTransforms(DEFAULT_TIME_DIMENSION, [null, null])).toHaveLength(0); - }); -}); - -describe('getTrendlineParamFormulaTransforms()', () => { - test('should return the correct formula for each polynomial method', () => { - expect(getTrendlineParamFormulaTransforms('x', 'linear', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'quadratic', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'polynomial-1', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'polynomial-2', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'polynomial-3', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2) + datum.coef[3] * pow(datum.x, 3)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'polynomial-8', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * pow(datum.x, 1) + datum.coef[2] * pow(datum.x, 2) + datum.coef[3] * pow(datum.x, 3) + datum.coef[4] * pow(datum.x, 4) + datum.coef[5] * pow(datum.x, 5) + datum.coef[6] * pow(datum.x, 6) + datum.coef[7] * pow(datum.x, 7) + datum.coef[8] * pow(datum.x, 8)' - ); - }); - test('should return the correct formula for other non-polynomial regression methods', () => { - expect(getTrendlineParamFormulaTransforms('x', 'exponential', 'linear')[0].expr).toEqual( - 'datum.coef[0] + exp(datum.coef[1] * datum.x)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'logarithmic', 'linear')[0].expr).toEqual( - 'datum.coef[0] + datum.coef[1] * log(datum.x)' - ); - expect(getTrendlineParamFormulaTransforms('x', 'power', 'linear')[0].expr).toEqual( - 'datum.coef[0] * pow(datum.x, datum.coef[1])' - ); - }); - test('should use normalized dimension for time scaleType', () => { - expect(getTrendlineParamFormulaTransforms('x', 'exponential', 'time')[0].expr).toEqual( - 'datum.coef[0] + exp(datum.coef[1] * datum.xNormalized)' - ); - }); -}); - -describe('getAggregateTransform()', () => { - test('should return the correct method', () => { - expect(getAggregateTransform(defaultLineProps, 'average')).toHaveProperty('ops', ['mean']); - expect(getAggregateTransform(defaultLineProps, 'median')).toHaveProperty('ops', ['median']); - }); -}); - -describe('getRegressionTransform()', () => { - test('should return the correct regression method', () => { - expect(getRegressionTransform(defaultLineProps, 'exponential', false)).toHaveProperty('method', 'exp'); - expect(getRegressionTransform(defaultLineProps, 'logarithmic', false)).toHaveProperty('method', 'log'); - expect(getRegressionTransform(defaultLineProps, 'power', false)).toHaveProperty('method', 'pow'); - expect(getRegressionTransform(defaultLineProps, 'linear', false)).toHaveProperty('method', 'poly'); - expect(getRegressionTransform(defaultLineProps, 'quadratic', false)).toHaveProperty('method', 'poly'); - expect(getRegressionTransform(defaultLineProps, 'polynomial-4', false)).toHaveProperty('method', 'poly'); - expect(getRegressionTransform(defaultLineProps, 'polynomial-25', false)).toHaveProperty('method', 'poly'); - }); - test('should return the correct order for polynomials', () => { - expect(getRegressionTransform(defaultLineProps, 'linear', false)).toHaveProperty('order', 1); - expect(getRegressionTransform(defaultLineProps, 'quadratic', false)).toHaveProperty('order', 2); - expect(getRegressionTransform(defaultLineProps, 'polynomial-4', false)).toHaveProperty('order', 4); - expect(getRegressionTransform(defaultLineProps, 'polynomial-25', false)).toHaveProperty('order', 25); - expect(getRegressionTransform(defaultLineProps, 'power', false).order).toBeUndefined(); - }); - test('should use ${dimension}Normalized as ouput dimension if scaleType is time', () => { - const transform = getRegressionTransform( - { ...defaultLineProps, dimension: 'x', scaleType: 'time' }, - 'linear', - false - ); - expect(transform.as).toHaveLength(2); - expect(transform.as).toEqual(['xNormalized', TRENDLINE_VALUE]); - }); - test('should have params on transform and no `as` property when params is true', () => { - const transform = getRegressionTransform(defaultLineProps, 'linear', true); - expect(transform.as).toBeUndefined(); - expect(transform).toHaveProperty('params', true); - }); -}); - describe('getPolynomialOrder()', () => { test('should trow error if the polynomial order is less than 1', () => { expect(() => getPolynomialOrder('polynomial-0')).toThrowError(); }); }); -describe('getMovingAverageTransform()', () => { - test('should return a window transform with the correct frame', () => { - const transform = getMovingAverageTransform(defaultLineProps, 'movingAverage-7'); - expect(transform).toHaveProperty('type', 'window'); - expect(transform).toHaveProperty('frame', [6, 0]); - }); - test('should throw error if the method is not of form "moveingAverage-${number}"', () => { - expect(() => getMovingAverageTransform(defaultLineProps, 'linear')).toThrowError(); - expect(() => getMovingAverageTransform(defaultLineProps, 'movingAverage-0')).toThrowError(); - }); -}); - -describe('getTrendlineSignals()', () => { - test('should return voronoi hover signal if ChartTooltip exists', () => { - const signals = getTrendlineSignals({ - ...defaultLineProps, - children: [createElement(Trendline, {}, createElement(ChartTooltip))], - }); - expect(signals).toHaveLength(2); - expect(signals[0]).toHaveProperty('name', 'line0Trendline_hoveredId'); - expect(signals[1]).toHaveProperty('name', 'line0Trendline_hoveredSeries'); - }); - - test('should not return any signals if there is not a ChartTooltip', () => { - const signals = getTrendlineSignals(defaultLineProps); - expect(signals).toHaveLength(0); - }); - - test('should return voronoi selected signal if ChartPopover exists', () => { - const signals = getTrendlineSignals({ - ...defaultLineProps, - children: [ - createElement(Trendline, {}, createElement(ChartTooltip)), - createElement(Trendline, {}, createElement(ChartPopover)), - ], - }); - expect(signals).toHaveLength(4); - expect(signals[2]).toHaveProperty('name', 'line0Trendline_selectedId'); - expect(signals[3]).toHaveProperty('name', 'line0Trendline_selectedSeries'); - }); - - test('should not return selected signal if there is not a ChartPopover', () => { - const signals = getTrendlineSignals({ - ...defaultLineProps, - children: [createElement(Trendline, {}, createElement(ChartTooltip))], - }); - expect(signals).toHaveLength(2); - expect(signals[0].name).not.toContain('selected'); +describe('getRegressionExtent()', () => { + test('should the correct extent based on extent value', () => { + const name = 'line0Trendline0'; + expect(getRegressionExtent([1, 2], name, false)).toHaveProperty('signal', '[1, 2]'); + expect(getRegressionExtent([1, 2], name, true)).toHaveProperty( + 'signal', + `[(1 - data('${FILTERED_TABLE}')[0].datetimeMin + ${MS_PER_DAY}) / ${MS_PER_DAY}, (2 - data('${FILTERED_TABLE}')[0].datetimeMin + ${MS_PER_DAY}) / ${MS_PER_DAY}]`, + ); + expect(getRegressionExtent([null, null], name, false)).toHaveProperty( + 'signal', + `[${name}_extent[0], ${name}_extent[1]]`, + ); + expect(getRegressionExtent(['domain', 'domain'], name, false)).toHaveProperty( + 'signal', + `[${name}_extent[0] - (${name}_extent[1] - ${name}_extent[0]) * 0.3, ${name}_extent[1] + (${name}_extent[1] - ${name}_extent[0]) * 0.3]`, + ); }); }); diff --git a/src/specBuilder/trendline/trendlineUtils.ts b/src/specBuilder/trendline/trendlineUtils.ts index dbdb0eb04..a0bc4fcef 100644 --- a/src/specBuilder/trendline/trendlineUtils.ts +++ b/src/specBuilder/trendline/trendlineUtils.ts @@ -10,27 +10,8 @@ * governing permissions and limitations under the License. */ import { Trendline } from '@components/Trendline'; -import { FILTERED_TABLE, LINEAR_PADDING, MARK_ID, MS_PER_DAY, TRENDLINE_VALUE } from '@constants'; -import { getSeriesIdTransform, getTableData } from '@specBuilder/data/dataUtils'; -import { getLineHoverMarks, getLineStrokeOpacity } from '@specBuilder/line/lineMarkUtils'; -import { LineMarkProps } from '@specBuilder/line/lineUtils'; -import { - getColorProductionRule, - getLineWidthProductionRule, - getStrokeDashProductionRule, - getXProductionRule, - hasInteractiveChildren, - hasPopover, - hasTooltip, -} from '@specBuilder/marks/markUtils'; -import { - getGenericSignal, - getSeriesHoveredSignal, - getUncontrolledHoverSignal, -} from '@specBuilder/signal/signalSpecBuilder'; -import { getDimensionField, getFacetsFromProps } from '@specBuilder/specUtils'; +import { FILTERED_TABLE, MS_PER_DAY, TRENDLINE_VALUE } from '@constants'; import { sanitizeTrendlineChildren } from '@utils'; -import { produce } from 'immer'; import { AggregateMethod, LineSpecProps, @@ -44,37 +25,33 @@ import { TrendlineSpecProps, WindowMethod, } from 'types'; -import { - AggregateOp, - CollectTransform, - Data, - FilterTransform, - FormulaTransform, - GroupMark, - JoinAggregateTransform, - LineMark, - LookupTransform, - RegressionMethod, - RegressionTransform, - Scale, - ScaleType, - Signal, - SourceData, - Transforms, - WindowTransform, -} from 'vega'; +import { ScaleType, SignalRef } from 'vega'; /** These are all the spec props that currently support trendlines */ -type TrendlineParentProps = LineSpecProps | ScatterSpecProps; +export type TrendlineParentProps = LineSpecProps | ScatterSpecProps; +/** + * gets all the trendlines from the children and applies all the default trendline props + * @param children + * @param markName + * @returns TrendlineSpecProps[] + */ export const getTrendlines = (children: MarkChildElement[], markName: string): TrendlineSpecProps[] => { const trendlineElements = children.filter((child) => child.type === Trendline) as TrendlineElement[]; return trendlineElements.map((trendline, index) => applyTrendlinePropDefaults(trendline.props, markName, index)); }; +/** + * applies all the default trendline props + * @param param0 + * @param markName + * @param index + * @returns TrendlineSpecProps + */ export const applyTrendlinePropDefaults = ( { children, + dimensionExtent, dimensionRange = [null, null], displayOnHover = false, highlightRawPoint = false, @@ -85,10 +62,11 @@ export const applyTrendlinePropDefaults = ( ...props }: TrendlineProps, markName: string, - index: number + index: number, ): TrendlineSpecProps => ({ children: sanitizeTrendlineChildren(children), displayOnHover, + dimensionExtent: dimensionExtent ?? dimensionRange, dimensionRange, highlightRawPoint, lineType, @@ -100,363 +78,13 @@ export const applyTrendlinePropDefaults = ( ...props, }); -export const getTrendlineMarks = (markProps: TrendlineParentProps): GroupMark[] => { - const { children, color, lineType, name } = markProps; - const { facets } = getFacetsFromProps({ color, lineType }); - - const marks: GroupMark[] = []; - const trendlines = getTrendlines(children, name); - for (const trendlineProps of trendlines) { - const dataSuffix = isRegressionMethod(trendlineProps.method) ? '_highResolutionData' : '_data'; - marks.push({ - name: `${trendlineProps.name}_group`, - type: 'group', - clip: true, - from: { - facet: { - name: `${trendlineProps.name}_facet`, - data: trendlineProps.name + dataSuffix, - groupby: facets, - }, - }, - marks: [getTrendlineLineMark(markProps, trendlineProps)], - }); - } - - if (trendlines.some((trendline) => hasTooltip(trendline.children))) { - marks.push( - getTrendlineHoverMarks( - markProps, - trendlines.some((trendlineProps) => trendlineProps.highlightRawPoint) - ) - ); - } - - return marks; -}; - -const getTrendlineLineMark = (markProps: TrendlineParentProps, trendlineProps: TrendlineSpecProps): LineMark => { - const { colorScheme, dimension } = markProps; - const scaleType = getScaleType(markProps); - const { lineType, lineWidth, metric, name } = trendlineProps; - - const x = trendlineUsesNormalizedDimension(trendlineProps.method, scaleType) - ? { scale: 'xTrendline', field: `${dimension}Normalized` } - : getXProductionRule(scaleType, dimension); - const color = trendlineProps.color ? { value: trendlineProps.color } : markProps.color; - - return { - name, - type: 'line', - from: { data: `${name}_facet` }, - interactive: false, - encode: { - enter: { - y: { scale: 'yLinear', field: metric }, - stroke: getColorProductionRule(color, colorScheme), - strokeDash: getStrokeDashProductionRule({ value: lineType }), - strokeWidth: getLineWidthProductionRule({ value: lineWidth }), - }, - update: { - x, - strokeOpacity: getLineStrokeOpacity(getLineMarkProps(markProps, trendlineProps)), - }, - }, - }; -}; - -const getLineMarkProps = ( - markProps: TrendlineParentProps, - { displayOnHover, lineWidth, metric, name, opacity }: TrendlineSpecProps, - override?: Partial -): LineMarkProps => { - const { children, color, colorScheme, dimension, interactiveMarkName, lineType } = markProps; - const popoverMarkName = 'popoverMarkName' in markProps ? markProps.popoverMarkName : undefined; - const scaleType = getScaleType(markProps); - const staticPoint = 'staticPoint' in markProps ? markProps.staticPoint : undefined; - return { - children, - color, - colorScheme, - dimension, - displayOnHover, - interactiveMarkName, - lineType, - lineWidth: { value: lineWidth }, - metric, - name, - opacity: { value: opacity }, - popoverMarkName, - scaleType, - staticPoint, - ...override, - }; -}; - -const getTrendlineHoverMarks = (lineProps: TrendlineParentProps, highlightRawPoint: boolean): GroupMark => { - const { children, metric, name } = lineProps; - const trendlines = getTrendlines(children, name); - const trendlineHoverProps: LineMarkProps = getLineMarkProps(lineProps, trendlines[0], { - name: `${name}Trendline`, - children: trendlines.map((trendline) => trendline.children).flat(), - metric: TRENDLINE_VALUE, - }); - - return { - name: `${name}Trendline_hoverGroup`, - type: 'group', - clip: true, - marks: getLineHoverMarks( - trendlineHoverProps, - `${name}_allTrendlineData`, - highlightRawPoint ? metric : undefined - ), - }; -}; - -/** - * Adds the necessary data sources and transforms for the trendlines - * NOTE: this function mutates the data array because it gets called from within a data produce function - * @param data - * @param markProps - */ -export const addTrendlineData = (data: Data[], markProps: TrendlineParentProps) => { - const { dimension } = markProps; - data.push(...getTrendlineData(markProps)); - - if (hasTrendlineWithNormailizedDimension(markProps)) { - const tableData = getTableData(data); - tableData.transform = addNormalizedDimensionTransform(tableData.transform ?? [], dimension); - } -}; - -const addNormalizedDimensionTransform = produce((transforms, dimension) => { - if (transforms.findIndex((transform) => 'as' in transform && transform.as === `${dimension}Normalized`) === -1) { - transforms.push({ - type: 'joinaggregate', - fields: [dimension], - as: [`${dimension}Min`], - ops: ['min'], - }); - transforms.push({ - type: 'formula', - expr: `(datum.${dimension} - datum.${dimension}Min + ${MS_PER_DAY}) / ${MS_PER_DAY}`, - as: `${dimension}Normalized`, - }); - } -}); - -/** - * adds the data transforms and data sources for the trendlines - * @param data - * @param markProps - */ -export const getTrendlineData = (markProps: TrendlineParentProps): SourceData[] => { - const data: SourceData[] = []; - const { children, color, dimension, lineType, name: markName } = markProps; - const scaleType = getScaleType(markProps); - const trendlines = getTrendlines(children, markName); - - const concatenatedTrendlineData: { name: string; source: string[] } = { - name: `${markName}_allTrendlineData`, - source: [], - }; - - for (const trendlineProps of trendlines) { - const { children: trendlineChildren, method, name, dimensionRange } = trendlineProps; - const dimensionRangeTransforms = getTrendlineDimensionRangeTransforms(dimension, dimensionRange); - const { facets } = getFacetsFromProps({ color, lineType }); - - if (isRegressionMethod(method)) { - data.push({ - name: `${name}_highResolutionData`, - source: FILTERED_TABLE, - transform: [ - ...dimensionRangeTransforms, - ...getTrendlineStatisticalTransforms(markProps, trendlineProps), - getSeriesIdTransform(facets), - ], - }); - if (hasInteractiveChildren(trendlineChildren)) { - data.push( - { - name: `${name}_params`, - source: FILTERED_TABLE, - transform: [ - ...dimensionRangeTransforms, - ...getTrendlineStatisticalTransforms(markProps, trendlineProps, true), - ], - }, - { - name: `${name}_data`, - source: FILTERED_TABLE, - transform: [ - ...dimensionRangeTransforms, - getTrendlineParamLookupTransform(markProps, trendlineProps), - ...getTrendlineParamFormulaTransforms(dimension, method, scaleType), - ], - } - ); - } - } else if (isAggregateMethod(method)) { - data.push({ - name: `${name}_data`, - source: FILTERED_TABLE, - transform: [ - ...dimensionRangeTransforms, - ...getTrendlineStatisticalTransforms(markProps, trendlineProps), - ], - }); - } else if (isWindowMethod(method)) { - // we want to filter down to the dimension range after calculating the moving average - data.push({ - name: `${name}_data`, - source: FILTERED_TABLE, - transform: [ - ...getTrendlineStatisticalTransforms(markProps, trendlineProps), - ...dimensionRangeTransforms, - ], - }); - } - if (hasInteractiveChildren(trendlineChildren)) { - concatenatedTrendlineData.source.push(`${name}_data`); - } - } - - if (trendlines.some((trendline) => hasInteractiveChildren(trendline.children))) { - data.push(concatenatedTrendlineData); - - const selectSignal = `${markName}Trendline_selectedId`; - const hoverSignal = `${markName}Trendline_hoveredId`; - const trendlineHasPopover = trendlines.some((trendline) => hasPopover(trendline.children)); - const expr = trendlineHasPopover - ? `${selectSignal} === datum.${MARK_ID} || !${selectSignal} && ${hoverSignal} === datum.${MARK_ID}` - : `${hoverSignal} === datum.${MARK_ID}`; - - data.push({ - name: `${markName}Trendline_highlightedData`, - source: `${markName}_allTrendlineData`, - transform: [ - { - type: 'filter', - expr, - }, - ], - }); - } - - return data; -}; - -/** - * gets the filter transforms that will restrict the data to the dimension range - * @param dimension - * @param dimensionRange - * @returns filterTansforms - */ -export const getTrendlineDimensionRangeTransforms = ( - dimension: string, - dimensionRange: [number | null, number | null] -): FilterTransform[] => { - const filterExpressions: string[] = []; - if (dimensionRange[0] !== null) { - filterExpressions.push(`datum.${dimension} >= ${dimensionRange[0]}`); - } - if (dimensionRange[1] !== null) { - filterExpressions.push(`datum.${dimension} <= ${dimensionRange[1]}`); - } - if (filterExpressions.length) { - return [ - { - type: 'filter', - expr: filterExpressions.join(' && '), - }, - ]; - } - return []; -}; - -const getTrendlineParamLookupTransform = ( - { color, lineType }: TrendlineParentProps, - { name }: TrendlineSpecProps -): LookupTransform => { - const { facets } = getFacetsFromProps({ color, lineType }); - return { - type: 'lookup', - from: `${name}_params`, - key: 'keys', - fields: facets, - values: ['coef'], - }; -}; - -/** - * This transform is used to calculate the value of the trendline using the coef and the dimension - * @param dimension mark dimension - * @param method trenline method - * @returns formula transorfm - */ -export const getTrendlineParamFormulaTransforms = ( - dimension: string, - method: TrendlineMethod, - scaleType: ScaleType | undefined -): FormulaTransform[] => { - let expr = ''; - const trendlineDimension = trendlineUsesNormalizedDimension(method, scaleType) - ? `${dimension}Normalized` - : dimension; - if (isPolynomialMethod(method)) { - const order = getPolynomialOrder(method); - expr = [ - 'datum.coef[0]', - ...Array(order) - .fill(0) - .map((_e, i) => `datum.coef[${i + 1}] * pow(datum.${trendlineDimension}, ${i + 1})`), - ].join(' + '); - } else if (method === 'exponential') { - expr = `datum.coef[0] + exp(datum.coef[1] * datum.${trendlineDimension})`; - } else if (method === 'logarithmic') { - expr = `datum.coef[0] + datum.coef[1] * log(datum.${trendlineDimension})`; - } else if (method === 'power') { - expr = `datum.coef[0] * pow(datum.${trendlineDimension}, datum.coef[1])`; - } - - if (!expr) return []; - return [ - { - type: 'formula', - expr, - as: TRENDLINE_VALUE, - }, - ]; -}; - -export const getTrendlineScales = (props: TrendlineParentProps): Scale[] => { - const { dimension } = props; - - if (hasTrendlineWithNormailizedDimension(props)) { - return [ - { - name: 'xTrendline', - type: 'linear', - range: 'width', - domain: { data: FILTERED_TABLE, fields: [`${dimension}Normalized`] }, - padding: LINEAR_PADDING, - zero: false, - nice: false, - }, - ]; - } - return []; -}; - /** - * determines if the supplied method is a polynomial method - * @see https://vega.github.io/vega/docs/transforms/regression/ + * determines if the supplied method is an aggregate method (average, median) + * @see https://vega.github.io/vega/docs/transforms/aggregate/ * @param method * @returns boolean */ -const isAggregateMethod = (method: TrendlineMethod): method is AggregateMethod => +export const isAggregateMethod = (method: TrendlineMethod): method is AggregateMethod => ['average', 'median'].includes(method); /** @@ -465,25 +93,25 @@ const isAggregateMethod = (method: TrendlineMethod): method is AggregateMethod = * @param method * @returns boolean */ -const isRegressionMethod = (method: TrendlineMethod): method is RscRegressionMethod => +export const isRegressionMethod = (method: TrendlineMethod): method is RscRegressionMethod => isPolynomialMethod(method) || ['exponential', 'logarithmic', 'power'].includes(method); /** - * determines if the supplied method is a polynomial method - * @see https://vega.github.io/vega/docs/transforms/regression/ + * determines if the supplied method is a windowing method + * @see https://vega.github.io/vega/docs/transforms/window/ * @param method * @returns boolean */ -const isPolynomialMethod = (method: TrendlineMethod): boolean => - method.startsWith('polynomial-') || ['linear', 'quadratic'].includes(method); +export const isWindowMethod = (method: TrendlineMethod): method is WindowMethod => method.startsWith('movingAverage-'); /** - * determines if the supplied method is a windowing method - * @see https://vega.github.io/vega/docs/transforms/window/ + * determines if the supplied method is a polynomial method + * @see https://vega.github.io/vega/docs/transforms/regression/ * @param method * @returns boolean */ -const isWindowMethod = (method: TrendlineMethod): method is WindowMethod => method.startsWith('movingAverage-'); +export const isPolynomialMethod = (method: TrendlineMethod): boolean => + method.startsWith('polynomial-') || ['linear', 'quadratic'].includes(method); /** * determines if the supplied method is a regression method that uses the normalized dimension @@ -491,7 +119,7 @@ const isWindowMethod = (method: TrendlineMethod): method is WindowMethod => meth * @param method * @returns boolean */ -const trendlineUsesNormalizedDimension = (method: TrendlineMethod, scaleType: ScaleType | undefined): boolean => +export const trendlineUsesNormalizedDimension = (method: TrendlineMethod, scaleType: ScaleType | undefined): boolean => scaleType === 'time' && isRegressionMethod(method); /** @@ -499,130 +127,15 @@ const trendlineUsesNormalizedDimension = (method: TrendlineMethod, scaleType: Sc * @param markProps * @returns boolean */ -const hasTrendlineWithNormailizedDimension = (markProps: TrendlineParentProps): boolean => { +export const hasTrendlineWithNormailizedDimension = (markProps: TrendlineParentProps): boolean => { const trendlines = getTrendlines(markProps.children, markProps.name); // only need to add the normalized dimension transform if there is a regression trendline and the dimension is time const hasRegressionTrendline = trendlines.some((trendline) => isRegressionMethod(trendline.method)); - const hasTimeScale = getScaleType(markProps) === 'time'; + const hasTimeScale = getTrendlineScaleType(markProps) === 'time'; return hasRegressionTrendline && hasTimeScale; }; -/** - * gets the statistical transforms that will calculate the trendline values - * @param markProps - * @param trendlineProps - * @returns dataTransforms - */ -const getTrendlineStatisticalTransforms = ( - markProps: TrendlineParentProps, - { method }: TrendlineSpecProps, - params: boolean = false -): Transforms[] => { - const scaleType = getScaleType(markProps); - const dimension = getDimensionField(markProps.dimension, scaleType); - if (isAggregateMethod(method)) { - return [getSortTransform(dimension), getAggregateTransform(markProps, method)]; - } - if (isRegressionMethod(method)) { - return [getRegressionTransform(markProps, method, params)]; - } - if (isWindowMethod(method)) { - return [getSortTransform(dimension), getMovingAverageTransform(markProps, method)]; - } - - return []; -}; - -/** - * gets the sort transform for the provided dimension - * this is used to sort aggregate and window methods - * @param dimension - * @returns CollectTransform - */ -const getSortTransform = (dimension: string): CollectTransform => ({ - type: 'collect', - sort: { - field: dimension, - }, -}); - -/** - * gets the join aggreagate transform used for calculating the average trendline - * @param facets data facets - * @param metric data y key - * @returns JoinAggregateTransform - */ -export const getAggregateTransform = ( - { color, lineType, metric }: TrendlineParentProps, - method: AggregateMethod -): JoinAggregateTransform => { - const { facets } = getFacetsFromProps({ color, lineType }); - const operations: Record = { - average: 'mean', - median: 'median', - }; - return { - type: 'joinaggregate', - groupby: facets, - ops: [operations[method]], - fields: [metric], - as: [TRENDLINE_VALUE], - }; -}; - -/** - * gets the regression transform used for calculating the regression trendline - * regression trendlines are ones that use the x value as a parameter - * @see https://vega.github.io/vega/docs/transforms/regression/ - * @param markProps - * @param method - * @param params - * @returns - */ -export const getRegressionTransform = ( - markProps: TrendlineParentProps, - method: TrendlineMethod, - params: boolean -): RegressionTransform => { - const { color, dimension, lineType, metric } = markProps; - const { facets } = getFacetsFromProps({ color, lineType }); - - let regressionMethod: RegressionMethod | undefined; - let order: number | undefined; - - switch (method) { - case 'exponential': - regressionMethod = 'exp'; - break; - case 'logarithmic': - regressionMethod = 'log'; - break; - case 'power': - regressionMethod = 'pow'; - break; - default: - order = getPolynomialOrder(method); - regressionMethod = 'poly'; - break; - } - - let trendlineDimension = dimension; - if (getScaleType(markProps) === 'time') { - trendlineDimension += 'Normalized'; - } - - return { - type: 'regression', - method: regressionMethod, - order, - groupby: facets, - x: trendlineDimension, - y: metric, - as: params ? undefined : [trendlineDimension, TRENDLINE_VALUE], - params, - }; -}; /** * gets the order of the polynomial * y = a + b * x + … + k * pow(x, order) @@ -647,52 +160,39 @@ export const getPolynomialOrder = (method: TrendlineMethod): number => { return order; }; -export const getMovingAverageTransform = ( - markProps: TrendlineParentProps, - method: TrendlineMethod -): WindowTransform => { - const frameWidth = parseInt(method.split('-')[1]); - - const { color, lineType, metric } = markProps; - const { facets } = getFacetsFromProps({ color, lineType }); - - if (isNaN(frameWidth) || frameWidth < 1) { - throw new Error( - `Invalid moving average frame width: ${frameWidth}, frame width must be an integer greater than 0` - ); - } - return { - type: 'window', - ops: ['mean'], - groupby: facets, - fields: [metric], - as: [TRENDLINE_VALUE], - frame: [frameWidth - 1, 0], - }; -}; - -export const getTrendlineSignals = (markProps: TrendlineParentProps): Signal[] => { - const signals: Signal[] = []; - const { children, name: markName } = markProps; - const trendlines = getTrendlines(children, markName); - - if (trendlines.some((trendline) => hasTooltip(trendline.children))) { - signals.push(getUncontrolledHoverSignal(`${markName}Trendline`, true, `${markName}Trendline_voronoi`)); - signals.push(getSeriesHoveredSignal(`${markName}Trendline`, true, `${markName}Trendline_voronoi`)); - } - - if (trendlines.some((trendline) => trendline.displayOnHover)) { - signals.push(getSeriesHoveredSignal(markName, true, `${markName}_voronoi`)); - } - - if (trendlines.some((trendline) => hasPopover(trendline.children))) { - signals.push(getGenericSignal(`${markName}Trendline_selectedId`)); - signals.push(getGenericSignal(`${markName}Trendline_selectedSeries`)); - } +/** + * gets the extent used in the regression transform + * @param dimensionExtent + * @param name + * @param isNormalized + * @returns + */ +export const getRegressionExtent = ( + dimensionExtent: TrendlineSpecProps['dimensionExtent'], + name: string, + isNormalized: boolean, +): SignalRef => { + const extentName = `${name}_extent`; + const extentSignal = dimensionExtent + .map((value, i) => { + switch (value) { + case null: + return `${extentName}[${i}]`; + case 'domain': + return `${extentName}[${i}] ${i === 0 ? '-' : '+'} (${extentName}[1] - ${extentName}[0]) * 0.3`; + default: + // if this is a normalized date, we need to normalize the value + if (isNormalized) { + return `(${value} - data('${FILTERED_TABLE}')[0].datetimeMin + ${MS_PER_DAY}) / ${MS_PER_DAY}`; + } + return value; + } + }) + .join(', '); - return signals; + return { signal: `[${extentSignal}]` }; }; -const getScaleType = (markProps: TrendlineParentProps): RscScaleType => { +export const getTrendlineScaleType = (markProps: TrendlineParentProps): RscScaleType => { return 'scaleType' in markProps ? markProps.scaleType : markProps.dimensionScaleType; }; diff --git a/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.story.tsx b/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.story.tsx index 1e6600bf2..e858f04ec 100644 --- a/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.story.tsx +++ b/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.story.tsx @@ -1,3 +1,15 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + import { ReactElement } from 'react'; import useChartProps from '@hooks/useChartProps'; @@ -5,7 +17,7 @@ import { Axis, Chart, Legend, Scatter, Trendline } from '@rsc'; import { StoryFn } from '@storybook/react'; import { bindWithProps } from '@test-utils'; -import { basicFeatureMatrixData } from './data'; +import { basicFeatureMatrixData, multipleSegmentFeatureMatrixData } from './data'; export default { title: 'RSC/Chart/Examples', @@ -20,7 +32,28 @@ const FeatureMatrixStory: StoryFn = (args): ReactElement => { - + + + + + ); +}; + +const MultipleSegmentFeatureMatrixStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps(args); + + return ( + + + + + @@ -35,4 +68,12 @@ FeatureMatrix.args = { data: basicFeatureMatrixData, }; -export { FeatureMatrix }; +const MultipleSegmentFeatureMatrix = bindWithProps(MultipleSegmentFeatureMatrixStory); +MultipleSegmentFeatureMatrix.args = { + width: 'auto', + maxWidth: 850, + height: 500, + data: multipleSegmentFeatureMatrixData, +}; + +export { FeatureMatrix, MultipleSegmentFeatureMatrix }; diff --git a/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.test.tsx b/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.test.tsx index 50fcfa318..b0c8578cc 100644 --- a/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.test.tsx +++ b/src/stories/ChartExamples/FeatureMatrix/FeatureMatrix.test.tsx @@ -1,7 +1,19 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + import { findAllMarksByGroupName, findChart, findMarksByGroupName, render } from '@test-utils'; import { spectrumColors } from '@themes'; -import { FeatureMatrix } from './FeatureMatrix.story'; +import { FeatureMatrix, MultipleSegmentFeatureMatrix } from './FeatureMatrix.story'; const colors = spectrumColors.light; @@ -22,10 +34,43 @@ describe('FeatureMatrix', () => { expect(point).toHaveAttribute('stroke-width', '0'); // trendline styling - const trendline = await findMarksByGroupName(chart, 'scatter0Trendline0'); + const trendline = await findMarksByGroupName(chart, 'scatter0Trendline0', 'line'); expect(trendline).toBeInTheDocument(); + expect(trendline).toHaveAttribute('stroke', colors['gray-900']); + expect(trendline).toHaveAttribute('stroke-width', '1'); + expect(trendline).toHaveAttribute('stroke-dasharray', ''); + }); +}); + +describe('MultipleSegmentFeatureMatrix', () => { + test('Single series should render correctly', async () => { + render(); + + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + + const points = await findAllMarksByGroupName(chart, 'scatter0'); + expect(points).toHaveLength(18); + + // point styling + const point = points[0]; + expect(point).toHaveAttribute('fill', colors['categorical-100']); + expect(point).toHaveAttribute('fill-opacity', '1'); + expect(point).toHaveAttribute('stroke-width', '0'); + + expect(points[6]).toHaveAttribute('fill', colors['categorical-200']); + expect(points[12]).toHaveAttribute('fill', colors['categorical-300']); + + // trendline styling + const trendlines = await findAllMarksByGroupName(chart, 'scatter0Trendline0', 'line'); + expect(trendlines).toHaveLength(3); + + const trendline = trendlines[0]; expect(trendline).toHaveAttribute('stroke', colors['categorical-100']); expect(trendline).toHaveAttribute('stroke-width', '1'); expect(trendline).toHaveAttribute('stroke-dasharray', ''); + + expect(trendlines[1]).toHaveAttribute('stroke', colors['categorical-200']); + expect(trendlines[2]).toHaveAttribute('stroke', colors['categorical-300']); }); }); diff --git a/src/stories/ChartExamples/FeatureMatrix/data.ts b/src/stories/ChartExamples/FeatureMatrix/data.ts index db8f236df..08cf25e44 100644 --- a/src/stories/ChartExamples/FeatureMatrix/data.ts +++ b/src/stories/ChartExamples/FeatureMatrix/data.ts @@ -1,3 +1,15 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + export const basicFeatureMatrixData = [ { event: 'Open-editor', @@ -36,3 +48,79 @@ export const basicFeatureMatrixData = [ countAvg: 6.28, }, ]; + +export const multipleSegmentFeatureMatrixData = [ + ...basicFeatureMatrixData, + { + event: 'Open-editor', + segment: 'Non Day 1 Exporter', + dauPercent: 0.1344, + countAvg: 3.75, + }, + { + event: 'View-express-home', + segment: 'Non Day 1 Exporter', + dauPercent: 0.1336, + countAvg: 0.84, + }, + { + event: 'View-quickaction-editor', + segment: 'Non Day 1 Exporter', + dauPercent: 0.0967, + countAvg: 1.27, + }, + { + event: 'Generate-image', + segment: 'Non Day 1 Exporter', + dauPercent: 0.0658, + countAvg: 2.36, + }, + { + event: 'Generate-text-effects', + segment: 'Non Day 1 Exporter', + dauPercent: 0.0272, + countAvg: 2.71, + }, + { + event: 'Search-inspire', + segment: 'Non Day 1 Exporter', + dauPercent: 0.1149, + countAvg: 5.8, + }, + { + event: 'Open-editor', + segment: 'Day 7 Exporter', + dauPercent: 0.1365, + countAvg: 4.68, + }, + { + event: 'View-express-home', + segment: 'Day 7 Exporter', + dauPercent: 0.1064, + countAvg: 5.22, + }, + { + event: 'View-quickaction-editor', + segment: 'Day 7 Exporter', + dauPercent: 0.0795, + countAvg: 1.51, + }, + { + event: 'Generate-image', + segment: 'Day 7 Exporter', + dauPercent: 0.0578, + countAvg: 2.2, + }, + { + event: 'Generate-text-effects', + segment: 'Day 7 Exporter', + dauPercent: 0.0183, + countAvg: 1.75, + }, + { + event: 'Search-inspire', + segment: 'Day 7 Exporter', + dauPercent: 0.0998, + countAvg: 2.98, + }, +]; diff --git a/src/stories/components/Trendline/Trendline.story.tsx b/src/stories/components/Trendline/Trendline.story.tsx index b95a12825..19ef0cab3 100644 --- a/src/stories/components/Trendline/Trendline.story.tsx +++ b/src/stories/components/Trendline/Trendline.story.tsx @@ -22,6 +22,14 @@ export default { title: 'RSC/Trendline', component: Trendline, argTypes: { + lineType: { + control: 'select', + options: ['solid', 'dashed', 'dotted', 'dotDash', 'shortDash', 'longDash', 'twoDash'], + }, + lineWidth: { + control: 'inline-radio', + options: ['XS', 'S', 'M', 'L', 'XL'], + }, method: { control: 'select', options: [ @@ -46,14 +54,6 @@ export default { 'quadratic', ], }, - lineType: { - control: 'select', - options: ['solid', 'dashed', 'dotted', 'dotDash', 'shortDash', 'longDash', 'twoDash'], - }, - lineWidth: { - control: 'inline-radio', - options: ['S', 'M', 'L'], - }, }, }; @@ -141,6 +141,15 @@ Basic.args = { lineWidth: 'S', }; +const DimensionExtent = bindWithProps(TrendlineWithDialogsStory); +DimensionExtent.args = { + method: 'linear', + lineType: 'dashed', + lineWidth: 'S', + highlightRawPoint: true, + dimensionExtent: ['domain', 'domain'], +}; + const DimensionRange = bindWithProps(TrendlineWithDialogsStory); DimensionRange.args = { method: 'linear', @@ -171,4 +180,4 @@ TooltipAndPopoverOnParentLine.args = { lineWidth: 'S', }; -export { Basic, DimensionRange, DisplayOnHover, TooltipAndPopover, TooltipAndPopoverOnParentLine }; +export { Basic, DimensionExtent, DimensionRange, DisplayOnHover, TooltipAndPopover, TooltipAndPopoverOnParentLine }; diff --git a/src/types/Chart.ts b/src/types/Chart.ts index b2bbc3e33..11a1c7b7e 100644 --- a/src/types/Chart.ts +++ b/src/types/Chart.ts @@ -460,7 +460,19 @@ export interface TrendlineProps { children?: Children; /** The line color of the trendline. If undefined, will default to the color of the series that it represents. */ color?: SpectrumColor | string; - /** The dimension range that the statistical transform should be calculated and drawn for. If the start or end values are null, then the dimension range will not be bounded. */ + /** + * The dimenstion range to draw the trendline for. If undefined, the value will default to the value of dimensionRange. + * + * If 'domain' is used as a start or end value, this will extrapolate the trendline out to the beginning and end of the chart domain respectively. + * + * If null is used as a start or end value, the trendline will be be drawn from the first data point to the last data point respectively. + */ + dimensionExtent?: [number | 'domain' | null, number | 'domain' | null]; + /** + * The dimension range that the statistical transform should be calculated for. If undefined, the value will default to [null, null] + * + * If the start or end values are null, then the dimension range will not be bounded for the start or end respectively. + */ dimensionRange?: [number | null, number | null]; /** Whether the trendline should only be visible when hovering over the parent line */ displayOnHover?: boolean; diff --git a/src/types/specBuilderTypes.ts b/src/types/specBuilderTypes.ts index 731f266f2..f5b665af4 100644 --- a/src/types/specBuilderTypes.ts +++ b/src/types/specBuilderTypes.ts @@ -159,6 +159,7 @@ export interface MetricRangeSpecProps extends PartiallyRequired {} type TrendlinePropsWithDefaults = + | 'dimensionExtent' | 'dimensionRange' | 'displayOnHover' | 'highlightRawPoint' diff --git a/yarn.lock b/yarn.lock index b47dd8f1e..2760a3488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5113,7 +5113,7 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@trivago/prettier-plugin-sort-imports@^4.0.0": +"@trivago/prettier-plugin-sort-imports@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz#725f411646b3942193a37041c84e0b2116339789" integrity sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==