@deck.gl/core
Version:
deck.gl core library
416 lines (361 loc) • 13 kB
text/typescript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import {deepEqual} from '../utils/deep-equal';
import log from '../utils/log';
import {flatten} from '../utils/flatten';
import type Controller from '../controllers/controller';
import type {ViewStateChangeParameters, InteractionState} from '../controllers/controller';
import type Viewport from '../viewports/viewport';
import type View from '../views/view';
import type {Timeline} from '@luma.gl/engine';
import type {EventManager} from 'mjolnir.js';
import type {ConstructorOf} from '../types/types';
import type {default as MapView, MapViewState} from '../views/map-view';
export type ViewOrViews = View | View[] | null;
type ViewStateOf<ViewT> = ViewT extends View<infer ViewStateT> ? ViewStateT : never;
type OneOfViews<ViewsT extends ViewOrViews> = ViewsT extends null
? MapView
: ViewsT extends View[]
? ViewsT[number]
: ViewsT;
export type AnyViewStateOf<ViewsT extends ViewOrViews> = ViewStateOf<OneOfViews<ViewsT>>;
export type ViewStateMap<ViewsT extends ViewOrViews> = ViewsT extends null
? MapViewState
: ViewsT extends View
? ViewStateOf<ViewsT>
: {[viewId: string]: AnyViewStateOf<ViewsT>};
/** This is a very lose type of all "acceptable" viewState
* It's not good for type hinting but matches what may exist internally
*/
export type ViewStateObject<ViewsT extends ViewOrViews> =
| ViewStateMap<ViewsT>
| AnyViewStateOf<ViewsT>
| {[viewId: string]: AnyViewStateOf<ViewsT>};
/** ViewManager props directly supplied by the user */
type ViewManagerProps<ViewsT extends ViewOrViews> = {
views: ViewsT;
viewState: ViewStateObject<ViewsT> | null;
onViewStateChange?: (params: ViewStateChangeParameters<AnyViewStateOf<ViewsT>>) => void;
onInteractionStateChange?: (state: InteractionState) => void;
width?: number;
height?: number;
};
export default class ViewManager<ViewsT extends View[]> {
width: number;
height: number;
views: View[];
viewState: ViewStateObject<ViewsT>;
controllers: {[viewId: string]: Controller<any> | null};
timeline: Timeline;
private _viewports: Viewport[];
private _viewportMap: {[viewId: string]: Viewport};
private _isUpdating: boolean;
private _needsRedraw: string | false;
private _needsUpdate: string | false;
private _eventManager: EventManager;
private _eventCallbacks: {
onViewStateChange?: (params: ViewStateChangeParameters) => void;
onInteractionStateChange?: (state: InteractionState) => void;
};
constructor(
props: ViewManagerProps<ViewsT> & {
// Initial options
timeline: Timeline;
eventManager: EventManager;
}
) {
// List of view descriptors, gets re-evaluated when width/height changes
this.views = [];
this.width = 100;
this.height = 100;
this.viewState = {} as any;
this.controllers = {};
this.timeline = props.timeline;
this._viewports = []; // Generated viewports
this._viewportMap = {};
this._isUpdating = false;
this._needsRedraw = 'First render';
this._needsUpdate = 'Initialize';
this._eventManager = props.eventManager;
this._eventCallbacks = {
onViewStateChange: props.onViewStateChange,
onInteractionStateChange: props.onInteractionStateChange
};
Object.seal(this);
// Init with default map viewport
this.setProps(props);
}
/** Remove all resources and event listeners */
finalize(): void {
for (const key in this.controllers) {
const controller = this.controllers[key];
if (controller) {
controller.finalize();
}
}
this.controllers = {};
}
/** Check if a redraw is needed */
needsRedraw(
opts: {
/** Reset redraw flags to false */
clearRedrawFlags?: boolean;
} = {clearRedrawFlags: false}
): string | false {
const redraw = this._needsRedraw;
if (opts.clearRedrawFlags) {
this._needsRedraw = false;
}
return redraw;
}
/** Mark the manager as dirty. Will rebuild all viewports and update controllers. */
setNeedsUpdate(reason: string): void {
this._needsUpdate = this._needsUpdate || reason;
this._needsRedraw = this._needsRedraw || reason;
}
/** Checks each viewport for transition updates */
updateViewStates(): void {
for (const viewId in this.controllers) {
const controller = this.controllers[viewId];
if (controller) {
controller.updateTransition();
}
}
}
/** Get a set of viewports for a given width and height
* TODO - Intention is for deck.gl to autodeduce width and height and drop the need for props
* @param rect (object, optional) - filter the viewports
* + not provided - return all viewports
* + {x, y} - only return viewports that contain this pixel
* + {x, y, width, height} - only return viewports that overlap with this rectangle
*/
getViewports(rect?: {x: number; y: number; width?: number; height?: number}): Viewport[] {
if (rect) {
return this._viewports.filter(viewport => viewport.containsPixel(rect));
}
return this._viewports;
}
/** Get a map of all views */
getViews(): {[viewId: string]: View} {
const viewMap = {};
this.views.forEach(view => {
viewMap[view.id] = view;
});
return viewMap;
}
/** Resolves a viewId string to a View */
getView(viewId: string): View | undefined {
return this.views.find(view => view.id === viewId);
}
/** Returns the viewState for a specific viewId. Matches the viewState by
1. view.viewStateId
2. view.id
3. root viewState
then applies the view's filter if any */
getViewState(viewOrViewId: string | View): AnyViewStateOf<ViewsT> {
const view: View | undefined =
typeof viewOrViewId === 'string' ? this.getView(viewOrViewId) : viewOrViewId;
// Backward compatibility: view state for single view
const viewState = (view && this.viewState[view.getViewStateId()]) || this.viewState;
return view ? view.filterViewState(viewState) : viewState;
}
getViewport(viewId: string): Viewport | undefined {
return this._viewportMap[viewId];
}
/**
* Unproject pixel coordinates on screen onto world coordinates,
* (possibly [lon, lat]) on map.
* - [x, y] => [lng, lat]
* - [x, y, z] => [lng, lat, Z]
* @param {Array} xyz -
* @param {Object} opts - options
* @param {Object} opts.topLeft=true - Whether origin is top left
* @return {Array|null} - [lng, lat, Z] or [X, Y, Z]
*/
unproject(xyz: number[], opts?: {topLeft?: boolean}): number[] | null {
const viewports = this.getViewports();
const pixel = {x: xyz[0], y: xyz[1]};
for (let i = viewports.length - 1; i >= 0; --i) {
const viewport = viewports[i];
if (viewport.containsPixel(pixel)) {
const p = xyz.slice();
p[0] -= viewport.x;
p[1] -= viewport.y;
return viewport.unproject(p, opts);
}
}
return null;
}
/** Update the manager with new Deck props */
setProps(props: Partial<ViewManagerProps<ViewsT>>) {
if (props.views) {
this._setViews(props.views);
}
if (props.viewState) {
this._setViewState(props.viewState);
}
if ('width' in props || 'height' in props) {
this._setSize(props.width as number, props.height as number);
}
// Important: avoid invoking _update() inside itself
// Nested updates result in unexpected side effects inside _rebuildViewports()
// when using auto control in pure-js
if (!this._isUpdating) {
this._update();
}
}
//
// PRIVATE METHODS
//
private _update(): void {
this._isUpdating = true;
// Only rebuild viewports if the update flag is set
if (this._needsUpdate) {
this._needsUpdate = false;
this._rebuildViewports();
}
// If viewport transition(s) are triggered during viewports update, controller(s)
// will immediately call `onViewStateChange` which calls `viewManager.setProps` again.
if (this._needsUpdate) {
this._needsUpdate = false;
this._rebuildViewports();
}
this._isUpdating = false;
}
private _setSize(width: number, height: number): void {
if (width !== this.width || height !== this.height) {
this.width = width;
this.height = height;
this.setNeedsUpdate('Size changed');
}
}
// Update the view descriptor list and set change flag if needed
// Does not actually rebuild the `Viewport`s until `getViewports` is called
private _setViews(views: View[]): void {
views = flatten(views, Boolean);
const viewsChanged = this._diffViews(views, this.views);
if (viewsChanged) {
this.setNeedsUpdate('views changed');
}
this.views = views;
}
private _setViewState(viewState: ViewStateObject<ViewsT>): void {
if (viewState) {
// depth = 3 when comparing viewStates: viewId.position.0
const viewStateChanged = !deepEqual(viewState, this.viewState, 3);
if (viewStateChanged) {
this.setNeedsUpdate('viewState changed');
}
this.viewState = viewState;
} else {
log.warn('missing `viewState` or `initialViewState`')();
}
}
private _createController(
view: View,
props: {id: string; type: ConstructorOf<Controller<any>>}
): Controller<any> {
const Controller = props.type;
const controller = new Controller({
timeline: this.timeline,
eventManager: this._eventManager,
// Set an internal callback that calls the prop callback if provided
onViewStateChange: this._eventCallbacks.onViewStateChange,
onStateChange: this._eventCallbacks.onInteractionStateChange,
makeViewport: viewState =>
this.getView(view.id)?.makeViewport({
viewState,
width: this.width,
height: this.height
})
});
return controller;
}
private _updateController(
view: View,
viewState: AnyViewStateOf<ViewsT>,
viewport: Viewport | null,
controller?: Controller<any> | null
): Controller<any> | null {
const controllerProps = view.controller;
if (controllerProps && viewport) {
const resolvedProps = {
...viewState,
...controllerProps,
id: view.id,
x: viewport.x,
y: viewport.y,
width: viewport.width,
height: viewport.height
};
// Create controller if not already existing or if the type of the
// controller has changed.
if (!controller || controller.constructor !== controllerProps.type) {
controller = this._createController(view, resolvedProps);
}
if (controller) {
controller.setProps(resolvedProps);
}
return controller;
}
return null;
}
// Rebuilds viewports from descriptors towards a certain window size
private _rebuildViewports(): void {
const {views} = this;
const oldControllers = this.controllers;
this._viewports = [];
this.controllers = {};
let invalidateControllers = false;
// Create controllers in reverse order, so that views on top receive events first
for (let i = views.length; i--; ) {
const view = views[i];
const viewState = this.getViewState(view);
const viewport = view.makeViewport({viewState, width: this.width, height: this.height});
let oldController = oldControllers[view.id];
const hasController = Boolean(view.controller);
if (hasController && !oldController) {
// When a new controller is added, invalidate all controllers below it so that
// events are registered in the correct order
invalidateControllers = true;
}
if ((invalidateControllers || !hasController) && oldController) {
// Remove and reattach invalidated controller
oldController.finalize();
oldController = null;
}
// Update the controller
this.controllers[view.id] = this._updateController(view, viewState, viewport, oldController);
if (viewport) {
this._viewports.unshift(viewport);
}
}
// Remove unused controllers
for (const id in oldControllers) {
const oldController = oldControllers[id];
if (oldController && !this.controllers[id]) {
oldController.finalize();
}
}
this._buildViewportMap();
}
_buildViewportMap(): void {
// Build a view id to view index
this._viewportMap = {};
this._viewports.forEach(viewport => {
if (viewport.id) {
// TODO - issue warning if multiple viewports use same id
this._viewportMap[viewport.id] = this._viewportMap[viewport.id] || viewport;
}
});
}
// Check if viewport array has changed, returns true if any change
// Note that descriptors can be the same
_diffViews(newViews: View[], oldViews: View[]): boolean {
if (newViews.length !== oldViews.length) {
return true;
}
return newViews.some((_, i) => !newViews[i].equals(oldViews[i]));
}
}