@deck.gl/carto
Version:
CARTO official integration with Deck.gl. Build geospatial applications using CARTO and Deck.gl.
371 lines (335 loc) • 11 kB
text/typescript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
/* eslint-disable @typescript-eslint/no-shadow */
import {
DEFAULT_API_BASE_URL,
APIErrorContext,
CartoAPIError,
GeojsonResult,
JsonResult,
TilejsonResult,
Format,
MapType,
QueryParameters,
SourceOptions,
buildPublicMapUrl,
buildStatsUrl,
h3QuerySource,
h3TableSource,
quadbinQuerySource,
quadbinTableSource,
vectorQuerySource,
vectorTableSource,
vectorTilesetSource,
requestWithParameters
} from '@carto/api-client';
import {ParseMapResult, parseMap} from './parse-map';
import {assert} from '../utils';
import type {Basemap} from './types';
import {fetchBasemapProps} from './basemap';
import {DEFAULT_AGGREGATION_EXP} from '../constants';
type Dataset = {
id: string;
type: MapType;
source: string;
cache?: number;
connectionName: string;
geoColumn: string;
data: TilejsonResult | GeojsonResult | JsonResult;
columns: string[];
format: Format;
aggregationExp: string;
aggregationResLevel: number;
queryParameters: QueryParameters;
};
/* global clearInterval, setInterval, URL */
/* eslint-disable complexity, max-statements, max-params */
async function _fetchMapDataset(dataset: Dataset, context: _FetchMapContext) {
const {
aggregationExp,
aggregationResLevel,
connectionName,
columns,
format,
geoColumn,
source,
type,
queryParameters
} = dataset;
const cache: {value?: number} = {};
const globalOptions = {
...context,
cache,
connectionName,
format
} as SourceOptions;
if (type === 'tileset') {
// TODO do we want a generic tilesetSource?
// @ts-ignore
dataset.data = await vectorTilesetSource({...globalOptions, tableName: source});
} else {
const [spatialDataType, spatialDataColumn] = geoColumn ? geoColumn.split(':') : ['geom'];
if (spatialDataType === 'geom') {
const options = {...globalOptions, spatialDataColumn};
if (type === 'table') {
dataset.data = await vectorTableSource({...options, columns, tableName: source});
} else if (type === 'query') {
dataset.data = await vectorQuerySource({
...options,
columns,
sqlQuery: source,
queryParameters
});
}
} else if (spatialDataType === 'h3') {
const options = {
...globalOptions,
aggregationExp: aggregationExp || DEFAULT_AGGREGATION_EXP,
aggregationResLevel,
spatialDataColumn
};
if (type === 'table') {
dataset.data = await h3TableSource({...options, tableName: source});
} else if (type === 'query') {
dataset.data = await h3QuerySource({...options, sqlQuery: source, queryParameters});
}
} else if (spatialDataType === 'quadbin') {
const options = {
...globalOptions,
aggregationExp: aggregationExp || DEFAULT_AGGREGATION_EXP,
aggregationResLevel,
spatialDataColumn
};
if (type === 'table') {
dataset.data = await quadbinTableSource({...options, tableName: source});
} else if (type === 'query') {
dataset.data = await quadbinQuerySource({...options, sqlQuery: source, queryParameters});
}
}
}
let cacheChanged = true;
if (cache.value) {
cacheChanged = dataset.cache !== cache.value;
dataset.cache = cache.value;
}
return cacheChanged;
}
async function _fetchTilestats(attribute: string, dataset: Dataset, context: _FetchMapContext) {
const {connectionName, data, id, source, type, queryParameters} = dataset;
const {apiBaseUrl} = context;
const errorContext: APIErrorContext = {
requestType: 'Tile stats',
connection: connectionName,
type,
source
};
if (!('tilestats' in data)) {
throw new CartoAPIError(new Error(`Invalid dataset for tilestats: ${id}`), errorContext);
}
const baseUrl = buildStatsUrl({attribute, apiBaseUrl, ...dataset});
const client = new URLSearchParams(data.tiles[0]).get('client');
const headers = {Authorization: `Bearer ${context.accessToken}`};
const parameters: Record<string, string> = {};
if (client) {
parameters.client = client;
}
if (type === 'query') {
parameters.q = source;
if (queryParameters) {
parameters.queryParameters = JSON.stringify(queryParameters);
}
}
const stats = await requestWithParameters({
baseUrl,
headers,
parameters,
errorContext,
maxLengthURL: context.maxLengthURL
});
// Replace tilestats for attribute with value from API
const {attributes} = data.tilestats.layers[0];
const index = attributes.findIndex(d => d.attribute === attribute);
attributes[index] = stats;
return true;
}
async function fillInMapDatasets({datasets}: {datasets: Dataset[]}, context: _FetchMapContext) {
const promises = datasets.map(dataset => _fetchMapDataset(dataset, context));
return await Promise.all(promises);
}
async function fillInTileStats(
{datasets, keplerMapConfig}: {datasets: Dataset[]; keplerMapConfig: any},
context: _FetchMapContext
) {
const attributes: {attribute: string; dataset: any}[] = [];
const {layers} = keplerMapConfig.config.visState;
for (const layer of layers) {
for (const channel of Object.keys(layer.visualChannels)) {
const attribute = layer.visualChannels[channel]?.name;
if (attribute) {
const dataset = datasets.find(d => d.id === layer.config.dataId);
if (dataset && dataset.type !== 'tileset' && (dataset.data as TilejsonResult).tilestats) {
// Only fetch stats for QUERY & TABLE map types
attributes.push({attribute, dataset});
}
}
}
}
// Remove duplicates to avoid repeated requests
const filteredAttributes: {attribute: string; dataset: any}[] = [];
for (const a of attributes) {
if (
!filteredAttributes.find(
({attribute, dataset}) => attribute === a.attribute && dataset === a.dataset
)
) {
filteredAttributes.push(a);
}
}
const promises = filteredAttributes.map(({attribute, dataset}) =>
_fetchTilestats(attribute, dataset, context)
);
return await Promise.all(promises);
}
export type FetchMapOptions = {
/**
* CARTO platform access token. Only required for private maps.
*/
accessToken?: string;
/**
* Base URL of the CARTO Maps API.
*
* Example for account located in EU-west region: `https://gcp-eu-west1.api.carto.com`
*
* @default https://gcp-us-east1.api.carto.com
*/
apiBaseUrl?: string;
/**
* Identifier of map created in CARTO Builder.
*/
cartoMapId: string;
clientId?: string;
/**
* Custom HTTP headers added to map instantiation and data requests.
*/
headers?: Record<string, string>;
/**
* Interval in seconds at which to autoRefresh the data. If provided, `onNewData` must also be provided.
*/
autoRefresh?: number;
/**
* Callback function that will be invoked whenever data in layers is changed. If provided, `autoRefresh` must also be provided.
*/
onNewData?: (map: any) => void;
/**
* Maximum URL character length. Above this limit, requests use POST.
* Used to avoid browser and CDN limits.
* @default {@link DEFAULT_MAX_LENGTH_URL}
*/
maxLengthURL?: number;
};
/**
* Context reused while fetching and updating a map with fetchMap().
*/
type _FetchMapContext = {apiBaseUrl: string} & Pick<
FetchMapOptions,
'accessToken' | 'clientId' | 'headers' | 'maxLengthURL'
>;
export type FetchMapResult = ParseMapResult & {
/**
* Basemap properties.
*/
basemap: Basemap | null;
stopAutoRefresh?: () => void;
};
/* eslint-disable max-statements */
export async function fetchMap({
accessToken,
apiBaseUrl = DEFAULT_API_BASE_URL,
cartoMapId,
clientId,
headers,
autoRefresh,
onNewData,
maxLengthURL
}: FetchMapOptions): Promise<FetchMapResult> {
assert(cartoMapId, 'Must define CARTO map id: fetchMap({cartoMapId: "XXXX-XXXX-XXXX"})');
if (accessToken) {
headers = {Authorization: `Bearer ${accessToken}`, ...headers};
}
if (autoRefresh || onNewData) {
assert(onNewData, 'Must define `onNewData` when using autoRefresh');
assert(typeof onNewData === 'function', '`onNewData` must be a function');
assert(
typeof autoRefresh === 'number' && autoRefresh > 0,
'`autoRefresh` must be a positive number'
);
}
const baseUrl = buildPublicMapUrl({apiBaseUrl, cartoMapId});
const errorContext: APIErrorContext = {requestType: 'Public map', mapId: cartoMapId};
const map = await requestWithParameters({baseUrl, headers, errorContext, maxLengthURL});
const context: _FetchMapContext = {
accessToken: map.token || accessToken,
apiBaseUrl,
clientId,
headers,
maxLengthURL
};
// Periodically check if the data has changed. Note that this
// will not update when a map is published.
let stopAutoRefresh: (() => void) | undefined;
if (autoRefresh) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const intervalId = setInterval(async () => {
const changed = await fillInMapDatasets(map, {
...context,
headers: {
...headers,
'If-Modified-Since': new Date().toUTCString()
}
});
if (onNewData && changed.some(v => v === true)) {
onNewData(parseMap(map));
}
}, autoRefresh * 1000);
stopAutoRefresh = () => {
clearInterval(intervalId);
};
}
const geojsonLayers = map.keplerMapConfig.config.visState.layers.filter(
({type}) => type === 'geojson' || type === 'point'
);
const geojsonDatasetIds = geojsonLayers.map(({config}) => config.dataId);
map.datasets.forEach(dataset => {
if (geojsonDatasetIds.includes(dataset.id)) {
const {config} = geojsonLayers.find(({config}) => config.dataId === dataset.id);
dataset.format = 'geojson';
// Support for very old maps. geoColumn was not stored in the past
if (!dataset.geoColumn && config.columns.geojson) {
dataset.geoColumn = config.columns.geojson;
}
}
});
const [basemap] = await Promise.all([
fetchBasemapProps({config: map.keplerMapConfig.config, errorContext}),
// Mutates map.datasets so that dataset.data contains data
fillInMapDatasets(map, context)
]);
// Mutates attributes in visualChannels to contain tile stats
await fillInTileStats(map, context);
const out = {...parseMap(map), basemap, ...{stopAutoRefresh}};
const textLayers = out.layers.filter(layer => {
const pointType = layer.props.pointType || '';
return pointType.includes('text');
});
/* global FontFace, window, document */
if (textLayers.length && window.FontFace && !document.fonts.check('12px Inter')) {
// Fetch font needed for labels
const font = new FontFace(
'Inter',
'url(https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2)'
);
await font.load().then(f => document.fonts.add(f));
}
return out;
}