Skip to content

Commit

Permalink
feat: add uncluster plugin support #226
Browse files Browse the repository at this point in the history
  • Loading branch information
wazolab committed Aug 21, 2024
1 parent 478d0e3 commit 0d2e79e
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 171 deletions.
57 changes: 22 additions & 35 deletions components/Map/MapBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import type {
} from 'maplibre-gl'
import type { PropType } from 'vue'
import { UnCluster } from '@teritorio/maplibre-gl-teritorio-cluster'
import { defineNuxtComponent } from '#app'
import Attribution from '~/components/Map/Attribution.vue'
import Map from '~/components/Map/Map.vue'
import type { ApiPoi } from '~/lib/apiPois'
import { MAP_ZOOM } from '~/lib/constants'
import type { MapPoi } from '~/lib/mapPois'
import { markerLayerTextFactory, updateMarkers } from '~/lib/markerLayerFactory'
import { markerLayerTextFactory } from '~/lib/markerLayerFactory'
import { createClusterHTML, createSingleMarkerHTML } from '~/lib/clusters'
import type { MapStyleEnum } from '~/utils/types'
const POI_SOURCE = 'poi'
Expand Down Expand Up @@ -109,13 +111,15 @@ export default defineNuxtComponent({
poiLayerTemplate: LayerSpecification | undefined
markers: { [id: string]: Marker }
fullAttribution: string
uncluster: UnCluster | null
} {
return {
map: null!,
poiFilter: null,
poiLayerTemplate: undefined,
markers: {},
fullAttribution: '',
uncluster: null,
}
},
Expand Down Expand Up @@ -244,7 +248,7 @@ export default defineNuxtComponent({
cluster: cluster === undefined ? true : cluster,
clusterRadius: 32,
clusterProperties: clusterProps,
clusterMaxZoom: 15,
clusterMaxZoom: 22,
tolerance: 0.6,
data: {
type: 'FeatureCollection',
Expand All @@ -266,6 +270,12 @@ export default defineNuxtComponent({
onMapInit(map: MapGL) {
this.map = map
this.uncluster = new UnCluster(map, POI_SOURCE, { clusterMaxZoom: 17 }, createClusterHTML, createSingleMarkerHTML)
// this.uncluster.addEventListener('click', (e) => {
// const { pinMarker, selectedFeatureId } = e.target as UnCluster
// })
this.$emit('mapInit', map)
},
Expand Down Expand Up @@ -361,13 +371,7 @@ export default defineNuxtComponent({
&& this.map.getSource(POI_SOURCE)
&& this.map.isSourceLoaded(POI_SOURCE)
) {
this.markers = updateMarkers(
this.map as MapGL,
this.markers,
POI_SOURCE,
this.fitBounds,
(feature: ApiPoi, marker?: Marker) => this.$emit('featureClick', feature, marker),
)
this.uncluster?.render()
}
// @ts-expect-error: eventName is not in events definition
Expand All @@ -380,28 +384,14 @@ export default defineNuxtComponent({
<template>
<div id="map-container" class="tw-w-full tw-h-full tw-flex tw-flex-col">
<Map
:center="center"
:bounds="bounds"
:fit-bounds-options="fitBoundsOptions()"
:zoom="selectionZoom.poi"
:fullscreen-control="fullscreenControl"
:extra-attributions="extraAttributions"
:map-style="mapStyle"
:rotate="rotate"
:show-attribution="showAttribution && !offMapAttribution"
:hide-control="hideControl"
:hash="hash"
:cooperative-gestures="cooperativeGestures"
class="tw-grow tw-h-full"
@map-init="onMapInit($event)"
@map-data="onMapRender('mapData', $event)"
@map-drag-end="onMapRender('mapDragEnd', $event)"
@map-move-end="onMapRender('mapMoveEnd', $event)"
@map-resize="onMapRender('mapResize', $event)"
@map-rotate-end="onMapRender('mapRotateEnd', $event)"
@map-touch-move="onMapRender('mapTouchMove', $event)"
@map-zoom-end="onMapRender('mapZoomEnd', $event)"
@map-style-load="onMapStyleLoad($event)"
:center="center" :bounds="bounds" :fit-bounds-options="fitBoundsOptions()" :zoom="selectionZoom.poi"
:fullscreen-control="fullscreenControl" :extra-attributions="extraAttributions" :map-style="mapStyle"
:rotate="rotate" :show-attribution="showAttribution && !offMapAttribution" :hide-control="hideControl"
:hash="hash" :cooperative-gestures="cooperativeGestures" class="tw-grow tw-h-full" @map-init="onMapInit($event)"
@map-data="onMapRender('mapData', $event)" @map-drag-end="onMapRender('mapDragEnd', $event)"
@map-move-end="onMapRender('mapMoveEnd', $event)" @map-resize="onMapRender('mapResize', $event)"
@map-rotate-end="onMapRender('mapRotateEnd', $event)" @map-touch-move="onMapRender('mapTouchMove', $event)"
@map-zoom-end="onMapRender('mapZoomEnd', $event)" @map-style-load="onMapStyleLoad($event)"
@full-attribution="fullAttribution = $event"
>
<template #controls>
Expand All @@ -411,10 +401,7 @@ export default defineNuxtComponent({
<slot name="body" />
</template>
</Map>
<Attribution
v-if="showAttribution && offMapAttribution"
:attribution="fullAttribution"
/>
<Attribution v-if="showAttribution && offMapAttribution" :attribution="fullAttribution" />
</div>
</template>

Expand Down
67 changes: 52 additions & 15 deletions lib/clusters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { MapGeoJSONFeature } from 'maplibre-gl'
import { createApp } from 'vue'
import TeritorioIconBadge from '~/components/UI/TeritorioIconBadge.vue'

function getMarkerDonutSegment(start: number, end: number, r: number, r0: number, colorFill: string): string {
if (end - start === 1)
end -= 0.00001
Expand Down Expand Up @@ -39,40 +43,48 @@ function getMarkerDonutSegment(start: number, end: number, r: number, r0: number
].join(' ')
}

export function createMarkerDonutChart(countPerColor: Record<string, number>, totalCount: number): HTMLElement {
const r
= totalCount >= 1000
? 40
: totalCount >= 100
? 32
: totalCount >= 10
? 24
: 16
export function createClusterHTML(props: MapGeoJSONFeature['properties']): HTMLDivElement {
const {
cluster: _a,
cluster_id: _b,
point_count,
_c: _d,
point_count_abbreviated: _e,
...countPercolor
} = props

const r = point_count >= 1000
? 40
: point_count >= 100
? 32
: point_count >= 10
? 24
: 16
const r0 = r - 5
const w = r * 2

let html = `<svg width="${w}" height="${w}" viewbox="0 0 ${w} ${w}" text-anchor="middle" class="cluster-donut">`

let total = 0
let offsets = 0
Object.entries(countPerColor).forEach(([color, count]) => {
Object.entries(countPercolor).forEach(([color, count]) => {
total += count
html += getMarkerDonutSegment(
offsets / totalCount,
total / totalCount,
offsets / point_count,
total / point_count,
r,
r0,
color,
)
offsets = total
})

if (total !== totalCount)
html += getMarkerDonutSegment(total / totalCount, 1, r, r0, '#ccc')
if (total !== point_count)
html += getMarkerDonutSegment(total / point_count, 1, r, r0, '#ccc')

html += `<circle cx="${r}" cy="${r}" r="${r0}" fill="white" />
<text dominant-baseline="central" transform="translate(${r}, ${r})">
${totalCount.toLocaleString()}
${point_count.toLocaleString()}
</text>
</svg>`

Expand All @@ -81,3 +93,28 @@ export function createMarkerDonutChart(countPerColor: Record<string, number>, to
el.innerHTML = html
return el
}

export function createSingleMarkerHTML(feature: MapGeoJSONFeature) {
const el = document.createElement('div')

if (typeof feature.properties?.metadata === 'string')
feature.properties.metadata = JSON.parse(feature.properties.metadata)

if (typeof feature.properties?.display === 'string')
feature.properties.display = JSON.parse(feature.properties?.display)

if (typeof feature.properties?.editorial === 'string')
feature.properties.editorial = JSON.parse(feature.properties?.editorial)

el.id = feature.properties?.metadata?.id || feature.properties?.id

createApp(TeritorioIconBadge, {
colorFill: feature.properties.display?.color_fill,
picto: feature.properties.display?.icon,
image: feature.properties!['image:thumbnail'],
size: null,
text: feature.properties.display?.text,
}).mount(el)

return el
}
121 changes: 0 additions & 121 deletions lib/markerLayerFactory.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import type {
FitBoundsOptions,
GeoJSONSource,
LayerSpecification,
LngLatBounds,
Map,
Marker,
SymbolLayerSpecification,
} from 'maplibre-gl'
import { createApp } from 'vue'

import type { ApiPoi } from './apiPois'
import { getBBoxFeatures } from './bbox'
import { createMarkerDonutChart } from './clusters'

import TeritorioIconBadge from '~/components/UI/TeritorioIconBadge.vue'
import type { TupleLatLng } from '~/utils/types'
import { createMarker } from '~/composables/useMarker'
Expand Down Expand Up @@ -102,116 +94,3 @@ export function makerHtmlFactory(

return marker
}

export function updateMarkers(
map: Map,
markers: { [id: string]: Marker },
src: string,
fitBounds: (bounds: LngLatBounds, options: FitBoundsOptions) => void,
markerClickCallBack: ((feature: ApiPoi, marker?: Marker) => void) | undefined,
) {
const markerIdPrevious = Object.keys(markers)
const markerIdcurrent: string[] = []

const features = map.querySourceFeatures(src).filter(feature => feature.geometry.type === 'Point')
// for every cluster on the screen, create an HTML marker for it (if we didn't yet),
// and add it to the map if it's not there already
for (let i = 0; i < features.length; i++) {
const coords = (features[i].geometry as GeoJSON.Point).coordinates as [
number,
number,
]
const props = features[i].properties
if (props?.cluster) {
const id = `c${props.cluster_id}`
markerIdcurrent.push(id)
if (!markers[id]) {
const {
cluster: _a,
cluster_id: _b,
point_count,
_c: _d,
point_count_abbreviated: _e,
...countPercolor
} = props
const element = createMarkerDonutChart(countPercolor, point_count)

markers[id] = createMarker(coords, { element })
markers[id].addTo(map)

element.addEventListener('click', async (e) => {
e.stopPropagation()
const source = map.getSource(src) as GeoJSONSource

if (source && 'getClusterLeaves' in source) {
const leaves = await source.getClusterLeaves(props.cluster_id, props.point_count, 0)
if (leaves.length) {
const bounds = getBBoxFeatures(leaves)
if (bounds)
fitBounds(bounds, {})
}
}
})
}
}
else if (props?.metadata) {
if (typeof props.metadata === 'string')
props.metadata = JSON.parse(props.metadata)

const id = `m${features[i].id}`
markerIdcurrent.push(id)

// Workaround to correct shifting POI markers after zoom-in
if (!markers[id] || (markers[id] && (markers[id]?.getLngLat().lat !== (features[i].geometry as GeoJSON.Point).coordinates[1]))) {
if (markers[id])
markers[id].remove()

const markerCoords
= features[i].geometry.type === 'Point'
&& ((features[i].geometry as GeoJSON.Point).coordinates as TupleLatLng)
if (markerCoords) {
if (typeof props.display === 'string')
props.display = JSON.parse(props.display)

if (typeof props.editorial === 'string')
props.editorial = JSON.parse(props.editorial)

// Marker
markers[id] = makerHtmlFactory(
id,
markerCoords, // Using this to avoid misplaced marker
props.display?.color_fill || '#000000',
props.display?.icon || '#000000',
props['image:thumbnail'],
null,
props.display?.text,
)

// Click handler
if (markerClickCallBack && props.editorial?.popup_fields) {
const el = markers[id].getElement()

el.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation()
const pinMarker = createMarker(markerCoords)

markerClickCallBack(features[i] as unknown as ApiPoi, pinMarker)
})
}
markers[id].addTo(map)
}
}
}
}

// for every marker we've added previously, remove those that are no longer visible
const markerIdcurrentSet = new Set(markerIdcurrent)
markerIdPrevious.forEach((id) => {
if (!markerIdcurrentSet.has(id)) {
markers[id].remove()
delete markers[id]
}
})

return markers
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@sentry/vue": "^7.113.0",
"@tailwindcss/typography": "^0.5.9",
"@teritorio/map": "^0.12.7",
"@teritorio/maplibre-gl-teritorio-cluster": "../../@teritorio/maplibre-gl-teritorio-cluster",
"@teritorio/openmaptiles-gl-language": "^1.5.4",
"@turf/boolean-intersects": "^6.5.0",
"@types/geojson": "^7946.0.10",
Expand Down
Loading

0 comments on commit 0d2e79e

Please sign in to comment.