Taking Geospatial Data Analytics to the Next Level with Panel, DuckDB and MapLibre

Savaş Altürk
12 min readJan 25, 2025

--

Created by the author.
A map showing POI density in Turkey with H3 hexagons. Red areas mean more POIs. (Created by the author.)

Content

  • Introduction
  • Data Analysis
  • Creating a Custom Component
  • Bonus: Compile and Bundle ESM Components
  • Results and Applications

Introduction

In this blog post, we will explore how to prepare and query data using DuckDB. Next, we will create a custom component from scratch with Python Panel and integrate it with MapLibreGL JS. Finally, we will demonstrate how to dynamically update map data based on the zoom level, step by step.

Data Analysis

We will use DuckDB to explore the POI (Point of Interest) data shared by Foursquare as open data. We will query and retrieve data for Turkey and generate H3 indices for this data using the DuckDB H3 extension.

Project Setup

We will create the project structure using Python Uv. We will also install all the required packages to set up the working environment. We install the Uv package manager using the following code:

curl -LsSf https://astral.sh/uv/install.sh | sh

We create the project using the following code:

uv init open-poi
cd open-poi

To set up the virtual environment, we use the following command:

uv venv --python 3.11

We install the required packages for our project using the following commands:

uv add "duckdb>=1.1.3" "panel>=1.5.5"

To include Jupyter Notebook in our development environment, we install it using the following command:

uv add jupyterlab --dev

We create a notebook directory in our project folder and add a notebook file named “poi-data.ipynb” inside it. To install the spatial and H3 extensions for DuckDB:

import duckdb
import os


db_dir = '/Users/savo/Desktop/project/open-poi/data'
db_path = os.path.join(db_dir, 'poi.duckdb')
db = duckdb.connect(db_path, read_only=False)
db.sql("""
INSTALL spatial;
INSTALL h3 FROM community;
LOAD h3;
LOAD spatial;
""")

To load the data shared by Foursquare into DuckDB, use the following SQL queries. These create two separate tables: categories for all categories and places for data filtered to only include locations in Turkey (country = ‘TR’):

db.sql("""
CREATE TABLE categories AS
SELECT *
FROM read_parquet('s3://fsq-os-places-us-east-1/release/dt=2025-01-10/categories/parquet/*.zstd.parquet');
""")


db.sql("""
CREATE TABLE places AS
SELECT *
FROM read_parquet('s3://fsq-os-places-us-east-1/release/dt=2025-01-10/places/parquet/*.zstd.parquet')
WHERE country = 'TR';
""")

To convert the geom column to a geometry type, you can use the following SQL query. This uses the ST_GeomFromWKB function in DuckDB to transform the data in the geom column from WKB (Well-Known Binary) format to geometry type:

db.sql("""
ALTER TABLE places
ALTER COLUMN geom
SET DATA TYPE GEOMETRY USING ST_GeomFromWKB(geom);
""")

To generate different H3 resolutions (6, 7, 8, 9, 10, 11, 12) based on the zoom level, use the following code. This code adds new H3 columns to the places table for each resolution and populates them with the appropriate H3 indices:

for i in range(6, 13):
db.sql(f"""
ALTER TABLE places
ADD COLUMN h{i} BIGINT;

UPDATE places
SET h{i} = h3_latlng_to_cell(latitude, longitude, {i});
""")

To view the first 10 rows of the places table, use the following query:

db.sql("""
select * from places limit 10;
""")
┌──────────────────────────┬────────────────────────────┬───────────────────┬────────────────────┬────────────────┬──────────┬─────────┬──────────┬──────────────┬───────────┬─────────┬─────────┬──────────────┬────────────────┬─────────────┬─────────┬─────────┬─────────┬─────────────┬───────────┬─────────┬────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬────────────┬────────────────────┬────────────────────┬────────────────────┬────────────────────┬────────────────────┬────────────────────┬────────────────────┐
│ fsq_place_id │ name │ latitude │ longitude │ address │ locality │ region │ postcode │ admin_region │ post_town │ po_box │ country │ date_created │ date_refreshed │ date_closed │ tel │ website │ email │ facebook_id │ instagram │ twitter │ fsq_category_ids │ fsq_category_labels │ placemaker_url │ geom │ bbox │ dt │ h8 │ h9 │ h10 │ h11 │ h12 │ h6 │ h7 │
│ varchar │ varchar │ double │ double │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │ int64 │ varchar │ varchar │ varchar[] │ varchar[] │ varchar │ geometry │ struct(xmin double, ymin double, xmax double, ymax double) │ date │ int64 │ int64 │ int64 │ int64 │ int64 │ int64 │ int64 │
├──────────────────────────┼────────────────────────────┼───────────────────┼────────────────────┼────────────────┼──────────┼─────────┼──────────┼──────────────┼───────────┼─────────┼─────────┼──────────────┼────────────────┼─────────────┼─────────┼─────────┼─────────┼─────────────┼───────────┼─────────┼────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┼────────────────────┤
│ 548dd16f498e73a77bd226ac │ boşlukta │ 36.45787811279297 │ 28.6865291595459 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2014-12-14 │ 2022-11-18 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d117941735] │ [Dining and Drinking > Bar > Beer Garden] │ https://foursquare.com/placemakers/review-place/548dd16f498e73a77bd226ac │ POINT (28.6865291595459 36.45787811279297) │ {'xmin': 28.6865291595459, 'ymin': 36.45787811279297, 'xmax': 28.6865291595459, 'ymax': 36.45787811279297} │ 2025-01-10 │ 613604689335812095 │ 618108288970522623 │ 622611888597762047 │ 627115488225112063 │ 631619087852481023 │ 604597490141888511 │ 609101089718927359 │
│ 5633c5f7498ef8307ca7af67 │ yunanistan meys adası │ 36.452003 │ 28.521215 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2015-10-30 │ 2023-06-04 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d11f941735] │ [Arts and Entertainment > Night Club] │ https://foursquare.com/placemakers/review-place/5633c5f7498ef8307ca7af67 │ POINT (28.521215 36.452003) │ {'xmin': 28.521215, 'ymin': 36.452003, 'xmax': 28.521215, 'ymax': 36.452003} │ 2025-01-10 │ 613604688220127231 │ 618108287846449151 │ 622611887473786879 │ 627115487101153279 │ 631619086728520191 │ 604597489068146687 │ 609101088594853887 │
│ 4d748ed6ed838cfa3db90f6b │ In The Middle Of The Ocean │ 36.48043456798146 │ 27.986096001856815 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2011-03-07 │ 2020-05-13 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d1d6941735] │ [Arts and Entertainment > Strip Club] │ https://foursquare.com/placemakers/review-place/4d748ed6ed838cfa3db90f6b │ POINT (27.986096001856815 36.48043456798146) │ {'xmin': 27.986096001856815, 'ymin': 36.48043456798146, 'xmax': 27.986096001856815, 'ymax': 36.48043456798146} │ 2025-01-10 │ 613602565562564607 │ 618106165188100095 │ 622609764815405055 │ 627113364442767359 │ 631616964070134271 │ 604595366414778367 │ 609098965941485567 │
│ 4e2bf9997d8b7deda6d63598 │ с. Осен │ 36.473671524827 │ 27.944868263091678 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2011-07-24 │ 2024-05-13 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4d4b7105d754a06377d81259] │ [Landmarks and Outdoors] │ https://foursquare.com/placemakers/review-place/4e2bf9997d8b7deda6d63598 │ POINT (27.944868263091678 36.473671524827) │ {'xmin': 27.944868263091678, 'ymin': 36.473671524827, 'xmax': 27.944868263091678, 'ymax': 36.473671524827} │ 2025-01-10 │ 613602565549981695 │ 618106165176041471 │ 622609764803346431 │ 627113364430696447 │ 631616964058063871 │ 604595366414778367 │ 609098965924708351 │
│ 56a2608b498ea88459b43522 │ Mekan Ahmet Kaya 🎼M.K.A │ 36.481425 │ 27.953742 │ Köklüce Mah. │ Adana │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2016-01-22 │ 2016-01-22 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d110951735] │ [Business and Professional Services > Health and Beauty Service > Hair Salon] │ https://foursquare.com/placemakers/review-place/56a2608b498ea88459b43522 │ POINT (27.953742 36.481425) │ {'xmin': 27.953742, 'ymin': 36.481425, 'xmax': 27.953742, 'ymax': 36.481425} │ 2025-01-10 │ 613602565545787391 │ 618106165171322879 │ 622609764798660607 │ 627113364426014719 │ 631616964053382655 │ 604595366414778367 │ 609098965924708351 │
│ 51bf53f8498ebfb3b5111893 │ Aegean Paradise │ 36.47986700108901 │ 27.91068575555217 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2013-06-17 │ 2024-08-28 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d12d951735] │ [Travel and Transportation > Boat or Ferry] │ https://foursquare.com/placemakers/review-place/51bf53f8498ebfb3b5111893 │ POINT (27.91068575555217 36.47986700108901) │ {'xmin': 27.91068575555217, 'ymin': 36.47986700108901, 'xmax': 27.91068575555217, 'ymax': 36.47986700108901} │ 2025-01-10 │ 613602565463998463 │ 618106165089796095 │ 622609764717035519 │ 627113364344385535 │ 631616963971752959 │ 604595366280560639 │ 609098965840822271 │
│ 5182b89b498ef7fe022a583e │ karadeniz gemisi │ 36.47224968518209 │ 27.9005527573379 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2013-05-02 │ 2024-03-27 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d12d951735] │ [Travel and Transportation > Boat or Ferry] │ https://foursquare.com/placemakers/review-place/5182b89b498ef7fe022a583e │ POINT (27.9005527573379 36.47224968518209) │ {'xmin': 27.9005527573379, 'ymin': 36.47224968518209, 'xmax': 27.9005527573379, 'ymax': 36.47224968518209} │ 2025-01-10 │ 613602565459804159 │ 618106165086650367 │ 622609764713922559 │ 627113364341280767 │ 631616963968648703 │ 604595366280560639 │ 609098965840822271 │
│ 51fe5d75498e6b2a7c94ebe5 │ At Sea │ 36.6067663612536 │ 27.497631660121836 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2013-08-04 │ 2022-10-01 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d162941735] │ [Landmarks and Outdoors > Other Great Outdoors] │ https://foursquare.com/placemakers/review-place/51fe5d75498e6b2a7c94ebe5 │ POINT (27.497631660121836 36.6067663612536) │ {'xmin': 27.497631660121836, 'ymin': 36.6067663612536, 'xmax': 27.497631660121836, 'ymax': 36.6067663612536} │ 2025-01-10 │ 613602568938979327 │ 618106168565039103 │ 622609768192344063 │ 627113367819694079 │ 631616967447062527 │ 604595369770221567 │ 609098969313705983 │
│ 576d9b01cd10ae8e580b280a │ Gül's Home │ 36.59754180908203 │ 27.490215301513672 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2016-06-24 │ 2022-06-15 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d11f941735] │ [Arts and Entertainment > Night Club] │ https://foursquare.com/placemakers/review-place/576d9b01cd10ae8e580b280a │ POINT (27.490215301513672 36.59754180908203) │ {'xmin': 27.490215301513672, 'ymin': 36.59754180908203, 'xmax': 27.490215301513672, 'ymax': 36.59754180908203} │ 2025-01-10 │ 613602571333926911 │ 618106170960510975 │ 622609770587750399 │ 627113370215112703 │ 631616969842481663 │ 604595372186140671 │ 609098971712847871 │
│ 54dd6137498ed8acae1d1f3c │ Bodrum Fethiye Yolu │ 36.61823818872215 │ 27.494644146013997 │ Denizin Ortası │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ TR │ 2015-02-13 │ 2023-11-19 │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ NULL │ [4bf58dd8d48988d12d951735] │ [Travel and Transportation > Boat or Ferry] │ https://foursquare.com/placemakers/review-place/54dd6137498ed8acae1d1f3c │ POINT (27.494644146013997 36.61823818872215) │ {'xmin': 27.494644146013997, 'ymin': 36.61823818872215, 'xmax': 27.494644146013997, 'ymax': 36.61823818872215} │ 2025-01-10 │ 613602568999796735 │ 618106168626905087 │ 622609768254078975 │ 627113367881445375 │ 631616967508805119 │ 604595369770221567 │ 609098969380814847 │
├──────────────────────────┴────────────────────────────┴───────────────────┴────────────────────┴────────────────┴──────────┴─────────┴──────────┴──────────────┴───────────┴─────────┴─────────┴──────────────┴────────────────┴─────────────┴─────────┴─────────┴─────────┴─────────────┴───────────┴─────────┴────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┴────────────────────┤
│ 10 rows 34 columns │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Creating a Custom Component

We will build a custom component from scratch using Panel. This component will enable interactive map rendering and dynamic data updates with MapLibreGL JS.

Create a file named app.py in your project directory and add the following starter code. This code imports the essential packages needed for the application.

# app.py

import json
import panel as pn
import duckdb
from panel.custom import JSComponent
import param
from pathlib import Path

To connect to the DuckDB database and load the necessary extensions:

db = duckdb.connect(database='data/poi.duckdb', read_only=True)
db.sql("""
INSTALL spatial;
INSTALL h3 FROM community;
LOAD h3;
LOAD spatial;
""")

Below is the function that retrieves data from the database based on the zoom level (z) and bounding box (bounds) and converts it into GeoJSON format:


def generate_geojson(z: int, bounds: dict = None):
"""
Generates a GeoJSON FeatureCollection from database query results.
Args:
z (int): The zoom level for H3 hexagons.
bounds (dict, optional): A dictionary containing the bounding box coordinates with keys 'minx', 'maxx', 'miny', and 'maxy'.
Returns:
dict: A GeoJSON FeatureCollection containing the H3 hexagons and their associated properties.
"""

q = f"""
SELECT h3_h3_to_string(h{z}) as h3,
COUNT(*) as count,
NTILE(10) OVER (ORDER BY count) as q10,
ST_AsGeoJSON(ST_GeomFromText(h3_cell_to_boundary_wkt(h{z}))) as geojson
FROM places
WHERE bbox.xmin >= {bounds['minx']} AND bbox.xmax <= {bounds['maxx']}
AND bbox.ymin >= {bounds['miny']} AND bbox.ymax <= {bounds['maxy']}
and h{z} is not null
GROUP BY h{z}
"""

result = db.sql(q).df().to_dict(orient='records')
# Convert the list of JSON objects to a GeoJSON FeatureCollection
return {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": json.loads(row["geojson"]),
"properties": {
"h3": row["h3"],
"count": row["count"],
"q10": row["q10"]
}
}
for row in result
]
}

The Map class is designed to create an interactive map using MapLibre GL. This class retrieves GeoJSON data from the database based on the zoom level and bounding box, dynamically updating the map.

Class Attributes:

• data_geo: A param.Dict property that holds GeoJSON data. This data is dynamically updated to reflect what is displayed on the map.

• _esm: Path to the JavaScript file (src/map.js) that powers the MapLibre GL map component.

• __css__: Loads the required CSS file for the map’s styling. This file provides the visual styles for the MapLibre GL map.

  • _handle_msg: A method that processes messages from the map component. The method passes this information to the generate_geojson function and updates the data_geo property with the returned GeoJSON data.
class Map(JSComponent):
data_geo = param.Dict()
_esm = Path("src/map.js")
__css__ = [
"https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.min.css"]

def _handle_msg(self, data):
print(dict(data))
self.data_geo = generate_geojson(z=data["zoom"], bounds=data["bounds"])

Below code(src/map.js) creates a MapLibre GL-based interactive map component and provides visualization functionality. Below is a summary of the code:

import maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/+esm';

export function render({ model, el }) {
const map = new maplibregl.Map({
container: el, // container id
style: "https://tiles.openfreemap.org/styles/positron", // style URL
center: [28.9784, 41.0082], // starting position [lng, lat] (Istanbul)
zoom: 10,
});

map.addControl(
new maplibregl.NavigationControl({
visualizePitch: true,
visualizeRoll: true,
showZoom: true,
showCompass: true,
}),
"top-left"
);

map.addControl(new maplibregl.FullscreenControl(), "top-left");
model.on("after_render", () => {
console.log(model.data_geo);
map.on("load", () => {
map.addSource("geojson", {
type: "geojson",
data: model.data_geo,
});

map.addLayer({
id: "geojson",
type: "fill-extrusion",
source: "geojson",
paint: {

'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'q10'],
1, '#ffffb2',
5, '#fd8d3c',
10, '#bd0026'
],

'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'q10'],
1, 100,
10, 1000
],
'fill-extrusion-opacity': 0.8
},
});
});
map.on("click", "geojson", (e) => {
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(e.features[0].properties.q10)
.addTo(map);
});

model.on("change:data_geo", () => {
const source = map.getSource("geojson");
if (source) {
source.setData(model.data_geo);
}
});
});


function handleMoveChange() {
const currentZoom = Math.floor(map.getZoom());
console.log("Zoom Level: ", currentZoom);
const bounds = map.getBounds();
const boundsJson = {
minx: bounds.getWest(),
maxx: bounds.getEast(),
miny: bounds.getSouth(),
maxy: bounds.getNorth(),
};

if (currentZoom >= 12) {
model.send_msg({ zoom: 10, bounds: boundsJson });
} else if (currentZoom >= 5 && currentZoom < 12) {
model.send_msg({ zoom: 6, bounds: boundsJson });
}
}

map.on("moveend", () => {
handleMoveChange();
});
}

Code Summary:

1. Initializing the Map:

• The map starts at Istanbul’s coordinates (41.0082, 28.9784) with a zoom level of 10.

• Control tools like maplibregl.NavigationControl and FullscreenControl are added.

2. Adding GeoJSON Source and Layer:

• On map load (map.on(“load”)), a GeoJSON source is added using model.data_geo.

• A fill-extrusion layer is added:

Colors: Transitions based on q10 values (low values: light yellow; high values: red).

Height: Layer height changes based on q10 values.

Opacity: Set to 80% using fill-extrusion-opacity.

3. Map Click Event:

• When a GeoJSON feature is clicked, a popup shows the q10 value of the clicked feature.

4. Updating Data:

• With model.on(“change:data_geo”), the GeoJSON source on the map is updated dynamically.

5. Listening for Zoom and Map Movements:

• map.on(“moveend”): When the map is moved or zoomed, handleMoveChange is triggered.

• handleMoveChange:

  • Retrieves the current zoom level and map bounds (bounds).
  • Based on the zoom level, the appropriate H3 resolution (e.g., h12, h10, h8) is determined, and a message is sent to the server using model.send_msg.
  • Determines the appropriate h3 resolution based on the zoom level (e.g., zoom ≥ 12 uses h10).

We use the Map class, and the servable() method makes the Panel application deployable on a web server.

geojson_result = generate_geojson(z=8, bounds={
'minx': 28.417753942870576,
'maxx': 29.539046057128076,
'miny': 40.80035286716085,
'maxy': 41.21539357234562})

m = Map(data_geo=geojson_result, sizing_mode='stretch_both')
pn.Column(m, sizing_mode='stretch_both').servable()

Full code(app.py):

To run the application:

panel serve app.py --dev

Bonus: Compile and Bundle ESM Components

Create a folder named component and navigate to it in the terminal:

cd component

Install Esbuild globally:

npm install -g esbuild

Install the MapLibreGL package:

npm install -y maplibregl

Create a file named index.js in the component directory and add the provided code.

import maplibregl from 'maplibre-gl';

export function render({ model, el }) {
const map = new maplibregl.Map({
container: el, // container id
style: "https://tiles.openfreemap.org/styles/positron", // style URL
center: [28.9784, 41.0082], // starting position [lng, lat] (Istanbul)
zoom: 10,
});

map.addControl(
new maplibregl.NavigationControl({
visualizePitch: true,
visualizeRoll: true,
showZoom: true,
showCompass: true,
}),
"top-left"
);

map.addControl(new maplibregl.FullscreenControl(), "top-left");
model.on("after_render", () => {
console.log(model.data_geo);
map.on("load", () => {
map.addSource("geojson", {
type: "geojson",
data: model.data_geo,
});

map.addLayer({
id: "geojson",
type: "fill-extrusion",
source: "geojson",
paint: {

'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'q10'],
1, '#ffffb2',
5, '#fd8d3c',
10, '#bd0026'
],

'fill-extrusion-height': [
'interpolate',
['linear'],
['get', 'q10'],
1, 100,
10, 1000
],
'fill-extrusion-opacity': 0.8
},
});
});
map.on("click", "geojson", (e) => {
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(e.features[0].properties.q10)
.addTo(map);
});

model.on("change:data_geo", () => {
const source = map.getSource("geojson");
if (source) {
source.setData(model.data_geo);
}
});
});


function handleMoveChange() {
const currentZoom = Math.floor(map.getZoom());
console.log("Zoom Level: ", currentZoom);
const bounds = map.getBounds();
const boundsJson = {
minx: bounds.getWest(),
maxx: bounds.getEast(),
miny: bounds.getSouth(),
maxy: bounds.getNorth(),
};

if (currentZoom >= 12) {
model.send_msg({ zoom: 10, bounds: boundsJson });
} else if (currentZoom >= 5 && currentZoom < 12) {
model.send_msg({ zoom: 6, bounds: boundsJson });
}
}

map.on("moveend", () => {
handleMoveChange();
});
}

Use Esbuild to bundle the JavaScript file:

esbuild index.js --bundle --format=esm --minify --outfile=dist/maplibregl.bundle.js

This generates the maplibregl.bundle.js file, which can be used in our project.

In this updated Map class, the _esm property has been removed. Instead, the _bundle property is used to point to the maplibregl.bundle.js file. This allows the component to utilize the bundled JavaScript file in the application.

class Map(JSComponent):
data_geo = param.Dict()
_bundle = "component/dist/maplibregl.bundle.js" # Path to the compiled bundle.js file
__css__ = [
"https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.min.css"
]

def _handle_msg(self, data):
print(dict(data))
self.data_geo = generate_geojson(z=data["zoom"], bounds=data["bounds"])

Results and Applications

In this blog post, we explored step by step how to build a powerful geospatial data analysis application using DuckDB, Panel, and MapLibreGL. Thanks to the flexible structure offered by Panel, we observed how easy it is to quickly develop interactive and user-friendly applications. Panel’s user-friendly nature and seamless integration capabilities significantly accelerate the application development process.

Thank you for reading! If you have any questions or feedback, feel free to reach out to me. 😊

Note: To access the full code, click here:

Resources

Panel: https://github.com/holoviz/panel
DuckDB: https://github.com/duckdb/duckdb
DuckDB-Spatial: https://github.com/duckdb/duckdb-spatial
Duckdb-H3: https://github.com/isaacbrodsky/h3-duckdb

--

--

Responses (1)