Skip to content

Commit 180d1ad

Browse files
authored
Fetchmap example (#41)
* First high-vibes version * Readme improvements * Copy improvements in HTML * Simplify and clean example * Simplify legend * Improve tooltip
1 parent 9587c76 commit 180d1ad

File tree

10 files changed

+1266
-0
lines changed

10 files changed

+1266
-0
lines changed

fetchmap/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Example: fetchMap
2+
3+
This example demonstrates how to use the `fetchMap` function from `@carto/api-client` (version 0.5.5 or higher) to retrieve the configuration of a map created in CARTO Builder and render it using Deck.gl. Developers can then customize those layers and integrate them in their own application layers. This enables developers to collaborate with cartographers and other non-developers who will create layers directlly in CARTO, and greatly reduces the time to create an application.
4+
5+
Check the [documentation and technical reference for fetchMap](https://docs.carto.com/carto-for-developers/reference/fetchmap) to learn more.
6+
7+
## Usage
8+
9+
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/CartoDB/deck.gl-examples/tree/master/fetchmap?file=index.ts)
10+
11+
> [!WARNING]
12+
> Please make sure you recreate the `.env` file from this repository in your Stackblitz project.
13+
14+
Or run it locally:
15+
16+
```bash
17+
npm install
18+
# or
19+
yarn
20+
```
21+
22+
Commands:
23+
24+
- `npm run dev` is the development target, to serve the app and hot reload.
25+
- `npm run build` is the production target, to create the final bundle and write to disk.

fetchmap/index.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link
6+
rel="icon"
7+
type="image/png"
8+
sizes="32x32"
9+
href="https://app.carto.com/favicon/favicon-32x32.png"
10+
/>
11+
<link
12+
rel="icon"
13+
type="image/png"
14+
sizes="16x16"
15+
href="https://app.carto.com/favicon/favicon-16x16.png"
16+
/>
17+
<link rel="preconnect" href="https://fonts.googleapis.com" />
18+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
19+
<link
20+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
21+
rel="stylesheet"
22+
/>
23+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
24+
<title>CARTO + deck.gl - fetchMap Example</title>
25+
</head>
26+
<body>
27+
<div id="app">
28+
<div id="map"></div>
29+
<canvas id="deck-canvas"></canvas>
30+
<div id="story-card">
31+
<p class="overline">✨👀 You're viewing</p>
32+
<h2>fetchMap Example</h2>
33+
<p>
34+
This example showcases how to integrate a map created in CARTO Builder into a custom CARTO
35+
+ Deck.gl application. It uses <code>fetchMap</code> from
36+
<code>@carto/api-client</code> to retrieve the map configuration and layers.
37+
</p>
38+
<p class="small">
39+
Learn more about
40+
<a
41+
href="https://docs.carto.com/carto-for-developers/reference/fetchmap"
42+
rel="noopener noreferrer"
43+
target="_blank"
44+
>fetchMap</a
45+
>.
46+
</p>
47+
48+
<div id="map-selector-container" class="content-section-container">
49+
<h3 class="map-selector-title">Select one or more available maps</h3>
50+
<!-- Checkboxes will be dynamically inserted here by the script -->
51+
<button id="load-maps-button" class="map-selector-button">Load Maps</button>
52+
</div>
53+
54+
<div class="content-section-container">
55+
<h3 class="map-selector-title">Loaded maps</h3>
56+
<div id="map-info-widget">
57+
<dl>
58+
<dt>Map Title(s):</dt>
59+
<dd id="mapName">Loading...</dd>
60+
61+
<dt>Number of Layers:</dt>
62+
<dd id="layerCount">Loading...</dd>
63+
</dl>
64+
</div>
65+
</div>
66+
67+
<hr />
68+
<p class="caption">
69+
Source:
70+
<a
71+
href="https://carto.com/spatial-data-catalog/browser"
72+
rel="noopener noreferrer"
73+
target="_blank"
74+
>CARTO Data Observatory</a
75+
>
76+
</p>
77+
</div>
78+
</div>
79+
<script type="module" src="./index.ts"></script>
80+
</body>
81+
</html>

fetchmap/index.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import './style.css';
2+
import maplibregl from 'maplibre-gl';
3+
import 'maplibre-gl/dist/maplibre-gl.css';
4+
import {Deck, Layer} from '@deck.gl/core';
5+
import {fetchMap, FetchMapResult, LayerDescriptor} from '@carto/api-client';
6+
import {BASEMAP} from '@deck.gl/carto';
7+
import {LayerFactory} from './utils';
8+
import {createLegend} from './legend';
9+
import './legend.css';
10+
import {buildTooltip} from './tooltip';
11+
12+
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
13+
14+
// Define configurations for multiple maps
15+
const MAP_CONFIGS = [
16+
{id: '26bce5fe-29d4-48dc-914f-14355971f143', name: 'US Weather'},
17+
{id: '6ac6f3bb-9c90-4e7e-9ecd-3c7343704c24', name: 'US Wind Map'}
18+
];
19+
20+
// Define the structure for aggregated map data
21+
interface AggregatedMapData {
22+
title: string;
23+
layers: LayerDescriptor[];
24+
popupSettings: FetchMapResult['popupSettings'] | null;
25+
initialViewState?: Deck['props']['initialViewState'];
26+
}
27+
28+
let currentMapData: AggregatedMapData | null = null; // Stores aggregated data from selected maps
29+
30+
// Base options for fetchMap, cartoMapId will be added per call
31+
const baseFetchMapOptions = {
32+
apiBaseUrl
33+
// accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN // if you want to use a private (non-public) map
34+
};
35+
36+
// HTML Elements
37+
const mapNameEl = document.querySelector<HTMLDListElement>('#mapName');
38+
const layerCountEl = document.querySelector<HTMLDListElement>('#layerCount');
39+
const mapSelectorContainer = document.getElementById('map-selector-container');
40+
const loadMapsButton = document.getElementById('load-maps-button');
41+
42+
// Initial Deck.gl view state - this will be updated by fetchMap data
43+
const INITIAL_VIEW_STATE = {
44+
latitude: 0,
45+
longitude: 0,
46+
zoom: 1,
47+
bearing: 0,
48+
pitch: 0
49+
};
50+
51+
const deck = new Deck({
52+
canvas: 'deck-canvas',
53+
initialViewState: INITIAL_VIEW_STATE,
54+
controller: true,
55+
onViewStateChange: ({viewState}) => {
56+
const {longitude, latitude, ...rest} = viewState;
57+
map.jumpTo({center: [longitude, latitude], ...rest});
58+
},
59+
getTooltip: ({object, layer}) => {
60+
if (!layer) return null;
61+
return buildTooltip(object, layer, currentMapData);
62+
}
63+
});
64+
65+
// Add basemap
66+
const map = new maplibregl.Map({
67+
container: 'map',
68+
style: BASEMAP.VOYAGER, // Using Voyager as a default
69+
interactive: false
70+
});
71+
72+
async function initialize(selectedMapIds: string[]) {
73+
try {
74+
// Clear previous map state
75+
const existingLegend = document.querySelector('.legend-wrapper');
76+
if (existingLegend) {
77+
existingLegend.remove();
78+
}
79+
deck.setProps({layers: []});
80+
currentMapData = null;
81+
82+
if (selectedMapIds.length === 0) {
83+
if (mapNameEl) mapNameEl.innerHTML = 'No Map Selected';
84+
if (layerCountEl) layerCountEl.innerHTML = '0';
85+
map.jumpTo({
86+
center: [INITIAL_VIEW_STATE.longitude, INITIAL_VIEW_STATE.latitude],
87+
zoom: INITIAL_VIEW_STATE.zoom,
88+
bearing: INITIAL_VIEW_STATE.bearing,
89+
pitch: INITIAL_VIEW_STATE.pitch
90+
});
91+
deck.setProps({initialViewState: INITIAL_VIEW_STATE});
92+
return;
93+
}
94+
95+
// Fetch map data for each selected map
96+
const allFetchedMapData: FetchMapResult[] = [];
97+
for (const mapId of selectedMapIds) {
98+
try {
99+
const fetchOptions = {...baseFetchMapOptions, cartoMapId: mapId};
100+
const mapData = await fetchMap(fetchOptions);
101+
allFetchedMapData.push(mapData);
102+
} catch (error) {
103+
// Optionally, show a partial error or skip this map
104+
}
105+
}
106+
107+
// If no maps were fetched, show an error
108+
if (allFetchedMapData.length === 0) {
109+
if (mapNameEl) mapNameEl.innerHTML = 'Error loading maps';
110+
if (layerCountEl) layerCountEl.innerHTML = 'Error';
111+
return;
112+
}
113+
114+
// Aggregate data from all fetched maps
115+
const aggregatedLayers: LayerDescriptor[] = [];
116+
const mergedPopupSettingsLayers: NonNullable<
117+
NonNullable<FetchMapResult['popupSettings']>['layers']
118+
> = {};
119+
let finalInitialViewState = {...INITIAL_VIEW_STATE};
120+
const mapTitles: string[] = [];
121+
122+
allFetchedMapData.forEach((mapData, index) => {
123+
mapTitles.push(mapData.title || `Map ${index + 1}`);
124+
if (mapData.layers) {
125+
aggregatedLayers.push(...mapData.layers);
126+
}
127+
if (mapData.popupSettings && mapData.popupSettings.layers) {
128+
for (const layerId in mapData.popupSettings.layers) {
129+
// Simple merge: last one wins if layer IDs conflict across maps.
130+
mergedPopupSettingsLayers[layerId] = mapData.popupSettings.layers[layerId];
131+
}
132+
}
133+
134+
// Use initial view state from the first successfully fetched map
135+
if (index === 0) {
136+
// Prioritize the first map's view state
137+
if (mapData.initialViewState) {
138+
finalInitialViewState = {...INITIAL_VIEW_STATE, ...mapData.initialViewState};
139+
} else if ((mapData as any).mapOptions && (mapData as any).mapOptions.viewState) {
140+
finalInitialViewState = {
141+
longitude: (mapData as any).mapOptions.viewState.longitude,
142+
latitude: (mapData as any).mapOptions.viewState.latitude,
143+
zoom: (mapData as any).mapOptions.viewState.zoom,
144+
pitch: (mapData as any).mapOptions.viewState.pitch || 0,
145+
bearing: (mapData as any).mapOptions.viewState.bearing || 0
146+
};
147+
}
148+
}
149+
});
150+
151+
currentMapData = {
152+
title: mapTitles.length > 1 ? mapTitles.join(' & ') : mapTitles[0] || 'Untitled Map',
153+
layers: aggregatedLayers,
154+
popupSettings:
155+
Object.keys(mergedPopupSettingsLayers).length > 0
156+
? {layers: mergedPopupSettingsLayers}
157+
: null,
158+
initialViewState: finalInitialViewState
159+
};
160+
161+
// Update widgets
162+
if (mapNameEl) {
163+
mapNameEl.innerHTML = currentMapData.title;
164+
}
165+
if (layerCountEl) {
166+
layerCountEl.innerHTML = currentMapData.layers.length.toString();
167+
}
168+
169+
// Create and append the new legend for combined layers
170+
if (currentMapData.layers && currentMapData.layers.length > 0) {
171+
const legendElement = createLegend(currentMapData.layers);
172+
document.body.appendChild(legendElement);
173+
174+
// Add event listener to toggle layer visibility
175+
legendElement.addEventListener('togglelayervisibility', (event: Event) => {
176+
const customEvent = event as CustomEvent<{layerId: string; visible: boolean}>;
177+
const {layerId, visible} = customEvent.detail;
178+
const currentDeckLayers = (deck.props.layers || []) as Layer[];
179+
const newLayers = currentDeckLayers.map((layer: Layer) => {
180+
if (layer && layer.id === layerId) {
181+
return layer.clone({visible});
182+
}
183+
return layer;
184+
});
185+
deck.setProps({layers: newLayers});
186+
});
187+
}
188+
189+
// Finally, update the deck.gl view state and layers
190+
deck.setProps({
191+
initialViewState: finalInitialViewState,
192+
layers: LayerFactory(currentMapData.layers)
193+
});
194+
195+
map.jumpTo({
196+
center: [finalInitialViewState.longitude, finalInitialViewState.latitude],
197+
zoom: finalInitialViewState.zoom,
198+
bearing: finalInitialViewState.bearing,
199+
pitch: finalInitialViewState.pitch
200+
});
201+
} catch (error) {
202+
if (mapNameEl) mapNameEl.innerHTML = 'Error loading map(s)';
203+
if (layerCountEl) layerCountEl.innerHTML = 'Error';
204+
currentMapData = null; // Clear data on error
205+
deck.setProps({layers: []}); // Clear layers on error
206+
}
207+
}
208+
209+
// Initialize map selector and load default map(s)
210+
function setupMapSelector() {
211+
if (!mapSelectorContainer || !loadMapsButton) {
212+
// Fallback: Load the first map by default if UI elements are missing but configs exist
213+
if (MAP_CONFIGS.length > 0) {
214+
initialize([MAP_CONFIGS[0].id]);
215+
} else {
216+
initialize([]); // Show "No Map Selected" or similar
217+
}
218+
return;
219+
}
220+
221+
MAP_CONFIGS.forEach((mapConfig, index) => {
222+
const checkbox = document.createElement('input');
223+
checkbox.type = 'checkbox';
224+
// Ensure unique ID for each checkbox, useful for labels
225+
checkbox.id = `map-checkbox-${mapConfig.id}-${index}`;
226+
checkbox.value = mapConfig.id;
227+
checkbox.name = 'mapSelection';
228+
229+
const label = document.createElement('label');
230+
label.htmlFor = checkbox.id;
231+
label.textContent = mapConfig.name;
232+
233+
const div = document.createElement('div');
234+
div.className = 'map-selector-option';
235+
div.appendChild(checkbox);
236+
div.appendChild(label);
237+
mapSelectorContainer.appendChild(div);
238+
});
239+
240+
mapSelectorContainer.appendChild(loadMapsButton);
241+
242+
loadMapsButton.addEventListener('click', () => {
243+
const selectedCheckboxes = document.querySelectorAll<HTMLInputElement>(
244+
'input[name="mapSelection"]:checked'
245+
);
246+
const selectedIds = Array.from(selectedCheckboxes).map(cb => cb.value);
247+
initialize(selectedIds);
248+
});
249+
250+
// Load the first map by default on initial page load
251+
const firstCheckbox =
252+
mapSelectorContainer.querySelector<HTMLInputElement>('input[type="checkbox"]');
253+
if (firstCheckbox) {
254+
firstCheckbox.checked = true;
255+
initialize([firstCheckbox.value]);
256+
} else if (MAP_CONFIGS.length > 0) {
257+
// Fallback if somehow checkboxes weren't created but configs exist
258+
initialize([MAP_CONFIGS[0].id]);
259+
} else {
260+
initialize([]); // No maps to load if no configs
261+
}
262+
}
263+
264+
// Initialize map selector and load default map(s)
265+
setupMapSelector();

0 commit comments

Comments
 (0)