From 2d0009ca7879170afd0bc2d5438b6a590e30addc Mon Sep 17 00:00:00 2001 From: Marshall Peterson Date: Thu, 14 Dec 2023 17:13:08 -0700 Subject: [PATCH] normalize trendline x data --- src/specBuilder/line/lineSpecBuilder.ts | 40 ++--- .../trendline/trendlineUtils.test.ts | 15 +- src/specBuilder/trendline/trendlineUtils.ts | 141 +++++++++++++++--- 3 files changed, 150 insertions(+), 46 deletions(-) diff --git a/src/specBuilder/line/lineSpecBuilder.ts b/src/specBuilder/line/lineSpecBuilder.ts index 21d7a8935..c73b8dbe3 100644 --- a/src/specBuilder/line/lineSpecBuilder.ts +++ b/src/specBuilder/line/lineSpecBuilder.ts @@ -17,7 +17,12 @@ import { getMetricRanges, } from '@specBuilder/metricRange/metricRangeUtils'; import { getFacetsFromProps } from '@specBuilder/specUtils'; -import { getTrendlineData, getTrendlineMarks, getTrendlineSignals } from '@specBuilder/trendline/trendlineUtils'; +import { + addTrendlineData, + getTrendlineMarks, + getTrendlineScales, + getTrendlineSignals, +} from '@specBuilder/trendline/trendlineUtils'; import { sanitizeMarkChildren, toCamelCase } from '@utils'; import { produce } from 'immer'; import { ColorScheme, LineProps, LineSpecProps, MarkChildElement } from 'types'; @@ -91,7 +96,7 @@ export const addData = produce((data, props) => { data.push(getLineHighlightedData(name, FILTERED_TABLE, hasPopover(children))); } if (staticPoint) data.push(getLineStaticPointData(name, staticPoint, FILTERED_TABLE)); - data.push(...getTrendlineData(props)); + addTrendlineData(data, props); }); export const addSignals = produce((signals, props) => { @@ -114,21 +119,22 @@ export const addSignals = produce((signals, props) => } }); -export const setScales = produce( - (scales, { metric, dimension, color, lineType, opacity, padding, scaleType, children, name }) => { - // add dimension scale - addContinuousDimensionScale(scales, { scaleType, dimension, padding }); - // add color to the color domain - addFieldToFacetScaleDomain(scales, 'color', color); - // add lineType to the lineType domain - addFieldToFacetScaleDomain(scales, 'lineType', lineType); - // add opacity to the opacity domain - addFieldToFacetScaleDomain(scales, 'opacity', opacity); - // find the linear scale and add our fields to it - addMetricScale(scales, getMetricKeys(metric, children, name)); - return scales; - } -); +export const setScales = produce((scales, props) => { + const { metric, dimension, color, lineType, opacity, padding, scaleType, children, name } = props; + // add dimension scale + addContinuousDimensionScale(scales, { scaleType, dimension, padding }); + // add color to the color domain + addFieldToFacetScaleDomain(scales, 'color', color); + // add lineType to the lineType domain + addFieldToFacetScaleDomain(scales, 'lineType', lineType); + // add opacity to the opacity domain + addFieldToFacetScaleDomain(scales, 'opacity', opacity); + // find the linear scale and add our fields to it + addMetricScale(scales, getMetricKeys(metric, children, name)); + // add trendline scales + scales.push(...getTrendlineScales(props)); + return scales; +}); // The order that marks are added is important since it determines the draw order. export const addLineMarks = produce((marks, props) => { diff --git a/src/specBuilder/trendline/trendlineUtils.test.ts b/src/specBuilder/trendline/trendlineUtils.test.ts index 708ca522b..0b72ebdbd 100644 --- a/src/specBuilder/trendline/trendlineUtils.test.ts +++ b/src/specBuilder/trendline/trendlineUtils.test.ts @@ -23,15 +23,16 @@ import { FILTERED_TABLE, TRENDLINE_VALUE, } from '@constants'; +import { baseData } from '@specBuilder/specUtils'; import { LineSpecProps } from 'types'; import { Facet, From } from 'vega'; import { + addTrendlineData, applyTrendlinePropDefaults, getMovingAverageTransform, getPolynomialOrder, getRegressionTransform, - getTrendlineData, getTrendlineDimensionRangeTransforms, getTrendlineMarks, getTrendlineParamFormulaTransforms, @@ -146,9 +147,9 @@ describe('getTrendlineMarks()', () => { }); }); -describe('getTrendlineData()', () => { +describe('addTrendlineData()', () => { test('should return data source for trendline', () => { - const trendlineData = getTrendlineData(defaultLineProps); + const trendlineData = addTrendlineData(baseData, defaultLineProps); expect(trendlineData).toStrictEqual([ { name: 'line0Trendline0_data', @@ -167,7 +168,7 @@ describe('getTrendlineData()', () => { }); test('should add data sources for hover interactiontions if ChartTooltip exists', () => { - const trendlineData = getTrendlineData({ + const trendlineData = addTrendlineData(baseData, { ...defaultLineProps, children: [createElement(Trendline, {}, createElement(ChartTooltip))], }); @@ -177,7 +178,7 @@ describe('getTrendlineData()', () => { }); test('should add _highResolutionData if doing a regression method', () => { - const trendlineData = getTrendlineData({ + const trendlineData = addTrendlineData(baseData, { ...defaultLineProps, children: [createElement(Trendline, { method: 'linear' })], }); @@ -186,7 +187,7 @@ describe('getTrendlineData()', () => { }); test('should add _params and _data if doing a regression method and there is a tooltip on the trendline', () => { - const trendlineData = getTrendlineData({ + const trendlineData = addTrendlineData(baseData, { ...defaultLineProps, children: [createElement(Trendline, { method: 'linear' }, createElement(ChartTooltip))], }); @@ -196,7 +197,7 @@ describe('getTrendlineData()', () => { }); test('should add window trandform and then dimension range filter transform for movingAverage', () => { - const trendlineData = getTrendlineData({ + const trendlineData = addTrendlineData(baseData, { ...defaultLineProps, children: [createElement(Trendline, { method: 'movingAverage-3', dimensionRange: [1, 2] })], }); diff --git a/src/specBuilder/trendline/trendlineUtils.ts b/src/specBuilder/trendline/trendlineUtils.ts index e6b45fece..c836df072 100644 --- a/src/specBuilder/trendline/trendlineUtils.ts +++ b/src/specBuilder/trendline/trendlineUtils.ts @@ -10,11 +10,18 @@ * governing permissions and limitations under the License. */ import { Trendline } from '@components/Trendline'; -import { FILTERED_TABLE, MARK_ID, TRENDLINE_VALUE } from '@constants'; -import { getSeriesIdTransform } from '@specBuilder/data/dataUtils'; -import { getLineHoverMarks, getLineMark } from '@specBuilder/line/lineMarkUtils'; -import { LineMarkProps } from '@specBuilder/line/lineUtils'; -import { hasInteractiveChildren, hasPopover, hasTooltip } from '@specBuilder/marks/markUtils'; +import { FILTERED_TABLE, LINEAR_PADDING, MARK_ID, TRENDLINE_VALUE } from '@constants'; +import { getSeriesIdTransform, getTableData } from '@specBuilder/data/dataUtils'; +import { getLineHoverMarks, getLineStrokeOpacity } from '@specBuilder/line/lineMarkUtils'; +import { LineMarkProps, getXProductionRule } from '@specBuilder/line/lineUtils'; +import { + getColorProductionRule, + getLineWidthProductionRule, + getStrokeDashProductionRule, + hasInteractiveChildren, + hasPopover, + hasTooltip, +} from '@specBuilder/marks/markUtils'; import { getGenericSignal, getSeriesHoveredSignal, @@ -22,6 +29,7 @@ import { } from '@specBuilder/signal/signalSpecBuilder'; import { getFacetsFromProps } from '@specBuilder/specUtils'; import { sanitizeTrendlineChildren } from '@utils'; +import { produce } from 'immer'; import { BarSpecProps, LineSpecProps, @@ -32,6 +40,7 @@ import { TrendlineSpecProps, } from 'types'; import { + Data, FilterTransform, FormulaTransform, GroupMark, @@ -40,6 +49,7 @@ import { LookupTransform, RegressionMethod, RegressionTransform, + Scale, Signal, SourceData, Transforms, @@ -115,18 +125,36 @@ export const getTrendlineMarks = (markProps: LineSpecProps): GroupMark[] => { }; const getTrendlineLineMark = (markProps: LineSpecProps, trendlineProps: TrendlineSpecProps): LineMark => { - const mergedTrendlineProps: LineMarkProps = { - ...markProps, - name: trendlineProps.name, - children: trendlineProps.children, - color: trendlineProps.color ? { value: trendlineProps.color } : markProps.color, - metric: trendlineProps.metric, - lineType: { value: trendlineProps.lineType }, - lineWidth: { value: trendlineProps.lineWidth }, - opacity: { value: trendlineProps.opacity }, - displayOnHover: trendlineProps.displayOnHover, + const { colorScheme, dimension, scaleType } = markProps; + const { displayOnHover, lineType, lineWidth, metric, name, opacity } = trendlineProps; + const x = + scaleType === 'time' + ? { 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({ + ...markProps, + displayOnHover, + opacity: { value: opacity }, + }), + }, + }, }; - return getLineMark(mergedTrendlineProps, `${trendlineProps.name}_facet`); }; const getTrendlineHoverMarks = (lineProps: LineSpecProps, highlightRawPoint: boolean): GroupMark => { @@ -150,10 +178,60 @@ const getTrendlineHoverMarks = (lineProps: LineSpecProps, highlightRawPoint: boo }; }; +export const addTrendlineData = (data: Data[], markProps: BarSpecProps | LineSpecProps) => { + const { dimension } = markProps; + data.push(...getTrendlineData(markProps)); + + if ('scaleType' in markProps && markProps.scaleType === 'time') { + // const tableDataIndex = data.findIndex((d) => d.name === FILTERED_TABLE); + const tableData = getTableData(data); + tableData.transform = addNormalizedDimensionTransform(tableData.transform ?? [], dimension); + // if (!data[tableDataIndex].transform) { + // data[tableDataIndex].transform = []; + // } + // // make sure the normalized dimension hasn't been added yet + // if ( + // (data[tableDataIndex].transform as Transforms[]).findIndex( + // (transform) => 'as' in transform && transform.as === `${dimension}Normalized` + // ) === -1 + // ) { + // const minimumDimension: JoinAggregateTransform = { + // type: 'joinaggregate', + // fields: [dimension], + // as: [`${dimension}Min`], + // ops: ['min'], + // }; + // // normalizes the time data to number of days since the minimum date + 1 day + // const normalizedDimension: FormulaTransform = { + // type: 'formula', + // expr: `(datum.${dimension} - datum.${dimension}Min + 86400000) / 86400000`, + // as: `${dimension}Normalized`, + // }; + // (data[tableDataIndex].transform as Transforms[]).push(minimumDimension, normalizedDimension); + // } + } +}; + +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 + 86400000) / 86400000`, + as: `${dimension}Normalized`, + }); + } +}); + /** - * gets the data source for the trendline + * adds the data transforms and data sources for the trendlines + * @param data * @param markProps - * @param trendlineProps */ export const getTrendlineData = (markProps: BarSpecProps | LineSpecProps): SourceData[] => { const data: SourceData[] = []; @@ -327,6 +405,25 @@ export const getTrendlineParamFormulaTransforms = (dimension: string, method: Tr ]; }; +export const getTrendlineScales = (props: LineSpecProps | BarSpecProps): Scale[] => { + const { children, dimension, name } = props; + const trendlines = getTrendlines(children, name); + if (trendlines.length && 'scaleType' in props && props.scaleType === 'time') { + 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/ @@ -434,9 +531,9 @@ export const getRegressionTransform = ( break; } - let asDimension = dimension; + let trendlineDimension = dimension; if ('scaleType' in markProps && markProps.scaleType === 'time') { - asDimension = 'datetime0'; + trendlineDimension += 'Normalized'; } return { @@ -444,9 +541,9 @@ export const getRegressionTransform = ( method: regressionMethod, order, groupby: facets, - x: dimension, + x: trendlineDimension, y: metric, - as: params ? undefined : [asDimension, TRENDLINE_VALUE], + as: params ? undefined : [trendlineDimension, TRENDLINE_VALUE], params, }; };