mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 12:36:57 +00:00
Clustering (#75)
* Clustering * Add package * Shift markers based on the time control to keep they separated visually
This commit is contained in:
@@ -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<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 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 (
|
||||
<TournamentMarker
|
||||
ref={(ref) => (markerRefs.current[tournament._id] = ref)}
|
||||
key={tournament._id}
|
||||
tournament={tournament}
|
||||
colour={colour}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const group = useMemo(
|
||||
() => (
|
||||
<LayerGroup>
|
||||
<MarkerClusterGroup
|
||||
ref={clusterRef}
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
</LayerGroup>
|
||||
),
|
||||
[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 (
|
||||
<TournamentMarker
|
||||
key={tournament._id}
|
||||
tournament={tournament}
|
||||
colour={colour}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return <LayerGroup>{layerGroup}</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 (
|
||||
<section
|
||||
id="tournament-map"
|
||||
@@ -111,10 +293,18 @@ export default function TournamentMap() {
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{classicalMarkers}
|
||||
{rapidMarkers}
|
||||
{blitzMarkers}
|
||||
{otherMarkers}
|
||||
{classic && (
|
||||
<TimeControlGroup timeControl={TimeControl.Classic} colour="green" />
|
||||
)}
|
||||
{rapid && (
|
||||
<TimeControlGroup timeControl={TimeControl.Rapid} colour="blue" />
|
||||
)}
|
||||
{blitz && (
|
||||
<TimeControlGroup timeControl={TimeControl.Blitz} colour="yellow" />
|
||||
)}
|
||||
{other && (
|
||||
<TimeControlGroup timeControl={TimeControl.KO} colour="red" />
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
<div className="flex items-center justify-center lg:hidden">
|
||||
|
||||
@@ -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<MarkerProps, "position">;
|
||||
|
||||
export const TournamentMarker = ({
|
||||
tournament,
|
||||
colour,
|
||||
...markerProps
|
||||
}: TournamentMarkerProps) => {
|
||||
export const TournamentMarker = forwardRef<
|
||||
L.Marker<any> | null,
|
||||
TournamentMarkerProps
|
||||
>(({ tournament, colour, ...markerProps }, ref) => {
|
||||
const t = useTranslations("Tournaments");
|
||||
const markerRef = useRef<L.Marker<any> | 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 (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
ref={ref}
|
||||
position={position.current}
|
||||
icon={iconOptions}
|
||||
eventHandlers={{
|
||||
@@ -108,4 +74,6 @@ export const TournamentMarker = ({
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
TournamentMarker.displayName = "TournamentMarker";
|
||||
|
||||
Reference in New Issue
Block a user