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 = () => { >
- -
- -
+ "flex flex-1", + }} + /> +
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 ( +