mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Group tournaments into a single map marker based on position & date (#105)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { TimeControl } from "@/types";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useCallback, useEffect } from "react";
|
||||
import L, { LatLngLiteral, Marker, DomUtil } from "leaflet";
|
||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "react-leaflet";
|
||||
import { FaAngleDoubleDown } from "react-icons/fa";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { groupBy } from "lodash";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
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
|
||||
@@ -108,16 +108,24 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => {
|
||||
[normsOnly, timeControl, tournaments],
|
||||
);
|
||||
|
||||
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
|
||||
|
||||
const hoveredListTournamentId = useAtomValue(
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
);
|
||||
|
||||
const expandAndBounceIfNeeded = useCallback(() => {
|
||||
if (hoveredListTournamentId) {
|
||||
const markerRef = markerRefs.current[hoveredListTournamentId];
|
||||
const tournament = filteredTournaments.find(
|
||||
(t) => t.id === hoveredListTournamentId,
|
||||
);
|
||||
if (!tournament) return false;
|
||||
|
||||
const markerRef = markerRefs.current[tournament.groupId];
|
||||
if (markerRef) {
|
||||
if (clusterRef.current) {
|
||||
const visibleMarker = clusterRef.current.getVisibleParent(markerRef);
|
||||
if (!visibleMarker) return;
|
||||
|
||||
// @ts-ignore
|
||||
if (visibleMarker.__proto__ === L.MarkerCluster.prototype) {
|
||||
@@ -155,16 +163,21 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => {
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [hoveredListTournamentId]);
|
||||
}, [filteredTournaments, hoveredListTournamentId]);
|
||||
|
||||
const onSpiderified = useCallback(
|
||||
(e: L.MarkerClusterSpiderfyEvent) => {
|
||||
// Once expanded, bounce the appropriate marker
|
||||
|
||||
if (hoveredListTournamentId) {
|
||||
const tournament = filteredTournaments.find(
|
||||
(t) => t.id === hoveredListTournamentId,
|
||||
);
|
||||
if (!tournament) return;
|
||||
|
||||
expandedClusterMarkerRef.current = e.cluster;
|
||||
const markerRef = markerRefs.current[
|
||||
hoveredListTournamentId
|
||||
tournament.groupId
|
||||
] as BouncingMarker;
|
||||
|
||||
if (markerRef && e.markers.includes(markerRef)) {
|
||||
@@ -173,7 +186,7 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[hoveredListTournamentId],
|
||||
[filteredTournaments, hoveredListTournamentId],
|
||||
);
|
||||
|
||||
const onUnSpiderified = useCallback(
|
||||
@@ -224,12 +237,12 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => {
|
||||
[timeControl],
|
||||
);
|
||||
|
||||
const markers = filteredTournaments.map((tournament) => {
|
||||
const markers = Object.values(groupedTournaments).map((tournamentGroup) => {
|
||||
return (
|
||||
<TournamentMarker
|
||||
ref={(ref) => (markerRefs.current[tournament._id] = ref)}
|
||||
key={tournament._id}
|
||||
tournament={tournament}
|
||||
ref={(ref) => (markerRefs.current[tournamentGroup[0].groupId] = ref)}
|
||||
key={tournamentGroup[0].groupId}
|
||||
tournamentGroup={tournamentGroup}
|
||||
colour={colour}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,36 +7,39 @@ import { Marker, Popup, MarkerProps } from "react-leaflet";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { FaTrophy } from "react-icons/fa";
|
||||
import { last } from "lodash";
|
||||
|
||||
import { debouncedHoveredMapTournamentIdAtom } from "@/app/atoms";
|
||||
import { debouncedHoveredMapTournamentGroupIdAtom } from "@/app/atoms";
|
||||
|
||||
type TournamentMarkerProps = {
|
||||
tournament: Tournament;
|
||||
tournamentGroup: Tournament[];
|
||||
colour: string;
|
||||
} & Omit<MarkerProps, "position">;
|
||||
|
||||
export const TournamentMarker = forwardRef<
|
||||
L.Marker<any> | null,
|
||||
TournamentMarkerProps
|
||||
>(({ tournament, colour, ...markerProps }, ref) => {
|
||||
>(({ tournamentGroup, colour, ...markerProps }, ref) => {
|
||||
const t = useTranslations("Tournaments");
|
||||
|
||||
const baseTournament = tournamentGroup[0];
|
||||
|
||||
// We add shifts based on the time control, so that they don't hide each other
|
||||
const position = useRef({
|
||||
lat: tournament.latLng.lat,
|
||||
lat: baseTournament.latLng.lat,
|
||||
lng:
|
||||
tournament.latLng.lng +
|
||||
(tournament.timeControl === TimeControl.Rapid
|
||||
baseTournament.latLng.lng +
|
||||
(baseTournament.timeControl === TimeControl.Rapid
|
||||
? -0.01
|
||||
: tournament.timeControl === TimeControl.Blitz
|
||||
: baseTournament.timeControl === TimeControl.Blitz
|
||||
? 0.01
|
||||
: tournament.timeControl === TimeControl.Other
|
||||
: baseTournament.timeControl === TimeControl.Other
|
||||
? 0.02
|
||||
: 0),
|
||||
});
|
||||
|
||||
const setHoveredMapTournamentId = useSetAtom(
|
||||
debouncedHoveredMapTournamentIdAtom,
|
||||
const setHoveredMapTournamentGroupId = useSetAtom(
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
|
||||
const iconOptions = useMemo(
|
||||
@@ -52,30 +55,50 @@ export const TournamentMarker = forwardRef<
|
||||
[colour],
|
||||
);
|
||||
|
||||
const startDate = baseTournament.date;
|
||||
const endDate = last(tournamentGroup)!.date;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={ref}
|
||||
position={position.current}
|
||||
icon={iconOptions}
|
||||
eventHandlers={{
|
||||
mouseover: () => setHoveredMapTournamentId(tournament._id),
|
||||
mouseout: () => setHoveredMapTournamentId(null),
|
||||
mouseover: () =>
|
||||
setHoveredMapTournamentGroupId(
|
||||
`${baseTournament.groupId}_${baseTournament.timeControl}`,
|
||||
),
|
||||
mouseout: () => setHoveredMapTournamentGroupId(null),
|
||||
}}
|
||||
{...markerProps}
|
||||
>
|
||||
<Popup>
|
||||
<div className="flex flex-col gap-3">
|
||||
<b>{tournament.date}</b>
|
||||
<Popup maxWidth={10000}>
|
||||
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
|
||||
<b>
|
||||
{baseTournament.date}
|
||||
{endDate !== startDate && ` - ${endDate}`}
|
||||
</b>
|
||||
|
||||
<a href={tournament.url} target="_blank" rel="noopener noreferrer">
|
||||
{tournament.tournament}
|
||||
</a>
|
||||
{tournament.norm && (
|
||||
<div className="flex flex-col gap-0">
|
||||
{tournamentGroup.map((tournament) => (
|
||||
<a
|
||||
key={tournament.id}
|
||||
href={tournament.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tournament.tournament}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tournamentGroup.some((t) => t.norm) && (
|
||||
<div className="flex items-center">
|
||||
<FaTrophy className="mr-3 h-4 w-4" />
|
||||
{t("norm")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t("approx")}
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
filteredTournamentsListAtom,
|
||||
syncVisibleAtom,
|
||||
normsOnlyAtom,
|
||||
hoveredMapTournamentIdAtom,
|
||||
debouncedHoveredMapTournamentIdAtom,
|
||||
hoveredMapTournamentGroupIdAtom,
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
} from "@/app/atoms";
|
||||
import { useBreakpoint } from "@/hooks/tailwind";
|
||||
@@ -28,9 +28,11 @@ export default function TournamentTable() {
|
||||
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
|
||||
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
|
||||
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
|
||||
const hoveredMapTournamentId = useAtomValue(hoveredMapTournamentIdAtom);
|
||||
const debouncedHoveredMapTournamentId = useAtomValue(
|
||||
debouncedHoveredMapTournamentIdAtom,
|
||||
const hoveredMapTournamentGroupId = useAtomValue(
|
||||
hoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
const debouncedHoveredMapTournamentGroupId = useAtomValue(
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
const setHoveredListTournamentId = useSetAtom(
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
@@ -39,14 +41,13 @@ export default function TournamentTable() {
|
||||
const isLg = useBreakpoint("lg");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLg || debouncedHoveredMapTournamentId === null) return;
|
||||
|
||||
const tournamentRow = document.getElementById(
|
||||
debouncedHoveredMapTournamentId,
|
||||
if (!isLg || debouncedHoveredMapTournamentGroupId === null) return;
|
||||
const tournamentRow = document.querySelector(
|
||||
`[data-group-id="${debouncedHoveredMapTournamentGroupId}"]`,
|
||||
);
|
||||
|
||||
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [debouncedHoveredMapTournamentId, isLg]);
|
||||
}, [debouncedHoveredMapTournamentGroupId, isLg]);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -118,13 +119,15 @@ export default function TournamentTable() {
|
||||
) : (
|
||||
filteredTournaments.map((tournament) => (
|
||||
<tr
|
||||
key={tournament._id}
|
||||
id={tournament._id}
|
||||
onMouseEnter={() => setHoveredListTournamentId(tournament._id)}
|
||||
key={tournament.id}
|
||||
id={tournament.id}
|
||||
data-group-id={`${tournament.groupId}_${tournament.timeControl}`}
|
||||
onMouseEnter={() => setHoveredListTournamentId(tournament.id)}
|
||||
onMouseLeave={() => setHoveredListTournamentId(null)}
|
||||
className={twMerge(
|
||||
"scroll-m-20 bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900",
|
||||
hoveredMapTournamentId === tournament._id &&
|
||||
hoveredMapTournamentGroupId ===
|
||||
`${tournament.groupId}_${tournament.timeControl}` &&
|
||||
"bg-gray-200 dark:bg-gray-900",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import clientPromise from "@/lib/mongodb";
|
||||
|
||||
import { errorLog } from "@/utils/logger";
|
||||
import { Tournament, TimeControl } from "@/types";
|
||||
import { groupBy } from "lodash";
|
||||
import { parse, differenceInDays, isSameDay } from "date-fns";
|
||||
|
||||
import TournamentsDisplay from "./TournamentsDisplay";
|
||||
import { ObjectId } from "mongodb";
|
||||
@@ -54,13 +56,61 @@ const getTournaments = async () => {
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return data.map<Tournament>((t) => ({
|
||||
...t,
|
||||
_id: t._id.toString(),
|
||||
timeControl: tcMap[t.time_control] ?? TimeControl.Other,
|
||||
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
|
||||
norm: t.norm_tournament,
|
||||
}));
|
||||
// Group the tournaments by their location
|
||||
const groupedByLocation = groupBy(
|
||||
data,
|
||||
(t) => `${t.coordinates[0]}_${t.coordinates[1]}`,
|
||||
);
|
||||
|
||||
// For each location, create an array of arrays of contiguous dates for this location
|
||||
const dateRangesByLocation: Record<string, Date[][]> = {};
|
||||
for (const location in groupedByLocation) {
|
||||
const tournaments = groupedByLocation[location];
|
||||
|
||||
// Note that this works since the tournaments are sorted by date
|
||||
const dateRanges = tournaments.reduce<Date[][]>(
|
||||
(acc, tournament) => {
|
||||
const group = acc[acc.length - 1];
|
||||
const date = parse(tournament.date, "dd/MM/yyyy", new Date());
|
||||
const diff = differenceInDays(date, group[group.length - 1] ?? date);
|
||||
if (diff > 1) {
|
||||
acc.push([date]);
|
||||
} else if (group.length === 0 || diff === 1) {
|
||||
group.push(date);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[[]],
|
||||
);
|
||||
|
||||
dateRangesByLocation[location] = dateRanges;
|
||||
}
|
||||
|
||||
return data.map<Tournament>((t) => {
|
||||
const location = `${t.coordinates[0]}_${t.coordinates[1]}`;
|
||||
const date = parse(t.date, "dd/MM/yyyy", new Date());
|
||||
const dateRanges = dateRangesByLocation[location];
|
||||
const rangeIndex = dateRanges.findIndex((ranges) =>
|
||||
ranges.some((d) => isSameDay(d, date)),
|
||||
);
|
||||
|
||||
// We place each tournament into a group based on location and date, so that
|
||||
// we can display a single map marker.
|
||||
const groupId = `${location}_${rangeIndex}`;
|
||||
|
||||
return {
|
||||
id: t._id.toString(),
|
||||
groupId,
|
||||
tournament: t.tournament,
|
||||
town: t.town,
|
||||
department: t.department,
|
||||
date: t.date,
|
||||
url: t.url,
|
||||
timeControl: tcMap[t.time_control] ?? TimeControl.Other,
|
||||
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
|
||||
norm: t.norm_tournament,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
errorLog(error);
|
||||
throw new Error("Error fetching tournament data");
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ export const blitzAtom = atom(true);
|
||||
export const otherAtom = atom(true);
|
||||
|
||||
export const {
|
||||
currentValueAtom: hoveredMapTournamentIdAtom,
|
||||
debouncedValueAtom: debouncedHoveredMapTournamentIdAtom,
|
||||
currentValueAtom: hoveredMapTournamentGroupIdAtom,
|
||||
debouncedValueAtom: debouncedHoveredMapTournamentGroupIdAtom,
|
||||
} = atomWithDebounce<string | null>(null);
|
||||
|
||||
export const { debouncedValueAtom: debouncedHoveredListTournamentIdAtom } =
|
||||
|
||||
Reference in New Issue
Block a user