mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 12:36:57 +00:00
Factorise Map logic
This commit is contained in:
@@ -1,80 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import L, { DomUtil, LatLngLiteral, Marker } from "leaflet";
|
import L, { LatLngLiteral } from "leaflet";
|
||||||
import "leaflet-defaulticon-compatibility";
|
import "leaflet-defaulticon-compatibility";
|
||||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||||
import "leaflet.smooth_marker_bouncing";
|
import "leaflet.smooth_marker_bouncing";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import { countBy, groupBy } from "lodash";
|
import { countBy, groupBy } from "lodash";
|
||||||
import { FaAngleDoubleDown } from "react-icons/fa";
|
|
||||||
import { LayerGroup, MapContainer, TileLayer } from "react-leaflet";
|
|
||||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
debouncedHoveredListTournamentIdAtom,
|
|
||||||
filteredTournamentsByTimeControlAtom,
|
filteredTournamentsByTimeControlAtom,
|
||||||
mapBoundsAtom,
|
|
||||||
normsOnlyAtom,
|
normsOnlyAtom,
|
||||||
} from "@/app/atoms";
|
} from "@/app/atoms";
|
||||||
import { TimeControlColours } from "@/app/constants";
|
import { TimeControlColours } from "@/app/constants";
|
||||||
import MapEvents from "@/components/MapEvents";
|
import { Map, MapMarker } from "@/components/Map";
|
||||||
import { generatePieSVG } from "@/lib/pie";
|
import { generatePieSVG } from "@/lib/pie";
|
||||||
import { TimeControl } from "@/types";
|
import { TimeControl } from "@/types";
|
||||||
|
|
||||||
import Legend from "./Legend";
|
import Legend from "./Legend";
|
||||||
import TimeControlFilters from "./TimeControlFilters";
|
import TimeControlFilters from "./TimeControlFilters";
|
||||||
import { TournamentMarker, TournamentMarkerRef } from "./TournamentMarker";
|
import { TournamentMarker } from "./TournamentMarker";
|
||||||
|
|
||||||
// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing
|
const TournamentMap = () => {
|
||||||
// to keep Typescript happy
|
|
||||||
declare class BouncingMarker extends Marker {
|
|
||||||
isBouncing(): boolean;
|
|
||||||
bounce(): void;
|
|
||||||
stopBouncing(): void;
|
|
||||||
|
|
||||||
_icon: HTMLElement;
|
|
||||||
_bouncingMotion?: {
|
|
||||||
bouncingAnimationPlaying: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TournamentMap() {
|
|
||||||
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
|
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
|
||||||
const normsOnly = useAtomValue(normsOnlyAtom);
|
const normsOnly = useAtomValue(normsOnlyAtom);
|
||||||
|
|
||||||
const setMapBounds = useSetAtom(mapBoundsAtom);
|
|
||||||
const hoveredListTournamentId = useAtomValue(
|
|
||||||
debouncedHoveredListTournamentIdAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const markerRefs = useRef<Record<string, TournamentMarkerRef>>({});
|
|
||||||
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
|
|
||||||
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
|
|
||||||
|
|
||||||
const filteredTournaments = useMemo(
|
const filteredTournaments = useMemo(
|
||||||
() => tournaments.filter((t) => (normsOnly ? t.norm : true)),
|
() => tournaments.filter((t) => (normsOnly ? t.norm : true)),
|
||||||
[normsOnly, tournaments],
|
[normsOnly, tournaments],
|
||||||
@@ -82,125 +34,6 @@ export default function TournamentMap() {
|
|||||||
|
|
||||||
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
|
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
|
||||||
|
|
||||||
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
|
||||||
|
|
||||||
const onScrollToTable = () => {
|
|
||||||
const tournamentTable = document.getElementById("tournament-table");
|
|
||||||
tournamentTable?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandAndBounceIfNeeded = useCallback(() => {
|
|
||||||
if (hoveredListTournamentId) {
|
|
||||||
const tournament = filteredTournaments.find(
|
|
||||||
(t) => t.id === hoveredListTournamentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tournament) {
|
|
||||||
const markerRef = markerRefs.current[tournament.groupId];
|
|
||||||
if (markerRef) {
|
|
||||||
if (clusterRef.current) {
|
|
||||||
const visibleMarker = clusterRef.current.getVisibleParent(
|
|
||||||
markerRef.getMarker(),
|
|
||||||
);
|
|
||||||
if (!visibleMarker) return;
|
|
||||||
|
|
||||||
// @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();
|
|
||||||
|
|
||||||
markerRef.bounce();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopBouncingMarkers();
|
|
||||||
return false;
|
|
||||||
}, [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[tournament.groupId];
|
|
||||||
|
|
||||||
if (markerRef && e.markers.includes(markerRef.getMarker())) {
|
|
||||||
stopBouncingMarkers();
|
|
||||||
markerRef.bounce();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filteredTournaments, 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) => {
|
const createClusterCustomIcon = useCallback((cluster: any) => {
|
||||||
const childCount = cluster.getChildCount();
|
const childCount = cluster.getChildCount();
|
||||||
const children = cluster.getAllChildMarkers();
|
const children = cluster.getAllChildMarkers();
|
||||||
@@ -237,71 +70,29 @@ export default function TournamentMap() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markers = useMemo(
|
const markers: MapMarker[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(groupedTournaments).map((tournamentGroup) => {
|
Object.values(groupedTournaments).map((tournamentGroup) => {
|
||||||
const { groupId } = tournamentGroup[0];
|
const { groupId } = tournamentGroup[0];
|
||||||
return (
|
return {
|
||||||
<TournamentMarker
|
markerId: groupId,
|
||||||
ref={(ref) => {
|
tableIds: tournamentGroup.map((t) => t.id),
|
||||||
markerRefs.current[groupId] = ref!;
|
component: (
|
||||||
}}
|
<TournamentMarker key={groupId} tournamentGroup={tournamentGroup} />
|
||||||
key={groupId}
|
),
|
||||||
tournamentGroup={tournamentGroup}
|
};
|
||||||
/>
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
[groupedTournaments],
|
[groupedTournaments],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="tournament-map" className="flex h-content flex-col">
|
<Map
|
||||||
<div className="p-3 lg:hidden">
|
markers={markers}
|
||||||
<TimeControlFilters />
|
legend={<Legend />}
|
||||||
</div>
|
filters={<TimeControlFilters />}
|
||||||
|
|
||||||
<MapContainer
|
|
||||||
center={center}
|
|
||||||
zoom={5}
|
|
||||||
scrollWheelZoom={false}
|
|
||||||
style={{
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
ref={(map) => {
|
|
||||||
if (map) {
|
|
||||||
setMapBounds(map.getBounds());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MapEvents />
|
|
||||||
<TileLayer
|
|
||||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<LayerGroup>
|
|
||||||
<MarkerClusterGroup
|
|
||||||
ref={clusterRef}
|
|
||||||
chunkedLoading
|
|
||||||
iconCreateFunction={createClusterCustomIcon}
|
iconCreateFunction={createClusterCustomIcon}
|
||||||
maxClusterRadius={40}
|
/>
|
||||||
showCoverageOnHover={false}
|
|
||||||
spiderfyOnMaxZoom
|
|
||||||
>
|
|
||||||
{markers}
|
|
||||||
</MarkerClusterGroup>
|
|
||||||
</LayerGroup>
|
|
||||||
$
|
|
||||||
</MapContainer>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center lg:hidden">
|
|
||||||
<button
|
|
||||||
className="p-3 text-primary-900 dark:text-white"
|
|
||||||
onClick={onScrollToTable}
|
|
||||||
>
|
|
||||||
<FaAngleDoubleDown />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default TournamentMap;
|
||||||
|
|||||||
@@ -9,24 +9,18 @@ import { useTranslations } from "next-intl";
|
|||||||
import { FaTrophy } from "react-icons/fa";
|
import { FaTrophy } from "react-icons/fa";
|
||||||
import { Marker, MarkerProps, Popup } from "react-leaflet";
|
import { Marker, MarkerProps, Popup } from "react-leaflet";
|
||||||
|
|
||||||
import { debouncedHoveredMapTournamentGroupIdAtom } from "@/app/atoms";
|
import { debouncedHoveredMapIdAtom } from "@/app/atoms";
|
||||||
import { TimeControlColours } from "@/app/constants";
|
import { TimeControlColours } from "@/app/constants";
|
||||||
|
import type { MarkerRef } from "@/components/Map";
|
||||||
import type { BouncingMarker } from "@/leafletTypes";
|
import type { BouncingMarker } from "@/leafletTypes";
|
||||||
import { Tournament } from "@/types";
|
import { Tournament } from "@/types";
|
||||||
|
|
||||||
export type TournamentMarkerRef = {
|
|
||||||
getMarker: () => L.Marker<any>;
|
|
||||||
bounce: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TournamentMarkerProps = {
|
type TournamentMarkerProps = {
|
||||||
tournamentGroup: Tournament[];
|
tournamentGroup: Tournament[];
|
||||||
} & Omit<MarkerProps, "position">;
|
} & Omit<MarkerProps, "position">;
|
||||||
|
|
||||||
export const TournamentMarker = forwardRef<
|
export const TournamentMarker = forwardRef<MarkerRef, TournamentMarkerProps>(
|
||||||
TournamentMarkerRef,
|
({ tournamentGroup, ...markerProps }, ref) => {
|
||||||
TournamentMarkerProps
|
|
||||||
>(({ tournamentGroup, ...markerProps }, ref) => {
|
|
||||||
const t = useTranslations("Tournaments");
|
const t = useTranslations("Tournaments");
|
||||||
const markerRef = useRef<L.Marker<any> | null>(null);
|
const markerRef = useRef<L.Marker<any> | null>(null);
|
||||||
|
|
||||||
@@ -45,9 +39,7 @@ export const TournamentMarker = forwardRef<
|
|||||||
|
|
||||||
const { date, latLng, groupId, timeControl } = tournamentGroup[0];
|
const { date, latLng, groupId, timeControl } = tournamentGroup[0];
|
||||||
|
|
||||||
const setHoveredMapTournamentGroupId = useSetAtom(
|
const setHoveredMapId = useSetAtom(debouncedHoveredMapIdAtom);
|
||||||
debouncedHoveredMapTournamentGroupIdAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconOptions = useMemo(
|
const iconOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -75,8 +67,8 @@ export const TournamentMarker = forwardRef<
|
|||||||
position={latLng}
|
position={latLng}
|
||||||
icon={iconOptions}
|
icon={iconOptions}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
mouseover: () => setHoveredMapTournamentGroupId(groupId),
|
mouseover: () => setHoveredMapId(groupId),
|
||||||
mouseout: () => setHoveredMapTournamentGroupId(null),
|
mouseout: () => setHoveredMapId(null),
|
||||||
}}
|
}}
|
||||||
{...markerProps}
|
{...markerProps}
|
||||||
>
|
>
|
||||||
@@ -112,6 +104,7 @@ export const TournamentMarker = forwardRef<
|
|||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
TournamentMarker.displayName = "TournamentMarker";
|
TournamentMarker.displayName = "TournamentMarker";
|
||||||
|
|||||||
@@ -10,52 +10,45 @@ import { Tooltip } from "react-tooltip";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
debouncedHoveredListTournamentIdAtom,
|
debouncedHoveredListIdAtom,
|
||||||
debouncedHoveredMapTournamentGroupIdAtom,
|
debouncedHoveredMapIdAtom,
|
||||||
filteredTournamentsListAtom,
|
filteredTournamentsListAtom,
|
||||||
hoveredMapTournamentGroupIdAtom,
|
hoveredMapIdAtom,
|
||||||
normsOnlyAtom,
|
normsOnlyAtom,
|
||||||
syncVisibleAtom,
|
syncVisibleAtom,
|
||||||
} from "@/app/atoms";
|
} from "@/app/atoms";
|
||||||
|
import ScrollToTopButton from "@/components/ScrollToTopButton";
|
||||||
|
import SearchBar from "@/components/SearchBar";
|
||||||
import { useBreakpoint } from "@/hooks/tailwind";
|
import { useBreakpoint } from "@/hooks/tailwind";
|
||||||
|
|
||||||
import ScrollToTopButton from "./ScrollToTopButton";
|
|
||||||
import SearchBar from "./SearchBar";
|
|
||||||
import TimeControlFilters from "./TimeControlFilters";
|
import TimeControlFilters from "./TimeControlFilters";
|
||||||
|
|
||||||
export default function TournamentTable() {
|
const TournamentTable = () => {
|
||||||
const t = useTranslations("Tournaments");
|
const t = useTranslations("Tournaments");
|
||||||
const at = useTranslations("App");
|
const at = useTranslations("App");
|
||||||
|
|
||||||
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
|
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
|
||||||
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
|
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
|
||||||
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
|
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
|
||||||
const hoveredMapTournamentGroupId = useAtomValue(
|
const hoveredMapId = useAtomValue(hoveredMapIdAtom);
|
||||||
hoveredMapTournamentGroupIdAtom,
|
const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom);
|
||||||
);
|
const setHoveredListId = useSetAtom(debouncedHoveredListIdAtom);
|
||||||
const debouncedHoveredMapTournamentGroupId = useAtomValue(
|
|
||||||
debouncedHoveredMapTournamentGroupIdAtom,
|
|
||||||
);
|
|
||||||
const setHoveredListTournamentId = useSetAtom(
|
|
||||||
debouncedHoveredListTournamentIdAtom,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLg = useBreakpoint("lg");
|
const isLg = useBreakpoint("lg");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLg || debouncedHoveredMapTournamentGroupId === null) return;
|
if (!isLg || debouncedHoveredMapId === null) return;
|
||||||
const tournamentRow = document.querySelector(
|
const tournamentRow = document.querySelector(
|
||||||
`[data-group-id="${debouncedHoveredMapTournamentGroupId}"]`,
|
`[data-group-id="${debouncedHoveredMapId}"]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [debouncedHoveredMapTournamentGroupId, isLg]);
|
}, [debouncedHoveredMapId, isLg]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="tournament-table grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-content lg:overflow-y-scroll lg:pb-0"
|
className="grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-content lg:overflow-y-scroll lg:pb-0"
|
||||||
id="tournament-table"
|
id="listing"
|
||||||
data-test="tournament-table-div"
|
|
||||||
>
|
>
|
||||||
<div className="z-10 flex w-full flex-wrap items-center justify-between gap-3 p-3">
|
<div className="z-10 flex w-full flex-wrap items-center justify-between gap-3 p-3">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
@@ -90,10 +83,7 @@ export default function TournamentTable() {
|
|||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
|
|
||||||
<div className="overflow-x-scroll">
|
<div className="overflow-x-scroll">
|
||||||
<table
|
<table className="relative min-w-full table-fixed text-center text-xs lg:w-full">
|
||||||
className="relative min-w-full table-fixed text-center text-xs lg:w-full"
|
|
||||||
data-test="tournament-table"
|
|
||||||
>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||||
@@ -108,7 +98,9 @@ export default function TournamentTable() {
|
|||||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||||
{t("timeControl")}
|
{t("timeControl")}
|
||||||
</th>
|
</th>
|
||||||
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600"></th>
|
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||||
|
{t("ffe")}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
@@ -125,11 +117,11 @@ export default function TournamentTable() {
|
|||||||
key={tournament.id}
|
key={tournament.id}
|
||||||
id={tournament.id}
|
id={tournament.id}
|
||||||
data-group-id={tournament.groupId}
|
data-group-id={tournament.groupId}
|
||||||
onMouseEnter={() => setHoveredListTournamentId(tournament.id)}
|
onMouseEnter={() => setHoveredListId(tournament.id)}
|
||||||
onMouseLeave={() => setHoveredListTournamentId(null)}
|
onMouseLeave={() => setHoveredListId(null)}
|
||||||
className={twMerge(
|
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",
|
"scroll-m-20 bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900",
|
||||||
hoveredMapTournamentGroupId === tournament.groupId &&
|
hoveredMapId === tournament.groupId &&
|
||||||
"bg-gray-200 dark:bg-gray-900",
|
"bg-gray-200 dark:bg-gray-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -139,7 +131,7 @@ export default function TournamentTable() {
|
|||||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||||
{tournament.town}
|
{tournament.town}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
<td className="px-1 py-2 text-left sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||||
<span>
|
<span>
|
||||||
{tournament.norm && (
|
{tournament.norm && (
|
||||||
<FaTrophy
|
<FaTrophy
|
||||||
@@ -154,9 +146,15 @@ export default function TournamentTable() {
|
|||||||
{at("timeControlEnum", { tc: tournament.timeControl })}
|
{at("timeControlEnum", { tc: tournament.timeControl })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||||
<a href={tournament.url} target="_blank">
|
<div className="flex justify-center">
|
||||||
|
<a
|
||||||
|
href={tournament.url}
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:text-primary-800"
|
||||||
|
>
|
||||||
<FaExternalLinkAlt />
|
<FaExternalLinkAlt />
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -173,4 +171,6 @@ export default function TournamentTable() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default TournamentTable;
|
||||||
|
|||||||
+11
-7
@@ -1,32 +1,35 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { LatLngBounds, LatLngLiteral } from "leaflet";
|
import { LatLngBounds, LatLngLiteral } from "leaflet";
|
||||||
|
|
||||||
import { TimeControl, Tournament } from "@/types";
|
import { Club, TimeControl, Tournament } from "@/types";
|
||||||
import atomWithDebounce from "@/utils/atomWithDebounce";
|
import atomWithDebounce from "@/utils/atomWithDebounce";
|
||||||
import { normalizedContains } from "@/utils/string";
|
import { normalizedContains } from "@/utils/string";
|
||||||
|
|
||||||
export const tournamentsAtom = atom<Tournament[]>([]);
|
|
||||||
export const mapBoundsAtom = atom<LatLngBounds | null>(null);
|
export const mapBoundsAtom = atom<LatLngBounds | null>(null);
|
||||||
export const syncVisibleAtom = atom(true);
|
export const syncVisibleAtom = atom(true);
|
||||||
|
export const searchStringAtom = atom("");
|
||||||
|
|
||||||
|
export const tournamentsAtom = atom<Tournament[]>([]);
|
||||||
export const normsOnlyAtom = atom(false);
|
export const normsOnlyAtom = atom(false);
|
||||||
|
|
||||||
export const searchStringAtom = atom("");
|
|
||||||
export const classicAtom = atom(true);
|
export const classicAtom = atom(true);
|
||||||
export const rapidAtom = atom(true);
|
export const rapidAtom = atom(true);
|
||||||
export const blitzAtom = atom(true);
|
export const blitzAtom = atom(true);
|
||||||
export const otherAtom = atom(true);
|
export const otherAtom = atom(true);
|
||||||
|
|
||||||
|
export const clubsAtom = atom<Club[]>([]);
|
||||||
|
|
||||||
export const franceCenterAtom = atom<LatLngLiteral>({
|
export const franceCenterAtom = atom<LatLngLiteral>({
|
||||||
lat: 47.0844,
|
lat: 47.0844,
|
||||||
lng: 2.3964,
|
lng: 2.3964,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
currentValueAtom: hoveredMapTournamentGroupIdAtom,
|
currentValueAtom: hoveredMapIdAtom,
|
||||||
debouncedValueAtom: debouncedHoveredMapTournamentGroupIdAtom,
|
debouncedValueAtom: debouncedHoveredMapIdAtom,
|
||||||
} = atomWithDebounce<string | null>(null);
|
} = atomWithDebounce<string | null>(null);
|
||||||
|
|
||||||
export const { debouncedValueAtom: debouncedHoveredListTournamentIdAtom } =
|
export const { debouncedValueAtom: debouncedHoveredListIdAtom } =
|
||||||
atomWithDebounce<string | null>(null, 1000, 100);
|
atomWithDebounce<string | null>(null, 1000, 100);
|
||||||
|
|
||||||
export const filteredTournamentsByTimeControlAtom = atom((get) => {
|
export const filteredTournamentsByTimeControlAtom = atom((get) => {
|
||||||
@@ -39,7 +42,8 @@ export const filteredTournamentsByTimeControlAtom = atom((get) => {
|
|||||||
|
|
||||||
return tournaments.filter(
|
return tournaments.filter(
|
||||||
(tournament) =>
|
(tournament) =>
|
||||||
!tournament.pending && tournament.status === 'scheduled' &&
|
!tournament.pending &&
|
||||||
|
tournament.status === "scheduled" &&
|
||||||
((tournament.timeControl === TimeControl.Classic && classic) ||
|
((tournament.timeControl === TimeControl.Classic && classic) ||
|
||||||
(tournament.timeControl === TimeControl.Rapid && rapid) ||
|
(tournament.timeControl === TimeControl.Rapid && rapid) ||
|
||||||
(tournament.timeControl === TimeControl.Blitz && blitz) ||
|
(tournament.timeControl === TimeControl.Blitz && blitz) ||
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import L, {
|
||||||
|
DomUtil,
|
||||||
|
LatLngLiteral,
|
||||||
|
Marker,
|
||||||
|
MarkerClusterGroupOptions,
|
||||||
|
} from "leaflet";
|
||||||
|
import "leaflet-defaulticon-compatibility";
|
||||||
|
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||||
|
import "leaflet.smooth_marker_bouncing";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { FaAngleDoubleDown } from "react-icons/fa";
|
||||||
|
import { LayerGroup, MapContainer, TileLayer } from "react-leaflet";
|
||||||
|
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||||
|
|
||||||
|
import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/app/atoms";
|
||||||
|
import MapEvents from "@/components/MapEvents";
|
||||||
|
|
||||||
|
export type MarkerRef = {
|
||||||
|
getMarker: () => L.Marker<any>;
|
||||||
|
bounce: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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 {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapMarker = {
|
||||||
|
markerId: string;
|
||||||
|
tableIds: string[];
|
||||||
|
component: React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapProps = {
|
||||||
|
filters?: React.ReactNode;
|
||||||
|
legend?: React.ReactNode;
|
||||||
|
markers: MapMarker[];
|
||||||
|
iconCreateFunction?: MarkerClusterGroupOptions["iconCreateFunction"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Map = ({
|
||||||
|
filters,
|
||||||
|
legend,
|
||||||
|
markers: markers,
|
||||||
|
iconCreateFunction,
|
||||||
|
}: MapProps) => {
|
||||||
|
const setMapBounds = useSetAtom(mapBoundsAtom);
|
||||||
|
|
||||||
|
const hoveredListTournamentId = useAtomValue(debouncedHoveredListIdAtom);
|
||||||
|
|
||||||
|
const markerRefs = useRef<Record<string, MarkerRef>>({});
|
||||||
|
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
|
||||||
|
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
|
||||||
|
|
||||||
|
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
||||||
|
|
||||||
|
const onScrollToTable = () => {
|
||||||
|
const tournamentTable = document.getElementById("listing");
|
||||||
|
tournamentTable?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAndBounceIfNeeded = useCallback(() => {
|
||||||
|
if (hoveredListTournamentId) {
|
||||||
|
const marker = markers.find((m) =>
|
||||||
|
m.tableIds.includes(hoveredListTournamentId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
const markerRef = markerRefs.current[marker.markerId];
|
||||||
|
if (markerRef) {
|
||||||
|
if (clusterRef.current) {
|
||||||
|
const visibleMarker = clusterRef.current.getVisibleParent(
|
||||||
|
markerRef.getMarker(),
|
||||||
|
);
|
||||||
|
if (!visibleMarker) return;
|
||||||
|
|
||||||
|
// @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();
|
||||||
|
|
||||||
|
markerRef.bounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBouncingMarkers();
|
||||||
|
return false;
|
||||||
|
}, [markers, hoveredListTournamentId]);
|
||||||
|
|
||||||
|
const onSpiderified = useCallback(
|
||||||
|
(e: L.MarkerClusterSpiderfyEvent) => {
|
||||||
|
// Once expanded, bounce the appropriate marker
|
||||||
|
|
||||||
|
if (hoveredListTournamentId) {
|
||||||
|
const marker = markers.find((m) =>
|
||||||
|
m.tableIds.includes(hoveredListTournamentId),
|
||||||
|
);
|
||||||
|
if (!marker) return;
|
||||||
|
|
||||||
|
expandedClusterMarkerRef.current = e.cluster;
|
||||||
|
const markerRef = markerRefs.current[marker.markerId];
|
||||||
|
|
||||||
|
if (markerRef && e.markers.includes(markerRef.getMarker())) {
|
||||||
|
stopBouncingMarkers();
|
||||||
|
markerRef.bounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markers, 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 referencedMarkers = useMemo(
|
||||||
|
() =>
|
||||||
|
markers.map((marker) =>
|
||||||
|
React.cloneElement(marker.component, {
|
||||||
|
ref: (ref: MarkerRef) => {
|
||||||
|
markerRefs.current[marker.markerId] = ref;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[markers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="tournament-map" className="flex h-content flex-col">
|
||||||
|
<div className="p-3 lg:hidden">{filters}</div>
|
||||||
|
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={5}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
ref={(map) => {
|
||||||
|
if (map) {
|
||||||
|
setMapBounds(map.getBounds());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapEvents />
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{legend}
|
||||||
|
<LayerGroup>
|
||||||
|
<MarkerClusterGroup
|
||||||
|
ref={clusterRef}
|
||||||
|
chunkedLoading
|
||||||
|
iconCreateFunction={iconCreateFunction}
|
||||||
|
maxClusterRadius={40}
|
||||||
|
showCoverageOnHover={false}
|
||||||
|
spiderfyOnMaxZoom
|
||||||
|
>
|
||||||
|
{referencedMarkers}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
</LayerGroup>
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center lg:hidden">
|
||||||
|
<button
|
||||||
|
className="p-3 text-primary-900 dark:text-white"
|
||||||
|
onClick={onScrollToTable}
|
||||||
|
>
|
||||||
|
<FaAngleDoubleDown />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,8 +15,7 @@ const ScrollToTopButton = () => {
|
|||||||
// determine scrollable element based on screen size - window or div
|
// determine scrollable element based on screen size - window or div
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isLgScreen
|
isLgScreen
|
||||||
? (scrollToTopElementRef.current =
|
? (scrollToTopElementRef.current = document.getElementById("listing"))
|
||||||
document.getElementById("tournament-table"))
|
|
||||||
: (scrollToTopElementRef.current = window);
|
: (scrollToTopElementRef.current = window);
|
||||||
}, [isLgScreen]);
|
}, [isLgScreen]);
|
||||||
|
|
||||||
Reference in New Issue
Block a user