Skip to content

Commit 21b71dd

Browse files
jonathanzongclaude
andcommitted
feat(animation): recompute scales per frame with time.rescale
`rescale` was declared on the time encoding but never read, so a frame's scales were always fixed across the whole animation. That is the right default -- it keeps positions comparable between frames -- but it makes some animations unreadable: in a racing bar chart the early frames' values are a rounding error against the final ones, so the bars start out invisible and never reorder. Read it. When set, redirect the affected scale domains to the current frame dataset during domain assembly. Three things are deliberately excluded. Scales with a discrete output range are left alone, since moving between their outputs is not a continuous change. The time scale is left alone because it defines the extent of the animation, and narrowing it to the current frame would collapse the very domain being played through. And only the unit's own raw and main sources are redirected, since other datasets have no per-frame counterpart. Both raw and main are redirected because a domain may read either: a band domain sorted by another field reads the raw source, and in a racing bar chart that domain is precisely the one whose reordering is the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ac3969 commit 21b71dd

7 files changed

Lines changed: 158 additions & 7 deletions

File tree

build/vega-lite-schema.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26942,6 +26942,7 @@
2694226942
"description": "__Required.__ A string defining the name of the field from which to pull a data value or an object defining iterated values from the [`repeat`](https://vega.github.io/vega-lite/docs/repeat.html) operator.\n\n__See also:__ [`field`](https://vega.github.io/vega-lite/docs/field.html) documentation.\n\n__Notes:__ 1) Dots (`.`) and brackets (`[` and `]`) can be used to access nested objects (e.g., `\"field\": \"foo.bar\"` and `\"field\": \"foo['bar']\"`). If field names contain dots or brackets but are not nested, you can use `\\\\` to escape dots and brackets (e.g., `\"a\\\\.b\"` and `\"a\\\\[0\\\\]\"`). See more details about escaping in the [field documentation](https://vega.github.io/vega-lite/docs/field.html). 2) `field` is not required if `aggregate` is `count`."
2694326943
},
2694426944
"rescale": {
26945+
"description": "Whether the animated marks' scales should be recomputed from the current frame rather than held fixed across the whole animation. Rescaling keeps each frame's data filling the view -- as in a racing bar chart, where the bars stay legible as their magnitudes grow -- at the cost of making positions incomparable between frames.\n\nScales with a discrete output range (`ordinal`, `bin-ordinal`, `quantile`, `quantize`, and `threshold`) are never rescaled, since interpolating between their outputs is not meaningful.\n\n__Default value:__ `false`",
2694526946
"type": "boolean"
2694626947
},
2694726948
"scale": {
@@ -29507,6 +29508,7 @@
2950729508
"description": "__Required.__ A string defining the name of the field from which to pull a data value or an object defining iterated values from the [`repeat`](https://vega.github.io/vega-lite/docs/repeat.html) operator.\n\n__See also:__ [`field`](https://vega.github.io/vega-lite/docs/field.html) documentation.\n\n__Notes:__ 1) Dots (`.`) and brackets (`[` and `]`) can be used to access nested objects (e.g., `\"field\": \"foo.bar\"` and `\"field\": \"foo['bar']\"`). If field names contain dots or brackets but are not nested, you can use `\\\\` to escape dots and brackets (e.g., `\"a\\\\.b\"` and `\"a\\\\[0\\\\]\"`). See more details about escaping in the [field documentation](https://vega.github.io/vega-lite/docs/field.html). 2) `field` is not required if `aggregate` is `count`."
2950829509
},
2950929510
"rescale": {
29511+
"description": "Whether the animated marks' scales should be recomputed from the current frame rather than held fixed across the whole animation. Rescaling keeps each frame's data filling the view -- as in a racing bar chart, where the bars stay legible as their magnitudes grow -- at the cost of making positions incomparable between frames.\n\nScales with a discrete output range (`ordinal`, `bin-ordinal`, `quantile`, `quantize`, and `threshold`) are never rescaled, since interpolating between their outputs is not meaningful.\n\n__Default value:__ `false`",
2951029512
"type": "boolean"
2951129513
},
2951229514
"scale": {

site/_includes/docs_toc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@
352352
- [Animation]({{site.baseurl}}/docs/animation.html)
353353
- [The Time Encoding Channel]({{site.baseurl}}/docs/animation.html#time-encoding)
354354
- [Keyframes vs. Continuous Time]({{site.baseurl}}/docs/animation.html#timing)
355+
- [Rescaling]({{site.baseurl}}/docs/animation.html#rescale)
355356
- [Limitations]({{site.baseurl}}/docs/animation.html#limitations)
356357
- [Config]({{site.baseurl}}/docs/config.html)
357358
- [Top-level Configuration]({{site.baseurl}}/docs/config.html#top-level-config)

site/docs/animation.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ This is the design described in [Animated Vega-Lite](https://vis.csail.mit.edu/p
2929

3030
The `time` channel names the field the animation runs over and how that field maps onto elapsed time.
3131

32-
{% include table.html props="field,type,timeUnit,scale" source="TimeFieldDef" %}
32+
{% include table.html props="field,type,timeUnit,scale,rescale" source="TimeFieldDef" %}
3333

3434
{:#timing}
3535

@@ -45,6 +45,18 @@ Set `"scale": {"type": "linear"}` instead when the field is continuous and what
4545

4646
Unlike a visual channel, the time channel accepts either scale type for any orderable field, temporal ones included: it maps onto elapsed playback time rather than a visual range.
4747

48+
{:#rescale}
49+
50+
## Rescaling
51+
52+
By default the scales are fixed across the whole animation, which keeps positions comparable between frames. Set `"rescale": true` on the time encoding to recompute them from each frame instead. This is what makes a racing bar chart readable: without it, the early frames' values are a rounding error against the final ones and the bars start out invisible.
53+
54+
```json
55+
"time": {"field": "date", "type": "ordinal", "rescale": true}
56+
```
57+
58+
Scales with a discrete output range (`ordinal`, `bin-ordinal`, `quantile`, `quantize`, and `threshold`) are never rescaled, since moving between their outputs is not a continuous change. Neither is the time scale itself, which defines the extent of the animation.
59+
4860
{:#limitations}
4961

5062
## Limitations

src/channeldef.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ import {isSignalRef} from './vega.schema.js';
106106
export type PrimitiveValue = number | string | boolean | null;
107107

108108
export type Value<ES extends ExprRef | SignalRef = ExprRef | SignalRef> =
109-
PrimitiveValue | number[] | Gradient | Text | ES;
109+
| PrimitiveValue
110+
| number[]
111+
| Gradient
112+
| Text
113+
| ES;
110114

111115
/**
112116
* Definition object for a constant value (primitive value or gradient definition) of an encoding channel.
@@ -139,7 +143,9 @@ export type ValueDefWithCondition<F extends FieldDef<any> | DatumDef<any>, V ext
139143
* A field definition or one or more value definition(s) with a parameter predicate.
140144
*/
141145
condition?:
142-
Conditional<F> | Conditional<ValueDef<V | ExprRef | SignalRef>> | Conditional<ValueDef<V | ExprRef | SignalRef>>[];
146+
| Conditional<F>
147+
| Conditional<ValueDef<V | ExprRef | SignalRef>>
148+
| Conditional<ValueDef<V | ExprRef | SignalRef>>[];
143149
};
144150

145151
export type StringValueDefWithCondition<F extends Field, T extends Type = StandardType> = ValueDefWithCondition<
@@ -393,7 +399,9 @@ export interface ScaleMixins {
393399
}
394400

395401
export type OffsetDef<F extends Field, T extends Type = StandardType> =
396-
ScaleFieldDef<F, T> | ScaleDatumDef<F> | ValueDef<number>;
402+
| ScaleFieldDef<F, T>
403+
| ScaleDatumDef<F>
404+
| ValueDef<number>;
397405

398406
export interface DatumDef<
399407
F extends Field = string,
@@ -534,6 +542,19 @@ export type PolarDef<F extends Field> = PositionFieldDefBase<F> | PositionDatumD
534542

535543
export type TimeDef<F extends Field> = TimeFieldDef<F>;
536544
export interface TimeMixins {
545+
/**
546+
* Whether the animated marks' scales should be recomputed from the current
547+
* frame rather than held fixed across the whole animation. Rescaling keeps
548+
* each frame's data filling the view -- as in a racing bar chart, where the
549+
* bars stay legible as their magnitudes grow -- at the cost of making
550+
* positions incomparable between frames.
551+
*
552+
* Scales with a discrete output range (`ordinal`, `bin-ordinal`, `quantile`,
553+
* `quantize`, and `threshold`) are never rescaled, since interpolating
554+
* between their outputs is not meaningful.
555+
*
556+
* __Default value:__ `false`
557+
*/
537558
rescale?: boolean;
538559
}
539560
export type TimeFieldDef<F extends Field> = ScaleFieldDef<F, StandardType> & TimeMixins;
@@ -637,7 +658,8 @@ export type MarkPropFieldDef<F extends Field, T extends Type = Type> = ScaleFiel
637658
export type MarkPropDatumDef<F extends Field> = LegendMixins & ScaleDatumDef<F>;
638659

639660
export type MarkPropFieldOrDatumDef<F extends Field, T extends Type = Type> =
640-
MarkPropFieldDef<F, T> | MarkPropDatumDef<F>;
661+
| MarkPropFieldDef<F, T>
662+
| MarkPropDatumDef<F>;
641663

642664
export interface LegendMixins {
643665
/**

src/compile/scale/domain.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
MULTIDOMAIN_SORT_OP_INDEX as UNIONDOMAIN_SORT_OP_INDEX,
1111
} from '../../aggregate.js';
1212
import {isBinning, isBinParams, isParameterExtent} from '../../bin.js';
13-
import {getSecondaryRangeChannel, isScaleChannel, isXorY, ScaleChannel} from '../../channel.js';
13+
import {getSecondaryRangeChannel, isScaleChannel, isXorY, ScaleChannel, TIME} from '../../channel.js';
1414
import {
1515
binRequiresRange,
1616
getBandPosition,
@@ -20,6 +20,7 @@ import {
2020
isFieldDef,
2121
ScaleDatumDef,
2222
ScaleFieldDef,
23+
TimeFieldDef,
2324
TypedFieldDef,
2425
valueExpr,
2526
vgField,
@@ -30,7 +31,15 @@ import {DateTime} from '../../datetime.js';
3031
import {ExprRef} from '../../expr.js';
3132
import * as log from '../../log/index.js';
3233
import {isPathMark, isRectBasedMark} from '../../mark.js';
33-
import {Domain, hasDiscreteDomain, isDomainUnionWith, isParameterDomain, ScaleConfig, ScaleType} from '../../scale.js';
34+
import {
35+
Domain,
36+
hasDiscreteDomain,
37+
hasDiscreteRange,
38+
isDomainUnionWith,
39+
isParameterDomain,
40+
ScaleConfig,
41+
ScaleType,
42+
} from '../../scale.js';
3443
import {ParameterExtent} from '../../selection.js';
3544
import {DEFAULT_SORT_OP, EncodingSortField, isSortArray, isSortByEncoding, isSortField} from '../../sort.js';
3645
import {normalizeTimeUnit, TimeUnit, TimeUnitTransformParams} from '../../timeunit.js';
@@ -58,6 +67,7 @@ import {isFacetModel, isUnitModel, Model} from '../model.js';
5867
import {SignalRefWrapper} from '../signal.js';
5968
import {Explicit, makeExplicit, makeImplicit, mergeValuesWithExplicit} from '../split.js';
6069
import {UnitModel} from '../unit.js';
70+
import {CURR} from '../selection/point.js';
6171
import {ScaleComponent, ScaleComponentIndex} from './component.js';
6272

6373
export function parseScaleDomain(model: Model) {
@@ -695,15 +705,53 @@ export function getFieldFromDomain(domain: VgDomain): string {
695705
return undefined;
696706
}
697707

708+
/**
709+
* When a scale should be recomputed from the current animation frame, the
710+
* datasets to redirect away from and the frame dataset to redirect to.
711+
*
712+
* A domain may read either the unit's raw source or its main one -- a band
713+
* domain sorted by another field uses the raw source, for instance -- and both
714+
* should follow the animation. Both redirect to the main source's frame
715+
* dataset, which is the data the marks themselves draw.
716+
*
717+
* The time scale is never redirected: it defines the extent of the animation,
718+
* so narrowing it to the current frame would collapse the very domain being
719+
* played through.
720+
*/
721+
function animationRescale(model: Model, channel: ScaleChannel): {sources: Set<string>; frame: string} | undefined {
722+
if (channel === TIME || !isUnitModel(model) || !model.isAnimated) {
723+
return undefined;
724+
}
725+
726+
if (!(model.encoding.time as TimeFieldDef<string>)?.rescale) {
727+
return undefined;
728+
}
729+
730+
if (hasDiscreteRange(model.component.scales[channel]?.get('type'))) {
731+
return undefined;
732+
}
733+
734+
const main = model.lookupDataSource(model.getDataName(DataSourceType.Main));
735+
return {
736+
sources: new Set([main, model.lookupDataSource(model.getDataName(DataSourceType.Raw))]),
737+
frame: main + CURR,
738+
};
739+
}
740+
698741
export function assembleDomain(model: Model, channel: ScaleChannel) {
699742
const scaleComponent: ScaleComponent = model.component.scales[channel];
743+
const rescale = animationRescale(model, channel);
700744

701745
const domains = scaleComponent.get('domains').map((domain: VgNonUnionDomain) => {
702746
// Correct references to data as the original domain's data was determined
703747
// in parseScale, which happens before parseData. Thus the original data
704748
// reference can be incorrect.
705749
if (isDataRefDomain(domain)) {
706750
domain.data = model.lookupDataSource(domain.data);
751+
752+
if (rescale?.sources.has(domain.data)) {
753+
domain.data = rescale.frame;
754+
}
707755
}
708756

709757
return domain;

src/scale.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ export function isContinuousToDiscrete(type: ScaleType): type is 'quantile' | 'q
180180
return CONTINUOUS_TO_DISCRETE_SCALES.has(type);
181181
}
182182

183+
/**
184+
* Whether a scale's *output* is a set of discrete values rather than a
185+
* continuous span. Note that `band` and `point` are not among these: their
186+
* domains are discrete but they map onto continuous positions.
187+
*/
188+
export function hasDiscreteRange(type: ScaleType): boolean {
189+
return type === 'ordinal' || type === 'bin-ordinal' || isContinuousToDiscrete(type);
190+
}
191+
183192
export interface ScaleConfig<ES extends ExprRef | SignalRef> extends ScaleInvalidDataConfigMixins {
184193
/**
185194
* If true, rounds numeric output values to integers.

test/compile/animation.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ const gapminder = (select: any, extraParams: any[] = []): TopLevelSpec => ({
2020
},
2121
});
2222

23+
const racingBars = (rescale: boolean): TopLevelSpec => ({
24+
data: {url: 'data/category-brands.csv'},
25+
params: [{name: 'frame', select: {type: 'point', on: 'timer'}}],
26+
transform: [{filter: {param: 'frame'}}],
27+
mark: 'bar',
28+
encoding: {
29+
x: {field: 'value', type: 'quantitative'},
30+
y: {field: 'name', type: 'nominal', sort: {field: 'value', order: 'descending'}},
31+
color: {field: 'category', type: 'nominal'},
32+
time: {field: 'date', type: 'ordinal', ...(rescale ? {rescale: true} : {})},
33+
},
34+
});
35+
36+
const scaleDomains = (spec: TopLevelSpec) =>
37+
Object.fromEntries(compile(spec).spec.scales.map((s) => [s.name, (s.domain as any)?.data]));
38+
2339
describe('animation', () => {
2440
describe('frame filter placement', () => {
2541
it('moves the frame filter off an upstream dataset', () => {
@@ -78,4 +94,45 @@ describe('animation', () => {
7894
);
7995
});
8096
});
97+
describe('rescale', () => {
98+
it('leaves scale domains on the full dataset by default', () => {
99+
const domains = scaleDomains(racingBars(false));
100+
expect(domains.x).not.toMatch(/_curr$/);
101+
expect(domains.y).not.toMatch(/_curr$/);
102+
});
103+
104+
it('reads continuous domains from the current frame', () => {
105+
expect(scaleDomains(racingBars(true)).x).toMatch(/_curr$/);
106+
});
107+
108+
it('reads a sorted band domain from the current frame', () => {
109+
// a band domain sorted by another field reads the raw source rather than
110+
// the main one; it has to follow the animation too, or the bars in a
111+
// racing bar chart never reorder
112+
expect(scaleDomains(racingBars(true)).y).toMatch(/_curr$/);
113+
});
114+
115+
it('leaves scales with a discrete range alone', () => {
116+
// interpolating between an ordinal scale's outputs is not meaningful
117+
expect(scaleDomains(racingBars(true)).color).not.toMatch(/_curr$/);
118+
});
119+
120+
it('leaves the time scale alone', () => {
121+
// the time scale defines the extent of the animation; narrowing it to the
122+
// current frame would collapse the domain being played through
123+
expect(scaleDomains(racingBars(true)).time).not.toMatch(/_curr$/);
124+
});
125+
126+
it('points the rescaled domains at a dataset that exists', () => {
127+
const compiled = compile(racingBars(true)).spec;
128+
const names = new Set(compiled.data.map((d) => d.name));
129+
130+
for (const scale of compiled.scales) {
131+
const data = (scale.domain as any)?.data;
132+
if (data) {
133+
expect(names).toContain(data);
134+
}
135+
}
136+
});
137+
});
81138
});

0 commit comments

Comments
 (0)