From b305b0578bb2e22bc8ed6d601b25354c6ae7f712 Mon Sep 17 00:00:00 2001 From: Timothy Armes Date: Tue, 18 Jul 2023 11:52:19 +0200 Subject: [PATCH] Group tournaments into a single map marker based on position & date (#105) --- app/[lang]/tournois/TournamentMap.tsx | 33 ++++++++---- app/[lang]/tournois/TournamentMarker.tsx | 61 +++++++++++++++------- app/[lang]/tournois/TournamentTable.tsx | 31 ++++++----- app/[lang]/tournois/page.tsx | 64 ++++++++++++++++++++--- app/atoms.ts | 4 +- hooks/tailwind.ts | 65 +++++++++++++++++++++++- package-lock.json | 28 ++++++++-- package.json | 3 ++ types.ts | 3 +- 9 files changed, 234 insertions(+), 58 deletions(-) diff --git a/app/[lang]/tournois/TournamentMap.tsx b/app/[lang]/tournois/TournamentMap.tsx index 2e63ee1..49bf268 100644 --- a/app/[lang]/tournois/TournamentMap.tsx +++ b/app/[lang]/tournois/TournamentMap.tsx @@ -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 ( (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} /> ); diff --git a/app/[lang]/tournois/TournamentMarker.tsx b/app/[lang]/tournois/TournamentMarker.tsx index b52c3b5..6d75fc2 100644 --- a/app/[lang]/tournois/TournamentMarker.tsx +++ b/app/[lang]/tournois/TournamentMarker.tsx @@ -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; export const TournamentMarker = forwardRef< L.Marker | 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 ( setHoveredMapTournamentId(tournament._id), - mouseout: () => setHoveredMapTournamentId(null), + mouseover: () => + setHoveredMapTournamentGroupId( + `${baseTournament.groupId}_${baseTournament.timeControl}`, + ), + mouseout: () => setHoveredMapTournamentGroupId(null), }} {...markerProps} > - -
- {tournament.date} + +
+ + {baseTournament.date} + {endDate !== startDate && ` - ${endDate}`} + - - {tournament.tournament} - - {tournament.norm && ( +
+ {tournamentGroup.map((tournament) => ( + + {tournament.tournament} + + ))} +
+ + {tournamentGroup.some((t) => t.norm) && (
{t("norm")}
)} + {t("approx")}
diff --git a/app/[lang]/tournois/TournamentTable.tsx b/app/[lang]/tournois/TournamentTable.tsx index 09db74f..8667ee1 100644 --- a/app/[lang]/tournois/TournamentTable.tsx +++ b/app/[lang]/tournois/TournamentTable.tsx @@ -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 (
( 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", )} > diff --git a/app/[lang]/tournois/page.tsx b/app/[lang]/tournois/page.tsx index f144d0c..0eb39fb 100644 --- a/app/[lang]/tournois/page.tsx +++ b/app/[lang]/tournois/page.tsx @@ -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((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 = {}; + for (const location in groupedByLocation) { + const tournaments = groupedByLocation[location]; + + // Note that this works since the tournaments are sorted by date + const dateRanges = tournaments.reduce( + (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((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"); diff --git a/app/atoms.ts b/app/atoms.ts index 7e94d84..957e482 100644 --- a/app/atoms.ts +++ b/app/atoms.ts @@ -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(null); export const { debouncedValueAtom: debouncedHoveredListTournamentIdAtom } = diff --git a/hooks/tailwind.ts b/hooks/tailwind.ts index 1381472..caf73d5 100644 --- a/hooks/tailwind.ts +++ b/hooks/tailwind.ts @@ -1,7 +1,68 @@ -import create from "@kodingdotninja/use-tailwind-breakpoint"; +import { useLayoutEffect, useEffect, useState, useRef, useMemo } from "react"; import resolveConfig from "tailwindcss/resolveConfig"; import tailwindConfig from "@/tailwind.config.js"; const config = resolveConfig(tailwindConfig); -export const { useBreakpoint } = create(config.theme!.screens); +const isSSR = + typeof window === "undefined" || !window.navigator || /ServerSideRendering|^Deno\//.test(window.navigator.userAgent); + +const isBrowser = !isSSR; +const useIsomorphicEffect = isBrowser ? useLayoutEffect : useEffect; + +export type CreatorReturnType = { + useBreakpoint(breakpoint: B, defaultValue?: boolean): boolean; + useBreakpointEffect(breakpoint: B, effect: (match: boolean) => void): void; + useBreakpointValue(breakpoint: B, valid: T, invalid: U): T | U; +}; + +function create(screens: object | undefined) { + if (!screens) { + throw new Error("Failed to create breakpoint hooks, given `screens` value is invalid."); + } + + function useBreakpoint(breakpoint: string, defaultValue: boolean = false) { + const [match, setMatch] = useState(() => defaultValue); + const matchRef = useRef(defaultValue); + + useIsomorphicEffect(() => { + if (!(isBrowser && "matchMedia" in window)) return undefined; + + function track() { + // @ts-expect-error accessing index with uncertain `screens` type + const value = (screens[breakpoint] as string) ?? "999999px"; + const query = window.matchMedia(`(min-width: ${value})`); + matchRef.current = query.matches; + if (matchRef.current != match) { + setMatch(matchRef.current); + } + } + + window.addEventListener("resize", track); + track(); + return () => window.removeEventListener("resize", track); + }); + + return match; + } + + function useBreakpointEffect(breakpoint: Breakpoint, effect: (match: boolean) => void) { + const match = useBreakpoint(breakpoint); + useEffect(() => effect(match)); + return null; + } + + function useBreakpointValue(breakpoint: Breakpoint, valid: T, invalid: U) { + const match = useBreakpoint(breakpoint); + const value = useMemo(() => (match ? valid : invalid), [invalid, match, valid]); + return value; + } + + return { + useBreakpoint, + useBreakpointEffect, + useBreakpointValue, + } as CreatorReturnType; +} + +export const { useBreakpoint, useBreakpointEffect, useBreakpointValue } = create(config.theme!.screens); diff --git a/package-lock.json b/package-lock.json index a93dca0..4f7958e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "@types/react-dom": "18.2.6", "@vercel/analytics": "^1.0.1", "autoprefixer": "10.4.14", + "date-fns": "^2.30.0", "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", + "lodash": "^4.17.21", "mongodb": "^5.7.0", "next": "^13.4.7", "next-intl": "^3.0.0-beta.7", @@ -43,6 +45,7 @@ "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", "@types/leaflet.markercluster": "^1.5.1", + "@types/lodash": "^4.14.195", "@types/nodemailer": "^6.4.8", "cypress": "^12.16.0", "eslint": "8.43.0", @@ -180,7 +183,6 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -991,6 +993,12 @@ "@types/leaflet": "*" } }, + "node_modules/@types/lodash": { + "version": "4.14.195", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==", + "dev": true + }, "node_modules/@types/node": { "version": "20.3.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", @@ -2331,6 +2339,21 @@ "node": ">=0.10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.8", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.8.tgz", @@ -6255,8 +6278,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", diff --git a/package.json b/package.json index 77495b6..3acef54 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,13 @@ "@types/react-dom": "18.2.6", "@vercel/analytics": "^1.0.1", "autoprefixer": "10.4.14", + "date-fns": "^2.30.0", "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", + "lodash": "^4.17.21", "mongodb": "^5.7.0", "next": "^13.4.7", "next-intl": "^3.0.0-beta.7", @@ -48,6 +50,7 @@ "@testing-library/react": "^14.0.0", "@types/leaflet": "^1.9.3", "@types/leaflet.markercluster": "^1.5.1", + "@types/lodash": "^4.14.195", "@types/nodemailer": "^6.4.8", "cypress": "^12.16.0", "eslint": "8.43.0", diff --git a/types.ts b/types.ts index 5d5cecc..09da43e 100644 --- a/types.ts +++ b/types.ts @@ -8,7 +8,8 @@ export enum TimeControl { } export interface Tournament { - _id: string; + id: string; + groupId: string; town: string; department: string; tournament: string;