From 5d58e2e9ead8356c972d7700b9218ba77889ad15 Mon Sep 17 00:00:00 2001 From: Husam Ibrahim Date: Tue, 10 May 2022 05:31:31 +0200 Subject: [PATCH] feat!: marker clusters --- package.json | 3 +- src/components/CustomControl.vue | 4 +- src/components/InfoWindow.vue | 6 +-- src/components/MarkerCluster.ts | 51 +++++++++++++++++++++++++ src/components/index.ts | 1 + src/composables/useSetupMapComponent.ts | 45 +++++++++++++++++----- src/shared/index.ts | 8 ++-- yarn.lock | 20 ++++++++++ 8 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/components/MarkerCluster.ts diff --git a/package.json b/package.json index d56dad9..00ce7eb 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "release": "standard-version" }, "dependencies": { - "@googlemaps/js-api-loader": "^1.12.11" + "@googlemaps/js-api-loader": "^1.12.11", + "@googlemaps/markerclusterer": "^2.0.6" }, "devDependencies": { "@ampproject/rollup-plugin-closure-compiler": "^0.26.0", diff --git a/src/components/CustomControl.vue b/src/components/CustomControl.vue index d294955..2934397 100644 --- a/src/components/CustomControl.vue +++ b/src/components/CustomControl.vue @@ -32,8 +32,8 @@ export default defineComponent({ setup(props, { emit }) { const controlRef = ref(null); - const map = inject(mapSymbol, ref(null)); - const api = inject(apiSymbol, ref(null)); + const map = inject(mapSymbol, ref()); + const api = inject(apiSymbol, ref()); const mapTilesLoaded = inject(mapTilesLoadedSymbol, ref(false)); const showContent = ref(false); diff --git a/src/components/InfoWindow.vue b/src/components/InfoWindow.vue index acae638..98d7f88 100644 --- a/src/components/InfoWindow.vue +++ b/src/components/InfoWindow.vue @@ -25,9 +25,9 @@ export default defineComponent({ const infoWindow = ref(); const infoWindowRef = ref(); - const map = inject(mapSymbol, ref(null)); - const api = inject(apiSymbol, ref(null)); - const anchor = inject(markerSymbol, ref(null)); + const map = inject(mapSymbol, ref()); + const api = inject(apiSymbol, ref()); + const anchor = inject(markerSymbol, ref()); let anchorClickListener: google.maps.MapsEventListener; const hasSlotContent = computed(() => slots.default?.().some((vnode) => vnode.type !== Comment)); diff --git a/src/components/MarkerCluster.ts b/src/components/MarkerCluster.ts new file mode 100644 index 0000000..bb4ac85 --- /dev/null +++ b/src/components/MarkerCluster.ts @@ -0,0 +1,51 @@ +import { defineComponent, PropType, ref, provide, inject, watch, markRaw, onBeforeUnmount } from "vue"; +import { MarkerClusterer, MarkerClustererOptions } from "@googlemaps/markerclusterer"; +import { mapSymbol, apiSymbol, markerClusterSymbol } from "../shared/index"; + +const markerClusterEvents = ["clusteringbegin", "clusteringend", "click"]; + +export default defineComponent({ + name: "MarkerCluster", + props: { + options: { + type: Object as PropType, + default: () => ({}), + }, + }, + emits: markerClusterEvents, + setup(props, { emit, expose, slots }) { + const markerCluster = ref(); + const map = inject(mapSymbol, ref()); + const api = inject(apiSymbol, ref()); + + provide(markerClusterSymbol, markerCluster); + + watch( + map, + () => { + if (map.value) { + markerCluster.value = markRaw(new MarkerClusterer({ map: map.value, ...props.options })); + + markerClusterEvents.forEach((event) => { + markerCluster.value?.addListener(event, (e: unknown) => emit(event, e)); + }); + } + }, + { + immediate: true, + } + ); + + onBeforeUnmount(() => { + if (markerCluster.value) { + api.value?.event.clearInstanceListeners(markerCluster.value); + markerCluster.value.clearMarkers(); + markerCluster.value.setMap(null); + } + }); + + expose({ markerCluster }); + + return () => slots.default?.(); + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index 2a8fcfe..0f0d051 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,3 +6,4 @@ export { default as Rectangle } from "./Rectangle"; export { default as Circle } from "./Circle"; export { default as CustomControl } from "./CustomControl.vue"; export { default as InfoWindow } from "./InfoWindow.vue"; +export { default as MarkerCluster } from "./MarkerCluster"; diff --git a/src/composables/useSetupMapComponent.ts b/src/composables/useSetupMapComponent.ts index 8f0ee2a..fbcea59 100644 --- a/src/composables/useSetupMapComponent.ts +++ b/src/composables/useSetupMapComponent.ts @@ -1,5 +1,5 @@ -import { watch, ref, Ref, inject, onBeforeUnmount } from "vue"; -import { apiSymbol, mapSymbol } from "../shared/index"; +import { watch, ref, Ref, inject, onBeforeUnmount, computed } from "vue"; +import { apiSymbol, mapSymbol, markerClusterSymbol } from "../shared/index"; export type IComponent = | google.maps.Marker @@ -24,8 +24,13 @@ export const useSetupMapComponent = ( let _component: IComponent | null = null; const component = ref(null); - const map = inject(mapSymbol, ref(null)); - const api = inject(apiSymbol, ref(null)); + const map = inject(mapSymbol, ref()); + const api = inject(apiSymbol, ref()); + const markerCluster = inject(markerClusterSymbol, ref()); + + const isMarkerInCluster = computed( + () => !!(markerCluster.value && api.value && _component instanceof api.value.Marker) + ); watch( [map, options], @@ -35,12 +40,27 @@ export const useSetupMapComponent = ( if (_component) { // eslint-disable-next-line @typescript-eslint/no-explicit-any _component.setOptions(options.value as any); - _component.setMap(map.value); + + if (isMarkerInCluster.value) { + markerCluster.value?.removeMarker(_component as google.maps.Marker); + markerCluster.value?.addMarker(_component as google.maps.Marker); + } } else { - component.value = _component = new api.value[componentName]({ - ...options.value, - map: map.value, - }); + if (componentName === "Marker") { + component.value = _component = new api.value[componentName](options.value); + } else { + component.value = _component = new api.value[componentName]({ + ...options.value, + map: map.value, + }); + } + + if (isMarkerInCluster.value) { + markerCluster.value?.addMarker(_component as google.maps.Marker); + } else { + _component.setMap(map.value); + } + events.forEach((event) => { _component?.addListener(event, (e: unknown) => emit(event, e)); }); @@ -55,7 +75,12 @@ export const useSetupMapComponent = ( onBeforeUnmount(() => { if (_component) { api.value?.event.clearInstanceListeners(_component); - _component.setMap(null); + + if (isMarkerInCluster.value) { + markerCluster.value?.removeMarker(_component as google.maps.Marker); + } else { + _component.setMap(null); + } } }); diff --git a/src/shared/index.ts b/src/shared/index.ts index 7c6c2b1..dc79192 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,9 +1,11 @@ import type { Loader } from "@googlemaps/js-api-loader"; +import type { MarkerClusterer } from "@googlemaps/markerclusterer"; import { InjectionKey, ref, Ref } from "vue"; -export const mapSymbol: InjectionKey> = Symbol("map"); -export const apiSymbol: InjectionKey> = Symbol("api"); -export const markerSymbol: InjectionKey> = Symbol("marker"); +export const mapSymbol: InjectionKey> = Symbol("map"); +export const apiSymbol: InjectionKey> = Symbol("api"); +export const markerSymbol: InjectionKey> = Symbol("marker"); +export const markerClusterSymbol: InjectionKey> = Symbol("markerCluster"); /** * Utilitary flag for components that need to know the map * was fully loaded (including its tiles) to decide their behavior diff --git a/yarn.lock b/yarn.lock index 7576e76..fd283b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,6 +256,14 @@ dependencies: fast-deep-equal "^3.1.3" +"@googlemaps/markerclusterer@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@googlemaps/markerclusterer/-/markerclusterer-2.0.6.tgz#f3c157e24f5c95ab1748710c5a584ae1fae51d61" + integrity sha512-kO8Q77V3aqR2tVZ3SDXs9ycCiWYpd+FadxIJVtDKlO9LlMs415GS686+XvDLMLorR/RvwQHkquHZM8RbZ3bCrg== + dependencies: + fast-deep-equal "^3.1.3" + supercluster "^7.1.3" + "@hutson/parse-repository-url@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz#98c23c950a3d9b6c8f0daed06da6c3af06981340" @@ -3955,6 +3963,11 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= +kdbush@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-3.0.0.tgz#f8484794d47004cc2d85ed3a79353dbe0abc2bf0" + integrity sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew== + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -6383,6 +6396,13 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" +supercluster@^7.1.3: + version "7.1.5" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.1.5.tgz#65a6ce4a037a972767740614c19051b64b8be5a3" + integrity sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg== + dependencies: + kdbush "^3.0.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"