diff --git a/package.json b/package.json
index e944f35..a7a0cde 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"@next/bundle-analyzer": "^14.0.4",
"@tanstack/react-query": "^5.29.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
+ "@turf/boolean-point-in-polygon": "^7.0.0-alpha.114",
"@types/node": "^20.8.4",
"autoprefixer": "^10.4.16",
"date-fns": "^2.30.0",
diff --git a/src/app/[locale]/(main)/clubs/ClubMap.tsx b/src/app/[locale]/(main)/clubs/ClubMap.tsx
index ba4a007..ffd7950 100644
--- a/src/app/[locale]/(main)/clubs/ClubMap.tsx
+++ b/src/app/[locale]/(main)/clubs/ClubMap.tsx
@@ -1,22 +1,20 @@
"use client";
-import { useCallback, useMemo } from "react";
+import { useMemo } from "react";
import { useAtomValue } from "jotai";
-import L, { LatLngLiteral } 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 { countBy, groupBy } from "lodash";
-import { clubsAtom } from "@/atoms";
+import { filteredClubsByZoneAtom } from "@/atoms";
import { Map, MapMarker } from "@/components/Map";
import { ClubMarker } from "./ClubMarker";
const ClubMap = () => {
- const clubs = useAtomValue(clubsAtom);
+ const clubs = useAtomValue(filteredClubsByZoneAtom);
const markers: MapMarker[] = useMemo(
() =>
diff --git a/src/app/[locale]/(main)/clubs/ClubTable.tsx b/src/app/[locale]/(main)/clubs/ClubTable.tsx
index ac3cfd6..a8d4a6c 100644
--- a/src/app/[locale]/(main)/clubs/ClubTable.tsx
+++ b/src/app/[locale]/(main)/clubs/ClubTable.tsx
@@ -2,7 +2,7 @@
import { useEffect } from "react";
-import { useAtom, useAtomValue, useSetAtom } from "jotai";
+import { useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl";
import { FaExternalLinkAlt } from "react-icons/fa";
import { twMerge } from "tailwind-merge";
@@ -12,17 +12,15 @@ import {
debouncedHoveredMapIdAtom,
filteredClubsListAtom,
hoveredMapIdAtom,
- syncVisibleAtom,
} from "@/atoms";
+import { RegionSelect } from "@/components/RegionSelect";
import SearchBar from "@/components/SearchBar";
import { useBreakpoint } from "@/hooks/tailwind";
const ClubTable = () => {
const t = useTranslations("Clubs");
- const at = useTranslations("App");
const filteredClubs = useAtomValue(filteredClubsListAtom);
- const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
const hoveredMapId = useAtomValue(hoveredMapIdAtom);
const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom);
const setHoveredListId = useSetAtom(debouncedHoveredListIdAtom);
@@ -45,18 +43,12 @@ const ClubTable = () => {
>
diff --git a/src/app/[locale]/(main)/tournaments/Legend.tsx b/src/app/[locale]/(main)/tournaments/Legend.tsx
index 9e09523..488b24f 100644
--- a/src/app/[locale]/(main)/tournaments/Legend.tsx
+++ b/src/app/[locale]/(main)/tournaments/Legend.tsx
@@ -5,14 +5,14 @@ import L from "leaflet";
import { useTranslations } from "next-intl";
import { useMap } from "react-leaflet";
-import { filteredTournamentsByTimeControlAtom } from "@/atoms";
+import { filteredTournamentsByTimeControlAndZoneAtom } from "@/atoms";
import { TimeControlColours } from "@/constants";
import { TimeControl } from "@/types";
const Legend = () => {
const at = useTranslations("App");
const map = useMap();
- const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
+ const tournaments = useAtomValue(filteredTournamentsByTimeControlAndZoneAtom);
const timeControls = useMemo(
() =>
diff --git a/src/app/[locale]/(main)/tournaments/TournamentMap.tsx b/src/app/[locale]/(main)/tournaments/TournamentMap.tsx
index 5ee6e4f..c98ed28 100644
--- a/src/app/[locale]/(main)/tournaments/TournamentMap.tsx
+++ b/src/app/[locale]/(main)/tournaments/TournamentMap.tsx
@@ -10,7 +10,10 @@ import "leaflet.smooth_marker_bouncing";
import "leaflet/dist/leaflet.css";
import { countBy, groupBy } from "lodash";
-import { filteredTournamentsByTimeControlAtom, normsOnlyAtom } from "@/atoms";
+import {
+ filteredTournamentsByTimeControlAndZoneAtom,
+ normsOnlyAtom,
+} from "@/atoms";
import { Map, MapMarker } from "@/components/Map";
import { TimeControlColours } from "@/constants";
import { generatePieSVG } from "@/lib/pie";
@@ -21,7 +24,7 @@ import TimeControlFilters from "./TimeControlFilters";
import { TournamentMarker } from "./TournamentMarker";
const TournamentMap = () => {
- const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
+ const tournaments = useAtomValue(filteredTournamentsByTimeControlAndZoneAtom);
const normsOnly = useAtomValue(normsOnlyAtom);
const filteredTournaments = useMemo(
diff --git a/src/app/[locale]/(main)/tournaments/TournamentTable.tsx b/src/app/[locale]/(main)/tournaments/TournamentTable.tsx
index 6cc4475..f2f9041 100644
--- a/src/app/[locale]/(main)/tournaments/TournamentTable.tsx
+++ b/src/app/[locale]/(main)/tournaments/TournamentTable.tsx
@@ -18,9 +18,11 @@ import {
filteredTournamentsListAtom,
hoveredMapIdAtom,
normsOnlyAtom,
- syncVisibleAtom,
+ regionFilterAtom,
} from "@/atoms";
+import { RegionSelect } from "@/components/RegionSelect";
import SearchBar from "@/components/SearchBar";
+import { Select } from "@/components/form/Select";
import { useBreakpoint } from "@/hooks/tailwind";
import useDatePickerWidth from "@/hooks/useDatePickerWidth";
import { DatePickerDirection } from "@/types";
@@ -36,7 +38,6 @@ const TournamentTable = () => {
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
- const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
const hoveredMapId = useAtomValue(hoveredMapIdAtom);
const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom);
@@ -87,16 +88,6 @@ const TournamentTable = () => {
/>
diff --git a/src/atoms.ts b/src/atoms.ts
index cbd0fbb..bc5d26a 100644
--- a/src/atoms.ts
+++ b/src/atoms.ts
@@ -1,6 +1,6 @@
+import booleanPointInPolygon from "@turf/boolean-point-in-polygon";
import {
endOfDay,
- formatISO,
isAfter,
isBefore,
parse,
@@ -8,9 +8,9 @@ import {
startOfDay,
} from "date-fns";
import { fr } from "date-fns/locale";
+import { FeatureCollection } from "geojson";
import { atom } from "jotai";
import { LatLngBounds } from "leaflet";
-import { c } from "next-safe-action/dist/index-EKyvnpX_.mjs";
import { Club, TimeControl, Tournament } from "@/types";
import atomWithDebounce from "@/utils/atomWithDebounce";
@@ -18,9 +18,18 @@ import { normalizedContains } from "@/utils/string";
setDefaultOptions({ locale: fr });
+type RegionFilter =
+ | "all"
+ | "map"
+ | {
+ id: string;
+ name: string;
+ features: FeatureCollection;
+ };
+
export const burgerMenuIsOpenAtom = atom(false);
export const mapBoundsAtom = atom(null);
-export const syncVisibleAtom = atom(true);
+export const regionFilterAtom = atom("map");
export const searchStringAtom = atom("");
export const tournamentsAtom = atom([]);
@@ -41,8 +50,9 @@ export const {
export const { debouncedValueAtom: debouncedHoveredListIdAtom } =
atomWithDebounce(null, 1000, 100);
-export const filteredTournamentsByTimeControlAtom = atom((get) => {
+export const filteredTournamentsByTimeControlAndZoneAtom = atom((get) => {
const tournaments = get(tournamentsAtom);
+ const regionFilter = get(regionFilterAtom);
const classic = get(classicAtom);
const rapid = get(rapidAtom);
@@ -52,30 +62,43 @@ export const filteredTournamentsByTimeControlAtom = atom((get) => {
const dateRange = get(dateRangeAtom);
const { startDate, endDate } = dateRange[0];
- const filteredTournaments = tournaments.filter((tournament) => {
+ const filterByTimeControl = tournaments.filter((tournament) => {
const tournamentDate = startOfDay(
parse(tournament.date, "dd/MM/yyyy", new Date()),
);
return (
!isBefore(tournamentDate, startDate) &&
- (endDate === undefined || !isAfter(tournamentDate, endDate)) &&
- !tournament.pending &&
- tournament.status === "scheduled" &&
- ((tournament.timeControl === TimeControl.Classic && classic) ||
- (tournament.timeControl === TimeControl.Rapid && rapid) ||
- (tournament.timeControl === TimeControl.Blitz && blitz) ||
- (tournament.timeControl === TimeControl.Other && other))
+ (endDate === undefined ||
+ (!isAfter(tournamentDate, endDate) &&
+ !tournament.pending &&
+ tournament.status === "scheduled" &&
+ ((tournament.timeControl === TimeControl.Classic && classic) ||
+ (tournament.timeControl === TimeControl.Rapid && rapid) ||
+ (tournament.timeControl === TimeControl.Blitz && blitz) ||
+ (tournament.timeControl === TimeControl.Other && other))))
);
});
- return filteredTournaments;
+ if (regionFilter === "all" || regionFilter === "map")
+ return filterByTimeControl;
+
+ return filterByTimeControl.filter((tournament) => {
+ return regionFilter.features?.features?.some(
+ (feature) =>
+ feature.geometry.type === "Polygon" &&
+ booleanPointInPolygon(
+ [tournament.latLng.lng, tournament.latLng.lat],
+ feature.geometry,
+ ),
+ );
+ });
});
export const filteredTournamentsListAtom = atom((get) => {
- const tournaments = get(filteredTournamentsByTimeControlAtom);
+ const tournaments = get(filteredTournamentsByTimeControlAndZoneAtom);
const mapBounds = get(mapBoundsAtom);
- const syncVisible = get(syncVisibleAtom);
+ const regionFilter = get(regionFilterAtom);
const normsOnly = get(normsOnlyAtom);
const searchString = get(searchStringAtom).trim();
@@ -93,23 +116,41 @@ export const filteredTournamentsListAtom = atom((get) => {
}
// If we not syncing to the map, return all tournaments
- if (mapBounds === null || !syncVisible) return filteredByNorm;
+ if (mapBounds === null || regionFilter !== "map") return filteredByNorm;
- // Filter by those in the current map bounds
+ // Filter by the map bounds
return filteredByNorm.filter((tournament) =>
mapBounds.contains(tournament.latLng),
);
});
-export const filteredClubsListAtom = atom((get) => {
+export const filteredClubsByZoneAtom = atom((get) => {
const clubs = get(clubsAtom);
+ const regionFilter = get(regionFilterAtom);
+
+ if (regionFilter === "all" || regionFilter === "map") return clubs;
+
+ return clubs.filter((club) => {
+ return regionFilter.features?.features?.some(
+ (feature) =>
+ feature.geometry.type === "Polygon" &&
+ booleanPointInPolygon(
+ [club.latLng.lng, club.latLng.lat],
+ feature.geometry,
+ ),
+ );
+ });
+});
+
+export const filteredClubsListAtom = atom((get) => {
+ const filteredByZone = get(filteredClubsByZoneAtom);
const mapBounds = get(mapBoundsAtom);
- const syncVisible = get(syncVisibleAtom);
+ const regionFilter = get(regionFilterAtom);
const searchString = get(searchStringAtom).trim();
// When searching, we search all the tournament, regardless of the map display
if (searchString !== "") {
- return clubs.filter(
+ return filteredByZone.filter(
(club) =>
normalizedContains(club.name, searchString) ||
(club.address && normalizedContains(club.address, searchString)),
@@ -117,10 +158,10 @@ export const filteredClubsListAtom = atom((get) => {
}
// If we not syncing to the map, return all clubs
- if (mapBounds === null || !syncVisible) return clubs;
+ if (mapBounds === null || regionFilter !== "map") return filteredByZone;
- // Filter by those in the current map bounds
- return clubs.filter((club) => mapBounds.contains(club.latLng));
+ // Filter by the map bounds
+ return filteredByZone.filter((club) => mapBounds.contains(club.latLng));
});
// Date picker atoms
diff --git a/src/components/RegionSelect.tsx b/src/components/RegionSelect.tsx
new file mode 100644
index 0000000..f0246d6
--- /dev/null
+++ b/src/components/RegionSelect.tsx
@@ -0,0 +1,58 @@
+import { useAtom } from "jotai";
+import { useTranslations } from "next-intl";
+import { SingleValue } from "react-select";
+
+import { regionFilterAtom } from "@/atoms";
+import { BaseOption, Select, SelectProps } from "@/components/form/Select";
+import { useZones } from "@/hooks/useZones";
+import { Zone } from "@/server/myZones";
+
+type RegionSelectProps = Omit;
+
+export const RegionSelect = (selectProps: RegionSelectProps) => {
+ const t = useTranslations("Tournaments");
+ const [regionFilter, setRegionFilter] = useAtom(regionFilterAtom);
+ const { zones } = useZones();
+
+ const zoneFilterOptions: BaseOption[] = [
+ {
+ value: "map",
+ label: t("syncWithMapOption"),
+ data: null,
+ },
+ {
+ value: "all",
+ label: t("ignoreMapOption"),
+ data: null,
+ },
+
+ ...zones.map((zone) => ({
+ value: zone.id,
+ label: zone.name,
+ data: zone,
+ })),
+ ];
+
+ const onChange = (option: SingleValue>) => {
+ if (!option) return;
+
+ if (option.value === "map" || option.value === "all")
+ setRegionFilter(option.value);
+ else setRegionFilter(option.data!);
+ };
+
+ return (
+