diff --git a/.gitignore b/.gitignore index 60a5673..ffaf263 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist node_modules storybook-static +.env \ No newline at end of file diff --git a/src/components/Annotation.tsx b/src/components/Annotation.tsx index c5e541f..ae85022 100644 --- a/src/components/Annotation.tsx +++ b/src/components/Annotation.tsx @@ -12,6 +12,7 @@ import AnnotationProps from './AnnotationProps'; import forwardMapkitEvent from '../util/forwardMapkitEvent'; import CalloutContainer from './CalloutContainer'; import { toMapKitDisplayPriority } from '../util/parameters'; +import { AnnotationClusterIdentifierContext, ClusterAnnotationContext } from './AnnotationCluster'; export default function Annotation({ latitude, @@ -42,7 +43,7 @@ export default function Annotation({ appearanceAnimation = '', visible = true, - clusteringIdentifier = null, + clusteringIdentifier: deprecatedClusterIdentifier = null, displayPriority = undefined, collisionMode = undefined, @@ -63,6 +64,8 @@ export default function Annotation({ const [annotation, setAnnotation] = useState(null); const contentEl = useMemo(() => document.createElement('div'), []); const map = useContext(MapContext); + const clusterAnnotation = useContext(ClusterAnnotationContext); + const clusteringIdentifier = useContext(AnnotationClusterIdentifierContext) ?? deprecatedClusterIdentifier; // Padding useEffect(() => { @@ -90,7 +93,6 @@ export default function Annotation({ // Callout useLayoutEffect(() => { if (!annotation) return; - const callOutObj: mapkit.AnnotationCalloutDelegate = {}; if (calloutElement && calloutElementRef.current !== null) { // @ts-expect-error @@ -171,7 +173,7 @@ export default function Annotation({ size, - selected, + // Note: 'selected' is handled separately to avoid conflicts with MapKit's internal selection animates, appearanceAnimation, draggable, @@ -192,6 +194,17 @@ export default function Annotation({ }, [annotation, prop]); }); + // Handle 'selected' separately to avoid fighting with MapKit's selection + // Only set selected when the prop value differs from the current annotation state + useEffect(() => { + if (!annotation) return; + if (selected === undefined) return; + // Only update if the values actually differ to avoid deselecting when MapKit just selected + if (annotation.selected !== selected) { + annotation.selected = selected; + } + }, [annotation, selected]); + // Events const handlerWithoutParameters = () => { }; const events = [ @@ -223,13 +236,20 @@ export default function Annotation({ new mapkit.Coordinate(latitude, longitude), () => contentEl, ); - map.addAnnotation(a); - setAnnotation(a); + + if (clusterAnnotation !== undefined) { + setAnnotation(clusterAnnotation); + } else { + map.addAnnotation(a); + setAnnotation(a); + } return () => { - map.removeAnnotation(a); + if (!clusterAnnotation) { + map.removeAnnotation(a); + } }; - }, [map, latitude, longitude]); + }, [map, latitude, longitude, clusterAnnotation]); return ( <> @@ -270,7 +290,7 @@ export default function Annotation({ , document.body, )} - {createPortal(children, contentEl)} + {clusterAnnotation !== undefined ? children : createPortal(children, contentEl)} ); } diff --git a/src/components/AnnotationCluster.tsx b/src/components/AnnotationCluster.tsx new file mode 100644 index 0000000..63385b2 --- /dev/null +++ b/src/components/AnnotationCluster.tsx @@ -0,0 +1,260 @@ +import React, { + createContext, Fragment, useContext, useEffect, useId, useState, useRef, + useLayoutEffect, +} from 'react'; +import { createPortal } from 'react-dom'; +import AnnotationClusterProps from './AnnotationClusterProps'; +import MapContext from '../context/MapContext'; + +export const AnnotationClusterIdentifierContext = createContext(null); +export const ClusterAnnotationContext = createContext(undefined); + +interface ClusterAnnotationData { + contentElement: HTMLDivElement; + annotation: mapkit.Annotation; + coordinate: mapkit.Coordinate; + memberAnnotations: mapkit.Annotation[]; + // Fingerprint based on member coordinates for stable identification + fingerprint: string; + // Track a unique ID for React keys + id: string; +} + +// Generate a unique ID for each cluster annotation +let clusterIdCounter = 0; +function generateClusterId(): string { + clusterIdCounter += 1; + return `cluster-${clusterIdCounter}`; +} + +// Create a fingerprint for a member annotation based on its stable properties +function getAnnotationFingerprint(annotation: mapkit.Annotation): string { + // Use coordinate with reasonable precision (8 decimal places is ~1mm) + return `${annotation.coordinate.latitude.toFixed(8)},${annotation.coordinate.longitude.toFixed(8)}`; +} + +// Create a fingerprint for a cluster based on its members' coordinates +function getClusterFingerprint(members: mapkit.Annotation[]): string { + return members + .map(getAnnotationFingerprint) + .sort() + .join('|'); +} + +// Check if two arrays contain the same annotation references +function haveSameMembers(a: mapkit.Annotation[], b: mapkit.Annotation[]): boolean { + if (a.length !== b.length) return false; + const setA = new Set(a); + return b.every((item) => setA.has(item)); +} + +// Helper component to measure content size and update callout offset +function ClusterContentMeasurer({ annotation, children }: { annotation: mapkit.Annotation, children: React.ReactNode }) { + const ref = useRef(null); + const initialCalloutOffset = useRef(null); + + useLayoutEffect(() => { + // Capture the initial callout offset set by the child Annotation component + initialCalloutOffset.current = annotation.calloutOffset; + }, []); + + useLayoutEffect(() => { + if (!ref.current) return; + const updateOffset = () => { + const height = ref.current?.offsetHeight || 0; + // Always apply vertical centering, but preserve any user-set horizontal offset + const xOffset = initialCalloutOffset.current?.x ?? 0; + annotation.calloutOffset = new DOMPoint(xOffset, height / 2); + }; + + // Initial + updateOffset(); + + // Observer for size changes + const observer = new ResizeObserver(updateOffset); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [annotation]); + + return
{children}
; +} + +export default function AnnotationCluster({ + children, + annotationForCluster, +}: AnnotationClusterProps) { + const clusterIdenfier = useId(); + const map = useContext(MapContext); + + // Use ref for the existing function to avoid stale closure issues + // Note: MapKit types declare this as returning void, but it actually returns an Annotation + const existingClusterFuncRef = useRef< + ((clusterAnnotation: mapkit.Annotation) => mapkit.Annotation | void) | undefined + >(undefined); + const hasStoredExistingFunc = useRef(false); + + const [clusterAnnotations, setClusterAnnotations] = useState([]); + + // Keep a ref in sync with state for use in the callback + const clusterAnnotationsRef = useRef([]); + useEffect(() => { + clusterAnnotationsRef.current = clusterAnnotations; + }, [clusterAnnotations]); + + // Store the annotationForCluster prop in a ref so the callback always has the latest + const annotationForClusterRef = useRef(annotationForCluster); + useEffect(() => { + annotationForClusterRef.current = annotationForCluster; + }, [annotationForCluster]); + + // Coordinates + useEffect(() => { + if (map === null) return undefined; + + // Store the existing function only once + if (!hasStoredExistingFunc.current) { + existingClusterFuncRef.current = map.annotationForCluster; + hasStoredExistingFunc.current = true; + } + + map.annotationForCluster = (clusterAnnotationData) => { + if (clusterAnnotationData.clusteringIdentifier === clusterIdenfier) { + if (annotationForClusterRef.current) { + // Create a fingerprint based on member coordinates (stable across calls) + const fingerprint = getClusterFingerprint(clusterAnnotationData.memberAnnotations); + + // Find existing cluster by fingerprint + const existingEntry = clusterAnnotationsRef.current.find( + (entry) => entry.fingerprint === fingerprint, + ); + + if (existingEntry) { + // Update coordinate if it changed + const coordChanged = existingEntry.coordinate.latitude + !== clusterAnnotationData.coordinate.latitude + || existingEntry.coordinate.longitude + !== clusterAnnotationData.coordinate.longitude; + + if (coordChanged) { + // IMPORTANT: Always update the annotation's coordinate to match what MapKit expects + existingEntry.annotation.coordinate = new mapkit.Coordinate( + clusterAnnotationData.coordinate.latitude, + clusterAnnotationData.coordinate.longitude, + ); + } + + // Check if members changed (reference check) + const membersChanged = !haveSameMembers( + existingEntry.memberAnnotations, + clusterAnnotationData.memberAnnotations, + ); + + // Update state if anything changed to ensure React renders correctly + if (coordChanged || membersChanged) { + setClusterAnnotations((prev) => prev.map((entry) => { + if (entry === existingEntry) { + return { + ...entry, + coordinate: clusterAnnotationData.coordinate, + memberAnnotations: clusterAnnotationData.memberAnnotations, + }; + } + return entry; + })); + } + + return existingEntry.annotation; + } + + // Create a new cluster annotation + const contentElement = document.createElement('div'); + + // Fix offset issues by centering content around a 0x0 container + contentElement.style.width = '0px'; + contentElement.style.height = '0px'; + contentElement.style.overflow = 'visible'; + contentElement.style.display = 'flex'; + contentElement.style.justifyContent = 'center'; + contentElement.style.alignItems = 'center'; + + // Ensure pointer events work + contentElement.style.pointerEvents = 'auto'; + + const a = new mapkit.Annotation( + new mapkit.Coordinate( + clusterAnnotationData.coordinate.latitude, + clusterAnnotationData.coordinate.longitude, + ), + () => contentElement, + { + title: clusterAnnotationData.title, + subtitle: clusterAnnotationData.subtitle, + calloutEnabled: true, + }, + ); + + const newEntry: ClusterAnnotationData = { + contentElement, + annotation: a, + coordinate: clusterAnnotationData.coordinate, + memberAnnotations: clusterAnnotationData.memberAnnotations, + fingerprint, + id: generateClusterId(), + }; + + // Update the ref immediately so subsequent calls in the same cycle can find it + clusterAnnotationsRef.current = [...clusterAnnotationsRef.current, newEntry]; + setClusterAnnotations((prev) => [...prev, newEntry]); + + return a; + } + + return clusterAnnotationData; + } + + // Delegate to the previous function if it exists + return existingClusterFuncRef.current + ? existingClusterFuncRef.current(clusterAnnotationData) + : clusterAnnotationData; + }; + + return () => { + // Cleanup: remove cluster annotations that are no longer valid + setClusterAnnotations([]); + clusterAnnotationsRef.current = []; + + if (!hasStoredExistingFunc.current || existingClusterFuncRef.current === undefined) { + // @ts-ignore + delete map.annotationForCluster; + } else { + // @ts-ignore + map.annotationForCluster = existingClusterFuncRef.current; + } + }; + }, [map, clusterIdenfier]); + + return ( + <> + {annotationForCluster && clusterAnnotations.map(({ + contentElement, annotation, coordinate, memberAnnotations, id, + }) => ( + + {createPortal( + + + {annotationForCluster( + memberAnnotations, + coordinate, + )} + + , + contentElement, + )} + + ))} + + {children} + + + ); +} diff --git a/src/components/AnnotationClusterProps.tsx b/src/components/AnnotationClusterProps.tsx new file mode 100644 index 0000000..90142c9 --- /dev/null +++ b/src/components/AnnotationClusterProps.tsx @@ -0,0 +1,7 @@ +export default interface AnnotationClusterProps { + annotationForCluster?: ( + memberAnnotations: mapkit.Annotation[], + coordinate: mapkit.Coordinate, + ) => React.ReactNode; + children: React.ReactNode; +} diff --git a/src/components/Marker.tsx b/src/components/Marker.tsx index cd01c62..68a1950 100644 --- a/src/components/Marker.tsx +++ b/src/components/Marker.tsx @@ -7,6 +7,7 @@ import { FeatureVisibility, toMapKitDisplayPriority, toMapKitFeatureVisibility } import MarkerProps from './MarkerProps'; import forwardMapkitEvent from '../util/forwardMapkitEvent'; import CalloutContainer from './CalloutContainer'; +import { AnnotationClusterIdentifierContext, ClusterAnnotationContext } from './AnnotationCluster'; export default function Marker({ latitude, @@ -18,7 +19,7 @@ export default function Marker({ subtitleVisibility = FeatureVisibility.Adaptive, titleVisibility = FeatureVisibility.Adaptive, - clusteringIdentifier = null, + clusteringIdentifier: deprecatedClusterIdentifier = null, displayPriority = undefined, collisionMode = undefined, @@ -60,6 +61,8 @@ export default function Marker({ }: MarkerProps) { const [marker, setMarker] = useState(null); const map = useContext(MapContext); + const clusterAnnotation = useContext(ClusterAnnotationContext); + const clusteringIdentifier = useContext(AnnotationClusterIdentifierContext) ?? deprecatedClusterIdentifier; // Enum properties useEffect(() => { @@ -233,13 +236,17 @@ export default function Marker({ const m = new mapkit.MarkerAnnotation( new mapkit.Coordinate(latitude, longitude), ); - map.addAnnotation(m); - setMarker(m); + if (clusterAnnotation !== undefined) { + setMarker(clusterAnnotation); + } else { + map.addAnnotation(m); + setMarker(m); + } return () => { map.removeAnnotation(m); }; - }, [map, latitude, longitude]); + }, [map, latitude, longitude, clusterAnnotation]); return createPortal(
diff --git a/src/stories/Annotation.stories.tsx b/src/stories/Annotation.stories.tsx index 4335b46..71b5800 100644 --- a/src/stories/Annotation.stories.tsx +++ b/src/stories/Annotation.stories.tsx @@ -1,16 +1,17 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Meta, StoryFn } from '@storybook/react'; import { fn } from '@storybook/test'; import Map from '../components/Map'; import Annotation from '../components/Annotation'; import { CoordinateRegion, FeatureVisibility } from '../util/parameters'; +import AnnotationCluster from '../components/AnnotationCluster'; // @ts-ignore const token = import.meta.env.STORYBOOK_MAPKIT_JS_TOKEN!; // SVG from https://webkul.github.io/vivid -function CustomMarker() { +function CustomMarker({ color = '#FF6E6E' }: { color?: string }) { return ( { ); }; CustomAnnotationCallout.storyName = 'Annotation with custom callout element'; + +export const AnnotationClustering = () => { + const clusteringIdentifier = 'id'; + const [selected, setSelected] = useState(null); + + const initialRegion: CoordinateRegion = useMemo(() => ({ + centerLatitude: 46.20738751546706, + centerLongitude: 6.155891756231, + latitudeDelta: 1, + longitudeDelta: 1, + }), []); + + const coordinates = [ + { latitude: 46.20738751546706, longitude: 6.155891756231, someId: 'A' }, + { latitude: 46.25738751546706, longitude: 6.185891756231, someId: 'B' }, + { latitude: 46.28738751546706, longitude: 6.2091756231, someId: 'C' }, + { latitude: 46.20738751546706, longitude: 6.185891756231, someId: 'D' }, + { latitude: 46.25738751546706, longitude: 6.2091756231, someId: 'E' }, + ]; + + const annotationClusterFunc = useCallback((memberAnnotations: mapkit.Annotation[], coordinate: mapkit.Coordinate) => ( + {memberAnnotations.map((clusterAnnotation) => clusterAnnotation.title).join(' & ')}
+ )} + onSelect={() => setSelected(memberAnnotations.map((clusterAnnotation) => clusterAnnotation.title).join(' & '))} + selected={selected === memberAnnotations.map((clusterAnnotation) => clusterAnnotation.title).join(' & ')} + calloutOffsetY={-40} + > + + + ), [selected]); + + return ( + <> + + + {coordinates.map(({ latitude, longitude, someId }) => ( + setSelected(someId)} + onDeselect={() => setSelected(null)} + collisionMode="Circle" + displayPriority={750} + key={someId} + > + + + ))} + + + +
+
+

{selected ? `Selected annotation #${selected}` : 'Not selected'}

+
+
+ + ); +}; + +AnnotationClustering.storyName = 'Clustering three annotations into one'; diff --git a/src/stories/Marker.stories.tsx b/src/stories/Marker.stories.tsx index c77a060..10f64ea 100644 --- a/src/stories/Marker.stories.tsx +++ b/src/stories/Marker.stories.tsx @@ -5,6 +5,7 @@ import './stories.css'; import Map from '../components/Map'; import Marker from '../components/Marker'; import { CoordinateRegion, FeatureVisibility } from '../util/parameters'; +import AnnotationCluster from '../components/AnnotationCluster'; // @ts-ignore const token = import.meta.env.STORYBOOK_MAPKIT_JS_TOKEN!; @@ -162,7 +163,7 @@ export const MoveableMarker = () => { ); }; -export const MarkerClustering = () => { +export const MarkerClusteringOld = () => { const clusteringIdentifier = 'id'; const [selected, setSelected] = useState(null); @@ -209,6 +210,59 @@ export const MarkerClustering = () => { ); }; +MarkerClusteringOld.storyName = 'Clustering three markers into one (old)'; + +export const MarkerClustering = () => { + const clusteringIdentifier = 'id'; + const [selected, setSelected] = useState(null); + + const initialRegion: CoordinateRegion = useMemo(() => ({ + centerLatitude: 46.20738751546706, + centerLongitude: 6.155891756231, + latitudeDelta: 1, + longitudeDelta: 1, + }), []); + + const coordinates = [ + { latitude: 46.20738751546706, longitude: 6.155891756231 }, + { latitude: 46.25738751546706, longitude: 6.185891756231 }, + { latitude: 46.28738751546706, longitude: 6.2091756231 }, + ]; + + return ( + <> + + + + { + coordinates.map(({ latitude, longitude }, index) => ( + setSelected(index + 1)} + onDeselect={() => setSelected(null)} + collisionMode="Circle" + displayPriority={750} + key={index} + /> + )) + } + + + +
+
+

{selected ? `Selected marker #${selected}` : 'Not selected'}

+
+
+ + ); +}; + MarkerClustering.storyName = 'Clustering three markers into one'; function CustomCalloutElement({ diff --git a/src/util/forwardMapkitEvent.ts b/src/util/forwardMapkitEvent.ts index e1a9c47..b4217e3 100644 --- a/src/util/forwardMapkitEvent.ts +++ b/src/util/forwardMapkitEvent.ts @@ -17,7 +17,6 @@ export default function forwardMapkitEvent( ) { useEffect(() => { if (!element || !handler) return undefined; - // @ts-ignore const mapkitHandler = (e) => { handler(eventMap(e));