DEV Community

Cover image for Building a Custom MapTiler Visualization Plugin for Apache Superset 3.1.0 (Complete Step-by-Step Guide)
Puspad Sharma
Puspad Sharma

Posted on

Building a Custom MapTiler Visualization Plugin for Apache Superset 3.1.0 (Complete Step-by-Step Guide)

INTRODUCTION

Apache Superset is a powerful open-source BI and data-visualization platform — but sometimes, you need a chart that doesn’t exist yet.
In this article, I’ll walk you through creating a custom visualization plugin for Apache Superset 3.1.0 that renders a MapLibre GL map using a MapTiler style.json.

We will build it completely from scratch with React + TypeScript, and include a fake-data fallback so the map still renders even if your dataset is empty.

Everything here is open, organization-neutral, and safe to reuse.

ENVIRONMENT

  • Apache Superset 3.1.0
  • Node.js 16 or 18
  • npm 7/8
  • React 18
  • TypeScript
  • maplibre-gl ^3.6.0

1. Scaffold a New Plugin

npm i -g yo
mkdir superset-plugin-chart-map-tiler
cd superset-plugin-chart-map-tiler
yo @superset-ui/superset
# Answer prompts:
#  Package name: superset-plugin-chart-map-tiler
#  Description: MapTiler Map
#  Type: Regular chart
npm install
npm run build

Enter fullscreen mode Exit fullscreen mode

You should see a dist/ folder — that’s your compiled plugin bundle.

2. Register the Plugin in Superset
Add or install your plugin inside superset-frontend:

# From <superset-root>/superset-frontend
npm i -S /absolute/path/to/superset-plugin-chart-map-tiler

Enter fullscreen mode Exit fullscreen mode

Edit src/visualizations/presets/MainPreset.tsx:

import MapTilerPlugin from 'superset-plugin-chart-map-tiler';
new MapTilerPlugin().configure({ key: 'map-tiler' }).register();

Enter fullscreen mode Exit fullscreen mode

Restart the dev server:

npm install
npm run dev-server

Enter fullscreen mode Exit fullscreen mode

You’ll now see “MapTiler Map” among chart types in Explore.

3. Define the Plugin Structure

superset-plugin-chart-map-tiler/
  package.json
  tsconfig.json
  src/
    plugin/
      index.ts
      controlPanel.ts
      buildQuery.ts
      transformProps.ts
      MapTilerChart.tsx
      types.ts

Enter fullscreen mode Exit fullscreen mode

4. Minimal Configuration Files
package.json

{
  "name": "superset-plugin-chart-map-tiler",
  "version": "0.1.0",
  "main": "dist/index.js",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "dev": "tsc -w -p tsconfig.json"
  },
  "peerDependencies": {
    "@superset-ui/chart-controls": "^3",
    "@superset-ui/core": "^3",
    "react": "^18",
    "react-dom": "^18"
  },
  "dependencies": { "maplibre-gl": "^3.6.0" }
}

Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2018",
    "module": "ESNext",
    "declaration": true,
    "outDir": "dist",
    "jsx": "react-jsx",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Enter fullscreen mode Exit fullscreen mode

5. Define Types → types.ts

export type MapTilerFormData = {
  mapStyleUrl: string;
  initialZoom: number;
  initialCenter: [number, number];
  latColumn?: string;
  lonColumn?: string;
  labelColumn?: string;
};

export type MapPoint = { lon: number; lat: number; label?: string };

export type MapTilerProps = {
  width: number;
  height: number;
  formData: MapTilerFormData;
  data: MapPoint[];
};

Enter fullscreen mode Exit fullscreen mode

6. Control Panel → controlPanel.ts

import { t, validateNumber } from '@superset-ui/core';
import { sections } from '@superset-ui/chart-controls';

export default {
  controlPanelSections: [
    {
      label: t('Map'),
      expanded: true,
      controlSetRows: [
        [{
          name: 'mapStyleUrl',
          config: {
            type: 'TextControl',
            label: t('Map Style URL'),
            description: t('MapTiler style.json URL'),
            default: 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_MAPTILER_KEY'
          },
        }],
        [{
          name: 'initialCenter',
          config: {
            type: 'TextControl',
            label: t('Initial Center [lng,lat]'),
            default: '0,20',
            renderTrigger: true
          },
        }],
        [{
          name: 'initialZoom',
          config: {
            type: 'TextControl',
            label: t('Initial Zoom'),
            default: '2',
            renderTrigger: true,
            validators: [validateNumber]
          },
        }]
      ]
    },
    {
      label: t('Data'),
      expanded: true,
      controlSetRows: [
        [{ name: 'latColumn',  config: { type: 'SelectControl', freeForm: true, label: t('Latitude Column'),  default: 'lat' } }],
        [{ name: 'lonColumn',  config: { type: 'SelectControl', freeForm: true, label: t('Longitude Column'), default: 'lon' } }],
        [{ name: 'labelColumn',config: { type: 'SelectControl', freeForm: true, label: t('Label Column'),     default: 'name' } }],
      ]
    },
    sections.annotationsAndLayersSection
  ]
};

Enter fullscreen mode Exit fullscreen mode

7. Query & Transform Logic
buildQuery.ts

import { BuildQuery, QueryFormData } from '@superset-ui/core';

const buildQuery: BuildQuery = (formData: QueryFormData) => ({
  queries: [{
    ...formData,
    columns: [
      (formData as any)['lonColumn'] || 'lon',
      (formData as any)['latColumn'] || 'lat',
      (formData as any)['labelColumn'] || 'name'
    ],
    metrics: [],
    orderby: [],
    filters: [],
    time_range: 'No filter'
  }]
});

export default buildQuery;

Enter fullscreen mode Exit fullscreen mode

transformProps.ts

import { ChartProps } from '@superset-ui/core';
import { MapPoint, MapTilerFormData, MapTilerProps } from './types';

export default function transformProps(chartProps: ChartProps): MapTilerProps {
  const { width, height, formData, queriesData } = chartProps;
  const fd = formData as any as MapTilerFormData;
  const center: [number, number] = String(fd.initialCenter || '0,0').split(',').map(Number) as any;
  const rows = (queriesData?.[0]?.data || []) as Record<string, any>[];

  const data: MapPoint[] =
    rows.length
      ? rows.map(r => ({
          lon: Number(r[fd.lonColumn || 'lon']),
          lat: Number(r[fd.latColumn || 'lat']),
          label: r[fd.labelColumn || 'name']
        }))
      : [
          { lon: 0, lat: 20, label: 'Demo A' },
          { lon: 12.5, lat: 41.9, label: 'Demo B' },
          { lon: 103.8, lat: 1.35, label: 'Demo C' }
        ];

  return { width, height, formData: { ...fd, initialCenter: center, initialZoom: +fd.initialZoom || 2 }, data };
}

Enter fullscreen mode Exit fullscreen mode

8. Render Map → MapTilerChart.tsx

import React, { useEffect, useMemo, useRef } from 'react';
import maplibregl, { Map, GeoJSONSource } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { MapTilerProps } from './types';

const sourceId = 'points-source';
const layerId = 'points-layer';

const toGeoJSON = (points: { lon: number; lat: number; label?: string }[]) => ({
  type: 'FeatureCollection',
  features: points.map(p => ({
    type: 'Feature',
    properties: { label: p.label || '' },
    geometry: { type: 'Point', coordinates: [p.lon, p.lat] }
  }))
});

export default function MapTilerChart({ width, height, formData, data }: MapTilerProps) {
  const ref = useRef<HTMLDivElement | null>(null);
  const mapRef = useRef<Map | null>(null);
  const geojson = useMemo(() => toGeoJSON(data), [data]);

  useEffect(() => {
    if (!ref.current || mapRef.current) return;
    mapRef.current = new maplibregl.Map({
      container: ref.current,
      style: formData.mapStyleUrl,
      center: formData.initialCenter,
      zoom: formData.initialZoom,
    });
    mapRef.current.on('load', () => {
      const map = mapRef.current!;
      map.addSource(sourceId, { type: 'geojson', data: geojson as any });
      map.addLayer({
        id: layerId,
        type: 'circle',
        source: sourceId,
        paint: { 'circle-radius': 6, 'circle-stroke-width': 1, 'circle-stroke-color': '#000', 'circle-color': '#3bb2d0' }
      });
    });
    return () => { mapRef.current?.remove(); mapRef.current = null; };
  }, []);

  useEffect(() => {
    const src = mapRef.current?.getSource(sourceId) as GeoJSONSource | undefined;
    if (src) src.setData(geojson as any);
  }, [geojson]);

  return <div ref={ref} style={{ width, height }} />;
}

Enter fullscreen mode Exit fullscreen mode

9. Plugin Entry → index.ts

import { ChartPlugin, ChartMetadata, t } from '@superset-ui/core';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import transformProps from './transformProps';
import ReactComponent from './MapTilerChart';

const metadata = new ChartMetadata({
  name: t('MapTiler Map'),
  thumbnail: '',
  description: t('Render a MapLibre map using a MapTiler style and point features'),
  tags: ['Map', 'MapLibre', 'MapTiler']
});

export default class MapTilerPlugin extends ChartPlugin {
  constructor() {
    super({ loadChart: () => Promise.resolve(ReactComponent), metadata, controlPanel, buildQuery, transformProps });
  }
}

Enter fullscreen mode Exit fullscreen mode

10. Try It in Explore

  1. Create a new chart → select MapTiler Map

  2. Set

Run the query → you’ll see the map render with demo points if your dataset is empty.

Quick demo dataset:

SELECT 0 AS lon, 20 AS lat, 'Demo A' AS name
UNION ALL SELECT 12.5, 41.9, 'Demo B'
UNION ALL SELECT 103.8, 1.35, 'Demo C';

Enter fullscreen mode Exit fullscreen mode

Conclusion

You now have a fully functional MapTiler visualization plugin for Superset 3.1.0 — complete with a MapLibre GL map, configurable controls, and a fake-data fallback.

Because everything is generic and open, you can adapt this pattern for Leaflet, OpenLayers, or other map sources just by swapping the renderer.

Resources:
Apache Superset Docs
MapLibre GL JS
MapTiler Docs
Superset Plugin Development Guide (GitHub)

Note

This article uses only open-source tools and Replace YOUR_MAPTILER_KEY with your own free key from MapTiler Cloud

Top comments (0)