Using SQLite Extensions - Geopoly
In this tutorial you will build a local attractions finder map-plication using GeoJSON data, a SQLite Cloud database, Mapbox GL JS (JavaScript Graphics Library), React, and SQLite’s built-in Geopoly extension.
Time to complete: 15-20 mins.
If you get stuck in the tutorial or prefer to play with the finished product, check out the example app on GitHub.
1. Initialize your app
- Create a new directory
sqlc-geopoly-demo
. From this directory, bootstrap a Node.js project.
mkdir sqlc-geopoly-demo
cd sqlc-geopoly-demo
npm init -y
2. Curate your GeoJSON data
-
We will leverage the Overpass API (an open-source, read-only API for fetching OpenStreetMap data) to query NY attractions.
-
Visit Overpass Turbo, the Overpass GUI. Copy and paste in the below query, which:
- defines New York as the area of interest;
- fetches nodes in the specified area that are tagged with the keys
amenity
,historic
,tourism
,leisure
, etc.; and - outputs the data.
[out:json][timeout:25];
area[name="New York"]->.newyork;
(
node["amenity"="events_venue"](area.newyork);
node["amenity"="exhibition_centre"](area.newyork);
node["amenity"="music_venue"](area.newyork);
node["amenity"="social_centre"](area.newyork);
node["amenity"="marketplace"](area.newyork);
node["building"="museum"](area.newyork);
node["historic"="building"](area.newyork);
node["tourism"="attraction"](area.newyork);
node["leisure"="park"](area.newyork);
node["natural"="beach"](area.newyork);
node["shop"="coffee"](area.newyork);
node["sport"="yoga"](area.newyork);
);
out body;
>;
out skel qt;
-
Run the query.
-
Click Export. Under Data, copy the GeoJSON.
-
Back in your project dir, create
data/geodata.json
. Paste the formatted GeoJSON into the file. It should look similar to the following:
{
"type": "FeatureCollection",
"generator": "overpass-turbo",
"copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.",
"timestamp": "2024-08-05T23:56:57Z",
"features": [
{
"type": "Feature",
"properties": {
"@id": "node/43058007",
"ele": "190",
"gnis:feature_id": "968527",
"leisure": "park",
"name": "Kibler Park"
},
"geometry": {
"type": "Point",
"coordinates": [-78.6723302, 43.1655945]
},
"id": "node/43058007"
},
...,
{
"type": "Feature",
"properties": {
"@id": "node/12093603396",
"amenity": "events_venue",
"name": "Azteca Venue Party Center"
},
"geometry": {
"type": "Point",
"coordinates": [-74.1244477, 40.6338683]
},
"id": "node/12093603396"
}
]
}
- For this tutorial, we’ll use the NY geodata. Once you have the app up-and-running, you can run your own Overpass queries to customize the geodata per your needs. See Additional Guidance on Overpass at the end of this tutorial.
3. Create a new SQLite Cloud database
-
If you haven’t already, sign up for a SQLite Cloud account and create a new project.
-
In your account dashboard’s left nav, click Databases, then Create Database. Name your new database
geopoly-demo
.
4. Create a Mapbox account
- Sign up for an Individual Mapbox account. (We’ll stay on the free tier.)
5. Set your environment variables
-
In your project dir, create a
.env
file.- This app will use
react-scripts
, which leverages Create React App under-the-hood. Create React App offers built-in support for env vars. You will not need to manually configurewebpack
or another bundler, but all vars will need to be prefixed withREACT_APP_
.
- This app will use
-
Add 2 env vars to the file:
REACT_APP_CONNECTION_STRING
. Copy and paste your connection string from your SQLite Cloud account dashboard.REACT_APP_MAPBOX_TOKEN
. In your Mapbox account dashboard’s nav, click Tokens. Copy and paste your default public token.
-
Install the SQLite Cloud JS SDK and
dotenv
package as dependencies:
npm i @sqlitecloud/drivers
npm i -D dotenv
6. Create your database tables
- In your project dir, create
src/helpers/createDatabase.js
. Copy and paste in the following code:
import { Database } from '@sqlitecloud/drivers';
import 'dotenv/config';
import geodata from '../../data/geodata.json' assert { type: 'json' };
async function createDatabase() {
// open a connection to your `geopoly-demo` database
const db = new Database(process.env.REACT_APP_CONNECTION_STRING);
const db_name = 'geopoly-demo';
await db.sql`USE DATABASE ${db_name};`;
// create a table with 2 columns: `rowid` and `_shape`
await db.sql`CREATE VIRTUAL TABLE polygons USING geopoly()`;
// create a table with 5 columns: `id`, `name`, `lng`, `lat`, and `coordinates`
await db.sql`CREATE TABLE attractions (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, lng REAL NOT NULL, lat REAL NOT NULL, coordinates TEXT NOT NULL)`;
// populate the `attractions` table using the GeoJSON FeatureCollection in `geodata.json`
for (const feature of geodata['features']) {
const { name } = feature.properties;
const { coordinates } = feature.geometry;
const [lng, lat] = coordinates;
await db.sql`INSERT INTO attractions(name, lng, lat, coordinates) VALUES(${name}, ${lng}, ${lat}, ${JSON.stringify(
coordinates
)})`;
}
db.close();
console.log('Geodata inserted!');
}
createDatabase();
- Add the following to your
package.json
:
"scripts": {
"create-tables": "node src/helpers/createDatabase.js"
},
"type": "module",
-
Run
npm run create-tables
.-
The time it will take for the command to finish creating the tables and inserting the geodata will depend on the size of your FeatureCollection. The NY Overpass query returns ~2000 Point features, so row insertion takes a couple of minutes.
-
To see the inserted NY attractions geodata, in your SQLite Cloud account dashboard’s left nav, click Console. In the database dropdown, select
geopoly-demo
. Copy, paste in, and run the following query:
-
SELECT * FROM attractions ORDER BY id DESC;
7. Set up the frontend
- In your project dir, create
public/index.html
. Copy and paste in the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Create a React web app that uses Mapbox GL JS to render a map"
/>
<title>Local Attractions Finder</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
- In the
src
dir, add 3 files:index.css
(for app styling),index.js
(the app entrypoint file), andApp.js
(the app’s sole component). Copy and paste in the following code for each file:
index.css
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@100..900&display=swap');
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font: 400 15px/22px 'Inter Tight', sans-serif;
-webkit-font-smoothing: antialiased;
}
.legend {
padding: 10px;
background-color: #23374b;
color: #fff;
font-family: monospace;
}
.sidebar {
position: absolute;
width: 25%;
height: 100%;
overflow: hidden;
}
.map-container {
position: absolute;
left: 25%;
width: 75%;
top: 0;
bottom: 0;
}
.heading {
padding: 0 10px;
border-bottom: 1px solid #eee;
}
.listings {
height: 72%;
overflow: auto;
padding-bottom: 15px;
}
.listings .item {
padding: 10px;
border-bottom: 1px solid #eee;
}
.listings .item.active {
background-color: #cfe1f8;
}
.listings .item:last-child {
border-bottom: none;
}
.listings .item .title {
color: #5a5877;
font-weight: 700;
text-decoration: none;
}
.listings .item.active .title,
.listings .item .title:hover {
color: #000;
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: none;
}
::-webkit-scrollbar-thumb {
background: #23374b;
}
.mapboxgl-popup {
padding-bottom: 20px;
}
.mapboxgl-popup-close-button {
display: none;
}
.mapboxgl-popup-content {
padding: 0;
}
.mapboxgl-popup-content h3 {
background: #cfe1f8;
color: #000;
margin: 0;
padding: 10px;
border-radius: 3px 3px 0 0;
font-weight: 700;
margin-top: -15px;
}
.mapboxgl-popup-content h4 {
margin: 0;
padding: 10px;
font-weight: 400;
}
.marker {
border: none;
cursor: pointer;
width: 32px;
height: 40px;
background-image: url('https://docs.mapbox.com/mapbox-gl-js/assets/custom_marker.png');
}
.mapboxgl-ctrl-geocoder {
border: 0;
border-radius: 0;
position: relative;
top: 0;
width: 800px;
margin-top: 0;
}
.mapboxgl-ctrl-geocoder > div {
min-width: 100%;
margin-left: 0;
}
- NOTE: To simplify this tutorial,
.marker.background-image
uses a custom Mapbox marker for the pins marking attractions on the map. The example app on GitHub uses a custom marker image included in the repo’simages
dir (excluded here).
index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import 'mapbox-gl/dist/mapbox-gl.css';
import './index.css';
import App from './App.js';
const container = document.querySelector('#root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
App.js
import { useState, useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import { point, distance } from '@turf/turf';
import { Database } from '@sqlitecloud/drivers';
import { getBbox } from './helpers/getBbox.js';
mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_TOKEN;
function App() {
const mapContainerRef = useRef();
const mapRef = useRef();
const [lng, setLng] = useState(-73.9654897);
const [lat, setLat] = useState(40.7824635);
const [zoom, setZoom] = useState(12);
const [places, setPlaces] = useState([]);
const [geometry, setGeometry] = useState([]);
const units = 'miles';
async function queryGeopoly(searchedLng, searchedLat) {
// open a connection to your `geopoly-demo` database
const db = new Database(process.env.REACT_APP_CONNECTION_STRING);
const db_name = 'geopoly-demo';
const radius = 0.05; // must be a positive number
const sides = 50; // 3-1000
// generate a new polygon to be added to your `polygons` table
const polygonCoords =
await db.sql`USE DATABASE ${db_name}; INSERT INTO polygons(_shape) VALUES(geopoly_regular(${searchedLng}, ${searchedLat}, ${radius}, ${sides})) RETURNING geopoly_json(_shape);`;
// point-in-polygon query to get all attractions in the generated polygon's area
const attractionsInPolygon =
await db.sql`USE DATABASE ${db_name}; SELECT name, coordinates FROM attractions WHERE geopoly_contains_point(${polygonCoords[0]['geopoly_json(_shape)']}, lng, lat);`;
db.close();
// remove unnamed attractions
const namedAttractions = attractionsInPolygon.filter(
(attraction) => attraction.name !== null
);
const attractionFeatures = namedAttractions.map((attraction, index) => {
const attractionCoordinates = JSON.parse(attraction['coordinates']);
const attractionFeature = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: attractionCoordinates,
},
properties: {
id: index,
title: attraction['name'],
// use Turf.js to calculate the distance between the searched location and the current attraction
distance: distance(
point([searchedLng, searchedLat]),
point(attractionCoordinates),
{
units, // either miles or kilometers
}
),
},
};
// apply clickable markers for all attractions
const marker = document.createElement('div');
marker.key = `marker-${attractionFeature.properties.id}`;
marker.id = `marker-${attractionFeature.properties.id}`;
marker.className = 'marker';
marker.addEventListener('click', (e) => {
handleClick(attractionFeature);
});
new mapboxgl.Marker(marker)
.setLngLat(attractionCoordinates)
.addTo(mapRef.current);
return attractionFeature;
});
// upsort attractions nearest the user's searched location
attractionFeatures.sort((a, b) => {
if (a.properties.distance > b.properties.distance) {
return 1;
}
if (a.properties.distance < b.properties.distance) {
return -1;
}
return 0;
});
setPlaces(attractionFeatures);
// use a helper function (defined in the next step) to fit/ zoom the map view to the searched location and its nearest attraction
if (attractionFeatures[0]) {
const bbox = getBbox(attractionFeatures, searchedLng, searchedLat);
mapRef.current.fitBounds(bbox, {
padding: 100,
});
new mapboxgl.Popup({ closeOnClick: false })
.setLngLat(attractionFeatures[0].geometry.coordinates)
.setHTML(
`<h3>${
attractionFeatures[0].properties.title
}</h3><h4>${attractionFeatures[0].properties.distance.toFixed(
2
)} ${units} away</h4>`
)
.addTo(mapRef.current);
}
// update the `geometry` state to hold the returned Polygon and attraction Point features
setGeometry([
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [JSON.parse(polygonCoords[0]['geopoly_json(_shape)'])],
},
},
...attractionFeatures,
]);
}
function drawFeatureCollection() {
const sourceId = 'newyork';
if (!mapRef.current.getSource(sourceId)) {
mapRef.current.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: geometry,
},
});
mapRef.current.addLayer({
id: 'polygon',
type: 'fill',
source: sourceId,
paint: {
'fill-color': '#888888',
'fill-opacity': 0.4,
},
filter: ['==', '$type', 'Polygon'],
});
mapRef.current.addLayer({
id: 'outline',
type: 'line',
source: sourceId,
layout: {},
paint: {
'line-color': '#000',
'line-width': 1,
},
});
} else {
mapRef.current.getSource(sourceId).setData({
type: 'FeatureCollection',
features: geometry,
});
}
}
function handleClick(feature) {
const center = feature.geometry.coordinates;
const { id, title, distance } = feature.properties;
mapRef.current.flyTo({
center,
zoom: 15,
});
const popUps = document.getElementsByClassName('mapboxgl-popup');
if (popUps[0]) {
popUps[0].remove();
}
new mapboxgl.Popup({ closeOnClick: false })
.setLngLat(center)
.setHTML(`<h3>${title}</h3><h4>${distance.toFixed(2)} ${units} away</h4>`)
.addTo(mapRef.current);
const activeItem = document.getElementsByClassName('active');
if (activeItem[0]) {
activeItem[0].classList.remove('active');
}
const listing = document.getElementById(`listing-${id}`);
listing.classList.add('active');
}
useEffect(() => {
// create, style, and center the map
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [lng, lat],
zoom,
});
// apply 3 controls to the top right of the map
// an address search input
const geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl,
zoom: 12,
});
// toggle fullscreen mode
const fullscreenCtrl = new mapboxgl.FullscreenControl({
container: mapContainerRef.current,
});
// locate the user on the map
const geolocateCtrl = new mapboxgl.GeolocateControl({
fitBoundsOptions: {
maxZoom: 12,
},
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
});
mapRef.current.addControl(geocoder);
mapRef.current.addControl(fullscreenCtrl);
mapRef.current.addControl(geolocateCtrl);
// track the map center coordinates and zoom level (displayed on the top left of the app)
function updateCoordinates() {
const { lng, lat } = mapRef.current.getCenter();
setLng(lng.toFixed(4));
setLat(lat.toFixed(4));
setZoom(mapRef.current.getZoom().toFixed(2));
}
mapRef.current.on('move', updateCoordinates);
// call the `queryGeopoly` function when the user clicks a geocoder result
geocoder.on('result', (e) => {
const existingMarkers = document.getElementsByClassName('marker');
while (existingMarkers[0]) {
existingMarkers[0].remove();
}
const popUps = document.getElementsByClassName('mapboxgl-popup');
while (popUps[0]) {
popUps[0].remove();
}
const [lng, lat] = e.result.geometry.coordinates;
queryGeopoly(lng, lat);
});
return () => {
mapRef.current.removeControl(geocoder);
mapRef.current.removeControl(fullscreenCtrl);
mapRef.current.removeControl(geolocateCtrl);
mapRef.current.off('move', updateCoordinates);
mapRef.current.remove();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// triggered by a `geometry` state update
useEffect(() => {
if (geometry.length !== 0) {
// draw the returned Polygon, its outline, and attraction Points on the map
drawFeatureCollection();
}
}, [geometry]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<>
<div className="sidebar">
<div className="legend">
<p>Center Lat: {lat}</p>
<p>Center Long: {lng}</p>
<p>Current Zoom: {zoom}</p>
</div>
<div className="heading">
<h2>Attractions Nearby:</h2>
</div>
<div className="listings">
{places.map((place, index) => (
<div
key={index}
id={`listing-${place.properties.id}`}
className={`item ${index === 0 && 'active'}`}
>
<a href="#" className="title" onClick={() => handleClick(place)}>
{place.properties.title}
</a>
<div>
{place.properties.distance.toFixed(2)} {units} away
</div>
</div>
))}
</div>
</div>
<div ref={mapContainerRef} className="map-container" />
</>
);
}
export default App;
8. Create a helper function
- Create
src/helpers/getBbox.js
. Copy and paste in the following code:
export function getBbox(sortedEvents, locationLng, locationLat) {
const lons = [
sortedEvents[0].geometry.coordinates[0],
locationLng,
];
const lats = [
sortedEvents[0].geometry.coordinates[1],
locationLat,
];
const sortedLons = lons.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
const sortedLats = lats.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
// return a bounding box, defined by a southwest coordinate pair and northeast coordinate pair
return [
[sortedLons[0], sortedLats[0]],
[sortedLons[1], sortedLats[1]],
];
}
9. Run your app!
- Replace your
package.json
code with the following, which includes all dependencies needed to run the app:
{
"name": "sqlc-geopoly-demo",
"version": "1.0.0",
"private": true,
"description": "",
"main": "index.js",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"create-tables": "node src/helpers/createDatabase.js"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": [
"defaults",
"not ie 11"
],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
"@sqlitecloud/drivers": "^1.0.193",
"@turf/turf": "^7.0.0",
"mapbox-gl": "^3.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "^5.0.1"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"dotenv": "^16.4.5"
}
}
- From your project dir, install the dependencies and start your local dev server.
npm i
npm start
- Visit
http://localhost:3000/
(adjust the port as-needed) in your browser to view the app.
10. Find attractions!
-
On app load, the map is centered on Central Park, NY.
-
In the geocoder (i.e. search input) at the top right of the map, enter “Empire” and click on the “Empire State Building” result. You can also search coordinates (see reverse geocoding).
-
When you select a geocoder result:
-
a polygon is generated by Geopoly, added to your
polygons
table, and displayed on the map; and -
all attractions in your
attractions
table inside the polygon area are listed in the left sidebar AND marked on the map. NOTE: the sidebar upsorts attractions nearest your searched location, in this case the “Empire State Building”.
-
-
To see the inserted polygon data, in your SQLite Cloud account dashboard’s left nav, click Console. In the database dropdown, select
geopoly-demo
. Copy, paste in, and run the following query.- The
geopoly_json
function parses the_shape
column’s[object ArrayBuffer]
data into an array of coordinate pairs representing the polygon’s vertices:[[-73.9355,40.7485],[-73.9359,40.7547], ...,[-73.9359,40.7422],[-73.9355,40.7485]]
. The array should contain (1 + # of polygon sides) coordinate pairs. The polygon is closed, so the first and last pairs both represent the same vertex.
- The
SELECT rowid, geopoly_json(_shape) FROM polygons;
-
The map zooms in to the nearest attraction to your searched location and highlights its corresponding top listing in the sidebar.
-
You can click on any attraction listing or marker to fly/ zoom to and center on that attraction on the map.
-
Turf.js uses the Haversine formula to account for global curvature when calculating the distance between your searched location and each attraction. However, you should still expect discrepancies between this app’s calculated distances vs, say, Google or Apple Maps.
-
And that’s it! You’ve successfully built a local attractions finder app that utilizes Geopoly to write geodata to and read from a SQLite Cloud database.
Additional Guidance on Overpass:
-
To fetch other attractions or any other kind of location data in NY or another area of interest to you, refer to OpenStreetMap’s Map features documentation. As a starting point, modify the area or key-value pairs in the NY query.
-
NOTE: The app works only with Point features (represented in the Map features tables’
Element
columns by an icon with a single dot). Be sure to query only nodes and the key-value pairs that can return Point data. For example, don’t use most of the values available for the Boundary key. -
To implement more complex or granular Point queries, refer to the Overpass QL documentation.
-
If you run a custom Overpass query:
- Add to or replace the FeatureCollection in
geodata.json
. - In your SQLite Cloud account dashboard’s left nav, click Databases. In the
geopoly-demo
row, click the down chevron and then Delete Database. - Create Database with the same name.
- From your project dir, run
npm run create-tables
. Your database tables will be re-created, and theattractions
table will be populated with your updated geodata.
- Add to or replace the FeatureCollection in
-
If you queried and stored attractions near your location, then after the app’s initial load, click on the GeolocateControl icon at the top right of the map and allow the browser to quickly center the map on your location. Search away!