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,