mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26: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:
Vendored
+6
-1
@@ -14,6 +14,11 @@
|
||||
"Rapide",
|
||||
"Rees",
|
||||
"sommes",
|
||||
"tournois"
|
||||
"spiderfied",
|
||||
"Spiderfy",
|
||||
"spiderified",
|
||||
"tournois",
|
||||
"unspiderfied",
|
||||
"unspiderfy"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,48 +16,205 @@ 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;
|
||||
};
|
||||
|
||||
export default function TournamentMap() {
|
||||
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
|
||||
const setMapBounds = useSetAtom(mapBoundsAtom);
|
||||
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
||||
|
||||
const onScrollToTable = () => {
|
||||
const tournamentTable = document.getElementById("tournament-table");
|
||||
tournamentTable?.scrollIntoView({ behavior: "smooth" });
|
||||
type TimeControlGroupProps = {
|
||||
timeControl: TimeControl;
|
||||
colour: string;
|
||||
};
|
||||
|
||||
const createLayerGroups = (
|
||||
timeControl: TimeControl,
|
||||
colour: string,
|
||||
tournaments: Tournament[]
|
||||
) => {
|
||||
const filteredTournaments = tournaments.filter(
|
||||
(t) => t.timeControl === timeControl
|
||||
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 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 (
|
||||
<TournamentMarker
|
||||
ref={(ref) => (markerRefs.current[tournament._id] = ref)}
|
||||
key={tournament._id}
|
||||
tournament={tournament}
|
||||
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(
|
||||
TimeControl.Classic,
|
||||
"green",
|
||||
tournaments
|
||||
export default function TournamentMap() {
|
||||
const setMapBounds = useSetAtom(mapBoundsAtom);
|
||||
const hoveredListTournamentId = useAtomValue(
|
||||
debouncedHoveredListTournamentIdAtom
|
||||
);
|
||||
const rapidMarkers = createLayerGroups(
|
||||
TimeControl.Rapid,
|
||||
"blue",
|
||||
tournaments
|
||||
);
|
||||
const blitzMarkers = createLayerGroups(
|
||||
TimeControl.Blitz,
|
||||
"yellow",
|
||||
tournaments
|
||||
);
|
||||
const otherMarkers = createLayerGroups(TimeControl.KO, "red", tournaments);
|
||||
|
||||
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 = () => {
|
||||
const tournamentTable = document.getElementById("tournament-table");
|
||||
tournamentTable?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export const {
|
||||
|
||||
export const {
|
||||
debouncedValueAtom: debouncedHoveredListTournamentIdAtom,
|
||||
} = atomWithDebounce<string | null>(null);
|
||||
} = atomWithDebounce<string | null>(null, 300, true);
|
||||
|
||||
export const filteredTournamentsByTimeControlAtom = atom((get) => {
|
||||
const tournaments = get(tournamentsAtom);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user