Pie charts for clusters

This commit is contained in:
Timothy Armes
2023-07-19 11:25:39 +02:00
parent 8c38ed7991
commit 924c799208
7 changed files with 190 additions and 157 deletions
+1
View File
@@ -2,6 +2,7 @@
"cSpell.words": [ "cSpell.words": [
"approximative", "approximative",
"colour", "colour",
"colours",
"contactez", "contactez",
"defaulticon", "defaulticon",
"Échecs", "Échecs",
+99 -97
View File
@@ -13,24 +13,20 @@ import {
} from "react-leaflet"; } from "react-leaflet";
import { FaAngleDoubleDown } from "react-icons/fa"; import { FaAngleDoubleDown } from "react-icons/fa";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { groupBy } from "lodash"; import { countBy, groupBy } from "lodash";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "@/css/marker-cluster.css";
import "leaflet-defaulticon-compatibility"; import "leaflet-defaulticon-compatibility";
import "leaflet.smooth_marker_bouncing"; import "leaflet.smooth_marker_bouncing";
import { import {
mapBoundsAtom, mapBoundsAtom,
debouncedHoveredListTournamentIdAtom, debouncedHoveredListTournamentIdAtom,
tournamentsAtom, filteredTournamentsByTimeControlAtom,
classicAtom,
rapidAtom,
blitzAtom,
otherAtom,
normsOnlyAtom, normsOnlyAtom,
} from "@/app/atoms"; } from "@/app/atoms";
import { pie } from "@/lib/pie";
import Legend from "./Legend"; import Legend from "./Legend";
import { TournamentMarker } from "./TournamentMarker"; import { TournamentMarker } from "./TournamentMarker";
@@ -68,16 +64,11 @@ const MapEvents = () => {
map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds)); map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds));
map.setMaxBounds(worldBounds); map.setMaxBounds(worldBounds);
map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging
}, [map]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return null; return null;
}; };
type TimeControlGroupProps = {
timeControl: TimeControl;
colour: string;
};
const stopBouncingMarkers = () => { const stopBouncingMarkers = () => {
const markers = const markers =
// @ts-ignore // @ts-ignore
@@ -99,27 +90,38 @@ const stopBouncingMarkers = () => {
}); });
}; };
const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => { export default function TournamentMap() {
const tournaments = useAtomValue(tournamentsAtom); 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, L.Marker<any> | null>>({}); const markerRefs = useRef<Record<string, L.Marker<any> | null>>({});
const clusterRef = useRef<L.MarkerClusterGroup | null>(null); const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null); const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
const filteredTournaments = useMemo( const filteredTournaments = useMemo(
() => () => tournaments.filter((t) => (normsOnly ? t.norm : true)),
tournaments [normsOnly, tournaments],
.filter((t) => t.timeControl === timeControl)
.filter((t) => (normsOnly ? t.norm : true)),
[normsOnly, timeControl, tournaments],
); );
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId); const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
const hoveredListTournamentId = useAtomValue( useEffect(() => {
debouncedHoveredListTournamentIdAtom, 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(() => { const expandAndBounceIfNeeded = useCallback(() => {
if (hoveredListTournamentId) { if (hoveredListTournamentId) {
@@ -231,73 +233,73 @@ const TimeControlGroup = ({ timeControl, colour }: TimeControlGroupProps) => {
expandedClusterMarkerRef.current.unspiderfy(); expandedClusterMarkerRef.current.unspiderfy();
}, [expandAndBounceIfNeeded, hoveredListTournamentId]); }, [expandAndBounceIfNeeded, hoveredListTournamentId]);
const createClusterCustomIcon = useCallback( const createClusterCustomIcon = useCallback((cluster: any) => {
(cluster: any) => { let childCount = cluster.getChildCount();
let childCount = cluster.getChildCount();
return new L.DivIcon({ const children = cluster.getAllChildMarkers();
html: "<div><span>" + childCount + "</span></div>",
className: `marker-cluster marker-cluster-${timeControl}`,
iconSize: new L.Point(40, 40),
});
},
[timeControl],
);
const markers = Object.values(groupedTournaments).map((tournamentGroup) => { // We added the time control to the icon options when creating the marker
return ( const timeControlCounts = countBy(
<TournamentMarker children,
ref={(ref) => (markerRefs.current[tournamentGroup[0].groupId] = ref)} (child: any) => child.options.icon.options.timeControl,
key={tournamentGroup[0].groupId}
tournamentGroup={tournamentGroup}
colour={colour}
/>
); );
});
const group = useMemo( const html = `
() => ( <div>
<LayerGroup>
<MarkerClusterGroup ${pie("absolute w-[30px] -z-10", 15, [
ref={clusterRef} {
chunkedLoading value: timeControlCounts[TimeControl.Classic] ?? 0,
iconCreateFunction={createClusterCustomIcon} colour: "#00ac39",
maxClusterRadius={40} },
> {
{markers} value: timeControlCounts[TimeControl.Rapid] ?? 0,
</MarkerClusterGroup> colour: "#0086c7",
</LayerGroup> },
), {
[createClusterCustomIcon, markers], value: timeControlCounts[TimeControl.Blitz] ?? 0,
colour: "#cec348",
},
{
value: timeControlCounts[TimeControl.Other] ?? 0,
colour: "#d10c3e",
},
])}
<span class="text-white font-semibold relative z-[300]">${childCount}</span>
</div>
`;
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 (
<TournamentMarker
ref={(ref) => (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 ( return (
<section id="tournament-map" className="flex h-content flex-col"> <section id="tournament-map" className="flex h-content flex-col">
<div className="p-3 lg:hidden"> <div className="p-3 lg:hidden">
@@ -323,19 +325,19 @@ export default function TournamentMap() {
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
<Legend /> <Legend />
<LayerGroup>
{classic && ( <MarkerClusterGroup
<TimeControlGroup timeControl={TimeControl.Classic} colour="green" /> ref={clusterRef}
)} chunkedLoading
{rapid && ( iconCreateFunction={createClusterCustomIcon}
<TimeControlGroup timeControl={TimeControl.Rapid} colour="blue" /> maxClusterRadius={40}
)} showCoverageOnHover={false}
{blitz && ( spiderfyOnMaxZoom
<TimeControlGroup timeControl={TimeControl.Blitz} colour="yellow" /> >
)} {markers}
{other && ( </MarkerClusterGroup>
<TimeControlGroup timeControl={TimeControl.Other} colour="red" /> </LayerGroup>
)} $
</MapContainer> </MapContainer>
<div className="flex items-center justify-center lg:hidden"> <div className="flex items-center justify-center lg:hidden">
+7 -23
View File
@@ -22,21 +22,7 @@ export const TournamentMarker = forwardRef<
>(({ tournamentGroup, colour, ...markerProps }, ref) => { >(({ tournamentGroup, colour, ...markerProps }, ref) => {
const t = useTranslations("Tournaments"); const t = useTranslations("Tournaments");
const baseTournament = tournamentGroup[0]; const { date, latLng, groupId, timeControl } = 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 setHoveredMapTournamentGroupId = useSetAtom( const setHoveredMapTournamentGroupId = useSetAtom(
debouncedHoveredMapTournamentGroupIdAtom, debouncedHoveredMapTournamentGroupIdAtom,
@@ -51,23 +37,21 @@ export const TournamentMarker = forwardRef<
iconAnchor: [12, 41], iconAnchor: [12, 41],
popupAnchor: [1, -34], popupAnchor: [1, -34],
shadowSize: [41, 41], shadowSize: [41, 41],
timeControl,
}), }),
[colour], [colour, timeControl],
); );
const startDate = baseTournament.date; const startDate = date;
const endDate = last(tournamentGroup)!.date; const endDate = last(tournamentGroup)!.date;
return ( return (
<Marker <Marker
ref={ref} ref={ref}
position={position.current} position={latLng}
icon={iconOptions} icon={iconOptions}
eventHandlers={{ eventHandlers={{
mouseover: () => mouseover: () => setHoveredMapTournamentGroupId(groupId),
setHoveredMapTournamentGroupId(
`${baseTournament.groupId}_${baseTournament.timeControl}`,
),
mouseout: () => setHoveredMapTournamentGroupId(null), mouseout: () => setHoveredMapTournamentGroupId(null),
}} }}
{...markerProps} {...markerProps}
@@ -75,7 +59,7 @@ export const TournamentMarker = forwardRef<
<Popup maxWidth={10000}> <Popup maxWidth={10000}>
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]"> <div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
<b> <b>
{baseTournament.date} {date}
{endDate !== startDate && ` - ${endDate}`} {endDate !== startDate && ` - ${endDate}`}
</b> </b>
+2 -3
View File
@@ -121,13 +121,12 @@ export default function TournamentTable() {
<tr <tr
key={tournament.id} key={tournament.id}
id={tournament.id} id={tournament.id}
data-group-id={`${tournament.groupId}_${tournament.timeControl}`} data-group-id={tournament.groupId}
onMouseEnter={() => setHoveredListTournamentId(tournament.id)} onMouseEnter={() => setHoveredListTournamentId(tournament.id)}
onMouseLeave={() => setHoveredListTournamentId(null)} onMouseLeave={() => setHoveredListTournamentId(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 === hoveredMapTournamentGroupId === tournament.groupId &&
`${tournament.groupId}_${tournament.timeControl}` &&
"bg-gray-200 dark:bg-gray-900", "bg-gray-200 dark:bg-gray-900",
)} )}
> >
+4 -3
View File
@@ -94,9 +94,10 @@ const getTournaments = async () => {
ranges.some((d) => isSameDay(d, date)), 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. // 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 { return {
id: t._id.toString(), id: t._id.toString(),
@@ -106,7 +107,7 @@ const getTournaments = async () => {
department: t.department, department: t.department,
date: t.date, date: t.date,
url: t.url, url: t.url,
timeControl: tcMap[t.time_control] ?? TimeControl.Other, timeControl,
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] }, latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
norm: t.norm_tournament, norm: t.norm_tournament,
}; };
-31
View File
@@ -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);
}
+77
View File
@@ -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 `<path d='${d}' style="fill: ${colour}" />`;
}
function svg(className: string, width: number, content: string ) {
return `<svg class="${className}" viewBox="0 0 ${width} ${width}"><g class='pie'>${content}</g></svg>`;
}
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(''));
}