Qlik Community

Qlik Design Blog

All about product and Qlik solutions: scripting, data modeling, visual design, extensions, best practices, etc.

Announcements
QlikWorld 2022, LIVE in Denver CO., May 16-19, 2022. REGISTER NOW TO RECEIVE EARLY BIRD PRICING
Yianni_Ververis
Employee
Employee

Today I will show you how to create a simple custom Mapbox GL extension for Nebula.js. 

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

https://community.qlik.com/t5/Qlik-Design-Blog/More-visualization-extensions-for-Nebula-js/ba-p/1799...

 

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

6bd03f1279c0294bbeea3bcac9ec3cf970ae4156fc3d35953994103cc848ed2c

 

 

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

 

1 Comment