mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Pie charts for clusters
This commit is contained in:
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
"cSpell.words": [
|
||||
"approximative",
|
||||
"colour",
|
||||
"colours",
|
||||
"contactez",
|
||||
"defaulticon",
|
||||
"Échecs",
|
||||
|
||||
@@ -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<Record<string, L.Marker<any> | null>>({});
|
||||
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
|
||||
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(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: "<div><span>" + childCount + "</span></div>",
|
||||
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 (
|
||||
<TournamentMarker
|
||||
ref={(ref) => (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(
|
||||
() => (
|
||||
<LayerGroup>
|
||||
<MarkerClusterGroup
|
||||
ref={clusterRef}
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
maxClusterRadius={40}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
</LayerGroup>
|
||||
),
|
||||
[createClusterCustomIcon, markers],
|
||||
const html = `
|
||||
<div>
|
||||
|
||||
${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",
|
||||
},
|
||||
])}
|
||||
<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 (
|
||||
<section id="tournament-map" className="flex h-content flex-col">
|
||||
<div className="p-3 lg:hidden">
|
||||
@@ -323,19 +325,19 @@ export default function TournamentMap() {
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{classic && (
|
||||
<TimeControlGroup timeControl={TimeControl.Classic} colour="green" />
|
||||
)}
|
||||
{rapid && (
|
||||
<TimeControlGroup timeControl={TimeControl.Rapid} colour="blue" />
|
||||
)}
|
||||
{blitz && (
|
||||
<TimeControlGroup timeControl={TimeControl.Blitz} colour="yellow" />
|
||||
)}
|
||||
{other && (
|
||||
<TimeControlGroup timeControl={TimeControl.Other} colour="red" />
|
||||
)}
|
||||
<LayerGroup>
|
||||
<MarkerClusterGroup
|
||||
ref={clusterRef}
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
maxClusterRadius={40}
|
||||
showCoverageOnHover={false}
|
||||
spiderfyOnMaxZoom
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
</LayerGroup>
|
||||
$
|
||||
</MapContainer>
|
||||
|
||||
<div className="flex items-center justify-center lg:hidden">
|
||||
|
||||
@@ -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 (
|
||||
<Marker
|
||||
ref={ref}
|
||||
position={position.current}
|
||||
position={latLng}
|
||||
icon={iconOptions}
|
||||
eventHandlers={{
|
||||
mouseover: () =>
|
||||
setHoveredMapTournamentGroupId(
|
||||
`${baseTournament.groupId}_${baseTournament.timeControl}`,
|
||||
),
|
||||
mouseover: () => setHoveredMapTournamentGroupId(groupId),
|
||||
mouseout: () => setHoveredMapTournamentGroupId(null),
|
||||
}}
|
||||
{...markerProps}
|
||||
@@ -75,7 +59,7 @@ export const TournamentMarker = forwardRef<
|
||||
<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}
|
||||
{date}
|
||||
{endDate !== startDate && ` - ${endDate}`}
|
||||
</b>
|
||||
|
||||
|
||||
@@ -121,13 +121,12 @@ export default function TournamentTable() {
|
||||
<tr
|
||||
key={tournament.id}
|
||||
id={tournament.id}
|
||||
data-group-id={`${tournament.groupId}_${tournament.timeControl}`}
|
||||
data-group-id={tournament.groupId}
|
||||
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",
|
||||
hoveredMapTournamentGroupId ===
|
||||
`${tournament.groupId}_${tournament.timeControl}` &&
|
||||
hoveredMapTournamentGroupId === tournament.groupId &&
|
||||
"bg-gray-200 dark:bg-gray-900",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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(''));
|
||||
}
|
||||
Reference in New Issue
Block a user