Clustering (#75)

* Clustering

* Add package

* Shift markers based on the time control to keep they separated visually
This commit is contained in:
Timothy Armes
2023-07-06 18:11:10 +02:00
committed by GitHub
parent 8318265c87
commit 0a2dfa8743
7 changed files with 339 additions and 108 deletions
+6 -1
View File
@@ -14,6 +14,11 @@
"Rapide", "Rapide",
"Rees", "Rees",
"sommes", "sommes",
"tournois" "spiderfied",
"Spiderfy",
"spiderified",
"tournois",
"unspiderfied",
"unspiderfy"
] ]
} }
+229 -39
View File
@@ -1,7 +1,10 @@
"use client"; "use client";
import { TimeControl, Tournament } from "@/types"; import { TimeControl } from "@/types";
import { LatLngLiteral } from "leaflet";
import { useMemo, useRef } from "react";
import L, { LatLngLiteral, Marker, DomUtil } from "leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import { import {
MapContainer, MapContainer,
TileLayer, TileLayer,
@@ -13,48 +16,205 @@ import { useAtomValue, useSetAtom } from "jotai";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "@/css/marker-cluster.css";
import "leaflet-defaulticon-compatibility"; import "leaflet-defaulticon-compatibility";
import "leaflet.smooth_marker_bouncing";
import { import {
filteredTournamentsByTimeControlAtom,
mapBoundsAtom, mapBoundsAtom,
debouncedHoveredListTournamentIdAtom,
tournamentsAtom,
classicAtom,
rapidAtom,
blitzAtom,
oneHourKOAtom,
} from "@/app/atoms"; } from "@/app/atoms";
import Legend from "./Legend"; import Legend from "./Legend";
import { TournamentMarker } from "./TournamentMarker"; import { TournamentMarker } from "./TournamentMarker";
import TimeControlFilters from "./TimeControlFilters"; 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 MapEvents = () => {
const setMapBounds = useSetAtom(mapBoundsAtom); const setMapBounds = useSetAtom(mapBoundsAtom);
const map = useMapEvent("moveend", () => { const map = useMapEvent("moveend", () => {
// Set the map bounds atoms when the user pans/zooms
setMapBounds(map.getBounds()); setMapBounds(map.getBounds());
}); });
return null; return null;
}; };
export default function TournamentMap() { type TimeControlGroupProps = {
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom); timeControl: TimeControl;
const setMapBounds = useSetAtom(mapBoundsAtom); colour: string;
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const onScrollToTable = () => {
const tournamentTable = document.getElementById("tournament-table");
tournamentTable?.scrollIntoView({ behavior: "smooth" });
}; };
const createLayerGroups = ( const stopBouncingMarkers = () => {
timeControl: TimeControl, const markers =
colour: string, // @ts-ignore
tournaments: Tournament[] Marker.prototype._orchestration.getBouncingMarkers() as BouncingMarker[];
) => {
const filteredTournaments = tournaments.filter( markers.forEach((marker) => {
(t) => t.timeControl === timeControl 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<Record<string, L.Marker<any> | null>>({});
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
const filteredTournaments = useMemo(
() => tournaments.filter((t) => t.timeControl === timeControl),
[timeControl, tournaments]
); );
const layerGroup = filteredTournaments.map((tournament) => { 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: "<div><span>" + childCount + "</span></div>",
className: `marker-cluster marker-cluster-${timeControl}`,
iconSize: new L.Point(40, 40),
});
},
[timeControl]
);
const markers = filteredTournaments.map((tournament) => {
return ( return (
<TournamentMarker <TournamentMarker
ref={(ref) => (markerRefs.current[tournament._id] = ref)}
key={tournament._id} key={tournament._id}
tournament={tournament} tournament={tournament}
colour={colour} colour={colour}
@@ -62,25 +222,47 @@ export default function TournamentMap() {
); );
}); });
return <LayerGroup>{layerGroup}</LayerGroup>; const group = useMemo(
() => (
<LayerGroup>
<MarkerClusterGroup
ref={clusterRef}
chunkedLoading
iconCreateFunction={createClusterCustomIcon}
>
{markers}
</MarkerClusterGroup>
</LayerGroup>
),
[createClusterCustomIcon, markers]
);
return group;
}; };
const classicalMarkers = createLayerGroups( export default function TournamentMap() {
TimeControl.Classic, const setMapBounds = useSetAtom(mapBoundsAtom);
"green", const hoveredListTournamentId = useAtomValue(
tournaments debouncedHoveredListTournamentIdAtom
); );
const rapidMarkers = createLayerGroups(
TimeControl.Rapid, const classic = useAtomValue(classicAtom);
"blue", const rapid = useAtomValue(rapidAtom);
tournaments const blitz = useAtomValue(blitzAtom);
); const other = useAtomValue(oneHourKOAtom);
const blitzMarkers = createLayerGroups(
TimeControl.Blitz, useEffect(() => {
"yellow", if (hoveredListTournamentId === null) {
tournaments stopBouncingMarkers();
); }
const otherMarkers = createLayerGroups(TimeControl.KO, "red", tournaments); }, [hoveredListTournamentId]);
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const onScrollToTable = () => {
const tournamentTable = document.getElementById("tournament-table");
tournamentTable?.scrollIntoView({ behavior: "smooth" });
};
return ( return (
<section <section
@@ -111,10 +293,18 @@ export default function TournamentMap() {
/> />
<Legend /> <Legend />
{classicalMarkers} {classic && (
{rapidMarkers} <TimeControlGroup timeControl={TimeControl.Classic} colour="green" />
{blitzMarkers} )}
{otherMarkers} {rapid && (
<TimeControlGroup timeControl={TimeControl.Rapid} colour="blue" />
)}
{blitz && (
<TimeControlGroup timeControl={TimeControl.Blitz} colour="yellow" />
)}
{other && (
<TimeControlGroup timeControl={TimeControl.KO} colour="red" />
)}
</MapContainer> </MapContainer>
<div className="flex items-center justify-center lg:hidden"> <div className="flex items-center justify-center lg:hidden">
+27 -59
View File
@@ -1,77 +1,43 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { forwardRef, useMemo, useRef } from "react";
import { Tournament } from "@/types"; import { TimeControl, Tournament } from "@/types";
import L, { LatLngLiteral, DomUtil } from "leaflet"; import L from "leaflet";
import { Marker, Popup, MarkerProps } from "react-leaflet"; import { Marker, Popup, MarkerProps } from "react-leaflet";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAtomValue, useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import "leaflet.smooth_marker_bouncing";
import { import { debouncedHoveredMapTournamentIdAtom } from "@/app/atoms";
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(),
};
};
type TournamentMarkerProps = { type TournamentMarkerProps = {
tournament: Tournament; tournament: Tournament;
colour: string; colour: string;
} & Omit<MarkerProps, "position">; } & Omit<MarkerProps, "position">;
export const TournamentMarker = ({ export const TournamentMarker = forwardRef<
tournament, L.Marker<any> | null,
colour, TournamentMarkerProps
...markerProps >(({ tournament, colour, ...markerProps }, ref) => {
}: TournamentMarkerProps) => {
const t = useTranslations("Tournaments"); const t = useTranslations("Tournaments");
const markerRef = useRef<L.Marker<any> | null>(null);
const position = useRef(coordinateRandomisation(tournament.latLng));
const hoveredListTournamentId = useAtomValue( // We add shifts based on the time control, so that they don't hide each other
debouncedHoveredListTournamentIdAtom 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( const setHoveredMapTournamentId = useSetAtom(
debouncedHoveredMapTournamentIdAtom 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( const iconOptions = useMemo(
() => () =>
new L.Icon({ new L.Icon({
@@ -87,7 +53,7 @@ export const TournamentMarker = ({
return ( return (
<Marker <Marker
ref={markerRef} ref={ref}
position={position.current} position={position.current}
icon={iconOptions} icon={iconOptions}
eventHandlers={{ eventHandlers={{
@@ -108,4 +74,6 @@ export const TournamentMarker = ({
</Popup> </Popup>
</Marker> </Marker>
); );
}; });
TournamentMarker.displayName = "TournamentMarker";
+1 -1
View File
@@ -21,7 +21,7 @@ export const {
export const { export const {
debouncedValueAtom: debouncedHoveredListTournamentIdAtom, debouncedValueAtom: debouncedHoveredListTournamentIdAtom,
} = atomWithDebounce<string | null>(null); } = atomWithDebounce<string | null>(null, 300, true);
export const filteredTournamentsByTimeControlAtom = atom((get) => { export const filteredTournamentsByTimeControlAtom = atom((get) => {
const tournaments = get(tournamentsAtom); const tournaments = get(tournamentsAtom);
+31
View File
@@ -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);
}
+34
View File
@@ -19,6 +19,7 @@
"jotai": "^2.2.1", "jotai": "^2.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1", "leaflet-defaulticon-compatibility": "^0.1.1",
"leaflet.markercluster": "^1.5.3",
"leaflet.smooth_marker_bouncing": "^3.0.3", "leaflet.smooth_marker_bouncing": "^3.0.3",
"mongodb": "^5.6.0", "mongodb": "^5.6.0",
"next": "^13.4.7", "next": "^13.4.7",
@@ -29,6 +30,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.10.1", "react-icons": "^4.10.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"tailwind-merge": "^1.13.2", "tailwind-merge": "^1.13.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
@@ -39,6 +41,7 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/leaflet": "^1.9.3", "@types/leaflet": "^1.9.3",
"@types/leaflet.markercluster": "^1.5.1",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.8",
"cypress": "^12.16.0", "cypress": "^12.16.0",
"eslint-plugin-cypress": "^2.13.3" "eslint-plugin-cypress": "^2.13.3"
@@ -923,6 +926,15 @@
"@types/geojson": "*" "@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": { "node_modules/@types/node": {
"version": "20.3.2", "version": "20.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", "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", "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.1.tgz",
"integrity": "sha512-vDBFdlUAwjSEGep9ih8kfJilf6yN8V9zTbF5NC/1ZwLeGko3RUQepspPnGCRMFV51dY3Lb3hziboicrFz+rxQA==" "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": { "node_modules/leaflet.smooth_marker_bouncing": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/leaflet.smooth_marker_bouncing/-/leaflet.smooth_marker_bouncing-3.0.3.tgz", "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" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+3
View File
@@ -24,6 +24,7 @@
"jotai": "^2.2.1", "jotai": "^2.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1", "leaflet-defaulticon-compatibility": "^0.1.1",
"leaflet.markercluster": "^1.5.3",
"leaflet.smooth_marker_bouncing": "^3.0.3", "leaflet.smooth_marker_bouncing": "^3.0.3",
"mongodb": "^5.6.0", "mongodb": "^5.6.0",
"next": "^13.4.7", "next": "^13.4.7",
@@ -34,6 +35,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.10.1", "react-icons": "^4.10.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"tailwind-merge": "^1.13.2", "tailwind-merge": "^1.13.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
@@ -44,6 +46,7 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/leaflet": "^1.9.3", "@types/leaflet": "^1.9.3",
"@types/leaflet.markercluster": "^1.5.1",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.8",
"cypress": "^12.16.0", "cypress": "^12.16.0",
"eslint-plugin-cypress": "^2.13.3" "eslint-plugin-cypress": "^2.13.3"