diff --git a/examples/_debug/animation.html b/examples/_debug/animation.html
new file mode 100644
index 00000000..8db45568
--- /dev/null
+++ b/examples/_debug/animation.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+ Animation Example
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 9c923282..18f5d785 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -34,6 +34,8 @@ import { CategoryDataView, FormulaDataView, HistogramDataView } from './viz/data
// Widgets
import { CategoryWidget, FormulaWidget, HistogramWidget } from './viz/widget';
+import { Animation } from './viz/animation/Animation';
+
/*
* --- Public API ---
*/
@@ -94,5 +96,6 @@ export const viz = {
widget,
style,
...basemaps,
- ...basics
+ ...basics,
+ Animation
};
diff --git a/src/lib/maps/Client.ts b/src/lib/maps/Client.ts
index 582874cb..731852ae 100644
--- a/src/lib/maps/Client.ts
+++ b/src/lib/maps/Client.ts
@@ -46,7 +46,8 @@ export class Client {
vector_extent: vectorExtent,
vector_simplify_extent: vectorSimplifyExtent,
metadata,
- aggregation
+ aggregation,
+ dates_as_numbers: true
}
}
]
diff --git a/src/lib/viz/animation/Animation.spec.ts b/src/lib/viz/animation/Animation.spec.ts
new file mode 100644
index 00000000..65a9d35c
--- /dev/null
+++ b/src/lib/viz/animation/Animation.spec.ts
@@ -0,0 +1,190 @@
+import { FeatureCollection } from 'geojson';
+import { CartoError } from '@/core/errors/CartoError';
+import { Animation } from './Animation';
+import { Layer } from '../layer';
+import { GeoJSONSource } from '../source';
+
+describe('Animation', () => {
+ describe('Instantiation', () => {
+ it('should create instance with defaults', () => {
+ const animationLayer = createLayer();
+ spyOn(animationLayer, 'addSourceField');
+
+ const animationColumn = 'timestamp';
+
+ expect(new Animation(animationLayer, { column: animationColumn })).toMatchObject({
+ layer: animationLayer,
+ column: animationColumn,
+ duration: 10,
+ fade: 0.15
+ });
+
+ expect(animationLayer.addSourceField).toHaveBeenCalledWith(animationColumn);
+ });
+
+ it('should throw is column is not present or not numeric', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'fake_column' });
+
+ expect(async () => {
+ await animation.start();
+ }).rejects.toEqual(
+ new CartoError({
+ type: 'maltype',
+ message: 'asasdasd'
+ })
+ );
+ });
+ });
+
+ describe('Animation Progress', () => {
+ it('should start animation and emit N animationStep', async () => {
+ const animationLayer = createLayer();
+
+ const animationStepHandler = jest.fn();
+
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ animation.on('animationStep', animationStepHandler);
+
+ await animation.start();
+ expect(animationStepHandler).toHaveBeenCalled();
+ animation.stop();
+ });
+ });
+
+ describe('Properties', () => {
+ describe('isPlaying', () => {
+ it('should return animation state', () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ expect(animation.isPlaying).toBe(false);
+ });
+ });
+ });
+
+ describe('Methods', () => {
+ describe('play', () => {
+ it('should change isAnimationPause to false', () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+
+ expect(animation.isPlaying).toBe(false);
+ animation.play();
+ expect(animation.isPlaying).toBe(true);
+ });
+ });
+
+ describe('pause', () => {
+ it('should change isAnimationPause to true', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ await animation.start();
+
+ expect(animation.isPlaying).toBe(true);
+ animation.pause();
+ expect(animation.isPlaying).toBe(false);
+ });
+ });
+
+ describe('reset', () => {
+ it('should reset animationCurrentValue', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+
+ await animation.start();
+ animation.pause();
+
+ animation.setProgressPct(0.8);
+ let animationProperties = animation.getLayerProperties();
+ expect(animationProperties.filterSoftRange[0]).toBe(7200);
+
+ animation.reset();
+ animationProperties = animation.getLayerProperties();
+ expect(animationProperties.filterSoftRange[0]).toBe(0);
+ });
+ });
+
+ describe('stop', () => {
+ it('should call pause, reset and emit animationEnd', () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+
+ spyOn(animation, 'pause');
+ spyOn(animation, 'reset');
+ spyOn(animation, 'emit');
+
+ animation.stop();
+
+ expect(animation.pause).toHaveBeenCalled();
+ expect(animation.reset).toHaveBeenCalled();
+ expect(animation.emit).toHaveBeenCalledWith('animationEnd');
+ });
+ });
+
+ describe('setCurrent', () => {
+ it('should set animation value', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ await animation.start();
+ animation.pause();
+
+ animation.setCurrent(5000);
+ const layerProperties = animation.getLayerProperties();
+
+ expect(layerProperties).toMatchObject({ filterSoftRange: [4000, 4015] });
+ });
+
+ it('should fail if value is over or below limits', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ await animation.start();
+ animation.pause();
+
+ expect(() => animation.setCurrent(1)).toThrow(
+ new CartoError({
+ type: '[Animation]',
+ message: 'Value should be between 1000 and 10000'
+ })
+ );
+ });
+ });
+
+ describe('setProgressPct', () => {
+ it('should set animation percentage', async () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+ await animation.start();
+ animation.pause();
+
+ animation.setProgressPct(0.75);
+ const layerProperties = animation.getLayerProperties();
+
+ expect(layerProperties.filterSoftRange[0]).toBe(6750);
+ });
+
+ it('should fail if percentage is over 1 or below 0', () => {
+ const animationLayer = createLayer();
+ const animation = new Animation(animationLayer, { column: 'timestamp' });
+
+ expect(() => animation.setProgressPct(1.2)).toThrow(
+ new CartoError({
+ type: '[Animation]',
+ message: 'Value should be between 0 and 1'
+ })
+ );
+ });
+ });
+ });
+});
+
+function createLayer() {
+ const source = new GeoJSONSource({} as FeatureCollection);
+ spyOn(source, 'getMetadata').and.returnValue({
+ stats: [{ name: 'timestamp', type: 'number', min: 1000, max: 10000 }]
+ });
+
+ const layer = new Layer(source);
+ spyOn(layer, 'isReady').and.returnValue(true);
+
+ return layer;
+}
diff --git a/src/lib/viz/animation/Animation.ts b/src/lib/viz/animation/Animation.ts
new file mode 100644
index 00000000..35cf0a44
--- /dev/null
+++ b/src/lib/viz/animation/Animation.ts
@@ -0,0 +1,193 @@
+import { DataFilterExtension } from '@deck.gl/extensions';
+import { CartoError } from '@/core/errors/CartoError';
+import { WithEvents } from '@/core/mixins/WithEvents';
+import type { Layer } from '../layer/Layer';
+import { NumericFieldStats } from '../source';
+
+const SCREEN_HZ = 60;
+
+export class Animation extends WithEvents {
+ private layer: Layer;
+ private column: string;
+ private duration: number;
+ private fade: number;
+
+ private isAnimationPaused = true;
+ private animationRange: AnimationRange = { min: Infinity, max: -Infinity };
+ private originalAnimationRange: AnimationRange = { min: Infinity, max: -Infinity };
+ private animationCurrentValue = 0;
+ private animationStep = 0;
+ private animationFadeDuration = 0;
+
+ constructor(layer: Layer, options: AnimationOptions) {
+ super();
+
+ const {
+ column,
+ duration = DEFAULT_ANIMATION_OPTIONS.duration,
+ fade = DEFAULT_ANIMATION_OPTIONS.fade
+ } = options;
+
+ this.layer = layer;
+ this.column = column;
+ this.duration = duration;
+ this.fade = fade;
+
+ this.layer.addSourceField(this.column);
+ this.registerAvailableEvents(['animationStart', 'animationEnd', 'animationStep']);
+ }
+
+ async start() {
+ if (!this.layer.isReady()) {
+ this.layer.on('tilesLoaded', () => this.start());
+ return;
+ }
+
+ await this.layer.addAnimation(this);
+ await this.init();
+ this.play();
+ this.emit('animationStart');
+ this.onAnimationFrame();
+ }
+
+ public get isPlaying() {
+ return !this.isAnimationPaused;
+ }
+
+ play() {
+ this.isAnimationPaused = false;
+ }
+
+ pause() {
+ this.isAnimationPaused = true;
+ }
+
+ reset() {
+ this.animationCurrentValue = this.animationRange.min;
+ }
+
+ stop() {
+ this.pause();
+ this.reset();
+ this.emit('animationEnd');
+ }
+
+ setCurrent(value: number) {
+ if (value > this.originalAnimationRange.max || value < this.originalAnimationRange.min) {
+ throw new CartoError({
+ type: '[Animation]',
+ message: `Value should be between ${this.originalAnimationRange.min} and ${this.originalAnimationRange.max}`
+ });
+ }
+
+ this.animationCurrentValue = value - this.originalAnimationRange.min;
+ }
+
+ setProgressPct(progress: number) {
+ if (progress > 1 || progress < 0) {
+ throw new CartoError({
+ type: '[Animation]',
+ message: `Value should be between 0 and 1`
+ });
+ }
+
+ const progressValue = progress * (this.animationRange.max - this.animationRange.min);
+ this.animationCurrentValue = this.animationRange.min + progressValue;
+ }
+
+ getLayerProperties() {
+ const animationRangeStart = this.animationCurrentValue;
+ const animationRangeEnd = Math.min(
+ this.animationCurrentValue + this.animationStep,
+ this.animationRange.max
+ );
+
+ // Range defines timestamp range for
+ // visible features (some features may be fading in/out)
+ const filterRange = [
+ animationRangeStart - this.animationFadeDuration,
+ animationRangeEnd + this.animationFadeDuration
+ ];
+
+ // Soft Range defines the timestamp range when
+ // features are at max opacity and size
+ const filterSoftRange = [animationRangeStart, animationRangeEnd];
+
+ return {
+ extensions: [new DataFilterExtension({ filterSize: 1 })],
+ getFilterValue: (feature: GeoJSON.Feature) => {
+ if (!feature) {
+ return null;
+ }
+
+ return (feature.properties || {})[this.column] - this.originalAnimationRange.min;
+ },
+ filterRange,
+ filterSoftRange
+ };
+ }
+
+ private async init() {
+ const ranges = this.getAnimationRange();
+ this.animationRange = ranges.transformedRange;
+ this.originalAnimationRange = ranges.originalRange;
+ this.animationCurrentValue = this.animationRange.min;
+
+ const animationRange = this.animationRange.max - this.animationRange.min;
+ this.animationStep = animationRange / (SCREEN_HZ * this.duration);
+ this.animationFadeDuration = this.fade * this.animationStep * SCREEN_HZ;
+ }
+
+ private onAnimationFrame() {
+ if (this.isAnimationPaused) {
+ return;
+ }
+
+ if (this.animationCurrentValue > this.animationRange.max) {
+ this.reset();
+ }
+
+
+ this.emit('animationStep');
+
+ requestAnimationFrame(() => {
+ this.animationCurrentValue += this.animationStep;
+ this.onAnimationFrame();
+ });
+ }
+
+ private getAnimationRange() {
+ const layerMetadata = this.layer.source.getMetadata();
+ const columnStats = layerMetadata.stats.find(
+ column => column.name === this.column
+ ) as NumericFieldStats;
+
+ if (!columnStats || columnStats.type !== 'number') {
+ throw new CartoError({
+ message: 'Specified column is not present or does not contain timestamps or dates',
+ type: '[Animation]'
+ });
+ }
+
+ return {
+ originalRange: { min: columnStats.min, max: columnStats.max },
+ transformedRange: { min: 0, max: columnStats.max - columnStats.min }
+ };
+ }
+}
+
+export interface AnimationOptions {
+ column: string;
+ duration?: number;
+ fade?: number;
+}
+
+export interface AnimationRange {
+ min: number;
+ max: number;
+}
+
+const DEFAULT_ANIMATION_OPTIONS = {
+ duration: 10,
+ fade: 0.15
+};
diff --git a/src/lib/viz/layer/Layer.ts b/src/lib/viz/layer/Layer.ts
index 826ef14d..8e830853 100644
--- a/src/lib/viz/layer/Layer.ts
+++ b/src/lib/viz/layer/Layer.ts
@@ -4,6 +4,7 @@ import { GeoJsonLayer, IconLayer } from '@deck.gl/layers';
import { MVTLayer } from '@deck.gl/geo-layers';
import ViewState from '@deck.gl/core/controllers/view-state';
import mitt from 'mitt';
+import { DataFilterExtension } from '@deck.gl/extensions';
import deepmerge from 'deepmerge';
import { GeoJSON } from 'geojson';
import { uuidv4 } from '@/core/utils/uuid';
@@ -22,6 +23,7 @@ import { FiltersCollection } from '../filters/FiltersCollection';
import { FunctionFilterApplicator } from '../filters/FunctionFilterApplicator';
import { ColumnFilters } from '../filters/types';
import { basicStyle } from '../style/helpers/basic-style';
+import { Animation } from '../animation/Animation';
export enum LayerEvent {
DATA_READY = 'dataReady',
@@ -63,6 +65,8 @@ export class Layer extends WithEvents implements StyledLayer {
);
private dataState: DATA_STATES = DATA_STATES.STARTING;
+ private animationTest: Animation | undefined;
+
constructor(
source: string | Source,
style: Style | StyleProperties = basicStyle(),
@@ -314,6 +318,19 @@ export class Layer extends WithEvents implements StyledLayer {
return this._source.getFeatures(excludedFilters);
}
+ async addAnimation(animationInstance: Animation) {
+ this.animationTest = animationInstance;
+ this.animationTest.on('animationStep', () => {
+ if (this._deckLayer) {
+ this.replaceDeckGLLayer();
+ }
+ });
+
+ if (this._deckLayer) {
+ await this.replaceDeckGLLayer();
+ }
+ }
+
private _getLayerProperties() {
const props = this._source.getProps();
const styleProps = this.getStyle().getLayerProps(this);
@@ -336,12 +353,28 @@ export class Layer extends WithEvents implements StyledLayer {
onHover: this._interactivity.onHover.bind(this._interactivity)
};
+ filters.getOptions();
const layerProps = {
...this._options,
...props,
...styleProps,
...events,
- ...filters.getOptions()
+ ...(this.animationTest
+ ? this.animationTest.getLayerProperties()
+ : {
+ filterEnabled: false,
+ extensions: [new DataFilterExtension({ filterSize: 1 })],
+ getFilterValue(feature: GeoJSON.Feature) {
+ if (!feature) {
+ return 0;
+ }
+
+ const { properties = {} } = feature;
+ return (properties as Record).incdate;
+ },
+ filterRange: [0, 0],
+ filterSoftRange: [0, 0]
+ })
};
// Merge Update Triggers to avoid overriding
@@ -349,7 +382,11 @@ export class Layer extends WithEvents implements StyledLayer {
// updateTriggers
layerProps.updateTriggers = deepmerge.all([
layerProps.updateTriggers || {},
- this.filtersCollection.getUpdateTriggers()
+ {
+ getFilterValue: [Math.trunc(Math.random() * 1000)],
+ filterRange: [Math.trunc(Math.random() * 1000)],
+ filterSoftRange: [Math.trunc(Math.random() * 1000)]
+ }
]);
return ensureRelatedStyleProps(layerProps);
diff --git a/src/lib/viz/source/GeoJSONSource.ts b/src/lib/viz/source/GeoJSONSource.ts
index e34a4d4d..0c338b08 100644
--- a/src/lib/viz/source/GeoJSONSource.ts
+++ b/src/lib/viz/source/GeoJSONSource.ts
@@ -196,6 +196,7 @@ export class GeoJSONSource extends Source {
const sample = createSample(values);
numericStats.push({
+ type: 'number',
name: propName,
min,
max,
diff --git a/src/lib/viz/source/Source.ts b/src/lib/viz/source/Source.ts
index d106c276..5f3d687c 100644
--- a/src/lib/viz/source/Source.ts
+++ b/src/lib/viz/source/Source.ts
@@ -22,6 +22,12 @@ export interface Stats {
export interface NumericFieldStats extends Stats {
name: string;
+ sample: number[];
+ type: string;
+ min: number;
+ max: number;
+ avg: number;
+ sum: number;
}
export interface Category {
diff --git a/src/lib/viz/source/__tests__/GeoJSONSource.spec.ts b/src/lib/viz/source/__tests__/GeoJSONSource.spec.ts
index 7776ad0b..3e18b34a 100644
--- a/src/lib/viz/source/__tests__/GeoJSONSource.spec.ts
+++ b/src/lib/viz/source/__tests__/GeoJSONSource.spec.ts
@@ -156,6 +156,7 @@ describe('SourceMetadata', () => {
stats: [
{
name: 'number',
+ type: 'number',
min: 10,
max: 70,
avg: 100 / 3,
@@ -190,6 +191,7 @@ describe('SourceMetadata', () => {
stats: [
{
name: 'number',
+ type: 'number',
min: 10,
max: 70,
avg: 100 / 3,