Skip to content

Commit 85a4768

Browse files
committed
Add maplibre-gl and support for vector tile rendering
1 parent a681cc9 commit 85a4768

File tree

10 files changed

+1085
-17
lines changed

10 files changed

+1085
-17
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
},
1818
"dependencies": {
1919
"@intlify/unplugin-vue-i18n": "^6.0.8",
20+
"@togglecorp/fujs": "^2.2.0",
21+
"@turf/bbox": "^7.2.0",
2022
"base-64": "^1.0.0",
2123
"firebase": "^11.8.1",
2224
"mapillary-js": "^4.1.2",
25+
"maplibre-gl": "^5.6.1",
2326
"ol": "^10.5.0",
2427
"ol-contextmenu": "^5.5.0",
2528
"ol-ext": "^4.0.31",

src/components/BaseMap.vue

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
<script setup lang="ts" generic="GeoJsonProperties extends GeoJSON.GeoJsonProperties">
2+
import 'maplibre-gl/dist/maplibre-gl.css';
3+
import { Map } from 'maplibre-gl';
4+
import { shallowRef, onMounted, onUnmounted, markRaw, watchEffect, onBeforeUnmount, computed } from 'vue';
5+
import getBbox from '@turf/bbox';
6+
import { isDefined, isNotDefined, listToMap } from '@togglecorp/fujs';
7+
8+
const BOUNDS_SOURCE_NAME = 'bounds-geojson-source';
9+
const BOUNDS_LINE_LAYER_NAME = 'bounds-geojson-line-layer';
10+
const BOUNDS_FILL_LAYER_NAME = 'bounds-geojson-fill-layer';
11+
12+
type Props = {
13+
geoJson: GeoJSON.GeoJSON<GeoJSON.Geometry, GeoJsonProperties>;
14+
mapWidth: number;
15+
mapHeight: number;
16+
mapState: {
17+
featureId: number,
18+
state: {
19+
key: string,
20+
value: any,
21+
}[]
22+
}[];
23+
}
24+
25+
const props = defineProps<Props>();
26+
27+
const emit = defineEmits<{
28+
onBoundFeatureClick: [properties: GeoJsonProperties],
29+
onBoundFeatureContextMenu: [properties: GeoJsonProperties],
30+
}>();
31+
32+
const mapContainer = shallowRef<HTMLDivElement | null>(null);
33+
const map = shallowRef<Map | null>(null);
34+
const lastHoveredBoundFeatureId = shallowRef<number | string>();
35+
36+
const bounds = computed(() => (
37+
getBbox(props.geoJson) as [number, number, number, number]
38+
));
39+
40+
const style = computed(() => ({
41+
width: `${props.mapWidth}px`,
42+
height: `${props.mapHeight}px`
43+
}));
44+
45+
watchEffect(() => {
46+
if (isDefined(map.value) && isDefined(props.mapHeight) && isDefined(props.mapWidth)) {
47+
const tileSize = Math.round(props.mapHeight / 3);
48+
49+
const baseTileSource = map.value.getSource('base-tile-source');
50+
if (isDefined(baseTileSource)) {
51+
baseTileSource.tileSize = tileSize;
52+
}
53+
54+
/*
55+
map.value.fitBounds(
56+
bounds.value,
57+
{
58+
duration: 0,
59+
padding: 0,
60+
},
61+
);
62+
*/
63+
}
64+
});
65+
66+
function clearLastHoveredFeatureState() {
67+
if (isDefined(map.value) && isDefined(lastHoveredBoundFeatureId.value)) {
68+
map.value.removeFeatureState(
69+
{
70+
id: lastHoveredBoundFeatureId.value,
71+
source: BOUNDS_SOURCE_NAME,
72+
},
73+
'hovered',
74+
);
75+
lastHoveredBoundFeatureId.value = undefined;
76+
}
77+
}
78+
79+
onMounted(() => {
80+
if (mapContainer.value) {
81+
const mapValue = markRaw(new Map({
82+
container: mapContainer.value,
83+
style: {
84+
version: 8,
85+
sources: {
86+
'base-tile-source': {
87+
type: 'raster',
88+
tiles: ['https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=7505'],
89+
tileSize: 256,
90+
attribution: '© 2019 Microsoft Corporation, Earthstar Geographics SIO',
91+
maxzoom: 19,
92+
},
93+
'overlay-tile-source': {
94+
type: 'vector',
95+
tiles: ['https://vector.osm.org/shortbread_v1/{z}/{x}/{y}.mvt'],
96+
attribution: 'Map data from OpenStreetMap',
97+
minzoom: 0,
98+
maxzoom: 14,
99+
},
100+
},
101+
layers: [
102+
{
103+
id: 'base-tile-layer',
104+
type: 'raster',
105+
source: 'base-tile-source',
106+
},
107+
{
108+
id: 'overlay-tile-layer',
109+
type: 'line',
110+
source: 'overlay-tile-source',
111+
'source-layer': 'buildings',
112+
paint: {
113+
'line-color': '#ffff00',
114+
'line-width': 2,
115+
},
116+
},
117+
],
118+
},
119+
center: [0, 0],
120+
zoom: 0,
121+
attributionControl: false,
122+
scrollZoom: false,
123+
doubleClickZoom: false,
124+
dragPan: false,
125+
pitchWithRotate: false,
126+
dragRotate: false,
127+
}));
128+
129+
mapValue.on('styledata', () => {
130+
map.value = mapValue;
131+
});
132+
133+
mapValue.on('mousemove', (data) => {
134+
if (!map.value) {
135+
return;
136+
}
137+
138+
const { point } = data;
139+
140+
const hoveredBoundFeatures = mapValue.queryRenderedFeatures(
141+
point,
142+
{ layers: [BOUNDS_FILL_LAYER_NAME] }
143+
);
144+
145+
const firstHoveredFeature = hoveredBoundFeatures[0];
146+
147+
if (isNotDefined(firstHoveredFeature)) {
148+
clearLastHoveredFeatureState();
149+
} else if (firstHoveredFeature.id !== lastHoveredBoundFeatureId.value) {
150+
if (isDefined(lastHoveredBoundFeatureId.value)) {
151+
map.value.removeFeatureState(
152+
{
153+
id: lastHoveredBoundFeatureId.value,
154+
source: BOUNDS_SOURCE_NAME,
155+
},
156+
'hovered',
157+
);
158+
}
159+
map.value.setFeatureState(
160+
{
161+
id: firstHoveredFeature.id,
162+
source: BOUNDS_SOURCE_NAME,
163+
},
164+
{'hovered': true },
165+
);
166+
lastHoveredBoundFeatureId.value = firstHoveredFeature.id;
167+
}
168+
});
169+
170+
mapValue.on('mouseout', clearLastHoveredFeatureState);
171+
172+
mapValue.on('click', (data) => {
173+
if (!map.value) {
174+
return;
175+
}
176+
177+
const { point } = data;
178+
179+
const clickedBoundFeatures = mapValue.queryRenderedFeatures(
180+
point,
181+
{ layers: [BOUNDS_FILL_LAYER_NAME] }
182+
);
183+
184+
const firstClickedFeature = clickedBoundFeatures[0];
185+
186+
if (isDefined(firstClickedFeature)) {
187+
emit('onBoundFeatureClick', firstClickedFeature.properties as GeoJsonProperties);
188+
}
189+
});
190+
191+
// for right click
192+
mapValue.on('contextmenu', (data) => {
193+
if (!map.value) {
194+
return;
195+
}
196+
const { point } = data;
197+
const clickedBoundFeatures = mapValue.queryRenderedFeatures(
198+
point,
199+
{ layers: [BOUNDS_FILL_LAYER_NAME] }
200+
);
201+
202+
const firstClickedFeature = clickedBoundFeatures[0];
203+
204+
if (isDefined(firstClickedFeature)) {
205+
emit('onBoundFeatureContextMenu', firstClickedFeature.properties as GeoJsonProperties);
206+
}
207+
});
208+
}
209+
});
210+
211+
onBeforeUnmount(() => {
212+
if (map.value) {
213+
map.value.remove();
214+
map.value = null;
215+
}
216+
});
217+
218+
watchEffect((onCleanup) => {
219+
if (isDefined(map.value)) {
220+
map.value.addSource(BOUNDS_SOURCE_NAME, {
221+
type: 'geojson',
222+
data: props.geoJson,
223+
});
224+
225+
map.value.addLayer({
226+
id: BOUNDS_FILL_LAYER_NAME,
227+
type: 'fill',
228+
source: BOUNDS_SOURCE_NAME,
229+
paint: {
230+
'fill-color': [
231+
'match',
232+
['feature-state', 'result'],
233+
0,
234+
'white',
235+
1,
236+
'green',
237+
2,
238+
'orange',
239+
3,
240+
'red',
241+
'orange',
242+
],
243+
'fill-opacity': [
244+
'case',
245+
[
246+
'any',
247+
['!=', ['feature-state', 'result'], 0],
248+
['boolean', ['feature-state', 'hovered'], false],
249+
],
250+
0.3,
251+
0,
252+
],
253+
}
254+
});
255+
256+
map.value.addLayer({
257+
id: BOUNDS_LINE_LAYER_NAME,
258+
type: 'line',
259+
source: BOUNDS_SOURCE_NAME,
260+
paint: {
261+
'line-color': '#ffffff',
262+
'line-width': [
263+
'case',
264+
['boolean', ['feature-state', 'selected'], false],
265+
20,
266+
2,
267+
],
268+
'line-opacity': [
269+
'case',
270+
['boolean', ['feature-state', 'selected'], false],
271+
0.7,
272+
1,
273+
],
274+
'line-offset': [
275+
'case',
276+
['boolean', ['feature-state', 'selected'], false],
277+
8,
278+
0,
279+
],
280+
},
281+
});
282+
283+
map.value.fitBounds(
284+
bounds.value,
285+
{
286+
duration: 0,
287+
padding: 0,
288+
},
289+
);
290+
291+
}
292+
293+
onCleanup(() => {
294+
if (isDefined(map.value)) {
295+
if (map.value.getLayer(BOUNDS_LINE_LAYER_NAME)) {
296+
map.value.removeLayer(BOUNDS_LINE_LAYER_NAME);
297+
}
298+
if (map.value.getLayer(BOUNDS_FILL_LAYER_NAME)) {
299+
map.value.removeLayer(BOUNDS_FILL_LAYER_NAME);
300+
}
301+
if (map.value.getSource(BOUNDS_SOURCE_NAME)) {
302+
map.value.removeSource(BOUNDS_SOURCE_NAME);
303+
}
304+
}
305+
});
306+
});
307+
308+
watchEffect(() => {
309+
if (isDefined(map.value)) {
310+
props.mapState.forEach((featureState) => {
311+
map.value?.setFeatureState(
312+
{
313+
id: featureState.featureId,
314+
source: BOUNDS_SOURCE_NAME,
315+
},
316+
listToMap(
317+
featureState.state,
318+
({ key }) => key,
319+
({ value }) => value,
320+
),
321+
)
322+
});
323+
}
324+
});
325+
326+
onUnmounted(() => {
327+
map.value?.remove();
328+
});
329+
</script>
330+
331+
<template>
332+
<div
333+
class="map-wrapper"
334+
:style="style"
335+
>
336+
<a href="https://www.maptiler.com" class="watermark">
337+
<img src="https://api.maptiler.com/resources/logo.svg" alt="MapTiler logo"/>
338+
</a>
339+
<div class="map" ref="mapContainer" />
340+
</div>
341+
</template>
342+
343+
<style scoped>
344+
.map-wrapper {
345+
position: relative;
346+
isolation: isolate;
347+
}
348+
349+
.map {
350+
width: 100%;
351+
height: 100%;
352+
}
353+
354+
.watermark {
355+
position: absolute;
356+
left: 10px;
357+
bottom: 10px;
358+
z-index: 1;
359+
}
360+
</style>

0 commit comments

Comments
 (0)