Skip to content

Commit

Permalink
feat: frame animations with time encoding and timer param (#8921)
Browse files Browse the repository at this point in the history
This change implements basic features of [Animated
Vega-Lite](https://vis.csail.mit.edu/pubs/animated-vega-lite/). With
this change, users can create frame animations using a `time` encoding,
a `timer` point selection, and a `filter` transform.

This change does _not_ include more complex features e.g.:
interpolation, custom predicates, rescale, interactive sliders, or
data-driven pausing.

- Adds a `time` encoding channel
- Adds `isTimerSelection` function to check if a selection is an
animation selections
- Builds `_curr` animation dataset for `timer` selections to store the
current animation frame
- Adds animation signals to track the elapsed time (`anim_clock`),
current animation value (`anim_value`), current position in the
animation field's domain (`t_index`), etc.
- When `time` encoding is present, updates associated marks' `from.data`
to use the animation dataset (current frame).

Relevant issue: #4060

Coauthor: @joshpoll

## Example specs

Hop example:

```json
{
  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
  "data": {
    "url": "data/seattle-weather.csv"
  },
  "mark": "tick",
  "config": {
    "tick": {
      "thickness": 3
    }
  },
  "params": [
    {
      "name": "date",
      "select": {
        "type": "point",
        "fields": [
          "date"
        ],
        "on": "timer"
      }
    }
  ],
  "transform": [
    {
      "filter": {
        "param": "date"
      }
    }
  ],
  "encoding": {
    "y": {
      "field": "precipitation",
      "type": "quantitative"
    },
    "time": {
      "field": "date"
    }
  }
}
```

Gapminder:
```json
{
  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
  "data": {
    "url": "data/gapminder.json"
  },
  "mark": "point",
  "params": [
    {
      "name": "avl",
      "select": {
        "type": "point",
        "fields": [
          "year"
        ],
        "on": "timer"
      }
    }
  ],
  "transform": [
    {
      "filter": {
        "param": "avl"
      }
    }
  ],
  "encoding": {
    "color": {
      "field": "country"
    },
    "x": {
      "field": "fertility",
      "type": "quantitative"
    },
    "y": {
      "field": "life_expect",
      "type": "quantitative"
    },
    "time": {
      "field": "year"
    }
  }
}
```

---------

Co-authored-by: Josh Pollock <jopo@mit.edu>
Co-authored-by: mattijn <mattijn@gmail.com>
  • Loading branch information
3 people authored Nov 14, 2024
1 parent 6839bd9 commit e3f9620
Show file tree
Hide file tree
Showing 41 changed files with 1,292 additions and 69 deletions.
289 changes: 289 additions & 0 deletions build/vega-lite-schema.json

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions examples/specs/animated_gapminder.vl.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"url": "data/gapminder.json"
},
"mark": "point",
"params": [
{
"name": "animation_frame",
"select": {
"type": "point",
"fields": [
"year"
],
"on": "timer"
}
}
],
"transform": [
{
"filter": {
"param": "animation_frame"
}
}
],
"encoding": {
"color": {
"field": "country"
},
"x": {
"field": "fertility",
"type": "quantitative"
},
"y": {
"field": "life_expect",
"type": "quantitative"
},
"time": {
"field": "year",
"type": "ordinal"
}
}
}
41 changes: 41 additions & 0 deletions examples/specs/animated_hop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"url": "data/seattle-weather.csv"
},
"mark": "tick",
"config": {
"tick": {
"thickness": 3
}
},
"params": [
{
"name": "animation_frame",
"select": {
"type": "point",
"fields": [
"date"
],
"on": "timer"
}
}
],
"transform": [
{
"filter": {
"param": "animation_frame"
}
}
],
"encoding": {
"y": {
"field": "precipitation",
"type": "quantitative"
},
"time": {
"field": "date",
"type": "ordinal"
}
}
}
19 changes: 19 additions & 0 deletions src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export const LONGITUDE = 'longitude' as const;
export const LATITUDE2 = 'latitude2' as const;
export const LONGITUDE2 = 'longitude2' as const;

// Time
export const TIME = 'time' as const;

// Mark property with scale
export const COLOR = 'color' as const;

Expand Down Expand Up @@ -136,6 +139,9 @@ const UNIT_CHANNEL_INDEX: Flag<Channel> = {
fill: 1,
stroke: 1,

// time
time: 1,

// other non-position with scale
opacity: 1,
fillOpacity: 1,
Expand Down Expand Up @@ -425,6 +431,16 @@ export function isXorYOffset(channel: Channel): channel is OffsetScaleChannel {
return hasOwnProperty(OFFSET_SCALE_CHANNEL_INDEX, channel);
}

const TIME_SCALE_CHANNEL_INDEX = {
time: 1
} as const;
export const TIME_SCALE_CHANNELS = keys(TIME_SCALE_CHANNEL_INDEX);
export type TimeScaleChannel = keyof typeof TIME_SCALE_CHANNEL_INDEX;

export function isTime(channel: ExtendedChannel): channel is TimeScaleChannel {
return channel in TIME_SCALE_CHANNEL_INDEX;
}

// NON_POSITION_SCALE_CHANNEL = SCALE_CHANNELS without position / offset
const {
// x2 and y2 share the same scale as x and y
Expand Down Expand Up @@ -465,6 +481,7 @@ export function supportLegend(channel: NonPositionScaleChannel) {
case FILLOPACITY:
case STROKEOPACITY:
case ANGLE:
case TIME:
return false;
}
}
Expand Down Expand Up @@ -552,6 +569,7 @@ function getSupportedMark(channel: ExtendedChannel): SupportedMark {
case YOFFSET:
case LATITUDE:
case LONGITUDE:
case TIME:
// all marks except geoshape. geoshape does not use X, Y -- it uses a projection
return ALL_MARKS_EXCEPT_GEOSHAPE;
case X2:
Expand Down Expand Up @@ -626,6 +644,7 @@ export function rangeType(channel: ExtendedChannel): RangeType {
case OPACITY:
case FILLOPACITY:
case STROKEOPACITY:
case TIME:

// X2 and Y2 use X and Y scales, so they similarly have continuous range. [falls through]
case X2:
Expand Down
14 changes: 11 additions & 3 deletions src/channeldef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
TEXT,
THETA,
THETA2,
TIME,
TOOLTIP,
URL,
X,
Expand Down Expand Up @@ -518,6 +519,12 @@ export interface PositionMixins {

export type PolarDef<F extends Field> = PositionFieldDefBase<F> | PositionDatumDefBase<F> | PositionValueDef;

export type TimeDef<F extends Field> = TimeFieldDef<F>;
export interface TimeMixins {
rescale?: boolean;
}
export type TimeFieldDef<F extends Field> = ScaleFieldDef<F, StandardType> & TimeMixins;

export function getBandPosition({
fieldDef,
fieldDef2,
Expand Down Expand Up @@ -1303,6 +1310,7 @@ export function channelCompatibility(
case RADIUS2:
case X2:
case Y2:
case TIME:
if (type === 'nominal' && !(fieldDef as any)['sort']) {
return {
compatible: false,
Expand Down Expand Up @@ -1338,13 +1346,13 @@ export function channelCompatibility(
*/
export function isFieldOrDatumDefForTimeFormat(fieldOrDatumDef: FieldDef<string> | DatumDef): boolean {
const {formatType} = getFormatMixins(fieldOrDatumDef);
return formatType === 'time' || (!formatType && isTimeFieldDef(fieldOrDatumDef));
return formatType === 'time' || (!formatType && isTemporalFieldDef(fieldOrDatumDef));
}

/**
* Check if field def has type `temporal`. If you want to also cover field defs that use a time format, use `isTimeFormatFieldDef`.
* Check if field def has type `temporal`. If you want to also cover field defs that use a time format, use `isFieldOrDatumDefForTimeFormat`.
*/
export function isTimeFieldDef(def: FieldDef<any> | DatumDef): boolean {
export function isTemporalFieldDef(def: FieldDef<any> | DatumDef): boolean {
return def && ((def as any)['type'] === 'temporal' || (isFieldDef(def) && !!def.timeUnit));
}

Expand Down
7 changes: 2 additions & 5 deletions src/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,8 @@ function assembleTopLevelModel(
// Config with Vega-Lite only config removed.
const vgConfig = model.config ? stripAndRedirectConfig(model.config) : undefined;

const data = [].concat(
model.assembleSelectionData([]),
// only assemble data in the root
assembleRootData(model.component.data, datasets)
);
const rootData = assembleRootData(model.component.data, datasets);
const data = model.assembleSelectionData(rootData);

const projections = model.assembleProjections();
const title = model.assembleTitle();
Expand Down
6 changes: 6 additions & 0 deletions src/compile/concat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {parseData} from './data/parse';
import {assembleLayoutSignals} from './layoutsize/assemble';
import {parseConcatLayoutSize} from './layoutsize/parse';
import {Model} from './model';
import {MULTI_VIEW_ANIMATION_UNSUPPORTED} from '../log/message';
import {isTimerSelection} from './selection';

export class ConcatModel extends Model {
public readonly children: Model[];
Expand Down Expand Up @@ -43,6 +45,10 @@ export class ConcatModel extends Model {
this.component.selection[key] = child.component.selection[key];
}
}

if (Object.values(this.component.selection).some(selCmpt => isTimerSelection(selCmpt))) {
log.error(MULTI_VIEW_ANIMATION_UNSUPPORTED);
}
}

public parseMarkGroup() {
Expand Down
6 changes: 6 additions & 0 deletions src/compile/facet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {parseChildrenLayoutSize} from './layoutsize/parse';
import {Model, ModelWithField} from './model';
import {assembleDomain, getFieldFromDomain} from './scale/domain';
import {assembleFacetSignals} from './selection/assemble';
import {isTimerSelection} from './selection';
import {MULTI_VIEW_ANIMATION_UNSUPPORTED} from '../log/message';

export function facetSortFieldName(
fieldDef: FacetFieldDef<string>,
Expand Down Expand Up @@ -113,6 +115,10 @@ export class FacetModel extends ModelWithField {
// within its unit.
this.child.parseSelections();
this.component.selection = this.child.component.selection;

if (Object.values(this.component.selection).some(selCmpt => isTimerSelection(selCmpt))) {
log.error(MULTI_VIEW_ANIMATION_UNSUPPORTED);
}
}

public parseMarkGroup() {
Expand Down
6 changes: 6 additions & 0 deletions src/compile/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {assembleLegends} from './legend/assemble';
import {Model} from './model';
import {assembleLayerSelectionMarks} from './selection/assemble';
import {UnitModel} from './unit';
import {isTimerSelection} from './selection';
import {MULTI_VIEW_ANIMATION_UNSUPPORTED} from '../log/message';

export class LayerModel extends Model {
// HACK: This should be (LayerModel | UnitModel)[], but setting the correct type leads to weird error.
Expand Down Expand Up @@ -68,6 +70,10 @@ export class LayerModel extends Model {
this.component.selection[key] = child.component.selection[key];
}
}

if (Object.values(this.component.selection).some(selCmpt => isTimerSelection(selCmpt))) {
log.error(MULTI_VIEW_ANIMATION_UNSUPPORTED);
}
}

public parseMarkGroup() {
Expand Down
19 changes: 0 additions & 19 deletions src/compile/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,25 +598,6 @@ export abstract class Model {
return undefined;
}

/**
* Corrects the data references in marks after assemble.
*/
public correctDataNames = (mark: VgMarkGroup) => {
// TODO: make this correct

// for normal data references
if (mark.from?.data) {
mark.from.data = this.lookupDataSource(mark.from.data);
}

// for access to facet data
if (mark.from?.facet?.data) {
mark.from.facet.data = this.lookupDataSource(mark.from.facet.data);
}

return mark;
};

/**
* Traverse a model's hierarchy to get the scale component for a particular channel.
*/
Expand Down
10 changes: 9 additions & 1 deletion src/compile/scale/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
X,
XOFFSET,
Y,
YOFFSET
YOFFSET,
TIME
} from '../../channel';
import {
getBandPosition,
Expand Down Expand Up @@ -317,6 +318,13 @@ function defaultRange(channel: ScaleChannel, model: UnitModel): VgRange {
];
}

case TIME: {
// if (scaleType === 'band') {
return {step: 1000 / config.scale.framesPerSecond};
// }
// return [0, config.scale.animationDuration * 1000]; // TODO(jzong): uncomment for linear scales when interpolation is implemented
}

case STROKEWIDTH:
// TODO: support custom rangeMin, rangeMax
return [config.scale.minStrokeWidth, config.scale.maxStrokeWidth];
Expand Down
12 changes: 12 additions & 0 deletions src/compile/scale/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getSizeChannel,
isColorChannel,
isScaleChannel,
isTime,
isXorY,
isXorYOffset,
rangeType,
Expand Down Expand Up @@ -76,6 +77,10 @@ function defaultType(
return 'ordinal';
}

if (isTime(channel)) {
return 'band';
}

if (isXorY(channel) || isXorYOffset(channel)) {
if (util.contains(['rect', 'bar', 'image', 'rule', 'tick'], mark.type)) {
// The rect/bar/tick mark should fit into a band.
Expand Down Expand Up @@ -111,7 +116,11 @@ function defaultType(
return 'ordinal';
} else if (isFieldDef(fieldDef) && fieldDef.timeUnit && normalizeTimeUnit(fieldDef.timeUnit).utc) {
return 'utc';
} else if (isTime(channel)) {
// return 'linear';
return 'band'; // TODO(jzong): when interpolation is implemented, this should be 'linear'
}

return 'time';

case 'quantitative':
Expand All @@ -125,6 +134,9 @@ function defaultType(
log.warn(log.message.discreteChannelCannotEncode(channel, 'quantitative'));
// TODO: consider using quantize (equivalent to binning) once we have it
return 'ordinal';
} else if (isTime(channel)) {
// return 'linear';
return 'band'; // TODO(jzong): when interpolation is implemented, this should be 'linear'
}

return 'linear';
Expand Down
Loading

0 comments on commit e3f9620

Please sign in to comment.