UNPKG

@carto/airship-bridge

Version:

Airship bridge to other libs (CARTO VL, CARTO.js)

528 lines (460 loc) 14.5 kB
import semver from 'semver'; import { AnimationControlsOptions, AnimationOptions, CategoricalHistogramOptions, CategoryOptions, NumericalHistogramOptions, VLBridgeOptions } from '../types'; import { getColumnName, getExpression } from '../util/Utils'; import { AnimationControls } from './animation-controls/AnimationControls'; import { BaseFilter } from './base/BaseFilter'; import { CategoryFilter } from './category/CategoryFilter'; import { CategoricalHistogramFilter } from './histogram/CategoricalHistogramFilter'; import { NumericalHistogramFilter } from './histogram/NumericalHistogramFilter'; import { GlobalRangeFilter } from './range/GlobalRangeFilter'; import { TimeSeries } from './time-series/TimeSeries'; const VL_VERSION = '^1.2.3'; /** * This class is the main interface to bind a VL layer to one or more Airship widgets. * * The normal usage is create an instance and use its public methods to generate filters for * different widgets. * * After you have specified all the required filters, simply call the method `build` and all will be * handled for you. Internally, a new layer will be created with an invisible Viz, as a source for all * the widget's data. * * Some caveats: * - You can create as many filters for a column you want, but only one per widget. * - If you enable non-read-only capabilities, it is recommended that the Viz filter property not to * be changed, as it will be changed internally by each filter. * * @export * @class VLBridge */ export default class VLBridge { private _carto: any; private _map: any; private _layer: any; private _source: any; private _vizFilters: BaseFilter[] = []; private _readOnlyLayer: any; private _id: string; private _animation: TimeSeries | AnimationControls; /** * Creates an instance of VLBridge. * @param {VLBridgeOptions} { carto, map, layer, source } * - carto: CARTO VL namespace * - map: CARTO VL map instance (Mapbox GL) * - layer: CARTO VL layer * - source: CARTO VL source * @memberof VLBridge */ constructor({ carto, map, layer, source }: VLBridgeOptions) { this._carto = carto; this._map = map; this._layer = layer; this._source = source; this._id = this._layer.id; this._rebuildFilters = this._rebuildFilters.bind(this); this._updateDataLayerVariables = this._updateDataLayerVariables.bind(this); if (!this._carto.expressions.globalHistogram) { throw new Error(`Provided VL version ${this._carto.version} lacks globalHistogram support.`); } if (!semver.satisfies(this._carto.version, VL_VERSION)) { throw new Error(`Provided VL version ${this._carto.version} not supported. Must satisfy ${VL_VERSION}`); } } /** * Create a numerical histogram filter. See {@link NumericalHistogramOptions} for more details * * @param {(any | string)} widget Your widget or a * selector to locate it * @param {string} column The column you want to pull data from * @param {NumericalHistogramOptions} [options={}] Options for the bridge * @returns {NumericalHistogramFilter} * @memberof VLBridge */ public numericalHistogram( widget: any | string, column: string | { propertyName: string }, options: NumericalHistogramOptions = {}): NumericalHistogramFilter { const { buckets, bucketRanges, weight, readOnly, totals } = options; const colName = getColumnName(column); const expression = getExpression(column); const histogram = new NumericalHistogramFilter( this._carto, this._layer, widget, colName, buckets, weight, this._source, bucketRanges, readOnly, totals, expression ); this._addFilter(histogram); return histogram; } /** * Create a categorical histogram filter. See {@link CategoricalHistogramOptions} for more details * * @param {(any | string)} widget Your widget or a selector to locate it * @param {string} column The column to pull data from * @param {CategoricalHistogramOptions} [options={}] Options for this particular filter * @returns {CategoricalHistogramFilter} * @memberof VLBridge */ public categoricalHistogram( widget: any | string, column: string | { propertyName: string }, options: CategoricalHistogramOptions = {}): CategoricalHistogramFilter { const { readOnly, totals, weight } = options; const colName = getColumnName(column); const expression = getExpression(column); const histogram = new CategoricalHistogramFilter( this._carto, this._layer, widget, colName, this._source, readOnly, weight, totals, expression ); this._addFilter(histogram); return histogram; } /** * Creates a numerical or categorical histogram, depending on the arguments. * * If neither buckets or bucketRanges are provided, a categorical one will be created. A numerical one otherwise * * @param {(any | string)} widget Your widget or a selector * @param {string} column The column to pull data from * @param {NumericalHistogramOptions} options Options for the Histogram * @returns {NumericalHistogramFilter | CategoricalHistogramFilter} * @memberof VLBridge */ public histogram( widget: any | string, column: string | { propertyName: string }, options: NumericalHistogramOptions = {}): NumericalHistogramFilter | CategoricalHistogramFilter { const { buckets, bucketRanges, readOnly, weight, totals } = options; if (buckets === undefined && bucketRanges === undefined) { const histogramWidget = widget as any; return this.categoricalHistogram(histogramWidget, column, { readOnly, totals, weight }); } return this.numericalHistogram(widget, column, { bucketRanges, buckets, readOnly, totals, weight }); } /** * Creates a category widget filter. * * You can provide an HTML element (like a button) on the options, so the filtering takes place * after the user clicks on it. This option is ignored if readOnly * * @param {any | string} widget An airship category widget, or a selector * @param {string} column Column to pull data from * @param {CategoryOptions} [options={}] * @returns * @memberof VLBridge */ public category( widget: any | string, column: string | { propertyName: string }, options: CategoryOptions = {}): CategoryFilter { const { readOnly, button, weight } = options; const colName = getColumnName(column); const expression = getExpression(column); const category = new CategoryFilter( this._carto, this._layer, widget, colName, this._source, weight, readOnly, button, expression ); this._addFilter(category); return category; } /** * Creates a time series widget filter. * * Internally this creates a {@link NumericalHistogramFilter} and instances a {@link TimeSeries}. * * One will take care of the histogram part and the other of the animation parts. * * There can only be one animation per layer (per VLBridge instance) * * @param {(any | string)} widget The Time series widget, or a selector * @param {string} column The string to pull data from it * @param {AnimationOptions} [options={}] * @memberof VLBridge */ public timeSeries( widget: any | string, column: string, options: AnimationOptions = {}) { if (this._animation) { throw new Error('There can only be one animation'); } const { buckets, bucketRanges, readOnly, totals, weight, duration, fade, variableName, propertyName, autoplay } = options; this._animation = new TimeSeries( this._carto, this._layer, column, widget, () => { if (propertyName === 'filter') { this._rebuildFilters(); } }, duration, fade, variableName, propertyName, autoplay ); const histogram = this.numericalHistogram(widget, column, { bucketRanges, buckets, readOnly, totals, weight }); histogram.setTimeSeries(true); histogram.on('rangeChanged', (range) => { this._animation.setRange(range); }); return this._animation; } /** * Creates an animation controls widget * * By default, the animation is set to the 'filter' property, * but it is possible to animate any style property using the 'propertyName' option. * * There can only be one animation per layer (per VLBridge instance) * * @param {(any | string)} widget The Animation Controls widget, or a selector * @param {string} column The name of the column in the dataset used in the animation * @param {AnimationControlsOptions} [options={}] * @param {number} [options.duration] Animation duration in seconds. It is 10 by default * @param {[number, number]} [options.fade] Animation fade in and out. * @param {string} [options.variableName] Name of the viz variable that has the animation assigned * @param {string} [options.propertyName] Name of the style property to apply the animation, 'filter' by default. * @memberof VLBridge */ public animationControls( widget: any | string, column: string, options: AnimationControlsOptions = {}) { const { duration, fade, variableName, propertyName = 'filter', autoplay } = options; this._animation = new AnimationControls( widget, this._carto, column, variableName, propertyName, duration, fade, autoplay, this._layer, () => { if (propertyName === 'filter') { this._rebuildFilters(); } }, null ); return this._animation; } /** * Creates a global range slider filter. * * @param {(any | string)} widget A range slider widget or a selector * @param {string} column The column to pull data from * @returns {GlobalRangeFilter} * @memberof VLBridge */ public globalRange(widget: any | string, column: string): GlobalRangeFilter { const range = new GlobalRangeFilter(this._carto, this._layer, widget, column, this._source); this._addFilter(range); return range; } /** * Call this method after creating all the different filters you require. * * It will internally do the following: * - Add new variables to your Viz, with the columns of all the non-read-only filters * - Create a new layer as the filters' data source * @returns * @memberof VLBridge */ public build() { if (this._vizFilters.length === 0) { return; } const onLoaded = () => { this._appendVariables(); this._buildDataLayer(); }; if (!this._layer.viz) { this._layer.on('loaded', onLoaded); } else { onLoaded(); } } private _addFilter(filter: BaseFilter) { filter.on('filterChanged', this._rebuildFilters); filter.on('expressionReady', this._updateDataLayerVariables); this._vizFilters.push(filter); } /** * This will append extra variables with all the columns of non-read-only filters. * * This is required so that whenever the filter is changed, the original viz layer * can be filtered by it. * * @private * @memberof VLBridge */ private _appendVariables() { const s = this._carto.expressions; this._vizFilters.forEach( (filter) => this._layer.viz.variables[filter.columnPropName] = s.prop(filter.column) ); } /** * This will create a new Layer using the same source as the original, add it to the map, and * pass it to all the filters so they can hook up to read the data * * It has style properties to make it invisible, plus all the expressions created by each filter. * * @private * @memberof VLBridge */ private _buildDataLayer() { const variables = this._getVariables(); const s = this._carto.expressions; const viz = new this._carto.Viz({ color: s.rgba(0, 0, 0, 0), strokeWidth: 0, variables, }); this._readOnlyLayer = new this._carto.Layer(`asbind_ro_${this._id}`, this._source, viz); this._readOnlyLayer.addTo(this._map); this._vizFilters.forEach((filter) => filter.setDataLayer(this._readOnlyLayer)); } private _getVariables() { const variables = this._readOnlyLayer !== undefined ? this._readOnlyLayer.viz.variables : {}; for (const filter of this._vizFilters) { const name = filter.name; if (filter.globalExpression) { variables[`${name}_global`] = filter.globalExpression; } if (filter.expression) { variables[name] = filter.expression; } variables[filter.columnPropName] = this._carto.expressions.prop(filter.column); } return variables; } private _updateDataLayerVariables(payload) { if (!this._readOnlyLayer.viz) { return; } this._readOnlyLayer.viz.variables[payload.name] = payload.expression; } /** * Gather all the VL filters from each filter, combine them and filter both the data layer and * the original layer with it. * * If there is an animation involved, it uses @animation and all the filters. * * @private * @memberof VLBridge */ private _rebuildFilters() { let newFilter = this._combineFilters( this._vizFilters .filter((hasFilter) => hasFilter.filter !== null) .map((hasFilter) => hasFilter.filter) ); // Update (if required) the readonly layer if (this._readOnlyLayer) { this._readOnlyLayer.viz.filter.blendTo(newFilter, 0); } if (this._layer.viz.filter.isAnimated() && this._animation) { if (this._layer.viz.variables[this._animation.variableName]) { newFilter = `@${this._animation.variableName} and ${newFilter}`; } } // Update the Visualization filter this._layer.viz.filter.blendTo(newFilter, 0); if (this._animation) { this._animation.restart(); } } private _combineFilters(filters) { if (filters.length === 0) { return '1'; } return filters.join(' and '); } }