Skip to content

Commit 2efa665

Browse files
committed
fix: address MapLibre migration review findings
Fix marker CSS scoping so result markers render, remove the map instance on unmount to avoid leaking WebGL contexts, make the base layer switcher keyboard-accessible, and restore Nominatim-backed place/address search that was dropped with the Leaflet geocoder.
1 parent e438ccc commit 2efa665

4 files changed

Lines changed: 155 additions & 5 deletions

File tree

app/App.vue

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ label {
2727
justify-content: center;
2828
width: 29px;
2929
height: 29px;
30+
padding: 0;
31+
border: none;
32+
background: none;
3033
cursor: pointer;
3134
}
3235
.lt-layers .lt-layers-toggle svg {
@@ -41,7 +44,8 @@ label {
4144
.lt-layers:hover .lt-layers-toggle {
4245
display: none;
4346
}
44-
.lt-layers:hover .lt-layers-list {
47+
.lt-layers:hover .lt-layers-list,
48+
.lt-layers:focus-within .lt-layers-list {
4549
display: block;
4650
}
4751
.lt-layers .lt-layers-list label {
@@ -56,6 +60,61 @@ label {
5660
height: 1em;
5761
}
5862
63+
.lt-geocoder {
64+
display: flex;
65+
align-items: center;
66+
position: relative;
67+
background: var(--bs-body-bg);
68+
}
69+
.lt-geocoder-icon {
70+
display: flex;
71+
align-items: center;
72+
justify-content: center;
73+
width: 29px;
74+
height: 29px;
75+
flex: none;
76+
}
77+
.lt-geocoder-icon svg {
78+
width: 14px;
79+
height: 14px;
80+
}
81+
.lt-geocoder input {
82+
width: 180px;
83+
border: none;
84+
outline: none;
85+
background: none;
86+
padding: 0 8px 0 0;
87+
}
88+
.lt-geocoder-results {
89+
position: absolute;
90+
top: 100%;
91+
left: 0;
92+
width: 280px;
93+
margin: 4px 0 0;
94+
padding: 0;
95+
list-style: none;
96+
background: var(--bs-body-bg);
97+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
98+
max-height: 200px;
99+
overflow-y: auto;
100+
}
101+
.lt-geocoder-results:empty {
102+
display: none;
103+
}
104+
.lt-geocoder-results button {
105+
display: block;
106+
width: 100%;
107+
border: none;
108+
background: none;
109+
text-align: left;
110+
padding: 6px 10px;
111+
white-space: normal;
112+
}
113+
.lt-geocoder-results button:hover,
114+
.lt-geocoder-results button:focus {
115+
background: rgba(0, 0, 0, 0.08);
116+
}
117+
59118
.flex-grow-1 {
60119
/* https://stackoverflow.com/a/36247448 */
61120
min-height: 0;

app/api/nominatim.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {fetchJSON} from './fetchJSON';
2+
3+
export interface NominatimResult {
4+
display_name: string;
5+
boundingbox: [string, string, string, string]; // [south, north, west, east]
6+
}
7+
8+
export async function search(query: string, signal?: AbortSignal): Promise<NominatimResult[]> {
9+
if (!query.trim()) return [];
10+
const url = new URL('https://nominatim.openstreetmap.org/search');
11+
url.searchParams.set('format', 'jsonv2');
12+
url.searchParams.set('q', query);
13+
url.searchParams.set('limit', '5');
14+
return fetchJSON<NominatimResult[]>(url.toString(), {signal});
15+
}

app/components/ltAllMap.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function buildPopup(title: CommonsFile): maplibregl.Popup {
9494
margin-left: calc(var(--bs-gutter-x) * -0.5);
9595
margin-right: calc(var(--bs-gutter-x) * -0.5);
9696
}
97-
.lt-circle-marker {
97+
:deep(.lt-circle-marker) {
9898
width: 14px;
9999
height: 14px;
100100
border-radius: 50%;

app/components/useMaplibreMap.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import {useLocalStorage} from '@vueuse/core';
1+
import {useDebounceFn, useLocalStorage} from '@vueuse/core';
22
import BoxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg?raw';
3+
import Search from 'bootstrap-icons/icons/search.svg?raw';
34
import Stack from 'bootstrap-icons/icons/stack.svg?raw';
45
import maplibregl from 'maplibre-gl';
5-
import {onMounted, type Ref} from 'vue';
6+
import {onMounted, onUnmounted, type Ref} from 'vue';
7+
8+
import {search as nominatimSearch, type NominatimResult} from '../api/nominatim';
69

710
import 'maplibre-gl/dist/maplibre-gl.css';
811

@@ -100,8 +103,11 @@ class BaseLayerControl implements maplibregl.IControl {
100103
const container = document.createElement('div');
101104
container.className = 'maplibregl-ctrl maplibregl-ctrl-group lt-layers';
102105

103-
const toggle = document.createElement('div');
106+
const toggle = document.createElement('button');
107+
toggle.type = 'button';
104108
toggle.className = 'lt-layers-toggle';
109+
toggle.setAttribute('aria-label', 'Base layer');
110+
toggle.setAttribute('aria-haspopup', 'true');
105111
toggle.innerHTML = Stack;
106112
container.append(toggle);
107113

@@ -142,6 +148,73 @@ class BaseLayerControl implements maplibregl.IControl {
142148
}
143149
}
144150

151+
/** Custom control to search for a place or address, backed by Nominatim. */
152+
class GeocoderControl implements maplibregl.IControl {
153+
private map?: maplibregl.Map;
154+
private results?: HTMLElement;
155+
private requestId = 0;
156+
private readonly search = useDebounceFn((query: string) => this.runSearch(query), 300);
157+
158+
onAdd(map: maplibregl.Map): HTMLElement {
159+
this.map = map;
160+
const container = document.createElement('div');
161+
container.className = 'maplibregl-ctrl maplibregl-ctrl-group lt-geocoder';
162+
163+
const icon = document.createElement('span');
164+
icon.className = 'lt-geocoder-icon';
165+
icon.setAttribute('aria-hidden', 'true');
166+
icon.innerHTML = Search;
167+
container.append(icon);
168+
169+
const input = document.createElement('input');
170+
input.type = 'search';
171+
input.placeholder = '…';
172+
input.setAttribute('aria-label', 'Search for a place or address');
173+
input.addEventListener('input', () => this.search(input.value));
174+
container.append(input);
175+
176+
const results = document.createElement('ul');
177+
results.className = 'lt-geocoder-results';
178+
container.append(results);
179+
this.results = results;
180+
181+
return container;
182+
}
183+
184+
private async runSearch(query: string): Promise<void> {
185+
const requestId = ++this.requestId;
186+
const results = this.results;
187+
if (!results) return;
188+
results.replaceChildren();
189+
if (!query.trim()) return;
190+
const places = await nominatimSearch(query);
191+
if (requestId !== this.requestId) return;
192+
for (const place of places) {
193+
const li = document.createElement('li');
194+
const button = document.createElement('button');
195+
button.type = 'button';
196+
button.textContent = place.display_name;
197+
button.addEventListener('click', () => this.select(place));
198+
li.append(button);
199+
results.append(li);
200+
}
201+
}
202+
203+
private select(place: NominatimResult): void {
204+
const [south, north, west, east] = place.boundingbox.map(Number);
205+
this.map?.fitBounds([
206+
[west, south],
207+
[east, north]
208+
]);
209+
this.results?.replaceChildren();
210+
}
211+
212+
onRemove(): void {
213+
this.map = undefined;
214+
this.results = undefined;
215+
}
216+
}
217+
145218
export function useMaplibreMap(mapRef: Ref<HTMLElement | null>) {
146219
const mapLayer = useLocalStorage('mapLayer', '');
147220
const mapView = useLocalStorage<MapView>('mapView', {
@@ -171,6 +244,7 @@ export function useMaplibreMap(mapRef: Ref<HTMLElement | null>) {
171244
})
172245
);
173246
map.addControl(new maplibregl.NavigationControl(), 'top-left');
247+
map.addControl(new GeocoderControl(), 'top-left');
174248
map.addControl(new BaseLayerControl(mapLayer, osm), 'top-right');
175249

176250
map.on('moveend', () => {
@@ -179,6 +253,8 @@ export function useMaplibreMap(mapRef: Ref<HTMLElement | null>) {
179253
});
180254
});
181255

256+
onUnmounted(() => map?.remove());
257+
182258
return {
183259
get map() {
184260
return map;

0 commit comments

Comments
 (0)