From ce74dcf2cb2da0208197ca0b35dafde405698416 Mon Sep 17 00:00:00 2001 From: Alberto Asuero Date: Thu, 24 Sep 2020 18:06:34 +0200 Subject: [PATCH] Add carto module to integrate with CARTO backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Víctor Velarde Co-authored-by: Javier Aragón --- .../carto/carto-bqtiler-layer.md | 80 +++++++++ docs/api-reference/carto/carto-sql-layer.md | 114 ++++++++++++ docs/api-reference/carto/overview.md | 56 ++++++ docs/table-of-contents.json | 8 + examples/get-started/pure-js/carto/README.md | 8 + examples/get-started/pure-js/carto/app.js | 110 ++++++++++++ examples/get-started/pure-js/carto/index.html | 58 ++++++ .../get-started/pure-js/carto/package.json | 25 +++ .../pure-js/carto/webpack.config.js | 13 ++ .../get-started/scripting/carto/index.html | 136 ++++++++++++++ modules/carto/bundle.js | 12 ++ modules/carto/package.json | 46 +++++ modules/carto/src/api/maps-api-client.js | 166 ++++++++++++++++++ modules/carto/src/auth.js | 18 ++ modules/carto/src/index.js | 3 + .../carto/src/layers/carto-bqtiler-layer.js | 20 +++ modules/carto/src/layers/carto-layer.js | 46 +++++ modules/carto/src/layers/carto-sql-layer.js | 19 ++ modules/main/package.json | 3 +- test/modules/carto/auth.spec.js | 35 ++++ .../modules/carto/carto-bqtiler-layer.spec.js | 57 ++++++ test/modules/carto/carto-sql-layer.spec.js | 57 ++++++ test/modules/carto/index.js | 3 + test/modules/imports-spec.js | 2 + test/modules/index.js | 1 + 25 files changed, 1095 insertions(+), 1 deletion(-) create mode 100644 docs/api-reference/carto/carto-bqtiler-layer.md create mode 100644 docs/api-reference/carto/carto-sql-layer.md create mode 100644 docs/api-reference/carto/overview.md create mode 100644 examples/get-started/pure-js/carto/README.md create mode 100644 examples/get-started/pure-js/carto/app.js create mode 100644 examples/get-started/pure-js/carto/index.html create mode 100644 examples/get-started/pure-js/carto/package.json create mode 100644 examples/get-started/pure-js/carto/webpack.config.js create mode 100644 examples/get-started/scripting/carto/index.html create mode 100644 modules/carto/bundle.js create mode 100644 modules/carto/package.json create mode 100644 modules/carto/src/api/maps-api-client.js create mode 100644 modules/carto/src/auth.js create mode 100644 modules/carto/src/index.js create mode 100644 modules/carto/src/layers/carto-bqtiler-layer.js create mode 100644 modules/carto/src/layers/carto-layer.js create mode 100644 modules/carto/src/layers/carto-sql-layer.js create mode 100644 test/modules/carto/auth.spec.js create mode 100644 test/modules/carto/carto-bqtiler-layer.spec.js create mode 100644 test/modules/carto/carto-sql-layer.spec.js create mode 100644 test/modules/carto/index.js diff --git a/docs/api-reference/carto/carto-bqtiler-layer.md b/docs/api-reference/carto/carto-bqtiler-layer.md new file mode 100644 index 00000000000..7fd56a74b5b --- /dev/null +++ b/docs/api-reference/carto/carto-bqtiler-layer.md @@ -0,0 +1,80 @@ + + +# CartoBQTilerLayer + +`CartoBQTilerLayer` is a layer to visualize large datasets (millions or billions of rows) directly from [Google BigQuery](https://cloud.google.com/bigquery) without having to move data outside of BigQuery. + +First you need first to generate a tileset of your dataset in your BigQuery account using CARTO BigQuery Tiler. For more info click [here](https://carto.com/bigquery/beta/). + +```js +import DeckGL from '@deck.gl/react'; +import {CartoBQTilerLayer} from '@deck.gl/carto'; + + +function App({viewState}) { + const layer = new CartoBQTilerLayer({ + data: 'cartobq.maps.nyc_taxi_points_demo_id', + getLineColor: [255, 255, 255], + getFillColor: [238, 77, 90], + pointRadiusMinPixels: 2, + lineWidthMinPixels: 1 + }); + + return ; +} +``` + +## Installation + +To install the dependencies from NPM: + +```bash +npm install deck.gl +# or +npm install @deck.gl/core @deck.gl/layers @deck.gl/carto +``` + +```js +import {CartoBQTilerLayer} from '@deck.gl/carto'; +new CartoBQTilerLayer({}); +``` + +To use pre-bundled scripts: + +```html + + + + + + + + +``` + +```js +new deck.carto.CartoBQTilerLayer({}); +``` + + +## Properties + +Inherits all properties from [`MVTLayer`](/docs/api-reference/geo-layers/mvt-layer.md). + + +##### `data` (String) + +Required. Tileset id + +##### `uniqueIdProperty` (String) + +* Default: `id` + +Optional. Needed for highlighting a feature split across two or more tiles if no [feature id](https://github.com/mapbox/vector-tile-spec/tree/master/2.1#42-features) is provided. + +A string pointing to a tile attribute containing a unique identifier for features across tiles. + + +## Source + +[modules/carto/src/layers/carto-bqtiler-layer.js](https://github.com/visgl/deck.gl/tree/master/modules/carto/src/layers/carto-bqtiler-layer.js) diff --git a/docs/api-reference/carto/carto-sql-layer.md b/docs/api-reference/carto/carto-sql-layer.md new file mode 100644 index 00000000000..b4873352bee --- /dev/null +++ b/docs/api-reference/carto/carto-sql-layer.md @@ -0,0 +1,114 @@ + + +# CartoSQLLayer + +`CartoSQLLayer` is a layer to visualize data hosted in your CARTO account and to apply custom SQL. + +```js +import DeckGL from '@deck.gl/react'; +import {CartoSQLLayer, setDefaultCredentials} from '@deck.gl/carto'; + +setDefaultCredentials({ + username: 'public', + apiKey: 'default_public' +}); + +function App({viewState}) { + const layer = new CartoSQLLayer({ + data: 'SELECT * FROM world_population_2015', + pointRadiusMinPixels: 2, + getLineColor: [0, 0, 0, 0.75], + getFillColor: [238, 77, 90], + lineWidthMinPixels: 1 + }) + + return ; +} +``` + +## Installation + +To install the dependencies from NPM: + +```bash +npm install deck.gl +# or +npm install @deck.gl/core @deck.gl/layers @deck.gl/carto +``` + +```js +import {CartoSQLLayer} from '@deck.gl/carto'; +new CartoSQLLayer({}); +``` + +To use pre-bundled scripts: + +```html + + + + + + + + +``` + +```js +new deck.carto.CartoSQLLayer({}); +``` + + +## Properties + +Inherits all properties from [`MVTLayer`](/docs/api-reference/geo-layers/mvt-layer.md). + + +##### `data` (String) + +Required. Either a sql query or a name of dataset + +##### `uniqueIdProperty` (String) + +* Default: `cartodb_id` + +Optional. A string pointing to a unique attribute at the result of the query. A unique attribute is needed for highlighting a feature split across two or more tiles. + + +##### `credentials` (Object) + +Optional. Object with the credentials to connect with CARTO. + +* Default: + +```js +{ + username: 'public', + apiKey: 'default_public', + serverUrlTemplate: 'https://{user}.carto.com' +} +``` + +##### `bufferSize` (Number) + +Optional. MVT BufferSize in pixels + +* Default: `1` + +##### `version` (String) + +Optional. MapConfig version + +* Default: `1.3.1` + + +##### `tileExtent` (String) + +Optional. Tile extent in tile coordinate space as defined by MVT specification. + +* Default: `4096` + + +## Source + +[modules/carto/src/layers/carto-sql-layer.js](https://github.com/visgl/deck.gl/tree/master/modules/carto/src/layers/carto-sql-layer.js) diff --git a/docs/api-reference/carto/overview.md b/docs/api-reference/carto/overview.md new file mode 100644 index 00000000000..cc04be6d508 --- /dev/null +++ b/docs/api-reference/carto/overview.md @@ -0,0 +1,56 @@ +# @deck.gl/carto + +Deck.gl is the preferred and official solution for creating modern Webapps using the [CARTO Location Intelligence platform](https://carto.com/) + + + + +It integrates with the [CARTO Maps API](https://carto.com/developers/maps-api/reference/) to: + +* Provide a geospatial backend storage for your geospatial data. +* Visualize large datasets which do not fit within browser memory. +* Provide an SQL spatial interface to work with your data. + + +## Install package + +```bash +npm install deck.gl +# or +npm install @deck.gl/core @deck.gl/layers @deck.gl/geo-layers @deck.gl/carto +``` + +## Usage + +```js +import DeckGL from '@deck.gl/react'; +import {CartoSQLLayer, setDefaultCredentials} from '@deck.gl/carto'; + +setDefaultCredentials({ + username: 'public', + apiKey: 'default_public' +}); + +function App({viewState}) { + const layer = new CartoSQLLayer({ + data: 'SELECT * FROM world_population_2015', + pointRadiusMinPixels: 2, + getLineColor: [0, 0, 0, 0.75], + getFillColor: [238, 77, 90], + lineWidthMinPixels: 1 + }) + + return ; +} +``` + +### Examples + +You can see real examples for the following: + +* [Scripting](https://carto.com/developers/deck-gl/examples/): Quick scripting examples to play with the module without NPM or Webpack. If you're not a web developer, this is probably what you're looking for. + +* [React](https://github.com/CartoDB/viz-doc/tree/master/deck.gl/examples/react): integrate in a React application. + +* [Pure JS](https://github.com/CartoDB/viz-doc/tree/master/deck.gl/examples/pure-js): integrate in a pure js application, using webpack. + diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index f41ec9d51d4..11643c37870 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -182,6 +182,14 @@ {"entry": "docs/api-reference/arcgis/load-arcgis-modules"} ] }, + { + "title": "@deck.gl/carto", + "entries": [ + {"entry": "docs/api-reference/carto/overview"}, + {"entry": "docs/api-reference/carto/carto-sql-layer"}, + {"entry": "docs/api-reference/carto/carto-bqtiler-layer"} + ] + }, { "title": "@deck.gl/google-maps", "entries": [ diff --git a/examples/get-started/pure-js/carto/README.md b/examples/get-started/pure-js/carto/README.md new file mode 100644 index 00000000000..1be8f8d376f --- /dev/null +++ b/examples/get-started/pure-js/carto/README.md @@ -0,0 +1,8 @@ +Pure js demo for CARTO layers + +### Usage +Copy the content of this folder to your project. Run +```js +yarn +yarn start +``` \ No newline at end of file diff --git a/examples/get-started/pure-js/carto/app.js b/examples/get-started/pure-js/carto/app.js new file mode 100644 index 00000000000..6ac8f2b2007 --- /dev/null +++ b/examples/get-started/pure-js/carto/app.js @@ -0,0 +1,110 @@ +import mapboxgl from 'mapbox-gl'; +import {Deck} from '@deck.gl/core'; +import {CartoSQLLayer, CartoBQTilerLayer, setDefaultCredentials} from '@deck.gl/carto'; + +const INITIAL_VIEW_STATE = { + latitude: 0, + longitude: 0, + zoom: 1 +}; + +setDefaultCredentials({ + username: 'public', + apiKey: 'default_public' +}); + +// Add Mapbox GL for the basemap. It's not a requirement if you don't need a basemap. +const map = new mapboxgl.Map({ + container: 'map', + style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', + interactive: false, + center: [INITIAL_VIEW_STATE.longitude, INITIAL_VIEW_STATE.latitude], + zoom: INITIAL_VIEW_STATE.zoom +}); + +// Define color breaks for CartoBQTilerLayer +const BUILDINGS_COLORS = { + ONE_MILLION: [207, 89, 126], + HUNDRED_THOUSAND: [232, 133, 113], + TEN_THOUSAND: [238, 180, 121], + THOUSAND: [233, 226, 156], + HUNDRED: [156, 203, 134], + TEN: [57, 177, 133], + OTHER: [0, 147, 146] +}; + +// Set the default visible layer +let visibleLayer = 'airports'; + +// Create Deck.GL map +export const deck = new Deck({ + canvas: 'deck-canvas', + width: '100%', + height: '100%', + initialViewState: INITIAL_VIEW_STATE, + controller: true, + onViewStateChange: ({viewState}) => { + // Synchronize Deck.gl view with Mapbox + map.jumpTo({ + center: [viewState.longitude, viewState.latitude], + zoom: viewState.zoom, + bearing: viewState.bearing, + pitch: viewState.pitch + }); + } +}); + +// Add event listener to radio buttons +document.getElementsByName('layer-visibility').forEach(e => { + e.addEventListener('click', () => { + visibleLayer = e.value; + render(); + }); +}); + +render(); + +// Function to render the layers. Will be invoked any time visibility changes. +function render() { + const layers = [ + new CartoSQLLayer({ + id: 'airports', + data: 'SELECT * FROM ne_10m_airports', + visible: visibleLayer === 'airports', + filled: true, + pointRadiusMinPixels: 2, + pointRadiusScale: 2000, + getRadius: f => 11 - f.properties.scalerank, + getFillColor: [200, 0, 80, 180], + autoHighlight: true, + highlightColor: [0, 0, 128, 128], + pickable: true + }), + new CartoBQTilerLayer({ + id: 'osm_buildings', + data: 'cartobq.maps.osm_buildings', + visible: visibleLayer === 'building', + getFillColor: object => { + // Apply color based on an attribute + if (object.properties.aggregated_total > 1000000) { + return BUILDINGS_COLORS.ONE_MILLION; + } else if (object.properties.aggregated_total > 100000) { + return BUILDINGS_COLORS.HUNDRED_THOUSAND; + } else if (object.properties.aggregated_total > 10000) { + return BUILDINGS_COLORS.TEN_THOUSAND; + } else if (object.properties.aggregated_total > 1000) { + return BUILDINGS_COLORS.THOUSAND; + } else if (object.properties.aggregated_total > 100) { + return BUILDINGS_COLORS.HUNDRED; + } else if (object.properties.aggregated_total > 10) { + return BUILDINGS_COLORS.TEN; + } + return BUILDINGS_COLORS.OTHER; + }, + pointRadiusMinPixels: 2, + stroked: false + }) + ]; + // update layers in deck.gl. + deck.setProps({layers}); +} diff --git a/examples/get-started/pure-js/carto/index.html b/examples/get-started/pure-js/carto/index.html new file mode 100644 index 00000000000..754aaf268bf --- /dev/null +++ b/examples/get-started/pure-js/carto/index.html @@ -0,0 +1,58 @@ + + + + + CARTO deck.gl example + + + + +
+
+ +
+
+

Layer selector

+
+ +
+
+
+ +
+
+
+ + + diff --git a/examples/get-started/pure-js/carto/package.json b/examples/get-started/pure-js/carto/package.json new file mode 100644 index 00000000000..59a522bf0bf --- /dev/null +++ b/examples/get-started/pure-js/carto/package.json @@ -0,0 +1,25 @@ +{ + "name": "pure-js-carto", + "version": "0.0.0", + "license": "MIT", + "scripts": { + "start": "webpack-dev-server --progress --hot --open", + "start-local": "webpack-dev-server --env.local --progress --hot --open", + "build": "webpack -p" + }, + "dependencies": { + "@deck.gl/core": "^8.2.7", + "@deck.gl/layers": "^8.2.7", + "mapbox-gl": "^1.12.0" + }, + "devDependencies": { + "webpack": "^4.20.2", + "webpack-cli": "^3.1.2", + "webpack-dev-server": "^3.1.1" + }, + "eslintConfig": { + "globals": { + "document": true + } + } +} diff --git a/examples/get-started/pure-js/carto/webpack.config.js b/examples/get-started/pure-js/carto/webpack.config.js new file mode 100644 index 00000000000..0bacc22fac4 --- /dev/null +++ b/examples/get-started/pure-js/carto/webpack.config.js @@ -0,0 +1,13 @@ +// NOTE: To use this example standalone (e.g. outside of deck.gl repo) +// delete the local development overrides at the bottom of this file + +const CONFIG = { + mode: 'development', + + entry: { + app: './app.js' + } +}; + +// This line enables bundling against src in this repo rather than installed module +module.exports = env => (env ? require('../../../webpack.config.local')(CONFIG)(env) : CONFIG); diff --git a/examples/get-started/scripting/carto/index.html b/examples/get-started/scripting/carto/index.html new file mode 100644 index 00000000000..b85f50bccf2 --- /dev/null +++ b/examples/get-started/scripting/carto/index.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + +
+ +
+

Layer selector

+
+ +
+
+
+ +
+
+
+ + + + diff --git a/modules/carto/bundle.js b/modules/carto/bundle.js new file mode 100644 index 00000000000..e7ecccb110a --- /dev/null +++ b/modules/carto/bundle.js @@ -0,0 +1,12 @@ +const CartoUtils = require('./src'); + +/* global window, global */ +const _global = typeof window === 'undefined' ? global : window; +const deck = _global.deck || {}; + +// Check if peer dependencies are included +if (!deck.LineLayer) { + throw new Error('@deck.gl/layers is not found'); +} + +module.exports = Object.assign(deck, {carto: CartoUtils}); diff --git a/modules/carto/package.json b/modules/carto/package.json new file mode 100644 index 00000000000..16f572e3765 --- /dev/null +++ b/modules/carto/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deck.gl/carto", + "description": "CARTO official integration with Deck.gl. Build geospatial applications using CARTO and Deck.gl.", + "license": "MIT", + "version": "8.2.0-beta.3", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "carto", + "cartodb", + "mvt", + "visualization", + "geospatial", + "layer" + ], + "repository": { + "type": "git", + "url": "https://github.com/visgl/deck.gl.git" + }, + "main": "dist/es5/index.js", + "module": "dist/esm/index.js", + "esnext": "dist/es6/index.js", + "files": [ + "dist", + "src", + "dist.min.js" + ], + "sideEffects": false, + "scripts": { + "build-bundle": "webpack --config ../../scripts/bundle.config.js", + "prepublishOnly": "npm run build-bundle && npm run build-bundle -- --env.dev" + }, + "dependencies": { + "@loaders.gl/loader-utils": "^2.2.3", + "@loaders.gl/mvt": "^2.2.3", + "@loaders.gl/tiles": "^2.2.3", + "@math.gl/web-mercator": "^3.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "^8.0.0", + "@deck.gl/layers": "^8.0.0", + "@deck.gl/geo-layers": "^8.2.7", + "@loaders.gl/core": "^2.2.0" + } +} diff --git a/modules/carto/src/api/maps-api-client.js b/modules/carto/src/api/maps-api-client.js new file mode 100644 index 00000000000..db07253451c --- /dev/null +++ b/modules/carto/src/api/maps-api-client.js @@ -0,0 +1,166 @@ +import {getDefaultCredentials} from '../auth'; + +const DEFAULT_USER_COMPONENT_IN_URL = '{user}'; +const REQUEST_GET_MAX_URL_LENGTH = 2048; + +/** + * Obtain a TileJson from Maps API v1 + */ +export async function getMapTileJSON(props) { + const {data, bufferSize, version, tileExtent, credentials} = props; + const creds = {...getDefaultCredentials(), ...credentials}; + + const mapConfig = createMapConfig({data, bufferSize, version, tileExtent}); + const layergroup = await instantiateMap({mapConfig, credentials: creds}); + + const tiles = layergroup.metadata.tilejson.vector; + return tiles; +} + +/** + * Create a mapConfig for Maps API + */ +function createMapConfig({data, bufferSize, version, tileExtent}) { + const isSQL = data.search(' ') > -1; + const sql = isSQL ? data : `SELECT * FROM ${data}`; + + const mapConfig = { + version, + buffersize: { + mvt: bufferSize + }, + layers: [ + { + type: 'mapnik', + options: { + sql: sql.trim(), + vector_extent: tileExtent + } + } + ] + }; + return mapConfig; +} + +/** + * Instantiate a map, either by GET or POST, using Maps API + */ +async function instantiateMap({mapConfig, credentials}) { + let response; + + try { + const config = JSON.stringify(mapConfig); + const request = createMapsApiRequest({config, credentials}); + /* global fetch */ + /* eslint no-undef: "error" */ + response = await fetch(request); + } catch (error) { + throw new Error(`Failed to connect to Maps API: ${error}`); + } + + const layergroup = await response.json(); + + if (!response.ok) { + dealWithWindshaftError({response, layergroup, credentials}); + } + + return layergroup; +} + +/** + * Display proper message from Maps API error + */ +function dealWithWindshaftError({response, layergroup, credentials}) { + switch (response.status) { + case 401: + throw new Error( + `Unauthorized access to Maps API: invalid combination of user ('${ + credentials.username + }') and apiKey ('${credentials.apiKey}')` + ); + case 403: + throw new Error( + `Unauthorized access to dataset: the provided apiKey('${ + credentials.apiKey + }') doesn't provide access to the requested data` + ); + default: + throw new Error(`${JSON.stringify(layergroup.errors)}`); + } +} + +/** + * Create a GET or POST request, with all required parameters + */ +function createMapsApiRequest({config, credentials}) { + const encodedApiKey = encodeParameter('api_key', credentials.apiKey); + const encodedClient = encodeParameter('client', `deck-gl-carto`); + const parameters = [encodedApiKey, encodedClient]; + const url = generateMapsApiUrl(parameters, credentials); + + const getUrl = `${url}&${encodeParameter('config', config)}`; + if (getUrl.length < REQUEST_GET_MAX_URL_LENGTH) { + return getRequest(getUrl); + } + + return postRequest(url, config); +} + +/** + * Generate a Maps API url for the request + */ +function generateMapsApiUrl(parameters, credentials) { + const base = `${serverURL(credentials)}api/v1/map`; + return `${base}?${parameters.join('&')}`; +} + +/** + * Prepare a url valid for the specified user + */ +function serverURL(credentials) { + let url = credentials.serverUrlTemplate.replace( + DEFAULT_USER_COMPONENT_IN_URL, + credentials.username + ); + + if (!url.endsWith('/')) { + url += '/'; + } + + return url; +} + +/** + * Simple GET request + */ +function getRequest(url) { + /* global Request */ + /* eslint no-undef: "error" */ + return new Request(url, { + method: 'GET', + headers: { + Accept: 'application/json' + } + }); +} + +/** + * Simple POST request + */ +function postRequest(url, payload) { + return new Request(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: payload + }); +} + +/** + * Simple encode parameter + */ +function encodeParameter(name, value) { + return `${name}=${encodeURIComponent(value)}`; +} diff --git a/modules/carto/src/auth.js b/modules/carto/src/auth.js new file mode 100644 index 00000000000..4d03292d9f2 --- /dev/null +++ b/modules/carto/src/auth.js @@ -0,0 +1,18 @@ +const defaultCredentials = { + username: 'public', + apiKey: 'default_public', + serverUrlTemplate: 'https://{user}.carto.com' +}; + +let credentials = defaultCredentials; + +export function setDefaultCredentials(opts) { + credentials = { + ...credentials, + ...opts + }; +} + +export function getDefaultCredentials() { + return credentials; +} diff --git a/modules/carto/src/index.js b/modules/carto/src/index.js new file mode 100644 index 00000000000..eb8b2cf0f4e --- /dev/null +++ b/modules/carto/src/index.js @@ -0,0 +1,3 @@ +export {getDefaultCredentials, setDefaultCredentials} from './auth.js'; +export {default as CartoSQLLayer} from './layers/carto-sql-layer'; +export {default as CartoBQTilerLayer} from './layers/carto-bqtiler-layer'; diff --git a/modules/carto/src/layers/carto-bqtiler-layer.js b/modules/carto/src/layers/carto-bqtiler-layer.js new file mode 100644 index 00000000000..f54a7c5dfff --- /dev/null +++ b/modules/carto/src/layers/carto-bqtiler-layer.js @@ -0,0 +1,20 @@ +import CartoLayer from './carto-layer'; + +const BQ_TILEJSON_ENDPOINT = 'https://us-central1-cartobq.cloudfunctions.net/tilejson'; + +export default class CartoBQTilerLayer extends CartoLayer { + async _updateTileJSON() { + /* global fetch */ + /* eslint no-undef: "error" */ + const response = await fetch(`${BQ_TILEJSON_ENDPOINT}?t=${this.props.data}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }); + const tilejson = await response.json(); + this.setState({tilejson}); + } +} + +CartoBQTilerLayer.layerName = 'CartoBQTilerLayer'; diff --git a/modules/carto/src/layers/carto-layer.js b/modules/carto/src/layers/carto-layer.js new file mode 100644 index 00000000000..f8f910ea202 --- /dev/null +++ b/modules/carto/src/layers/carto-layer.js @@ -0,0 +1,46 @@ +import {CompositeLayer} from '@deck.gl/core'; +import {MVTLayer} from '@deck.gl/geo-layers'; + +const defaultProps = { + data: null, + credentials: null +}; + +export default class CartoLayer extends CompositeLayer { + initializeState() { + this.state = { + tilejson: null + }; + } + + updateState({changeFlags}) { + const {data} = this.props; + if (changeFlags.dataChanged && data) { + this._updateTileJSON(); + } + } + + async _updateTileJSON() { + throw new Error(`You must use one of the specific carto layers: BQ or SQL type`); + } + + onHover(info, pickingEvent) { + const [mvtLayer] = this.getSubLayers(); + return mvtLayer ? mvtLayer.onHover(info, pickingEvent) : super.onHover(info, pickingEvent); + } + + renderLayers() { + if (!this.state.tilejson) return null; + + return new MVTLayer( + this.props, + this.getSubLayerProps({ + id: 'mvt', + data: this.state.tilejson.tiles + }) + ); + } +} + +CartoLayer.layerName = 'CartoLayer'; +CartoLayer.defaultProps = defaultProps; diff --git a/modules/carto/src/layers/carto-sql-layer.js b/modules/carto/src/layers/carto-sql-layer.js new file mode 100644 index 00000000000..e87b1b678f6 --- /dev/null +++ b/modules/carto/src/layers/carto-sql-layer.js @@ -0,0 +1,19 @@ +import CartoLayer from './carto-layer'; +import {getMapTileJSON} from '../api/maps-api-client'; + +const defaultProps = { + version: '1.3.1', // MapConfig Version (Maps API) + bufferSize: 1, // MVT buffersize in pixels, + tileExtent: 4096, // Tile extent in tile coordinate space (MVT spec.) + uniqueIdProperty: 'cartodb_id' +}; + +export default class CartoSQLLayer extends CartoLayer { + async _updateTileJSON() { + const tilejson = await getMapTileJSON(this.props); + this.setState({tilejson}); + } +} + +CartoSQLLayer.layerName = 'CartoSQLLayer'; +CartoSQLLayer.defaultProps = defaultProps; diff --git a/modules/main/package.json b/modules/main/package.json index 409446c5e6e..a5d6fc6a2d5 100644 --- a/modules/main/package.json +++ b/modules/main/package.json @@ -37,6 +37,7 @@ "@deck.gl/layers": "8.2.0-beta.3", "@deck.gl/mapbox": "8.2.0-beta.3", "@deck.gl/mesh-layers": "8.2.0-beta.3", - "@deck.gl/react": "8.2.0-beta.3" + "@deck.gl/react": "8.2.0-beta.3", + "@deck.gl/carto": "8.2.0-beta.3" } } diff --git a/test/modules/carto/auth.spec.js b/test/modules/carto/auth.spec.js new file mode 100644 index 00000000000..2cd0a9d3786 --- /dev/null +++ b/test/modules/carto/auth.spec.js @@ -0,0 +1,35 @@ +import test from 'tape-catch'; +import {getDefaultCredentials, setDefaultCredentials} from '@deck.gl/carto'; + +test('auth#getDefaultCredentials', t => { + const credentials = getDefaultCredentials(); + + t.notOk(credentials === null, 'default credentials available'); + t.end(); +}); + +test('auth#getDefaultCredentials', t => { + // partial (keeping other params) + setDefaultCredentials({username: 'a-new-username'}); + let credentials = getDefaultCredentials(); + t.ok(credentials.username === 'a-new-username', 'user update'); + t.ok(credentials.apiKey === 'default_public', 'keep default apiKey'); + t.ok( + credentials.serverUrlTemplate === 'https://{user}.carto.com', + 'keep default serverUrlTemplate' + ); + + // full + setDefaultCredentials({ + username: 'a-new-username', + apiKey: 'a-new-key', + serverUrlTemplate: 'https://a-custom-{user}.carto.com' + }); + + credentials = getDefaultCredentials(); + + t.ok(credentials.username === 'a-new-username', ''); + t.ok(credentials.apiKey === 'a-new-key'); + t.ok(credentials.serverUrlTemplate === 'https://a-custom-{user}.carto.com'); + t.end(); +}); diff --git a/test/modules/carto/carto-bqtiler-layer.spec.js b/test/modules/carto/carto-bqtiler-layer.spec.js new file mode 100644 index 00000000000..819d981a31e --- /dev/null +++ b/test/modules/carto/carto-bqtiler-layer.spec.js @@ -0,0 +1,57 @@ +import test from 'tape-catch'; +import {testLayer, generateLayerTests} from '@deck.gl/test-utils'; +import {CartoBQTilerLayer} from '@deck.gl/carto'; + +test('CartoBQTilerLayer', t => { + const testCases = generateLayerTests({ + Layer: CartoBQTilerLayer, + assert: t.ok, + onBeforeUpdate: ({testCase}) => t.comment(testCase.title) + }); + + testLayer({Layer: CartoBQTilerLayer, testCases, onError: t.notOk}); + t.end(); +}); + +test('CartoBQTilerLayer#_updateTileJSON', t => { + const testCases = [ + { + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.notOk(spies._updateTileJSON.called, 'no data, no map instantiation'); + t.ok(spies._updateTileJSON.callCount === 0); + } + }, + { + updateProps: {data: 'project.dataset.tileset_table_name'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok(spies._updateTileJSON.called, 'initial data triggers map instantiation'); + t.ok(spies._updateTileJSON.callCount === 1); + } + }, + { + updateProps: {data: 'project.dataset.tileset_table_name'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok( + spies._updateTileJSON.callCount === 0, + 'same data does not trigger a new map instantiation' + ); + } + }, + { + updateProps: {data: 'project.dataset.ANOTHER_tileset_table_name'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok( + spies._updateTileJSON.callCount === 1, + 'different data triggers a new map instantiation' + ); + } + } + ]; + + testLayer({Layer: CartoBQTilerLayer, testCases, onError: t.notOk}); + t.end(); +}); diff --git a/test/modules/carto/carto-sql-layer.spec.js b/test/modules/carto/carto-sql-layer.spec.js new file mode 100644 index 00000000000..f87ad7e905e --- /dev/null +++ b/test/modules/carto/carto-sql-layer.spec.js @@ -0,0 +1,57 @@ +import test from 'tape-catch'; +import {testLayer, generateLayerTests} from '@deck.gl/test-utils'; +import {CartoSQLLayer} from '@deck.gl/carto'; + +test('CartoSQLLayer', t => { + const testCases = generateLayerTests({ + Layer: CartoSQLLayer, + assert: t.ok, + onBeforeUpdate: ({testCase}) => t.comment(testCase.title) + }); + + testLayer({Layer: CartoSQLLayer, testCases, onError: t.notOk}); + t.end(); +}); + +test('CartoSQLLayer#_updateTileJSON', t => { + const testCases = [ + { + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.notOk(spies._updateTileJSON.called, 'no data, no map instantiation'); + t.ok(spies._updateTileJSON.callCount === 0); + } + }, + { + updateProps: {data: 'table_name'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok(spies._updateTileJSON.called, 'initial data triggers map instantiation'); + t.ok(spies._updateTileJSON.callCount === 1); + } + }, + { + updateProps: {data: 'table_name'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok( + spies._updateTileJSON.callCount === 0, + 'same data does not trigger a new map instantiation' + ); + } + }, + { + updateProps: {data: 'ANOTHER_TABLE'}, + spies: ['_updateTileJSON'], + onAfterUpdate({spies}) { + t.ok( + spies._updateTileJSON.callCount === 1, + 'different data triggers a new map instantiation' + ); + } + } + ]; + + testLayer({Layer: CartoSQLLayer, testCases, onError: t.notOk}); + t.end(); +}); diff --git a/test/modules/carto/index.js b/test/modules/carto/index.js new file mode 100644 index 00000000000..2330e3c943f --- /dev/null +++ b/test/modules/carto/index.js @@ -0,0 +1,3 @@ +import './carto-sql-layer.spec'; +import './carto-bqtiler-layer.spec'; +import './auth.spec'; diff --git a/test/modules/imports-spec.js b/test/modules/imports-spec.js index ccd8a7c8778..6c6fb397a06 100644 --- a/test/modules/imports-spec.js +++ b/test/modules/imports-spec.js @@ -26,6 +26,7 @@ import * as deck from 'deck.gl'; import * as layers from '@deck.gl/layers'; import * as aggregationLayers from '@deck.gl/aggregation-layers'; +import * as carto from '@deck.gl/carto'; import * as geoLayers from '@deck.gl/geo-layers'; import * as meshLayers from '@deck.gl/mesh-layers'; @@ -58,6 +59,7 @@ test('Top-level imports', t0 => { hasEmptyExports(aggregationLayers), 'No empty top-level export in @deck.gl/aggregation-layers' ); + t.notOk(hasEmptyExports(carto), 'No empty top-level export in @deck.gl/carto'); t.notOk(hasEmptyExports(geoLayers), 'No empty top-level export in @deck.gl/geo-layers'); t.notOk(hasEmptyExports(meshLayers), 'No empty top-level export in @deck.gl/mesh-layers'); t.end(); diff --git a/test/modules/index.js b/test/modules/index.js index ca66d1a58eb..a2c90623e7b 100644 --- a/test/modules/index.js +++ b/test/modules/index.js @@ -23,6 +23,7 @@ import './core'; import './layers'; import './aggregation-layers'; +import './carto'; import './geo-layers'; import './mesh-layers';