diff --git a/src/compositemark/boxplot.ts b/src/compositemark/boxplot.ts index c281c9890e..a58508a535 100644 --- a/src/compositemark/boxplot.ts +++ b/src/compositemark/boxplot.ts @@ -104,6 +104,8 @@ export function normalizeBoxPlot( const boxPlotType = getBoxPlotType(extent); const { + bins, + timeUnits, transform, continuousAxisChannelDef, continuousAxis, @@ -221,117 +223,109 @@ export function normalizeBoxPlot( }) ]; - // ## Filtered Layers + if (boxPlotType === 'min-max') { + return { + ...outerSpec, + transform: (outerSpec.transform ?? []).concat(transform), + layer: boxLayers + }; + } - let filteredLayersMixins: NormalizedUnitSpec | NormalizedLayerSpec; + // Tukey Box Plot - if (boxPlotType !== 'min-max') { - const lowerBoxExpr = `datum["lower_box_${continuousAxisChannelDef.field}"]`; - const upperBoxExpr = `datum["upper_box_${continuousAxisChannelDef.field}"]`; - const iqrExpr = `(${upperBoxExpr} - ${lowerBoxExpr})`; - const lowerWhiskerExpr = `${lowerBoxExpr} - ${extent} * ${iqrExpr}`; - const upperWhiskerExpr = `${upperBoxExpr} + ${extent} * ${iqrExpr}`; - const fieldExpr = `datum["${continuousAxisChannelDef.field}"]`; + const lowerBoxExpr = `datum["lower_box_${continuousAxisChannelDef.field}"]`; + const upperBoxExpr = `datum["upper_box_${continuousAxisChannelDef.field}"]`; + const iqrExpr = `(${upperBoxExpr} - ${lowerBoxExpr})`; + const lowerWhiskerExpr = `${lowerBoxExpr} - ${extent} * ${iqrExpr}`; + const upperWhiskerExpr = `${upperBoxExpr} + ${extent} * ${iqrExpr}`; + const fieldExpr = `datum["${continuousAxisChannelDef.field}"]`; - const joinaggregateTransform: JoinAggregateTransform = { - joinaggregate: boxParamsQuartiles(continuousAxisChannelDef.field), - groupby - }; + const joinaggregateTransform: JoinAggregateTransform = { + joinaggregate: boxParamsQuartiles(continuousAxisChannelDef.field), + groupby + }; - let filteredWhiskerSpec: NormalizedLayerSpec = undefined; - if (boxPlotType === 'tukey') { - filteredWhiskerSpec = { - transform: [ + const filteredWhiskerSpec: NormalizedLayerSpec = { + transform: [ + { + filter: `(${lowerWhiskerExpr} <= ${fieldExpr}) && (${fieldExpr} <= ${upperWhiskerExpr})` + }, + { + aggregate: [ { - filter: `(${lowerWhiskerExpr} <= ${fieldExpr}) && (${fieldExpr} <= ${upperWhiskerExpr})` + op: 'min', + field: continuousAxisChannelDef.field, + as: 'lower_whisker_' + continuousAxisChannelDef.field }, { - aggregate: [ - { - op: 'min', - field: continuousAxisChannelDef.field, - as: 'lower_whisker_' + continuousAxisChannelDef.field - }, - { - op: 'max', - field: continuousAxisChannelDef.field, - as: 'upper_whisker_' + continuousAxisChannelDef.field - }, - // preserve lower_box / upper_box - { - op: 'min', - field: 'lower_box_' + continuousAxisChannelDef.field, - as: 'lower_box_' + continuousAxisChannelDef.field - }, - { - op: 'max', - field: 'upper_box_' + continuousAxisChannelDef.field, - as: 'upper_box_' + continuousAxisChannelDef.field - }, - ...aggregate - ], - groupby - } + op: 'max', + field: continuousAxisChannelDef.field, + as: 'upper_whisker_' + continuousAxisChannelDef.field + }, + // preserve lower_box / upper_box + { + op: 'min', + field: 'lower_box_' + continuousAxisChannelDef.field, + as: 'lower_box_' + continuousAxisChannelDef.field + }, + { + op: 'max', + field: 'upper_box_' + continuousAxisChannelDef.field, + as: 'upper_box_' + continuousAxisChannelDef.field + }, + ...aggregate ], - layer: whiskerLayers - }; - } - - const {tooltip, ...encodingWithoutSizeColorContinuousAxisAndTooltip} = encodingWithoutSizeColorAndContinuousAxis; - - const {scale, axis} = continuousAxisChannelDef; - const title = getTitle(continuousAxisChannelDef); - const axisWithoutTitle = omit(axis, ['title']); - - const outlierLayersMixins = partLayerMixins(markDef, 'outliers', config.boxplot, { - transform: [{filter: `(${fieldExpr} < ${lowerWhiskerExpr}) || (${fieldExpr} > ${upperWhiskerExpr})`}], - mark: 'point', - encoding: { - [continuousAxis]: { - field: continuousAxisChannelDef.field, - type: continuousAxisChannelDef.type, - ...(title !== undefined ? {title} : {}), - ...(scale !== undefined ? {scale} : {}), - // add axis without title since we already added the title above - ...(isEmpty(axisWithoutTitle) ? {} : {axis: axisWithoutTitle}) - }, - ...encodingWithoutSizeColorContinuousAxisAndTooltip, - ...(customTooltipWithoutAggregatedField ? {tooltip: customTooltipWithoutAggregatedField} : {}) + groupby } - })[0]; - - if (outlierLayersMixins && filteredWhiskerSpec) { - filteredLayersMixins = { - transform: [joinaggregateTransform], - layer: [outlierLayersMixins, filteredWhiskerSpec] - }; - } else if (outlierLayersMixins) { - filteredLayersMixins = outlierLayersMixins; - filteredLayersMixins.transform.unshift(joinaggregateTransform); - } else if (filteredWhiskerSpec) { - filteredLayersMixins = filteredWhiskerSpec; - filteredLayersMixins.transform.unshift(joinaggregateTransform); - } - } + ], + layer: whiskerLayers + }; - if (filteredLayersMixins) { - // tukey box plot with outliers included - return { - ...outerSpec, - layer: [ - filteredLayersMixins, - { - // boxplot - transform, - layer: boxLayers - } - ] + const {tooltip, ...encodingWithoutSizeColorContinuousAxisAndTooltip} = encodingWithoutSizeColorAndContinuousAxis; + + const {scale, axis} = continuousAxisChannelDef; + const title = getTitle(continuousAxisChannelDef); + const axisWithoutTitle = omit(axis, ['title']); + + const outlierLayersMixins = partLayerMixins(markDef, 'outliers', config.boxplot, { + transform: [{filter: `(${fieldExpr} < ${lowerWhiskerExpr}) || (${fieldExpr} > ${upperWhiskerExpr})`}], + mark: 'point', + encoding: { + [continuousAxis]: { + field: continuousAxisChannelDef.field, + type: continuousAxisChannelDef.type, + ...(title !== undefined ? {title} : {}), + ...(scale !== undefined ? {scale} : {}), + // add axis without title since we already added the title above + ...(isEmpty(axisWithoutTitle) ? {} : {axis: axisWithoutTitle}) + }, + ...encodingWithoutSizeColorContinuousAxisAndTooltip, + ...(customTooltipWithoutAggregatedField ? {tooltip: customTooltipWithoutAggregatedField} : {}) + } + })[0]; + + let filteredLayersMixins: NormalizedLayerSpec; + const filteredLayersMixinsTransforms = [...bins, ...timeUnits, joinaggregateTransform]; + if (outlierLayersMixins) { + filteredLayersMixins = { + transform: filteredLayersMixinsTransforms, + layer: [outlierLayersMixins, filteredWhiskerSpec] }; + } else { + filteredLayersMixins = filteredWhiskerSpec; + filteredLayersMixins.transform.unshift(...filteredLayersMixinsTransforms); } + return { ...outerSpec, - transform: (outerSpec.transform ?? []).concat(transform), - layer: boxLayers + layer: [ + filteredLayersMixins, + { + // boxplot + transform, + layer: boxLayers + } + ] }; } @@ -423,6 +417,8 @@ function boxParams( ]; return { + bins, + timeUnits, transform, groupby, aggregate, diff --git a/test/compositemark/boxplot.test.ts b/test/compositemark/boxplot.test.ts index d105e21068..6b19c5e187 100644 --- a/test/compositemark/boxplot.test.ts +++ b/test/compositemark/boxplot.test.ts @@ -1070,4 +1070,31 @@ describe('normalizeBoxIQR', () => { const {tooltip} = normalizedSpecWithTooltip['layer'][0]['layer'][0]['encoding']; expect(tooltip).toEqual({field: 'year', type: 'quantitative'}); }); + + it("should include timeUnit transform in filteredLayerMixins' transform", () => { + const field = 'Date'; + const timeUnit = 'year'; + const normalizedSpec = normalize( + { + data: {url: 'data/population.json'}, + mark: 'boxplot', + encoding: { + x: { + field, + type: 'temporal', + timeUnit + }, + y: {field: 'Anomaly', type: 'quantitative'} + } + }, + defaultConfig + ); + + const filteredLayerMixins = normalizedSpec['layer'][1]; + expect(filteredLayerMixins.transform[0]).toEqual({ + timeUnit: {unit: 'year'}, + field, + as: `${timeUnit}_${field}` + }); + }); });