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,