Unlock a world of possibilities! Login now and discover the exclusive benefits awaiting you.
In the past I have talked about all of the existing cool extensions that we have available for nebula.js and can be used on your webpages.
https://community.qlik.com/t5/Qlik-Design-Blog/Sn-table-Nebula-js-latest-extension/ba-p/1780153
Today, I will just create a custom one with Mapb GL and show how to do on the fly.
Lets get started and connect to our app with Enigma.js and the Engine API.
const config = {
host: <your-server>,
appId: <your-qlik-app-id>
};
const openQlikApp = async () => {
const { senseUtilities, enigma } = window;
const schemaResponse = await fetch('https://unpkg.com/enigma.js/schemas/12.34.11.json');
const schema = await schemaResponse.json();
const url = senseUtilities.buildUrl(config);
const session = enigma.create({ schema, url });
return await session.open().then((global) => global.openDoc(config.appId));
}
Now we can register the extension by naming it "sn-mapbox".
const nebula = await stardust.embed(qlikApp, {
types: [{
name: 'sn-mapbox',
load: () => snMapbox,
}],
});
And render it into the dom with 4-5 dimensions. The first 3 are mandatory for Mapbox to work. We need a unique ID, a Latitude and Longitude. Then we can have a couple of more properties to create layers with dots and assign colors or size based on those.
nebula.render({
element: chartElement,
type: 'sn-mapbox',
fields: [ 'ID', 'lat', 'lon', 'gender', 'AgeBucket'],
});
Now, we can dive into the actual mapbox code. As per mapbox instructions, we must have a token and a style. If you do not have one, go ahead and register for a free limited one.
I am adding the default style and my personal testing token. I have also added the flyTo option for some entry animation.
const options = {
accessToken: 'pk.eyJ1IjoieWlhbm5pLXZlcnZlcmlzIiwiYSI6ImNrcWF0azdnejBjdm4yd3M3ajBmb2hpeGkifQ.rl7QWaaMtqRYNJ-vMIMoOA', // Change this to your free personal token
style: 'mapbox://styles/mapbox/streets-v11', // This is the default map style
center: [-60, 20],
zoom: 2,
pitch: 0,
bearing: 0,
circleRadius: 8,
circleOpacity: 1,
// Custom tooltip that displays the last 2 dimensions / properties
tooltip: (obj) => `
<div>Gender: ${obj.gender}</div>
<div>Age Bucket: ${obj.AgeBucket}</div>
`,
createLayers: true,
// Add a flying point for entry animation
flyTo: {
center: [-74.50, 40],
zoom: 4,
speed: 0.3,
curve: 1,
easing(t) {
return t;
}
},
// The colors for the dots
palette: [
'#3399CC', // Light Blue
'#CC6666', // Light Red
]
};
const snMapbox = () => {
return {
// Define the Engine API HyperCube
qae: {
properties: {
qHyperCubeDef: {
qDimensions: [],
qMeasures: [],
qInitialDataFetch: [{ qWidth: 5, qHeight: 2000 }],
qSuppressZero: true,
qSuppressMissing: true,
},
showTitles: true,
title: 'US Data',
subtitle: 'Random gender / age buckets',
footnote: 'Data is random, for this example only.',
},
data: {
targets: [
{
path: '/qHyperCubeDef',
dimensions: {
min: 1,
max: 5,
},
measures: {
min: 0,
max: 0,
},
},
],
},
},
component() {
const { stardust } = window;
const element = stardust.useElement();
const layout = stardust.useLayout();
const qData = layout.qHyperCube?.qDataPages[0];
const qMatrix = qData.qMatrix.filter(row => row.some(el => el.qNum !== "NaN"))
const property = layout.qHyperCube?.qDimensionInfo[3]?.qFallbackTitle;
const property2 = layout.qHyperCube?.qDimensionInfo[4]?.qFallbackTitle;
const [instance, setInstance] = stardust.useState();
let GeoJSON, map = null;
let mapData = [];
const propertyChildren = [...new Set(qMatrix.map((array) => array[3].qText))];
const propertyChildrenWithColors = propertyChildren.reduce((r, e, i) => r.push(e, options.palette[i]) && r, []);
// Create the Mapbox features based on our HyperCube data
const buildFeatures = (obj) => {
const featureObj = {
type: 'Feature',
properties: {
count: 1,
userID: obj.id,
[property]: obj[property],
},
geometry: {
type: 'Point',
coordinates: [obj.lng, obj.lat],
},
};
if (options.tooltip !== null) {
featureObj.properties.description = options.tooltip(obj);
}
return featureObj;
}
// Convert our HyperCube data into a GeoJSON for Mapbox
const buildGeoJSON = () => {
const goodGeoJSON = {
type: 'FeatureCollection',
features: [],
};
qMatrix.map((array) => {
if (typeof array[1].qNum !== 'number' || typeof array[2].qNum !== 'number') return false;
const obj = {
id: Number(array[0].qNum),
lat: Number(array[1].qNum),
lng: Number(array[2].qNum),
};
obj[property] = array[3].qText;
obj[property2] = array[4].qText;
const feature = buildFeatures(obj);
goodGeoJSON.features.push(feature);
return obj;
});
return goodGeoJSON;
}
// Create the layer that will hold the dots
const buildLayer = () => {
const match = ['match', ['get', property], ...propertyChildrenWithColors, '#FFF'];
const layer = {
id: 'dots',
type: 'circle',
source: 'hyperCubeData',
paint: {
'circle-stroke-width': 0,
'circle-radius': options.circleRadius,
'circle-color': match,
'circle-opacity': options.circleOpacity,
},
};
return layer;
}
// Create the map
const buildMap = () => {
// Add HyperCube data as GeoJSON
map.addSource('hyperCubeData', {
type: 'geojson',
data: GeoJSON,
});
// Create the layer
const layer = buildLayer();
map.addLayer(layer);
if (options.extraLayers && options.extraLayers.length) {
options.extraLayers.map((_layer) => map.addLayer(_layer));
}
// Create Tooltips and the triggering events
if (options.tooltip !== null) {
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
className: 'sn-mapbox-tooltip',
});
map.on('mouseenter', 'dots', (e) => {
map.getCanvas().style.cursor = 'pointer';
const coordinates = e.features[0].geometry.coordinates.slice();
const { description } = e.features[0].properties;
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
popup
.setLngLat(coordinates)
.setHTML(description)
.addTo(map);
});
map.on('mouseleave', 'dots', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
}
};
// Update layer data upon HyperCube change
const updateLayers = () => {
const nextChunk = qMatrix.map((array) => {
const obj = {
id: Number(array[0].qNum),
lat: Number(array[1].qNum),
lng: Number(array[2].qNum),
[property]: array[3].qText,
};
return buildFeatures(obj);
});
if (GeoJSON) {
GeoJSON = { ...GeoJSON, features: [...GeoJSON.features, ...nextChunk] };
map.getSource('hyperCubeData').setData(GeoJSON);
} else {
GeoJSON = buildGeoJSON();
buildMap();
}
};
stardust.useEffect(() => {
mapboxgl.accessToken = options.accessToken;
if (!map) {
// Initialize mapbox GL
map = new mapboxgl.Map({
container: element,
...options,
});
// Add layer with data
map.on('load', () => {
updateLayers(qData); // Draw the first set of data, in case we load all
mapData = [...mapData, ...qMatrix];
});
// Add intro animation
if (options.flyTo) {
map.flyTo(options.flyTo);
}
}
}, [layout]);
},
};
}
export default snMapbox;
This is the final result
You can view, fork and play with the above demo at
https://nebulajs-mapboxg-simple.glitch.me/
https://observablehq.com/@yianni-ververis/nebula-js-mapbox?collection=@yianni-ververis/nebula
/yianni
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.