diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b53af0..d1c54d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "approximative", "colour", + "colours", "contactez", "defaulticon", "Échecs", diff --git a/app/[lang]/tournois/TournamentMap.tsx b/app/[lang]/tournois/TournamentMap.tsx index 7816a7a..c646c29 100644 --- a/app/[lang]/tournois/TournamentMap.tsx +++ b/app/[lang]/tournois/TournamentMap.tsx @@ -13,24 +13,20 @@ import { } from "react-leaflet"; import { FaAngleDoubleDown } from "react-icons/fa"; import { useAtomValue, useSetAtom } from "jotai"; -import { groupBy } from "lodash"; +import { countBy, groupBy } from "lodash"; 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 { mapBoundsAtom, debouncedHoveredListTournamentIdAtom, - tournamentsAtom, - classicAtom, - rapidAtom, - blitzAtom, - otherAtom, + filteredTournamentsByTimeControlAtom, normsOnlyAtom, } from "@/app/atoms"; +import { pie } from "@/lib/pie"; import Legend from "./Legend"; import { TournamentMarker } from "./TournamentMarker"; @@ -68,16 +64,11 @@ const MapEvents = () => { map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds)); map.setMaxBounds(worldBounds); map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging - }, [map]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps return null; }; -type TimeControlGroupProps = { - timeControl: TimeControl; - colour: string; -}; - const stopBouncingMarkers = () => { const markers = // @ts-ignore @@ -99,27 +90,38 @@ const stopBouncingMarkers = () => { }); }; -const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => { - const tournaments = useAtomValue(tournamentsAtom); +export default function TournamentMap() { + const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom); const normsOnly = useAtomValue(normsOnlyAtom); + const setMapBounds = useSetAtom(mapBoundsAtom); + const hoveredListTournamentId = useAtomValue( + debouncedHoveredListTournamentIdAtom, + ); + const markerRefs = useRef | null>>({}); const clusterRef = useRef(null); const expandedClusterMarkerRef = useRef(null); const filteredTournaments = useMemo( - () => - tournaments - .filter((t) => t.timeControl === timeControl) - .filter((t) => (normsOnly ? t.norm : true)), - [normsOnly, timeControl, tournaments], + () => tournaments.filter((t) => (normsOnly ? t.norm : true)), + [normsOnly, tournaments], ); const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId); - const hoveredListTournamentId = useAtomValue( - debouncedHoveredListTournamentIdAtom, - ); + 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" }); + }; const expandAndBounceIfNeeded = useCallback(() => { if (hoveredListTournamentId) { @@ -231,73 +233,73 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => { expandedClusterMarkerRef.current.unspiderfy(); }, [expandAndBounceIfNeeded, hoveredListTournamentId]); - const createClusterCustomIcon = useCallback( - (cluster: any) => { - let childCount = cluster.getChildCount(); + const createClusterCustomIcon = useCallback((cluster: any) => { + let childCount = cluster.getChildCount(); - return new L.DivIcon({ - html: "
" + childCount + "
", - className: `marker-cluster marker-cluster-${timeControl}`, - iconSize: new L.Point(40, 40), - }); - }, - [timeControl], - ); + const children = cluster.getAllChildMarkers(); - const markers = Object.values(groupedTournaments).map((tournamentGroup) => { - return ( - (markerRefs.current[tournamentGroup[0].groupId] = ref)} - key={tournamentGroup[0].groupId} - tournamentGroup={tournamentGroup} - colour={colour} - /> + // We added the time control to the icon options when creating the marker + const timeControlCounts = countBy( + children, + (child: any) => child.options.icon.options.timeControl, ); - }); - const group = useMemo( - () => ( - - - {markers} - - - ), - [createClusterCustomIcon, markers], + const html = ` +
+ + ${pie("absolute w-[30px] -z-10", 15, [ + { + value: timeControlCounts[TimeControl.Classic] ?? 0, + colour: "#00ac39", + }, + { + value: timeControlCounts[TimeControl.Rapid] ?? 0, + colour: "#0086c7", + }, + { + value: timeControlCounts[TimeControl.Blitz] ?? 0, + colour: "#cec348", + }, + { + value: timeControlCounts[TimeControl.Other] ?? 0, + colour: "#d10c3e", + }, + ])} + ${childCount} +
+ `; + + return new L.DivIcon({ + html, + className: "marker-cluster", + iconSize: new L.Point(40, 40), + }); + }, []); + + const markers = useMemo( + () => + Object.values(groupedTournaments).map((tournamentGroup) => { + const { groupId, timeControl } = tournamentGroup[0]; + + const colours = { + [TimeControl.Classic]: "green", + [TimeControl.Rapid]: "blue", + [TimeControl.Blitz]: "yellow", + [TimeControl.Other]: "red", + }; + + return ( + (markerRefs.current[groupId] = ref)} + key={groupId} + tournamentGroup={tournamentGroup} + colour={colours[timeControl]} + /> + ); + }), + [groupedTournaments], ); - return group; -}; - -export default function TournamentMap() { - const setMapBounds = useSetAtom(mapBoundsAtom); - const hoveredListTournamentId = useAtomValue( - debouncedHoveredListTournamentIdAtom, - ); - - const classic = useAtomValue(classicAtom); - const rapid = useAtomValue(rapidAtom); - const blitz = useAtomValue(blitzAtom); - const other = useAtomValue(otherAtom); - - 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 (
@@ -323,19 +325,19 @@ export default function TournamentMap() { url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - - {classic && ( - - )} - {rapid && ( - - )} - {blitz && ( - - )} - {other && ( - - )} + + + {markers} + + + $
diff --git a/app/[lang]/tournois/TournamentMarker.tsx b/app/[lang]/tournois/TournamentMarker.tsx index 6d75fc2..a701c7c 100644 --- a/app/[lang]/tournois/TournamentMarker.tsx +++ b/app/[lang]/tournois/TournamentMarker.tsx @@ -22,21 +22,7 @@ export const TournamentMarker = forwardRef< >(({ 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: baseTournament.latLng.lat, - lng: - baseTournament.latLng.lng + - (baseTournament.timeControl === TimeControl.Rapid - ? -0.01 - : baseTournament.timeControl === TimeControl.Blitz - ? 0.01 - : baseTournament.timeControl === TimeControl.Other - ? 0.02 - : 0), - }); + const { date, latLng, groupId, timeControl } = tournamentGroup[0]; const setHoveredMapTournamentGroupId = useSetAtom( debouncedHoveredMapTournamentGroupIdAtom, @@ -51,23 +37,21 @@ export const TournamentMarker = forwardRef< iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41], + timeControl, }), - [colour], + [colour, timeControl], ); - const startDate = baseTournament.date; + const startDate = date; const endDate = last(tournamentGroup)!.date; return ( - setHoveredMapTournamentGroupId( - `${baseTournament.groupId}_${baseTournament.timeControl}`, - ), + mouseover: () => setHoveredMapTournamentGroupId(groupId), mouseout: () => setHoveredMapTournamentGroupId(null), }} {...markerProps} @@ -75,7 +59,7 @@ export const TournamentMarker = forwardRef<
- {baseTournament.date} + {date} {endDate !== startDate && ` - ${endDate}`} diff --git a/app/[lang]/tournois/TournamentTable.tsx b/app/[lang]/tournois/TournamentTable.tsx index 8667ee1..b773221 100644 --- a/app/[lang]/tournois/TournamentTable.tsx +++ b/app/[lang]/tournois/TournamentTable.tsx @@ -121,13 +121,12 @@ export default function TournamentTable() { 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", - hoveredMapTournamentGroupId === - `${tournament.groupId}_${tournament.timeControl}` && + hoveredMapTournamentGroupId === tournament.groupId && "bg-gray-200 dark:bg-gray-900", )} > diff --git a/app/[lang]/tournois/page.tsx b/app/[lang]/tournois/page.tsx index 0eb39fb..415b57c 100644 --- a/app/[lang]/tournois/page.tsx +++ b/app/[lang]/tournois/page.tsx @@ -94,9 +94,10 @@ const getTournaments = async () => { ranges.some((d) => isSameDay(d, date)), ); - // We place each tournament into a group based on location and date, so that + // We place each tournament into a group based on location and date and time control, so that // we can display a single map marker. - const groupId = `${location}_${rangeIndex}`; + const timeControl = tcMap[t.time_control] ?? TimeControl.Other; + const groupId = `${location}_${rangeIndex}_${timeControl}`; return { id: t._id.toString(), @@ -106,7 +107,7 @@ const getTournaments = async () => { department: t.department, date: t.date, url: t.url, - timeControl: tcMap[t.time_control] ?? TimeControl.Other, + timeControl, latLng: { lat: t.coordinates[0], lng: t.coordinates[1] }, norm: t.norm_tournament, }; diff --git a/css/marker-cluster.css b/css/marker-cluster.css deleted file mode 100644 index b8c5347..0000000 --- a/css/marker-cluster.css +++ /dev/null @@ -1,31 +0,0 @@ -.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-Other { - background-color: rgba(211, 67, 84, 0.6); -} - -.marker-cluster-Other div { - background-color: rgba(203, 41, 60, 0.6); -} diff --git a/lib/pie.ts b/lib/pie.ts new file mode 100644 index 0000000..5ec9010 --- /dev/null +++ b/lib/pie.ts @@ -0,0 +1,77 @@ +function round(n: number) { + return Math.round(n * 10) / 10; +} + +function polarToCartesian(radius: number, angleInDegrees: number) { + var radians = (angleInDegrees - 90) * Math.PI / 180; + + return { + x: round(radius + (radius * Math.cos(radians))), + y: round(radius + (radius * Math.sin(radians))) + }; +} + +function getDAttribute(radius: number, startAngle: number, endAngle: number) { + const isCircle = endAngle - startAngle === 360; + + if (isCircle) { + endAngle--; + } + + const start = polarToCartesian(radius, startAngle); + const end = polarToCartesian(radius, endAngle); + + const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1; + const d = [ + "M", start.x, start.y, + "A", radius, radius, 0, largeArcFlag, 1, end.x, end.y]; + + if (isCircle) { + d.push("Z"); + } else { + d.push("L", radius, radius, "L", start.x, start.y, "Z"); + } + + return d.join(" "); +} + +function path(d: string, colour: string) { + return ``; +} + +function svg(className: string, width: number, content: string ) { + return `${content}`; +} + +type PieSector = { + value: number; + colour: string; +} + +export function pie(className: string, radius: number, values: PieSector[]) { + + type Sector = { + colour: string; + degrees: number; + from?: number; + to?: number; + path?: string; + } + + const total = values.reduce((a, b) => a + b.value, 0); + const data: Sector[] = values.map(({ value, colour }) => ({ colour, degrees: value / total * 360 })); + + data.forEach((value, i, arr) => { + if (i === 0) { + value.from = 0; + value.to = value.degrees; + } else { + value.from = arr[i - 1].to!; + value.to = value.from + value.degrees; + } + + value.path = path(getDAttribute(radius, value.from, value.to ), value.colour); + }); + + return svg(className, radius * 2, data.map(o => o.path).join('')); +}