Skip to content

Commit e6084c8

Browse files
juandjarasrtenaDon McCurdy
authored
Add spatial index widgets examples (#31)
--------- Co-authored-by: Álex Tena <srtena@gmail.com> Co-authored-by: Don McCurdy <donmccurdy@cartodb.com>
1 parent 864591f commit e6084c8

File tree

22 files changed

+1142
-2
lines changed

22 files changed

+1142
-2
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ dist
2525

2626
# Don't lock libraries in examples
2727
yarn.lock
28-
package-lock.json
28+
package-lock.json
29+
30+
#.DS_Store
31+
.DS_Store

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
"devDependencies": {
55
"vite": "^4.5.0",
66
"ocular-dev-tools": "^2.0.0-alpha.15"
7-
}
7+
},
8+
"packageManager": "yarn@4.4.1+sha512.f825273d0689cc9ead3259c14998037662f1dcd06912637b21a450e8da7cfeb4b1965bbee73d16927baa1201054126bc385c6f43ff4aa705c8631d26e12460f1"
89
}

widgets-h3/.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# API Base URL (copy this from CARTO Workspace -> Developers)
2+
VITE_API_BASE_URL=https://gcp-us-east1.api.carto.com
3+
# This API Access Token only grants access to demo data for the examples (h3 spatial features demo data).
4+
# To replace this token with your own, go to app.carto.com -> Developers -> Credentials -> API Access Tokens.
5+
VITE_API_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiJ9.eyJhIjoiYWNfbHFlM3p3Z3UiLCJqdGkiOiJkOTU4OWMyZiJ9.78MdzU2J6y-J6Far71_Mh7IQO9eYIZD9nECUiZJAVL4

widgets-h3/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Example: CARTO Widgets over H3 Spatial Index sources
2+
3+
This is an evolution of our [H3 example](https://github.com/CartoDB/deck.gl-examples/tree/master/spatial-features-h3), adding charts and filtering capabilities.
4+
5+
It showcases how to use [Widget models in CARTO](https://docs.carto.com/carto-for-developers/charts-and-widgets) to easily build interactive data visualizations that stay synchronized with the map, with added interactions such as filtering with inputs or by clicking in the charts. And in this case, how to integrate them into spatial index sources, such as H3 and Quadbin, for optimal performance and scalability.
6+
7+
The UI for the charts is built using [Chart JS](https://www.chartjs.org/) but developers can plug their own charting or data visualization library.
8+
9+
Uses [Vite](https://vitejs.dev/) to bundle and serve files.
10+
11+
## Usage
12+
13+
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/CartoDB/deck.gl-examples/tree/master/widgets-h3?file=index.ts)
14+
15+
Or run it locally:
16+
17+
```bash
18+
npm install
19+
# or
20+
yarn
21+
```
22+
23+
Commands:
24+
25+
- `npm run dev` is the development target, to serve the app and hot reload.
26+
- `npm run build` is the production target, to create the final bundle and write to disk.

widgets-h3/images/scale.jpg

3.37 KB
Loading

widgets-h3/index.html

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>CARTO + deck.gl - H3 Widgets</title>
6+
</head>
7+
<body>
8+
<div id="map"></div>
9+
<canvas id="deck-canvas"></canvas>
10+
<div id="top-left">
11+
<div id="story-card">
12+
<p class="overline">✨👀 You're viewing</p>
13+
<h2>CARTO Widgets for H3 Sources</h2>
14+
<p>
15+
This example showcases how to build widgets (charts and filters) into visualizations using
16+
CARTO + deck.gl + H3 Spatial Index sources. Learn more about
17+
<a
18+
href="https://docs.carto.com/carto-for-developers/reference/carto-widgets-reference/"
19+
rel="noopener noreferrer"
20+
target="_blank"
21+
>widgets in CARTO.</a
22+
>
23+
</p>
24+
25+
<div class="layer-controls">
26+
<p class="overline">Variable</p>
27+
<select name="variable" id="variable" class="select"></select>
28+
<div>
29+
<img class="legend" src="./images/scale.jpg" alt="legend" />
30+
<div class="label-container">
31+
<div>Less</div>
32+
<div>More</div>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<div class="widgets">
38+
<div class="widget formula-widget">
39+
<p class="overline">Total</p>
40+
<div id="formula-data"></div>
41+
</div>
42+
<div class="widget histogram-widget relative">
43+
<button class="clear-btn">Clear filter</button>
44+
<p class="overline">Population by urbanity</p>
45+
<p class="loader hidden">Loading...</p>
46+
<canvas id="histogram-data" height="200px"></canvas>
47+
</div>
48+
</div>
49+
<hr />
50+
<p class="caption">
51+
Source:
52+
<a
53+
href="https://carto.com/spatial-data-catalog/browser/dataset/cdb_spatial_fea_94e6b1f/"
54+
rel="noopener noreferrer"
55+
target="_blank"
56+
>Spatial Features - United States of America (H3 Resolution 8)</a
57+
>
58+
</p>
59+
</div>
60+
</div>
61+
<script type="module" src="./index.ts"></script>
62+
</body>
63+
</html>

widgets-h3/index.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import './style.css';
2+
import 'maplibre-gl/dist/maplibre-gl.css';
3+
import maplibregl from 'maplibre-gl';
4+
import {Deck, MapViewState} from '@deck.gl/core';
5+
import {H3TileLayer, BASEMAP, colorBins} from '@deck.gl/carto';
6+
import {initSelectors} from './selectorUtils';
7+
import {debounce, getSpatialFilterFromViewState} from './utils';
8+
import {
9+
addFilter,
10+
Filters,
11+
FilterType,
12+
h3QuerySource,
13+
removeFilter,
14+
WidgetSource
15+
} from '@carto/api-client';
16+
import Chart from 'chart.js/auto';
17+
18+
const cartoConfig = {
19+
// @ts-expect-error misconfigured env variables
20+
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
21+
// @ts-expect-error misconfigured env variables
22+
accessToken: import.meta.env.VITE_API_ACCESS_TOKEN,
23+
connectionName: 'carto_dw'
24+
};
25+
26+
const INITIAL_VIEW_STATE: MapViewState = {
27+
latitude: 35.7128,
28+
longitude: -88.006,
29+
zoom: 5,
30+
pitch: 60,
31+
bearing: 0,
32+
minZoom: 3.5,
33+
maxZoom: 15
34+
};
35+
36+
type Source = ReturnType<typeof h3QuerySource>;
37+
38+
// Selectors variables
39+
let selectedVariable = 'population';
40+
let aggregationExp = `SUM(${selectedVariable})`;
41+
42+
let source: Source;
43+
let viewState = INITIAL_VIEW_STATE;
44+
const filters: Filters = {};
45+
46+
// DOM elements
47+
const variableSelector = document.getElementById('variable') as HTMLSelectElement;
48+
const formulaWidget = document.getElementById('formula-data') as HTMLDivElement;
49+
const histogramWidget = document.getElementById('histogram-data') as HTMLCanvasElement;
50+
const histogramClearBtn = document.querySelector(
51+
'.histogram-widget .clear-btn'
52+
) as HTMLButtonElement;
53+
histogramClearBtn.addEventListener('click', () => {
54+
removeFilter(filters, {column: 'urbanity'});
55+
render();
56+
});
57+
58+
let histogramChart: Chart;
59+
60+
variableSelector?.addEventListener('change', () => {
61+
const aggMethod = variableSelector.selectedOptions[0].dataset.aggMethod || 'SUM';
62+
63+
selectedVariable = variableSelector.value;
64+
aggregationExp = `${aggMethod}(${selectedVariable})`;
65+
66+
render();
67+
});
68+
69+
function render() {
70+
source = h3QuerySource({
71+
...cartoConfig,
72+
filters,
73+
dataResolution: 8,
74+
aggregationExp: `${aggregationExp} as value, any_value(urbanity) as urbanity`,
75+
sqlQuery:
76+
'SELECT * FROM cartobq.public_account.derived_spatialfeatures_usa_h3int_res8_v1_yearly_v2'
77+
});
78+
renderWidgets();
79+
renderLayers();
80+
}
81+
82+
function renderLayers() {
83+
const colorScale = colorBins({
84+
attr: 'value',
85+
domain: [0, 100, 1000, 10000, 100000, 1000000],
86+
colors: 'PinkYl'
87+
});
88+
89+
const layers = [
90+
new H3TileLayer({
91+
id: 'h3_layer',
92+
data: source,
93+
opacity: 0.75,
94+
pickable: true,
95+
extruded: true,
96+
getFillColor: (...args) => {
97+
const color = colorScale(...args);
98+
const d = args[0];
99+
const value = Math.floor(d.properties.value);
100+
if (value > 0) {
101+
return color;
102+
}
103+
return [0, 0, 0, 255 * 0.25];
104+
},
105+
getElevation: (...args) => {
106+
const d = args[0];
107+
return d.properties.value;
108+
},
109+
coverage: 0.95,
110+
elevationScale: 0.2,
111+
lineWidthMinPixels: 0.5,
112+
getLineWidth: 0.5,
113+
getLineColor: [255, 255, 255, 100]
114+
})
115+
];
116+
117+
deck.setProps({
118+
layers,
119+
getTooltip: ({object}) =>
120+
object && {
121+
html: `Hex ID: ${object.id}</br>
122+
${selectedVariable.toUpperCase()}: ${Number(object.properties.value).toFixed(2)}</br>
123+
Urbanity: ${object.properties.urbanity}</br>
124+
Aggregation Expression: ${aggregationExp}`
125+
}
126+
});
127+
}
128+
129+
async function renderWidgets() {
130+
const {widgetSource} = await source;
131+
await Promise.all([renderFormula(widgetSource), renderHistogram(widgetSource)]);
132+
}
133+
134+
async function renderFormula(ws: WidgetSource) {
135+
formulaWidget.innerHTML = '<span style="font-weight: 400; font-size: 14px;">Loading...</span>';
136+
const formula = await ws.getFormula({
137+
column: selectedVariable,
138+
operation: 'sum',
139+
spatialFilter: getSpatialFilterFromViewState(viewState),
140+
spatialIndexReferenceViewState: viewState
141+
});
142+
formulaWidget.textContent = Intl.NumberFormat('en-US', {
143+
maximumFractionDigits: 0
144+
// notation: 'compact'
145+
}).format(formula.value);
146+
}
147+
148+
const HISTOGRAM_WIDGET_ID = 'urbanity_widget';
149+
150+
async function renderHistogram(ws: WidgetSource) {
151+
histogramWidget.parentElement?.querySelector('.loader')?.classList.toggle('hidden', false);
152+
histogramWidget.classList.toggle('hidden', true);
153+
154+
const categories = await ws.getCategories({
155+
column: 'urbanity',
156+
operation: 'sum',
157+
operationColumn: selectedVariable,
158+
filterOwner: HISTOGRAM_WIDGET_ID,
159+
spatialFilter: getSpatialFilterFromViewState(viewState),
160+
spatialIndexReferenceViewState: viewState
161+
});
162+
163+
histogramWidget.parentElement?.querySelector('.loader')?.classList.toggle('hidden', true);
164+
histogramWidget.classList.toggle('hidden', false);
165+
166+
const selectedCategory = filters['urbanity']?.[FilterType.IN]?.values[0];
167+
const colors = categories.map(c =>
168+
c.name === selectedCategory ? 'rgba(255, 99, 132, 0.8)' : 'rgba(54, 162, 235, 0.75)'
169+
);
170+
171+
if (histogramChart) {
172+
histogramChart.data.labels = categories.map(c => c.name);
173+
histogramChart.data.datasets[0].data = categories.map(c => Math.floor(c.value));
174+
histogramChart.data.datasets[0].backgroundColor = colors;
175+
histogramChart.update();
176+
} else {
177+
histogramChart = new Chart(histogramWidget, {
178+
type: 'bar',
179+
data: {
180+
labels: categories.map(c => c.name),
181+
datasets: [
182+
{
183+
label: 'Urbanity category',
184+
data: categories.map(c => Math.floor(c.value)),
185+
backgroundColor: colors
186+
}
187+
]
188+
},
189+
options: {
190+
onClick: async (ev, elems, chart) => {
191+
const labels = chart.data.labels as string[];
192+
const index = elems[0]?.index;
193+
const categoryName = labels[index];
194+
if (!categoryName || categoryName === selectedCategory) {
195+
removeFilter(filters, {column: 'urbanity'});
196+
} else {
197+
addFilter(filters, {
198+
column: 'urbanity',
199+
type: FilterType.IN,
200+
values: [categoryName],
201+
owner: HISTOGRAM_WIDGET_ID
202+
});
203+
}
204+
render();
205+
}
206+
}
207+
});
208+
}
209+
}
210+
211+
const debouncedRenderWidgets = debounce(renderWidgets, 500);
212+
213+
// Main execution
214+
const map = new maplibregl.Map({
215+
container: 'map',
216+
style: BASEMAP.DARK_MATTER,
217+
interactive: false
218+
});
219+
220+
const deck = new Deck({
221+
canvas: 'deck-canvas',
222+
initialViewState: viewState,
223+
controller: true
224+
});
225+
deck.setProps({
226+
onViewStateChange: props => {
227+
const {longitude, latitude, ...rest} = props.viewState;
228+
map.jumpTo({center: [longitude, latitude], ...rest});
229+
viewState = props.viewState;
230+
debouncedRenderWidgets();
231+
}
232+
});
233+
234+
initSelectors();
235+
render();

widgets-h3/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "carto-deckgl-example-widgets-h3",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "vite",
7+
"dev-local": "vite --config ../vite.config.local.mjs",
8+
"build": "vite build",
9+
"preview": "vite preview",
10+
"link-deck": "yarn link @deck.gl/core && yarn link @deck.gl/layers && yarn link @deck.gl/geo-layers && yarn link @deck.gl/mesh-layers && yarn link @deck.gl/aggregation-layers && yarn link @deck.gl/carto && yarn link @deck.gl/extensions",
11+
"unlink-deck": "yarn unlink @deck.gl/core && yarn link @deck.gl/layers && yarn link @deck.gl/geo-layers && yarn link @deck.gl/mesh-layers && yarn link @deck.gl/aggregation-layers && yarn link @deck.gl/carto && yarn link @deck.gl/extensions",
12+
"format": "npx prettier \"**/*.{cjs,html,js,json,md,ts}\" --ignore-path ./.eslintignore --write"
13+
},
14+
"devDependencies": {
15+
"vite": "^4.5.0"
16+
},
17+
"dependencies": {
18+
"@carto/api-client": "^0.4.4",
19+
"@deck.gl/aggregation-layers": "^9.0.17",
20+
"@deck.gl/carto": "^9.0.17",
21+
"@deck.gl/core": "^9.0.17",
22+
"@deck.gl/extensions": "^9.0.17",
23+
"@deck.gl/geo-layers": "^9.0.17",
24+
"@deck.gl/layers": "^9.0.17",
25+
"@deck.gl/mesh-layers": "^9.0.17",
26+
"chart.js": "^4.4.7",
27+
"maplibre-gl": "^3.5.2"
28+
}
29+
}

0 commit comments

Comments
 (0)