diff --git a/app/.gitignore b/app/.gitignore
index cdf67590..04fe8b95 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,6 +1,7 @@
node_modules
dist
.vite
+.env.local
# Editor directories and files
.vscode/*
diff --git a/app/package-lock.json b/app/package-lock.json
index 03386548..3819046b 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -8,6 +8,7 @@
"name": "protomaps-basemaps-app",
"version": "0.0.0",
"dependencies": {
+ "@maplibre/maplibre-gl-geocoder": "^1.9.4",
"@maplibre/maplibre-gl-inspect": "^1.8.1",
"maplibre-gl": "5.19.0",
"pixelmatch": "^5.3.0",
@@ -1011,6 +1012,25 @@
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
+ "node_modules/@maplibre/maplibre-gl-geocoder": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-geocoder/-/maplibre-gl-geocoder-1.9.4.tgz",
+ "integrity": "sha512-ss0NMpjUgK1/8YrrikrAtdda41jERiGg+XqwPkj52AhwvQTLZEnZSU7IhqdyuE1FZ/QhlzAauMbyzJUTTxDscw==",
+ "license": "ISC",
+ "dependencies": {
+ "events": "^3.3.0",
+ "lodash.debounce": "^4.0.6",
+ "subtag": "^0.5.0",
+ "suggestions-list": "^0.0.2",
+ "xtend": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "maplibre-gl": ">=4.0.0"
+ }
+ },
"node_modules/@maplibre/maplibre-gl-inspect": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-inspect/-/maplibre-gl-inspect-1.8.1.tgz",
@@ -2374,6 +2394,15 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/expect-type": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
@@ -2450,6 +2479,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fuzzy": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz",
+ "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3023,6 +3060,12 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -3547,6 +3590,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/subtag": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/subtag/-/subtag-0.5.0.tgz",
+ "integrity": "sha512-CaIBcTSb/nyk4xiiSOtZYz1B+F12ZxW8NEp54CdT+84vmh/h4sUnHGC6+KQXUfED8u22PQjCYWfZny8d2ELXwg==",
+ "license": "ISC"
+ },
+ "node_modules/suggestions-list": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/suggestions-list/-/suggestions-list-0.0.2.tgz",
+ "integrity": "sha512-Yw0fdq14c6RQWQIfE1/8WEi9Dp8rjyCD6FhYA/Tit2/ADbE9Y4ADG4ezlvivsa8Civ5nz++pyVVBMjOMlgIUJw==",
+ "license": "ISC",
+ "dependencies": {
+ "fuzzy": "^0.1.1",
+ "xtend": "^4.0.0"
+ }
+ },
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
@@ -4270,6 +4329,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/app/package.json b/app/package.json
index ccd5c916..777b6a4d 100644
--- a/app/package.json
+++ b/app/package.json
@@ -12,6 +12,7 @@
"format": "biome format --write src test --javascript-formatter-indent-style=space --json-formatter-indent-style=space"
},
"dependencies": {
+ "@maplibre/maplibre-gl-geocoder": "^1.9.4",
"@maplibre/maplibre-gl-inspect": "^1.8.1",
"maplibre-gl": "5.19.0",
"pixelmatch": "^5.3.0",
@@ -20,9 +21,9 @@
"solid-js": "^1.9.5"
},
"devDependencies": {
- "@types/node": "^22.0.0",
"@biomejs/biome": "1.9.4",
"@tailwindcss/vite": "^4.1.17",
+ "@types/node": "^22.0.0",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.3",
"typescript": "^5.7.2",
diff --git a/app/src/MapView.tsx b/app/src/MapView.tsx
index 244a0de5..8d8e9646 100644
--- a/app/src/MapView.tsx
+++ b/app/src/MapView.tsx
@@ -12,6 +12,7 @@ import {
Popup,
addProtocol,
getRTLTextPluginStatus,
+ default as maplibregl,
removeProtocol,
setRTLTextPlugin,
} from "maplibre-gl";
@@ -22,6 +23,10 @@ import type {
StyleSpecification,
} from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
+import {
+ default as MaplibreGeocoder,
+ type MaplibreGeocoderApiConfig,
+} from "@maplibre/maplibre-gl-geocoder";
import type { LayerSpecification } from "@maplibre/maplibre-gl-style-spec";
import { FileSource, PMTiles, Protocol } from "pmtiles";
import {
@@ -43,6 +48,7 @@ import {
layersForVersion,
parseHash,
} from "./utils";
+import "@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css";
const STYLE_MAJOR_VERSION = 5;
@@ -51,6 +57,10 @@ const DEFAULT_TILES = "https://demo-bucket.protomaps.com/v4.pmtiles";
const ATTRIBUTION =
'Protomaps © OpenStreetMap';
+const GEOCODER_NUM_RESULTS = 10;
+const GEOCODE_EARTH_API_KEY =
+ import.meta.env.VITE_GEOCODE_EARTH_API_KEY || "ge-36393e37d3f44f4a";
+
function getSourceLayer(l: LayerSpecification): string {
if ("source-layer" in l && l["source-layer"]) {
return l["source-layer"];
@@ -297,6 +307,41 @@ function MapLibreView(props: {
}),
);
+ const geocodeEarthResults = async (
+ config: MaplibreGeocoderApiConfig,
+ endpoint: string,
+ ) => {
+ const { lat, lng } = map.getCenter();
+ const url = `https://api.geocode.earth/v1/${endpoint}?api_key=${GEOCODE_EARTH_API_KEY}&text=${encodeURIComponent(`${config.query}`)}&focus.point.lat=${lat}&focus.point.lon=${lng}&size=${GEOCODER_NUM_RESULTS}`;
+ const result = await fetch(url);
+ const json = await result.json();
+ for (const f of json.features) {
+ const props = f.properties;
+ f.place_name = props?.label;
+ }
+ return json;
+ };
+
+ map.addControl(
+ new MaplibreGeocoder(
+ {
+ getSuggestions: (config) =>
+ geocodeEarthResults(config, "autocomplete"),
+ forwardGeocode: (config) => geocodeEarthResults(config, "search"),
+ },
+ {
+ maplibregl,
+ showResultsWhileTyping: true,
+ placeholder: "Search a city or address",
+ limit: GEOCODER_NUM_RESULTS,
+ proximityMinZoom: 9,
+ marker: false,
+ flyTo: { animate: false },
+ },
+ ),
+ "top-left",
+ );
+
const popup = new Popup({
closeButton: true,
closeOnClick: false,