From 0a2dfa87437182e3905181774b3c39d2fb0c9379 Mon Sep 17 00:00:00 2001 From: Timothy Armes Date: Thu, 6 Jul 2023 18:11:10 +0200 Subject: [PATCH] Clustering (#75) * Clustering * Add package * Shift markers based on the time control to keep they separated visually --- .vscode/settings.json | 7 +- app/[lang]/tournois/TournamentMap.tsx | 284 +++++++++++++++++++---- app/[lang]/tournois/TournamentMarker.tsx | 86 +++---- app/atoms.ts | 2 +- css/marker-cluster.css | 31 +++ package-lock.json | 34 +++ package.json | 3 + 7 files changed, 339 insertions(+), 108 deletions(-) create mode 100644 css/marker-cluster.css diff --git a/.vscode/settings.json b/.vscode/settings.json index f4fbe17..76ffeb7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,11 @@ "Rapide", "Rees", "sommes", - "tournois" + "spiderfied", + "Spiderfy", + "spiderified", + "tournois", + "unspiderfied", + "unspiderfy" ] } diff --git a/app/[lang]/tournois/TournamentMap.tsx b/app/[lang]/tournois/TournamentMap.tsx index ae7e24f..2380ba5 100644 --- a/app/[lang]/tournois/TournamentMap.tsx +++ b/app/[lang]/tournois/TournamentMap.tsx @@ -1,7 +1,10 @@ "use client"; -import { TimeControl, Tournament } from "@/types"; -import { LatLngLiteral } from "leaflet"; +import { TimeControl } from "@/types"; + +import { useMemo, useRef } from "react"; +import L, { LatLngLiteral, Marker, DomUtil } from "leaflet"; +import MarkerClusterGroup from "react-leaflet-cluster"; import { MapContainer, TileLayer, @@ -13,29 +16,247 @@ import { useAtomValue, useSetAtom } from "jotai"; import "leaflet/dist/leaflet.css"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; +import "@/css/marker-cluster.css"; import "leaflet-defaulticon-compatibility"; +import "leaflet.smooth_marker_bouncing"; import { - filteredTournamentsByTimeControlAtom, mapBoundsAtom, + debouncedHoveredListTournamentIdAtom, + tournamentsAtom, + classicAtom, + rapidAtom, + blitzAtom, + oneHourKOAtom, } from "@/app/atoms"; import Legend from "./Legend"; import { TournamentMarker } from "./TournamentMarker"; import TimeControlFilters from "./TimeControlFilters"; +import { useCallback, useEffect } from "react"; + +// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing +// to keep Typescript happy +declare class BouncingMarker extends Marker { + isBouncing(): boolean; + bounce(): void; + stopBouncing(): void; + + _icon: HTMLElement; + _bouncingMotion?: { + bouncingAnimationPlaying: boolean; + }; +} const MapEvents = () => { const setMapBounds = useSetAtom(mapBoundsAtom); const map = useMapEvent("moveend", () => { + // Set the map bounds atoms when the user pans/zooms setMapBounds(map.getBounds()); }); return null; }; +type TimeControlGroupProps = { + timeControl: TimeControl; + colour: string; +}; + +const stopBouncingMarkers = () => { + const markers = + // @ts-ignore + Marker.prototype._orchestration.getBouncingMarkers() as BouncingMarker[]; + + markers.forEach((marker) => { + if (marker.isBouncing()) { + try { + marker.stopBouncing(); + // The plugin keeps bouncing until the end of the animation. We want to stop it immediately + // An issue has been raised on the project (https://github.com/hosuaby/Leaflet.SmoothMarkerBouncing/issues/52), until then we have this hack. + // We remove the class and reset some internal state + + DomUtil.removeClass(marker._icon, "bouncing"); + if (marker?._bouncingMotion?.bouncingAnimationPlaying === true) + marker._bouncingMotion.bouncingAnimationPlaying = false; + } catch {} + } + }); +}; + +const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => { + const tournaments = useAtomValue(tournamentsAtom); + const markerRefs = useRef | null>>({}); + const clusterRef = useRef(null); + const expandedClusterMarkerRef = useRef(null); + + const filteredTournaments = useMemo( + () => tournaments.filter((t) => t.timeControl === timeControl), + [timeControl, tournaments] + ); + + const hoveredListTournamentId = useAtomValue( + debouncedHoveredListTournamentIdAtom + ); + + const expandAndBounceIfNeeded = useCallback(() => { + if (hoveredListTournamentId) { + const markerRef = markerRefs.current[hoveredListTournamentId]; + if (markerRef) { + if (clusterRef.current) { + const visibleMarker = clusterRef.current.getVisibleParent(markerRef); + + // @ts-ignore + if (visibleMarker.__proto__ === L.MarkerCluster.prototype) { + // This is a cluster icon, we expand it. + const clusterMarker = visibleMarker as L.MarkerCluster; + + if ( + expandedClusterMarkerRef.current && + expandedClusterMarkerRef.current !== clusterMarker + ) { + expandedClusterMarkerRef.current.unspiderfy(); + } + + clusterMarker.spiderfy(); + + // Sometimes there marker that's still bouncing from the last time the group was expanded. + // We stop it quickly. + + setTimeout(() => { + stopBouncingMarkers(); + }, 50); + } else { + // This is a standard marker, we bounce it. + const marker = visibleMarker as BouncingMarker; + if (!marker.isBouncing()) { + stopBouncingMarkers(); + + marker.bounce(); + } + } + + return true; + } + } + } + + return false; + }, [hoveredListTournamentId]); + + const onSpiderified = useCallback( + (e: L.MarkerClusterSpiderfyEvent) => { + // Once expanded, bounce the appropriate marker + + if (hoveredListTournamentId) { + expandedClusterMarkerRef.current = e.cluster; + const markerRef = markerRefs.current[ + hoveredListTournamentId + ] as BouncingMarker; + + if (markerRef && e.markers.includes(markerRef)) { + stopBouncingMarkers(); + markerRef.bounce(); + } + } + }, + [hoveredListTournamentId] + ); + + const onUnSpiderified = useCallback( + (e: L.MarkerClusterSpiderfyEvent) => { + if (expandedClusterMarkerRef.current === e.cluster) + expandedClusterMarkerRef.current = null; + + // Once closed, we can expand the next group if needed + expandAndBounceIfNeeded(); + }, + [expandAndBounceIfNeeded] + ); + + useEffect(() => { + const ref = clusterRef.current; + + if (clusterRef.current) { + clusterRef.current.on("spiderfied", onSpiderified); + clusterRef.current.on("unspiderfied", onUnSpiderified); + } + + return () => { + if (ref) { + ref.off("spiderfied", onSpiderified); + ref.off("unspiderfied", onUnSpiderified); + } + }; + }, [onSpiderified, onUnSpiderified]); + + useEffect(() => { + // Expand/contract as hoveredListTournamentId changes + if (expandAndBounceIfNeeded()) return; + + if (expandedClusterMarkerRef.current) + expandedClusterMarkerRef.current.unspiderfy(); + }, [expandAndBounceIfNeeded, hoveredListTournamentId]); + + const createClusterCustomIcon = useCallback( + (cluster: any) => { + let childCount = cluster.getChildCount(); + + return new L.DivIcon({ + html: "
" + childCount + "
", + className: `marker-cluster marker-cluster-${timeControl}`, + iconSize: new L.Point(40, 40), + }); + }, + [timeControl] + ); + + const markers = filteredTournaments.map((tournament) => { + return ( + (markerRefs.current[tournament._id] = ref)} + key={tournament._id} + tournament={tournament} + colour={colour} + /> + ); + }); + + const group = useMemo( + () => ( + + + {markers} + + + ), + [createClusterCustomIcon, markers] + ); + + return group; +}; + export default function TournamentMap() { - const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom); const setMapBounds = useSetAtom(mapBoundsAtom); + const hoveredListTournamentId = useAtomValue( + debouncedHoveredListTournamentIdAtom + ); + + const classic = useAtomValue(classicAtom); + const rapid = useAtomValue(rapidAtom); + const blitz = useAtomValue(blitzAtom); + const other = useAtomValue(oneHourKOAtom); + + useEffect(() => { + if (hoveredListTournamentId === null) { + stopBouncingMarkers(); + } + }, [hoveredListTournamentId]); + const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; const onScrollToTable = () => { @@ -43,45 +264,6 @@ export default function TournamentMap() { tournamentTable?.scrollIntoView({ behavior: "smooth" }); }; - const createLayerGroups = ( - timeControl: TimeControl, - colour: string, - tournaments: Tournament[] - ) => { - const filteredTournaments = tournaments.filter( - (t) => t.timeControl === timeControl - ); - - const layerGroup = filteredTournaments.map((tournament) => { - return ( - - ); - }); - - return {layerGroup}; - }; - - const classicalMarkers = createLayerGroups( - TimeControl.Classic, - "green", - tournaments - ); - const rapidMarkers = createLayerGroups( - TimeControl.Rapid, - "blue", - tournaments - ); - const blitzMarkers = createLayerGroups( - TimeControl.Blitz, - "yellow", - tournaments - ); - const otherMarkers = createLayerGroups(TimeControl.KO, "red", tournaments); - return (
- {classicalMarkers} - {rapidMarkers} - {blitzMarkers} - {otherMarkers} + {classic && ( + + )} + {rapid && ( + + )} + {blitz && ( + + )} + {other && ( + + )}
diff --git a/app/[lang]/tournois/TournamentMarker.tsx b/app/[lang]/tournois/TournamentMarker.tsx index 7d295ca..b50d0d0 100644 --- a/app/[lang]/tournois/TournamentMarker.tsx +++ b/app/[lang]/tournois/TournamentMarker.tsx @@ -1,77 +1,43 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; -import { Tournament } from "@/types"; -import L, { LatLngLiteral, DomUtil } from "leaflet"; +import { forwardRef, useMemo, useRef } from "react"; +import { TimeControl, Tournament } from "@/types"; +import L from "leaflet"; import { Marker, Popup, MarkerProps } from "react-leaflet"; import { useTranslations } from "next-intl"; -import { useAtomValue, useSetAtom } from "jotai"; -import "leaflet.smooth_marker_bouncing"; +import { useSetAtom } from "jotai"; -import { - debouncedHoveredListTournamentIdAtom, - debouncedHoveredMapTournamentIdAtom, -} from "@/app/atoms"; - -const coordinateRandomisation = (latLng: LatLngLiteral): LatLngLiteral => { - const randomisation = () => Math.random() * (-0.01 - 0.01) + 0.01; - return { - lat: latLng.lat + randomisation(), - lng: latLng.lng + randomisation(), - }; -}; +import { debouncedHoveredMapTournamentIdAtom } from "@/app/atoms"; type TournamentMarkerProps = { tournament: Tournament; colour: string; } & Omit; -export const TournamentMarker = ({ - tournament, - colour, - ...markerProps -}: TournamentMarkerProps) => { +export const TournamentMarker = forwardRef< + L.Marker | null, + TournamentMarkerProps +>(({ tournament, colour, ...markerProps }, ref) => { const t = useTranslations("Tournaments"); - const markerRef = useRef | null>(null); - const position = useRef(coordinateRandomisation(tournament.latLng)); - const hoveredListTournamentId = useAtomValue( - debouncedHoveredListTournamentIdAtom - ); + // We add shifts based on the time control, so that they don't hide each other + const position = useRef({ + lat: tournament.latLng.lat, + lng: + tournament.latLng.lng + + (tournament.timeControl === TimeControl.Rapid + ? -0.01 + : tournament.timeControl === TimeControl.Blitz + ? 0.01 + : tournament.timeControl === TimeControl.KO + ? 0.02 + : 0), + }); + const setHoveredMapTournamentId = useSetAtom( debouncedHoveredMapTournamentIdAtom ); - useEffect(() => { - if (!markerRef.current) return; - if (hoveredListTournamentId === tournament._id) { - // @ts-ignore (the various bounce commands come from leaflet.smooth_marker_bouncing and aren't defined by the Typescript definitions) - markerRef.current.setBouncingOptions({ exclusive: true }); - - // @ts-ignore - markerRef.current.bounce(); - } else { - // @ts-ignore - if (markerRef.current.isBouncing()) { - // @ts-ignore - markerRef.current.stopBouncing(); - - // The plugin keeps bouncing until the end of the animation. We want to stop it immediately - // An issue has been raised on the project (https://github.com/hosuaby/Leaflet.SmoothMarkerBouncing/issues/52), until then we have this hack. - // We remove the class and reset some internal state - // @ts-ignore - DomUtil.removeClass(markerRef.current._icon, "bouncing"); - - if ( - // @ts-ignore - markerRef.current?._bouncingMotion?.bouncingAnimationPlaying === true - ) - // @ts-ignore - markerRef.current._bouncingMotion.bouncingAnimationPlaying = false; - } - } - }, [hoveredListTournamentId, tournament._id]); - const iconOptions = useMemo( () => new L.Icon({ @@ -87,7 +53,7 @@ export const TournamentMarker = ({ return ( ); -}; +}); + +TournamentMarker.displayName = "TournamentMarker"; diff --git a/app/atoms.ts b/app/atoms.ts index ebc5657..c27e431 100644 --- a/app/atoms.ts +++ b/app/atoms.ts @@ -21,7 +21,7 @@ export const { export const { debouncedValueAtom: debouncedHoveredListTournamentIdAtom, -} = atomWithDebounce(null); +} = atomWithDebounce(null, 300, true); export const filteredTournamentsByTimeControlAtom = atom((get) => { const tournaments = get(tournamentsAtom); diff --git a/css/marker-cluster.css b/css/marker-cluster.css new file mode 100644 index 0000000..ca08d45 --- /dev/null +++ b/css/marker-cluster.css @@ -0,0 +1,31 @@ +.marker-cluster-Classic { + background-color: rgba(145, 231, 135, 0.6); +} + +.marker-cluster-Classic div { + background-color: rgba(36, 171, 33, 0.6) +} + +.marker-cluster-Rapid { + background-color: rgba(85, 150, 201, 0.6) +} + +.marker-cluster-Rapid div { + background-color: rgba(39, 128, 201, 0.6) +} + +.marker-cluster-Blitz { + background-color: rgba(217, 211, 84, 0.6); +} + +.marker-cluster-Blitz div { + background-color: rgba(202, 196, 39, 0.6) +} + +.marker-cluster-KO { + background-color: rgba(211, 67, 84, 0.6); +} + +.marker-cluster-KO div { + background-color: rgba(203, 41, 60, 0.6); +} diff --git a/package-lock.json b/package-lock.json index cc4147f..8b13acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "jotai": "^2.2.1", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.1", + "leaflet.markercluster": "^1.5.3", "leaflet.smooth_marker_bouncing": "^3.0.3", "mongodb": "^5.6.0", "next": "^13.4.7", @@ -29,6 +30,7 @@ "react-dom": "^18.2.0", "react-icons": "^4.10.1", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "sharp": "^0.32.1", "tailwind-merge": "^1.13.2", "tailwindcss": "3.3.2", @@ -39,6 +41,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", + "@types/leaflet.markercluster": "^1.5.1", "@types/nodemailer": "^6.4.8", "cypress": "^12.16.0", "eslint-plugin-cypress": "^2.13.3" @@ -923,6 +926,15 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.1.tgz", + "integrity": "sha512-gzJzP10qO6Zkts5QNVmSAEDLYicQHTEBLT9HZpFrJiSww9eDAs5OWHvIskldf41MvDv1gbMukuEBQEawHn+wtA==", + "dev": true, + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/node": { "version": "20.3.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", @@ -4584,6 +4596,14 @@ "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.1.tgz", "integrity": "sha512-vDBFdlUAwjSEGep9ih8kfJilf6yN8V9zTbF5NC/1ZwLeGko3RUQepspPnGCRMFV51dY3Lb3hziboicrFz+rxQA==" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leaflet.smooth_marker_bouncing": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/leaflet.smooth_marker_bouncing/-/leaflet.smooth_marker_bouncing-3.0.3.tgz", @@ -5817,6 +5837,20 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz", + "integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-leaflet": "^4.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 0b05154..70e29f8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "jotai": "^2.2.1", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.1", + "leaflet.markercluster": "^1.5.3", "leaflet.smooth_marker_bouncing": "^3.0.3", "mongodb": "^5.6.0", "next": "^13.4.7", @@ -34,6 +35,7 @@ "react-dom": "^18.2.0", "react-icons": "^4.10.1", "react-leaflet": "^4.2.1", + "react-leaflet-cluster": "^2.1.0", "sharp": "^0.32.1", "tailwind-merge": "^1.13.2", "tailwindcss": "3.3.2", @@ -44,6 +46,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", + "@types/leaflet.markercluster": "^1.5.1", "@types/nodemailer": "^6.4.8", "cypress": "^12.16.0", "eslint-plugin-cypress": "^2.13.3"