From 83ab83eadc6ea9967235e87556d8896731a8ac33 Mon Sep 17 00:00:00 2001 From: julieg18 Date: Thu, 11 Jan 2024 09:46:32 -0600 Subject: [PATCH 1/4] Add `aggregate_param` to view-level transforms --- src/compile/data/aggregate.ts | 36 +++++++++++++++++++---------- src/transform.ts | 5 ++++ test/compile/data/aggregate.test.ts | 32 +++++++++++++++++++++---- test/compile/data/assemble.test.ts | 2 +- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/compile/data/aggregate.ts b/src/compile/data/aggregate.ts index a8a9cefba1..cc6d282af3 100644 --- a/src/compile/data/aggregate.ts +++ b/src/compile/data/aggregate.ts @@ -27,7 +27,7 @@ import {DataFlowNode} from './dataflow'; import {isRectBasedMark} from '../../mark'; import {OFFSETTED_RECT_END_SUFFIX, OFFSETTED_RECT_START_SUFFIX} from './timeunit'; -type Measures = Dict>>>; +type Measures = Dict; aggregate_param?: number}>>>; function addDimension(dims: Set, channel: Channel, fieldDef: FieldDef, model: ModelWithField) { const channelDef2 = isUnitModel(model) ? model.encoding[getSecondaryRangeChannel(channel)] : undefined; @@ -71,7 +71,9 @@ function mergeMeasures(parentMeasures: Measures, childMeasures: Measures) { for (const op of keys(ops)) { if (field in parentMeasures) { // add operator to existing measure field - parentMeasures[field][op] = new Set([...(parentMeasures[field][op] ?? []), ...ops[op]]); + parentMeasures[field][op] = { + aliases: new Set([...(parentMeasures[field][op]?.aliases ?? []), ...ops[op].aliases]) + }; } else { parentMeasures[field] = {[op]: ops[op]}; } @@ -121,23 +123,23 @@ export class AggregateNode extends DataFlowNode { if (aggregate) { if (aggregate === 'count') { meas['*'] ??= {}; - meas['*']['count'] = new Set([vgField(fieldDef, {forAs: true})]); + meas['*']['count'] = {aliases: new Set([vgField(fieldDef, {forAs: true})])}; } else { if (isArgminDef(aggregate) || isArgmaxDef(aggregate)) { const op = isArgminDef(aggregate) ? 'argmin' : 'argmax'; const argField = aggregate[op]; meas[argField] ??= {}; - meas[argField][op] = new Set([vgField({op, field: argField}, {forAs: true})]); + meas[argField][op] = {aliases: new Set([vgField({op, field: argField}, {forAs: true})])}; } else { meas[field] ??= {}; - meas[field][aggregate] = new Set([vgField(fieldDef, {forAs: true})]); + meas[field][aggregate] = {aliases: new Set([vgField(fieldDef, {forAs: true})])}; } // For scale channel with domain === 'unaggregated', add min/max so we can use their union as unaggregated domain if (isScaleChannel(channel) && model.scaleDomain(channel) === 'unaggregated') { meas[field] ??= {}; - meas[field]['min'] = new Set([vgField({field, aggregate: 'min'}, {forAs: true})]); - meas[field]['max'] = new Set([vgField({field, aggregate: 'max'}, {forAs: true})]); + meas[field]['min'] = {aliases: new Set([vgField({field, aggregate: 'min'}, {forAs: true})])}; + meas[field]['max'] = {aliases: new Set([vgField({field, aggregate: 'max'}, {forAs: true})])}; } } } else { @@ -157,14 +159,18 @@ export class AggregateNode extends DataFlowNode { const meas: Measures = {}; for (const s of t.aggregate) { - const {op, field, as} = s; + const {op, field, as, aggregate_param} = s; if (op) { if (op === 'count') { meas['*'] ??= {}; - meas['*']['count'] = new Set([as ? as : vgField(s, {forAs: true})]); + meas['*']['count'] = {aliases: new Set([as ? as : vgField(s, {forAs: true})])}; } else { meas[field] ??= {}; - meas[field][op] = new Set([as ? as : vgField(s, {forAs: true})]); + meas[field][op] = {aliases: new Set([as ? as : vgField(s, {forAs: true})])}; + + if (aggregate_param) { + meas[field][op].aggregate_param = aggregate_param; + } } } } @@ -202,7 +208,7 @@ export class AggregateNode extends DataFlowNode { for (const field of keys(this.measures)) { for (const op of keys(this.measures[field])) { - const m = this.measures[field][op]; + const m = this.measures[field][op].aliases; if (m.size === 0) { out.add(`${op}_${field}`); } else { @@ -222,13 +228,15 @@ export class AggregateNode extends DataFlowNode { const ops: AggregateOp[] = []; const fields: string[] = []; const as: string[] = []; + const aggregateParams: (number | null)[] = []; for (const field of keys(this.measures)) { for (const op of keys(this.measures[field])) { - for (const alias of this.measures[field][op]) { + for (const alias of this.measures[field][op].aliases) { as.push(alias); ops.push(op); fields.push(field === '*' ? null : replacePathInField(field)); + aggregateParams.push(this.measures[field][op].aggregate_param || null); } } } @@ -241,6 +249,10 @@ export class AggregateNode extends DataFlowNode { as }; + if (aggregateParams.some(param => typeof param === 'number')) { + result.aggregate_params = aggregateParams; + } + return result; } } diff --git a/src/transform.ts b/src/transform.ts index 33b10bb723..cb030f3de0 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -111,6 +111,11 @@ export interface AggregatedFieldDef { */ field?: FieldName; + /** + * A parameter that can be passed to aggregation functions. The aggregation operation `"exponential"` requires it. + */ + aggregate_param?: number; + /** * The output field names to use for each aggregated field. */ diff --git a/test/compile/data/aggregate.test.ts b/test/compile/data/aggregate.test.ts index b0d882152f..eda09785e7 100644 --- a/test/compile/data/aggregate.test.ts +++ b/test/compile/data/aggregate.test.ts @@ -49,9 +49,9 @@ describe('compile/data/aggregate', () => { const agg = AggregateNode.makeFromEncoding(null, model); expect(agg.hash()).toBe( - `Aggregate {"dimensions":"Set(\\"Origin\\")","measures":{"*":{"count":"Set(\\"${internalField( + `Aggregate {"dimensions":"Set(\\"Origin\\")","measures":{"*":{"count":{"aliases":"Set(\\"${internalField( 'count' - )}\\")"},"Acceleration":{"sum":"Set(\\"sum_Acceleration\\")"}}}` + )}\\")"}},"Acceleration":{"sum":{"aliases":"Set(\\"sum_Acceleration\\")"}}}}` ); }); }); @@ -309,6 +309,26 @@ describe('compile/data/aggregate', () => { as: ['Displacement_mean', 'Displacement_max', 'Acceleration_sum'] }); }); + + it('should produce the correct summary component from transform array with aggregation_params', () => { + const t: AggregateTransform = { + aggregate: [ + {op: 'sum', field: 'Acceleration', as: 'Acceleration_sum'}, + {op: 'exponential', field: 'Displacement', as: 'Displacement_exponential', aggregate_param: 0.3} + ], + groupby: ['Group'] + }; + + const agg = AggregateNode.makeFromTransform(null, t); + expect(agg.assemble()).toEqual({ + type: 'aggregate', + groupby: ['Group'], + ops: ['sum', 'exponential'], + fields: ['Acceleration', 'Displacement'], + as: ['Acceleration_sum', 'Displacement_exponential'], + aggregate_params: [null, 0.3] + }); + }); }); describe('producedFields', () => { @@ -336,8 +356,8 @@ describe('compile/data/aggregate', () => { }); it('should merge AggregateNodes with same dimensions', () => { const parent = new PlaceholderDataFlowNode(null); - const agg1 = new AggregateNode(parent, new Set(['a', 'b']), {a: {mean: new Set(['a_mean'])}}); - const agg2 = new AggregateNode(parent, new Set(['a', 'b']), {b: {mean: new Set(['b_mean'])}}); + const agg1 = new AggregateNode(parent, new Set(['a', 'b']), {a: {mean: {aliases: new Set(['a_mean'])}}}); + const agg2 = new AggregateNode(parent, new Set(['a', 'b']), {b: {mean: {aliases: new Set(['b_mean'])}}}); expect(agg1.merge(agg2)).toBe(true); expect(agg1.producedFields()).toEqual(new Set(['a_mean', 'b_mean'])); @@ -346,7 +366,9 @@ describe('compile/data/aggregate', () => { describe('assemble()', () => { it('should escape nested accesses', () => { - const agg = new AggregateNode(null, new Set(['foo.bar']), {'foo.baz': {mean: new Set(['foo_baz_mean'])}}); + const agg = new AggregateNode(null, new Set(['foo.bar']), { + 'foo.baz': {mean: {aliases: new Set(['foo_baz_mean'])}} + }); expect(agg.assemble()).toEqual({ as: ['foo_baz_mean'], fields: ['foo\\.baz'], diff --git a/test/compile/data/assemble.test.ts b/test/compile/data/assemble.test.ts index 95efd81ea0..8615ea0ff2 100644 --- a/test/compile/data/assemble.test.ts +++ b/test/compile/data/assemble.test.ts @@ -35,7 +35,7 @@ describe('compile/data/assemble', () => { const outputNodeRefCounts = {}; const raw = new OutputNode(null, 'rawOut', DataSourceType.Raw, outputNodeRefCounts); raw.parent = src; - const agg = new AggregateNode(null, new Set(['a']), {b: {count: new Set(['count_*'])}}); + const agg = new AggregateNode(null, new Set(['a']), {b: {count: {aliases: new Set(['count_*'])}}}); agg.parent = raw; const main = new OutputNode(null, 'mainOut', DataSourceType.Main, outputNodeRefCounts); main.parent = agg; From 1db302e22fc122451f5e568b60795089c70c7d6e Mon Sep 17 00:00:00 2001 From: julieg18 Date: Fri, 12 Jan 2024 08:56:03 -0600 Subject: [PATCH 2/4] Update site --- build/vega-lite-schema.json | 4 ++++ site/docs/transform/aggregate.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 02c23e5b0f..59bb94d420 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -71,6 +71,10 @@ "AggregatedFieldDef": { "additionalProperties": false, "properties": { + "aggregate_param": { + "description": "A parameter that can be passed to aggregation functions. The aggregation operation `\"exponential\"` requires it.", + "type": "number" + }, "as": { "$ref": "#/definitions/FieldName", "description": "The output field names to use for each aggregated field." diff --git a/site/docs/transform/aggregate.md b/site/docs/transform/aggregate.md index 1a86f11762..bc1a69a54c 100644 --- a/site/docs/transform/aggregate.md +++ b/site/docs/transform/aggregate.md @@ -86,7 +86,7 @@ An `aggregate` transform in the [`transform`](transform.html) array has the foll ### Aggregated Field Definition for Aggregate Transform -{% include table.html props="op,field,as" source="AggregatedFieldDef" %} +{% include table.html props="op,field,as,aggregate_param" source="AggregatedFieldDef" %} Note: It is important you [`parse`](data.html#format) your data types explicitly, especially if you are likely to have `null` values in your dataset and automatic type inference will fail. @@ -121,6 +121,7 @@ The supported **aggregation operations** are: | max | The maximum field value. | | argmin | An input data object containing the minimum field value.
**Note:** When used inside encoding, `argmin` must be specified as an object. (See below for an example.) | | argmax | An input data object containing the maximum field value.
**Note:** When used inside encoding, `argmax` must be specified as an object. (See below for an example.) | +| exponential | The exponential moving average of field values. Set the required weight (a number between `0` and `1`) with [`aggregate_param`](#aggregate-op-def).
**Note:** Cannot be used inside encoding. | {:#argmax} From 8f56013566fd887432892c07d4ea9a9dca29de1d Mon Sep 17 00:00:00 2001 From: julieg18 Date: Mon, 12 Feb 2024 18:59:28 -0600 Subject: [PATCH 3/4] add sma to encoding and update transform schema --- build/vega-lite-schema.json | 61 +++++++- examples/compiled/layer_line_exponential.png | Bin 0 -> 13632 bytes examples/compiled/layer_line_exponential.svg | 1 + .../compiled/layer_line_exponential.vg.json | 137 ++++++++++++++++++ examples/specs/layer_line_exponential.vl.json | 52 +++++++ site/docs/transform/aggregate.md | 12 +- src/aggregate.ts | 14 +- src/channeldef.ts | 22 ++- src/compile/data/aggregate.ts | 37 +++-- src/compositemark/boxplot.ts | 13 +- src/encoding.ts | 10 +- src/transform.ts | 12 +- test/channeldef.test.ts | 6 + test/compile/data/aggregate.test.ts | 43 +++++- test/encoding.test.ts | 32 ++++ 15 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 examples/compiled/layer_line_exponential.png create mode 100644 examples/compiled/layer_line_exponential.svg create mode 100644 examples/compiled/layer_line_exponential.vg.json create mode 100644 examples/specs/layer_line_exponential.vl.json diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 59bb94d420..60274992fb 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -12,6 +12,19 @@ }, { "$ref": "#/definitions/ArgminDef" + }, + { + "$ref": "#/definitions/ExponentialDef" + } + ] + }, + "AggregateFieldOp": { + "anyOf": [ + { + "$ref": "#/definitions/NonArgAggregateFieldOp" + }, + { + "$ref": "#/definitions/ExponentialDef" } ] }, @@ -71,10 +84,6 @@ "AggregatedFieldDef": { "additionalProperties": false, "properties": { - "aggregate_param": { - "description": "A parameter that can be passed to aggregation functions. The aggregation operation `\"exponential\"` requires it.", - "type": "number" - }, "as": { "$ref": "#/definitions/FieldName", "description": "The output field names to use for each aggregated field." @@ -84,7 +93,7 @@ "description": "The data field for which to compute aggregate function. This is required for all aggregation operations except `\"count\"`." }, "op": { - "$ref": "#/definitions/AggregateOp", + "$ref": "#/definitions/AggregateFieldOp", "description": "The aggregation operation to apply to the fields (e.g., `\"sum\"`, `\"average\"`, or `\"count\"`). See the [full list of supported aggregation operations](https://vega.github.io/vega-lite/docs/aggregate.html#ops) for more information." } }, @@ -8801,6 +8810,18 @@ ], "type": "string" }, + "ExponentialDef": { + "additionalProperties": false, + "properties": { + "exponential": { + "type": "number" + } + }, + "required": [ + "exponential" + ], + "type": "object" + }, "Expr": { "type": "string" }, @@ -17866,6 +17887,35 @@ ], "type": "object" }, + "NonArgAggregateFieldOp": { + "enum": [ + "argmax", + "argmin", + "average", + "count", + "distinct", + "max", + "mean", + "median", + "min", + "missing", + "product", + "q1", + "q3", + "ci0", + "ci1", + "stderr", + "stdev", + "stdevp", + "sum", + "valid", + "values", + "variance", + "variancep", + "exponentialb" + ], + "type": "string" + }, "NonArgAggregateOp": { "enum": [ "average", @@ -17889,7 +17939,6 @@ "values", "variance", "variancep", - "exponential", "exponentialb" ], "type": "string" diff --git a/examples/compiled/layer_line_exponential.png b/examples/compiled/layer_line_exponential.png new file mode 100644 index 0000000000000000000000000000000000000000..fbe062cda49f06d9c1c52cb7adf2573f5a536264 GIT binary patch literal 13632 zcmdseby$>NxAq|2ox%VEl1g_93>^Ye(g>1L(jC%`h=527f`EY1Ddo^5r8FoYjlj_G z?RkIaocEmb@AuDleV1O3G0!~D-fORQuY28V5v#4GOo&H^2Z2BcRaM}+5C~c;`1>sm z7P!XYTbF@9u%4u8&-dN#f}ii>XFp{RTqi`FCrU1J_bTr{ zV7fH^2a{xeDv`!Eqt z_0tl~??TS6)~{|l+aDr2m2u$ z{tHjT7Y=9rAJ@e_qU9Iu*GLl^8=FQ+0vPP?LrzXkvzvt~^2I;jKk)nPnfgS_!QoJ) zN&}ykZ_Ju77#Sv;)c@`E!@bt4tH9@rt+eUs=}4!_%F5X$4|c70Vk;F0GE!2r*N@2- z2hzD~JpbtaT3>&|b^#co5 zB&jiCzkTx?7X9%Z>*(+z@2IM(`rcljqP~>?m8QErL&#bR(WSn56QvCG!w|?CqGi^W zH87ou>G(89jCSX9#$wE`OiQ0=I2^Y88oG$woh#7hV8XdWL@yRPIjKi46G)TBrg;a_ z_xnXpPfvbr?IdmThv16#{un!%+4wgif(g!4Qb(Q8$v?*P3{<4Cp^yE2yQ7H_S%P*4 z2i?ThWBGDt*ZU#KOSdTLh9;wIScO8B#MaEEFpn zK7RaicGl>!Lr_pKH7o1KZ%}vya-;;rZUAnc8oekdRY_+U<3aE}H8~A*Zz{ z4F4SbS$rY&<(I^nug3}>srbk}_T21Y+p9m1Lb^;`+agO#IZCG-C#R=Z-^H{ zwwWh8&($;Y@DMRGGY4J!Zhcr>JZ2&K<&>}2SH0DKALIP8YiShw5pQlBNik!v?#%MO z1vHeYQBoI1S9;MJZD?VUTw{&!eiA9}{8Q*^&!?li?Q}7|?C}Q}8ynVs+o`#Q?Awd7 zkbUWwnwO(;>${O3s2?dlZ=ATP>mv!NXywIEZmYu&NXn3>{L~};J-UhC7(AFYd@Lm7 z!^p_Ux4*v!7Z|X_mo0-ab1zx3!K??+E@Sso6NW@var_-x$>pp9PlA!zHWk`uCm=(c1;&6feY4&-|7*`>`3W6034Q^PqH;)_B+# z+1+OU+@*I@ZrpdXzD7EDe>IE6t-F*pAko~J#pyVv)Rk{VC+~Bu$zd0J6Gz!JGcy-X zh&dmvxvpyR5^pMmc!{5ArbJ0@&Zg%hG4kt}qxaJuI4hNg@1z|Dn(uYFf|$8pqINNT z`QBi7N8Y2V{k7eDy=(C$Oq>!p#ZwYer|v{URORF_HW(6#_Ftlnsez)_U(8%6Ph1zdN;&ErPB zb9Y{X4OV0x^jWcw&io9pybT)U&`>cv`O)@WkJ%_V*%^B9CFeu+?}s{h3)wL}k+{k9 zy5@5>dDbn?TbA?$gswz2R2!nmjSWjw-N;i>ylg;NglKKL}X zDDzfrRIMOhy3PyoZ=PME)DG$lmczyfSMgCX~oJ8z>Pzjm?y{HX2m3cJUE z>SMENrQ{g;&Isi;3&InywPidah@%`P3ah5`E>#id6 z^P3d9yB;H7=hWlagRRx2KCSQDsn^QRY%Y1enhCe+Cq3)V zA!l-TIgzUxrq2kM{2Vo_<#_g5*dOlcuHoycYET{uJQ#64pwy#?vD&|6(}idWxyfkq znXYTJ(t`nC+kADg@qzjT-!&+JA{vj2WOF~B@>EMP)O39>g{R-3NEFI~a)peq5z5pj zzxM*4k8no}A@ETVomma>iS|@P!AB>ZG^`neO=^p6aI-$i?L#)6c~s$hYVsum%o~S2Z%|PuA3^r#HQ*#>rF9_*P=)+2=yK zh-Il1_O=uCPoFVMsjTd!T+H)29UHWxp282FQp2D;vKG&%rNEWGJl?|2EY1cNC!W}l zL6N*5Nb61-#!)%F;s@3mOGKWla{c~Or|eA-4W!z0&pzQU+4g$OyGLayR#UI;V_=`H zU;xX*qj4Wa3^=~sX-S#)yVf4c%3S|T<2hV8Cfv|`)uh9GW8}9qD(t`Qlk>ul0R9WA z;S2ko@7nt@NxWic<6(;3YwmC!AztbrwEVVF`^st&L&em0`q9h980Y9oF&#a^a;C3bIA@2Z zl-~3*#6jxWgR78G5Pyd)7Rg~u5LXaDDioOqaCwsF%Gd8W%Nq!{7bGbGYVi2gPjM}K zx<6=_eII4u8qtwQ>_U?sv#rb~zq0)jq_28Yo&}L-W*g=YEt+8W<7ERTHu=O8udOM`JOu-G_Lv6*Fs6-cfI%MBtUz zti{k>P^GCqMGw6M9`LV69cb=732fP~aCW%q;?!2;$hjG^KyKanC7^LPJ?2nB{{VS$ z-jOpjhrbs6(e1hq-WL?4JvSR(iH)yQ>fq*|009Tm6M^d*DygR(<|E3ueP+oRP0E?7 z6;k3H^SQdnkevxwRh;}g38nuZ!IP)m>6y~Zl!vKdd?poQnN^*@d5U^5`=deRtu9YS z_>4-Q=GUEU6mQ^D^YkRp3i4ThBak{yVV|*hO z2$fK{`x=k7BvET`y1a!#HcPZ5N#)`Cfp1@6tM-$ngkir(o-7mVMCka}jn|sw9e}a5 zUTl@mwgreT`fV^kLVrA0a(CyKxjD0jgjQ8m2|_g)wz3n>29*QG9Y0%MWZDdMFfWvi zmVd8f+ONgZj1mE1N{~d{UFV=9t>)SBnuIa}AUpj~)k8mLa%3J?bct3naDbfVv63%J z`-dTWT_H*74?(I8zbp6-DFu7+;KT8OQX*X>-~zOyq@+lv-rnAr>YbUL{D$c}66gJ`sb0=^ z*q-%tBIfWxs(L;m8U6QXiA+MfB#10RQ!Q0rcXxLhL0f{rz`&guJbe7rjEv~;@H?%y zH=Wf;-Ie(!4{Z*LYNPw$ z_rcN8OC}r|2~Q3Y*Tsa_uPGo8wX}#{czCodSTDBvjo`UHynub6X*#dN(K}nd4VI=T zKns%foOhwrHY!7$J4$1qX%7VPN%7Oj-F%h_F<_S?(KdI52R~u?S$XGI`yx#)O#)x> zQPj5%8SNx9cCM9;waDlVqA;%K%Q>uaLMdMB8CpD&XFEruvdT6#thPg$XmTW$BH5%g zH1@atwzg~nf`Tor?2?i#CSN@r^7bVX=dX-`A62imZ}W-CRE*~70FQc!Ny%61$08&| zp+MHf54%UEOHEIc{T44_$X;R5fpE*~0Y}9b`IPgg$4rQ*N4&p@`#G!Cj;b$>a2J0R zeB&n8VoX7>lM=!m>ze0;Gw!uobw^p%^ZLn*2QRcGtDvC3Z~ZL+dQWmIYv97R z^+1(ph>AN{_?dPdn`Bz?f;q&+UYJEnCF}|L1mC=(LZ;zF6U-At3Tw);#uMpwS^K#k z#k^iFi0LOqr|aLkgZx8aF9>hmZgqkcR9sjRdq#tBYA1jPui~3)M2c@DiEa$dnKyl>M?W$Y?xhB$JOkOKG z@@-yL??Rm!M**za3mh-hD9rRk{&YH?2D!a&4LpfS4r0FKh67F&2mke2S#Q=snt1z7 zX=^?1((jz@iSlfI5zMgb3PT@GWbS4r1qY?Tq$q93C-R-55h%cBDQfau>x#wMvB7Mm3Dm+NY}yGRNY$wN?UDd~DvE1YtJOx|+6p>6Q} zI3D`S?s$u*7hw_yvIxLxGKydsBsu-a>*>>eB3X4?i+GUce%8CMiTlH$VYs6&>7`8~ zL$2oP*CL)kqJoo;U*Gd49pn2oXo%F7< zXV3bcy@SWjEORWz*)_vjN-hPFa~f&6ZxU3eV`eR4W_C8_bTNRKx9ylLE-p^n*m!HG zag$oD-0N)H?sCk9(ru>J6II8IwY3j&!=GqA&OkT9c9g3FX)jG+HAP$s+Ma_T+V#0g zd6LyYoqI8?R|w|pZJtbK4pK)H0d4s0Mb&NB&pIdf%`xVLyP`z;`tPcJFe?{h z^ciRUNwQd0Yuqjl%A1pGhMzA5HVxabyi_F}kRat0OSBb&vdKWvx!~+{Mw?50W zzQ-pqHU~0R>c*x{SHN~c0F5^!7u!n_{b|>ogR?@;gTuXr z+VX;_1jgX|oi~qv^JUEy;2A#EQm)b(A}b8%3kc*Yl)q{jvNPYUQOFVY*Db@?_I}qh zQq&uC7nH^o%6Yk>3!dcdmwS@;&0A>z*m!J=D4Lr?ZHM3f-Z_Ks?rO@eesv%O`P^V3 zW2)Rt3q$T90JH2PJe^|as38Q%@Gdqm3fT6PSzL!{ltWP(UvMcNAk(i6wyPa(#qGC_ z4ZS*0^y_PtgyV6$L=T;GVQ`5u?!IBi1?6PkGD0{7=2T&|Y8_nnzCS541cbgYxCq#>C|M@lln3$o*&qZ`Zcr(&pnyJIWwAfG z+*4B08EPjxA8(L`Dfq2qIjMr#Fff@30@nRpTssDo`&TxLnG�a}T99YWn5qthL8{ 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 SH29Gyh^nF%{FA(8`2PWMpe`!_ literal 0 HcmV?d00001 diff --git a/examples/compiled/layer_line_exponential.svg b/examples/compiled/layer_line_exponential.svg new file mode 100644 index 0000000000..94f8fa7421 --- /dev/null +++ b/examples/compiled/layer_line_exponential.svg @@ -0,0 +1 @@ +20202021202220232024year05101520Avg Price \ No newline at end of file diff --git a/examples/compiled/layer_line_exponential.vg.json b/examples/compiled/layer_line_exponential.vg.json new file mode 100644 index 0000000000..b2b1360c19 --- /dev/null +++ b/examples/compiled/layer_line_exponential.vg.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 400, + "height": 200, + "style": "cell", + "data": [ + { + "name": "source_0", + "values": [ + {"price": 9.2, "year": 2020}, + {"price": 10.76, "year": 2020}, + {"price": 36.88, "year": 2021}, + {"price": 3.44, "year": 2021}, + {"price": 10.55, "year": 2022}, + {"price": 9.65, "year": 2022}, + {"price": 7.15, "year": 2023}, + {"price": 15, "year": 2023}, + {"price": 10.19, "year": 2024}, + {"price": 8.86, "year": 2024} + ] + }, + { + "name": "data_0", + "source": "source_0", + "transform": [ + { + "type": "aggregate", + "groupby": ["year"], + "ops": ["exponential", "mean"], + "fields": ["price", "price"], + "as": ["exponential_price", "mean_price"], + "aggregate_params": [0.5, null] + } + ] + } + ], + "marks": [ + { + "name": "layer_0_marks", + "type": "line", + "style": ["line"], + "sort": {"field": "datum[\"year\"]"}, + "from": {"data": "data_0"}, + "encode": { + "update": { + "stroke": {"value": "#4c78a8"}, + "description": { + "signal": "\"year: \" + (isValid(datum[\"year\"]) ? datum[\"year\"] : \"\"+datum[\"year\"]) + \"; Avg Price: \" + (format(datum[\"mean_price\"], \"\"))" + }, + "x": {"scale": "x", "field": "year"}, + "y": {"scale": "y", "field": "mean_price"}, + "defined": { + "signal": "isValid(datum[\"mean_price\"]) && isFinite(+datum[\"mean_price\"])" + } + } + } + }, + { + "name": "layer_1_marks", + "type": "line", + "style": ["line"], + "sort": {"field": "datum[\"year\"]"}, + "from": {"data": "data_0"}, + "encode": { + "update": { + "opacity": {"value": 0.5}, + "stroke": {"value": "#4c78a8"}, + "description": { + "signal": "\"year: \" + (isValid(datum[\"year\"]) ? datum[\"year\"] : \"\"+datum[\"year\"]) + \"; Exponential of price: \" + (format(datum[\"exponential_price\"], \"\"))" + }, + "x": {"scale": "x", "field": "year"}, + "y": {"scale": "y", "field": "exponential_price"}, + "defined": { + "signal": "isValid(datum[\"exponential_price\"]) && isFinite(+datum[\"exponential_price\"])" + } + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "point", + "domain": {"data": "data_0", "field": "year", "sort": true}, + "range": [0, {"signal": "width"}], + "padding": 0.5 + }, + { + "name": "y", + "type": "linear", + "domain": { + "data": "data_0", + "fields": ["mean_price", "exponential_price"] + }, + "range": [{"signal": "height"}, 0], + "nice": true, + "zero": true + } + ], + "axes": [ + { + "scale": "y", + "orient": "left", + "gridScale": "x", + "grid": true, + "tickCount": {"signal": "ceil(height/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "year", + "labelAlign": "right", + "labelAngle": 270, + "labelBaseline": "middle", + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "title": "Avg Price", + "labelOverlap": true, + "tickCount": {"signal": "ceil(height/40)"}, + "zindex": 0 + } + ] +} diff --git a/examples/specs/layer_line_exponential.vl.json b/examples/specs/layer_line_exponential.vl.json new file mode 100644 index 0000000000..680b853cac --- /dev/null +++ b/examples/specs/layer_line_exponential.vl.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "width": 400, + "data": { + "values": [ + {"price": 9.2, "year": 2020}, + {"price": 10.76, "year": 2020}, + {"price": 36.88, "year": 2021}, + {"price": 3.44, "year": 2021}, + {"price": 10.55, "year": 2022}, + {"price": 9.65, "year": 2022}, + {"price": 7.15, "year": 2023}, + {"price": 15.0, "year": 2023}, + {"price": 10.19, "year": 2024}, + {"price": 8.86, "year": 2024} + ] + }, + "layer": [ + { + "mark": "line", + "encoding": { + "x": { + "field": "year" + }, + "y": { + "field": "price", + "aggregate": "mean", + "type": "quantitative", + "title": "Avg Price" + } + } + }, + { + "mark": { + "type": "line", + "opacity": 0.5 + }, + "encoding": { + "x": { + "field": "year" + }, + "y": { + "field": "price", + "aggregate": { + "exponential": 0.5 + }, + "type": "quantitative" + } + } + } + ] +} diff --git a/site/docs/transform/aggregate.md b/site/docs/transform/aggregate.md index bc1a69a54c..41bab5fb05 100644 --- a/site/docs/transform/aggregate.md +++ b/site/docs/transform/aggregate.md @@ -86,7 +86,7 @@ An `aggregate` transform in the [`transform`](transform.html) array has the foll ### Aggregated Field Definition for Aggregate Transform -{% include table.html props="op,field,as,aggregate_param" source="AggregatedFieldDef" %} +{% include table.html props="op,field,as" source="AggregatedFieldDef" %} Note: It is important you [`parse`](data.html#format) your data types explicitly, especially if you are likely to have `null` values in your dataset and automatic type inference will fail. @@ -121,7 +121,7 @@ The supported **aggregation operations** are: | max | The maximum field value. | | argmin | An input data object containing the minimum field value.
**Note:** When used inside encoding, `argmin` must be specified as an object. (See below for an example.) | | argmax | An input data object containing the maximum field value.
**Note:** When used inside encoding, `argmax` must be specified as an object. (See below for an example.) | -| exponential | The exponential moving average of field values. Set the required weight (a number between `0` and `1`) with [`aggregate_param`](#aggregate-op-def).
**Note:** Cannot be used inside encoding. | +| exponential | The exponential moving average of field values.
**Note:** `exponential` must be specified as an object. (See below for an example.) | {:#argmax} @@ -142,3 +142,11 @@ This is equivalent to specifying argmax in an aggregate transform and encode its `argmax` can be useful for getting the last value in a line for label placement. + +## Exponential + +You can use the exponential aggregate to get the exponential moving average of a field, which forms a smooth alternative to a simple moving average. It is commonly used when you want to more heavily weigh recent values, but don't want a discontinuous drop-off when numbers drop out of an averaging window. + +The exponential operation can be specified by setting it to an object with `exponential` describing the weight (a number between 0 and 1) to use in the transformation. + +
diff --git a/src/aggregate.ts b/src/aggregate.ts index 910db866f0..501192d694 100644 --- a/src/aggregate.ts +++ b/src/aggregate.ts @@ -45,9 +45,13 @@ export interface ArgmaxDef { argmax: FieldName; } -export type NonArgAggregateOp = Exclude; +export interface ExponentialDef { + exponential: number; +} + +export type NonArgAggregateOp = Exclude; -export type Aggregate = NonArgAggregateOp | ArgmaxDef | ArgminDef; +export type Aggregate = NonArgAggregateOp | ArgmaxDef | ArgminDef | ExponentialDef; export function isArgminDef(a: Aggregate | string): a is ArgminDef { return !!a && !!a['argmin']; @@ -57,7 +61,11 @@ export function isArgmaxDef(a: Aggregate | string): a is ArgmaxDef { return !!a && !!a['argmax']; } -export function isAggregateOp(a: string | ArgminDef | ArgmaxDef): a is AggregateOp { +export function isExponentialDef(a: Aggregate | string): a is ExponentialDef { + return !!a && !!a['exponential']; +} + +export function isAggregateOp(a: string | ArgminDef | ArgmaxDef | ExponentialDef): a is AggregateOp { return isString(a) && !!AGGREGATE_OP_INDEX[a]; } diff --git a/src/channeldef.ts b/src/channeldef.ts index 0b8b96f0eb..5dc38f9815 100644 --- a/src/channeldef.ts +++ b/src/channeldef.ts @@ -1,6 +1,6 @@ import {Gradient, ScaleType, SignalRef, Text} from 'vega'; import {isArray, isBoolean, isNumber, isString} from 'vega-util'; -import {Aggregate, isAggregateOp, isArgmaxDef, isArgminDef, isCountingAggregateOp} from './aggregate'; +import {Aggregate, isAggregateOp, isArgmaxDef, isArgminDef, isCountingAggregateOp, isExponentialDef} from './aggregate'; import {Axis} from './axis'; import {autoMaxBins, Bin, BinParams, binToString, isBinned, isBinning} from './bin'; import { @@ -805,7 +805,7 @@ export function vgField( if (!opt.nofn) { if (isOpFieldDef(fieldDef)) { - fn = fieldDef.op; + fn = isExponentialDef(fieldDef.op) ? 'exponential' : fieldDef.op; } else { const {bin, aggregate, timeUnit} = fieldDef; if (isBinning(bin)) { @@ -819,7 +819,7 @@ export function vgField( argAccessor = `["${field}"]`; field = `argmin_${aggregate.argmin}`; } else { - fn = String(aggregate); + fn = isExponentialDef(aggregate) ? 'exponential' : String(aggregate); } } else if (timeUnit && !isBinnedTimeUnit(timeUnit)) { fn = timeUnitToString(timeUnit); @@ -893,7 +893,8 @@ export function verbalTitleFormatter(fieldDef: FieldDefBase, config: Con } else if (isArgminDef(aggregate)) { return `${field} for min ${aggregate.argmin}`; } else { - return `${titleCase(aggregate)} of ${field}`; + const aggregateOp = isExponentialDef(aggregate) ? 'exponential' : aggregate; + return `${titleCase(aggregateOp)} of ${field}`; } } return field; @@ -909,7 +910,9 @@ export function functionalTitleFormatter(fieldDef: FieldDefBase) { const timeUnitParams = timeUnit && !isBinnedTimeUnit(timeUnit) ? normalizeTimeUnit(timeUnit) : undefined; - const fn = aggregate || timeUnitParams?.unit || (timeUnitParams?.maxbins && 'timeunit') || (isBinning(bin) && 'bin'); + const aggregateOp = isExponentialDef(aggregate) ? 'exponential' : aggregate; + const fn = + aggregateOp || timeUnitParams?.unit || (timeUnitParams?.maxbins && 'timeunit') || (isBinning(bin) && 'bin'); if (fn) { return `${fn.toUpperCase()}(${field})`; } else { @@ -1136,7 +1139,14 @@ export function initFieldDef( const fieldDef = {...fd}; // Drop invalid aggregate - if (!compositeMark && aggregate && !isAggregateOp(aggregate) && !isArgmaxDef(aggregate) && !isArgminDef(aggregate)) { + if ( + !compositeMark && + aggregate && + !isAggregateOp(aggregate) && + !isArgmaxDef(aggregate) && + !isArgminDef(aggregate) && + !isExponentialDef(aggregate) + ) { log.warn(log.message.invalidAggregate(aggregate)); delete fieldDef.aggregate; } diff --git a/src/compile/data/aggregate.ts b/src/compile/data/aggregate.ts index cc6d282af3..7e46f6c96f 100644 --- a/src/compile/data/aggregate.ts +++ b/src/compile/data/aggregate.ts @@ -1,5 +1,5 @@ import {AggregateOp, AggregateTransform as VgAggregateTransform} from 'vega'; -import {isArgmaxDef, isArgminDef} from '../../aggregate'; +import {isArgmaxDef, isArgminDef, isExponentialDef} from '../../aggregate'; import { Channel, getPositionChannelFromLatLong, @@ -27,7 +27,7 @@ import {DataFlowNode} from './dataflow'; import {isRectBasedMark} from '../../mark'; import {OFFSETTED_RECT_END_SUFFIX, OFFSETTED_RECT_START_SUFFIX} from './timeunit'; -type Measures = Dict; aggregate_param?: number}>>>; +type Measures = Dict; aggregateParam?: number}>>>; function addDimension(dims: Set, channel: Channel, fieldDef: FieldDef, model: ModelWithField) { const channelDef2 = isUnitModel(model) ? model.encoding[getSecondaryRangeChannel(channel)] : undefined; @@ -74,6 +74,11 @@ function mergeMeasures(parentMeasures: Measures, childMeasures: Measures) { parentMeasures[field][op] = { aliases: new Set([...(parentMeasures[field][op]?.aliases ?? []), ...ops[op].aliases]) }; + + const childAggregateParam = childMeasures[field][op].aggregateParam; + if (childAggregateParam) { + parentMeasures[field][op].aggregateParam = childAggregateParam; + } } else { parentMeasures[field] = {[op]: ops[op]}; } @@ -130,6 +135,11 @@ export class AggregateNode extends DataFlowNode { const argField = aggregate[op]; meas[argField] ??= {}; meas[argField][op] = {aliases: new Set([vgField({op, field: argField}, {forAs: true})])}; + } else if (isExponentialDef(aggregate)) { + const op = 'exponential'; + const aggregateParam = aggregate[op]; + meas[field] ??= {}; + meas[field][op] = {aliases: new Set([vgField(fieldDef, {forAs: true})]), aggregateParam: aggregateParam}; } else { meas[field] ??= {}; meas[field][aggregate] = {aliases: new Set([vgField(fieldDef, {forAs: true})])}; @@ -159,17 +169,24 @@ export class AggregateNode extends DataFlowNode { const meas: Measures = {}; for (const s of t.aggregate) { - const {op, field, as, aggregate_param} = s; + const {op, field, as} = s; if (op) { + const aliases = new Set([as ? as : vgField(s, {forAs: true})]); if (op === 'count') { meas['*'] ??= {}; - meas['*']['count'] = {aliases: new Set([as ? as : vgField(s, {forAs: true})])}; + meas['*']['count'] = {aliases}; } else { - meas[field] ??= {}; - meas[field][op] = {aliases: new Set([as ? as : vgField(s, {forAs: true})])}; - - if (aggregate_param) { - meas[field][op].aggregate_param = aggregate_param; + if (isExponentialDef(op)) { + const opName = 'exponential'; + const aggregateParam = op[opName]; + meas[field] ??= {}; + meas[field][opName] = { + aliases, + aggregateParam + }; + } else { + meas[field] ??= {}; + meas[field][op] = {aliases}; } } } @@ -236,7 +253,7 @@ export class AggregateNode extends DataFlowNode { as.push(alias); ops.push(op); fields.push(field === '*' ? null : replacePathInField(field)); - aggregateParams.push(this.measures[field][op].aggregate_param || null); + aggregateParams.push(this.measures[field][op].aggregateParam || null); } } } diff --git a/src/compositemark/boxplot.ts b/src/compositemark/boxplot.ts index e047c2de09..66af9aaff6 100644 --- a/src/compositemark/boxplot.ts +++ b/src/compositemark/boxplot.ts @@ -7,7 +7,13 @@ import * as log from '../log'; import {isMarkDef, MarkDef, MarkInvalidMixins} from '../mark'; import {NormalizerParams} from '../normalize'; import {GenericUnitSpec, NormalizedLayerSpec, NormalizedUnitSpec} from '../spec'; -import {AggregatedFieldDef, CalculateTransform, JoinAggregateTransform, Transform} from '../transform'; +import { + AggregatedFieldDef, + CalculateTransform, + JoinAggregateTransform, + NonArgAggregateFieldOp, + Transform +} from '../transform'; import {isEmpty, omit} from '../util'; import {CompositeMarkNormalizer} from './base'; import { @@ -21,6 +27,7 @@ import { partLayerMixins, PartsMixins } from './common'; +import {FieldName} from '../channeldef'; export const BOXPLOT = 'boxplot' as const; export type BoxPlot = typeof BOXPLOT; @@ -333,7 +340,9 @@ export function normalizeBoxPlot( }; } -function boxParamsQuartiles(continousAxisField: string): AggregatedFieldDef[] { +function boxParamsQuartiles( + continousAxisField: string +): {op: NonArgAggregateFieldOp; field: FieldName; as: FieldName}[] { return [ { op: 'q1', diff --git a/src/encoding.ts b/src/encoding.ts index 0dafbc975e..71f4934f11 100644 --- a/src/encoding.ts +++ b/src/encoding.ts @@ -1,6 +1,5 @@ -import {AggregateOp} from 'vega'; import {array, isArray} from 'vega-util'; -import {isArgmaxDef, isArgminDef} from './aggregate'; +import {isArgmaxDef, isArgminDef, isExponentialDef} from './aggregate'; import {isBinned, isBinning} from './bin'; import { ANGLE, @@ -91,7 +90,7 @@ import {Config} from './config'; import * as log from './log'; import {Mark} from './mark'; import {EncodingFacetMapping} from './spec/facet'; -import {AggregatedFieldDef, BinTransform, TimeUnitTransform} from './transform'; +import {AggregatedFieldDef, BinTransform, AggregateFieldOp, TimeUnitTransform} from './transform'; import {isContinuous, isDiscrete, QUANTITATIVE, TEMPORAL} from './type'; import {keys, some} from './util'; import {isSignalRef} from './vega.schema'; @@ -415,7 +414,7 @@ export function extractTransformsFromEncoding(oldEncoding: Encoding, config }; if (aggOp) { - let op: AggregateOp; + let op: AggregateFieldOp; if (isArgmaxDef(aggOp)) { op = 'argmax'; @@ -425,6 +424,9 @@ export function extractTransformsFromEncoding(oldEncoding: Encoding, config op = 'argmin'; newField = vgField({op: 'argmin', field: aggOp.argmin}, {forAs: true}); newFieldDef.field = `${newField}.${field}`; + } else if (isExponentialDef(aggOp)) { + const exponentialValue = aggOp['exponential']; + op = {exponential: exponentialValue}; } else if (aggOp !== 'boxplot' && aggOp !== 'errorbar' && aggOp !== 'errorband') { op = aggOp; } diff --git a/src/transform.ts b/src/transform.ts index cb030f3de0..84b8131048 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -8,6 +8,7 @@ import {ParameterName} from './parameter'; import {normalizePredicate, Predicate} from './predicate'; import {SortField} from './sort'; import {TimeUnit, TimeUnitTransformParams} from './timeunit'; +import {ExponentialDef} from './aggregate'; export interface FilterTransform { /** @@ -98,24 +99,23 @@ export interface AggregateTransform { groupby?: FieldName[]; } +export type NonArgAggregateFieldOp = Exclude; + +export type AggregateFieldOp = NonArgAggregateFieldOp | ExponentialDef; + export interface AggregatedFieldDef { /** * The aggregation operation to apply to the fields (e.g., `"sum"`, `"average"`, or `"count"`). * See the [full list of supported aggregation operations](https://vega.github.io/vega-lite/docs/aggregate.html#ops) * for more information. */ - op: AggregateOp; + op: AggregateFieldOp; /** * The data field for which to compute aggregate function. This is required for all aggregation operations except `"count"`. */ field?: FieldName; - /** - * A parameter that can be passed to aggregation functions. The aggregation operation `"exponential"` requires it. - */ - aggregate_param?: number; - /** * The output field names to use for each aggregated field. */ diff --git a/test/channeldef.test.ts b/test/channeldef.test.ts index c25574932c..8c93addffe 100644 --- a/test/channeldef.test.ts +++ b/test/channeldef.test.ts @@ -39,6 +39,12 @@ describe('fieldDef', () => { ); }); + it('should support exponential operations', () => { + expect(vgField({aggregate: {exponential: 0.23}, field: 'a'}, {expr: 'datum'})).toBe('datum["exponential_a"]'); + + expect(vgField({op: {exponential: 0.54}, field: 'a', as: 'b'})).toBe('exponential_a'); + }); + it('should support prefix and field names with space', () => { expect(vgField({field: 'foo bar'}, {prefix: 'prefix'})).toBe('prefix_foo bar'); }); diff --git a/test/compile/data/aggregate.test.ts b/test/compile/data/aggregate.test.ts index eda09785e7..6425e9993c 100644 --- a/test/compile/data/aggregate.test.ts +++ b/test/compile/data/aggregate.test.ts @@ -268,6 +268,26 @@ describe('compile/data/aggregate', () => { as: ['argmin_a', 'argmax_c'] }); }); + + it('should produce the correct summary component for exponential', () => { + const model = parseUnitModel({ + mark: 'point', + encoding: { + x: {aggregate: {exponential: 0.25}, field: 'Displacement', type: 'quantitative'}, + y: {aggregate: 'sum', field: 'Acceleration', type: 'quantitative'} + } + }); + + const agg = AggregateNode.makeFromEncoding(null, model); + expect(agg.assemble()).toEqual({ + type: 'aggregate', + groupby: [], + ops: ['exponential', 'sum'], + fields: ['Displacement', 'Acceleration'], + as: ['exponential_Displacement', 'sum_Acceleration'], + aggregate_params: [0.25, null] + }); + }); }); describe('makeFromTransform', () => { @@ -310,11 +330,11 @@ describe('compile/data/aggregate', () => { }); }); - it('should produce the correct summary component from transform array with aggregation_params', () => { + it('should produce the correct summary component from transform array with exponential', () => { const t: AggregateTransform = { aggregate: [ {op: 'sum', field: 'Acceleration', as: 'Acceleration_sum'}, - {op: 'exponential', field: 'Displacement', as: 'Displacement_exponential', aggregate_param: 0.3} + {op: {exponential: 0.3}, field: 'Displacement', as: 'Displacement_exponential'} ], groupby: ['Group'] }; @@ -362,6 +382,25 @@ describe('compile/data/aggregate', () => { expect(agg1.merge(agg2)).toBe(true); expect(agg1.producedFields()).toEqual(new Set(['a_mean', 'b_mean'])); }); + it('should merge AggregateNodes without losing aggregateParam', () => { + const parent = new PlaceholderDataFlowNode(null); + const agg1 = new AggregateNode(parent, new Set(['a', 'b']), { + a: {sum: {aliases: new Set(['a_sum'])}} + }); + const agg2 = new AggregateNode(parent, new Set(['a', 'b']), { + b: {exponential: {aliases: new Set(['b_exponential']), aggregateParam: 0.5}} + }); + + expect(agg1.merge(agg2)).toBe(true); + expect(agg1.assemble()).toEqual({ + ops: ['sum', 'exponential'], + type: 'aggregate', + as: ['a_sum', 'b_exponential'], + fields: ['a', 'b'], + groupby: ['a', 'b'], + aggregate_params: [null, 0.5] + }); + }); }); describe('assemble()', () => { diff --git a/test/encoding.test.ts b/test/encoding.test.ts index 847bda7c6d..9d4d66804b 100644 --- a/test/encoding.test.ts +++ b/test/encoding.test.ts @@ -320,6 +320,38 @@ describe('encoding', () => { } }); }); + it('should extract aggregates with exponential operations from encoding', () => { + const output = extractTransformsFromEncoding( + initEncoding( + { + x: {field: 'a', type: 'quantitative'}, + y: { + aggregate: {exponential: 0.3}, + field: 'b', + type: 'quantitative' + } + }, + 'line', + false, + defaultConfig + ), + defaultConfig + ); + expect(output).toEqual({ + bins: [], + timeUnits: [], + aggregate: [{op: {exponential: 0.3}, field: 'b', as: 'exponential_b'}], + groupby: ['a'], + encoding: { + x: {field: 'a', type: 'quantitative'}, + y: { + field: 'exponential_b', + type: 'quantitative', + title: 'Exponential of b' + } + } + }); + }); it('should extract binning from encoding', () => { const output = extractTransformsFromEncoding( initEncoding( From 261728bbeadb674041266dc3276ffd46a4c7510d Mon Sep 17 00:00:00 2001 From: julieg18 Date: Tue, 13 Feb 2024 07:32:41 -0600 Subject: [PATCH 4/4] fix ts error shown in pr ci --- test/compositemark/errorbar.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/compositemark/errorbar.test.ts b/test/compositemark/errorbar.test.ts index f9d14a294a..ef8f91257b 100644 --- a/test/compositemark/errorbar.test.ts +++ b/test/compositemark/errorbar.test.ts @@ -1,4 +1,3 @@ -import {AggregateOp} from 'vega'; import {FieldName} from '../../src/channeldef'; import {ErrorBarCenter, ErrorBarExtent} from '../../src/compositemark/errorbar'; import {defaultConfig} from '../../src/config'; @@ -7,7 +6,7 @@ import {isMarkDef} from '../../src/mark'; import {normalize} from '../../src/normalize'; import {isLayerSpec, isUnitSpec} from '../../src/spec'; import {TopLevelUnitSpec} from '../../src/spec/unit'; -import {isAggregate, isCalculate, Transform} from '../../src/transform'; +import {AggregateFieldOp, isAggregate, isCalculate, Transform} from '../../src/transform'; import {some} from '../../src/util'; import {assertIsLayerSpec, assertIsUnitSpec} from '../util'; @@ -597,7 +596,7 @@ describe('normalizeErrorBar for all possible extents and centers with raw data i } }); -function isPartOfExtent(extent: ErrorBarExtent, op: AggregateOp) { +function isPartOfExtent(extent: ErrorBarExtent, op: AggregateFieldOp) { if (extent === 'ci') { return op === 'ci0' || op === 'ci1'; } else if (extent === 'iqr') {