diff --git a/frontend/components/MapOverlay.tsx b/frontend/components/MapOverlay.tsx index 0b96b455..ab12dd81 100644 --- a/frontend/components/MapOverlay.tsx +++ b/frontend/components/MapOverlay.tsx @@ -1,18 +1,19 @@ import React from "react"; import { Card } from "components/ui/card"; import { Button } from "components/ui/button"; -import { XIcon } from "lucide-react"; +import { ArrowLeftIcon, XIcon } from "lucide-react"; import { useModal } from "stores/modals"; import { cn } from "lib/utils"; type Props = { onClose: () => void; + closeVariant?: "dismiss" | "back"; className?: string; children: React.ReactNode; title: string; }; -export default function MapOverlay({ onClose, title, className, children }: Props) { +export default function MapOverlay({ onClose, closeVariant = "dismiss", title, className, children }: Props) { const { modalId } = useModal(); React.useEffect(() => { @@ -35,8 +36,14 @@ export default function MapOverlay({ onClose, title, className, children }: Prop

{title}

{children}
- diff --git a/frontend/components/Mapbox.tsx b/frontend/components/Mapbox.tsx index 6f655dca..1633bb43 100644 --- a/frontend/components/Mapbox.tsx +++ b/frontend/components/Mapbox.tsx @@ -3,7 +3,7 @@ import Map, { Marker, Source, Layer, GeolocateControl } from "react-map-gl"; import { Marker as MarkerT } from "lib/types"; import { Trip, CustomMarker } from "@birdplan/shared"; import { MarkerIconT } from "lib/icons"; -import { markerColors, getLatLngFromBounds } from "lib/helpers"; +import { markerColors, getLatLngFromBounds, layerHasFrequency } from "lib/helpers"; import MarkerWithIcon from "components/MarkerWithIcon"; import clsx from "clsx"; import { useModal } from "stores/modals"; @@ -92,14 +92,18 @@ export default function Mapbox({ }, }; + const hasFrequencyData = layerHasFrequency(obsLayer); + const obsLayerStyle = { id: "obs", type: "circle", paint: { - "circle-radius": isMobile ? 8 : 7, + "circle-radius": hasFrequencyData ? (isMobile ? 7 : 6) : isMobile ? 8 : 7, "circle-stroke-width": 0.75, "circle-stroke-color": "#555", - "circle-color": ["match", ["get", "isPersonal"], "true", "#555", "#ce0d02"], + "circle-color": hasFrequencyData + ? ["match", ["get", "colorIndex"], ...markerColors.flatMap((color, i) => [i, color]), markerColors[3]] + : ["match", ["get", "isPersonal"], "true", "#555", "#ce0d02"], }, }; @@ -171,7 +175,12 @@ export default function Mapbox({ handleHotspotClick(marker.id); }} > - + ))} {customMarkers?.map((marker) => ( @@ -214,7 +223,7 @@ export default function Mapbox({ )} - {obsLayer && ( + {obsLayer && !hasFrequencyData && (
Personal Location @@ -224,6 +233,21 @@ export default function Mapbox({
)} + {obsLayer && hasFrequencyData && ( +
+ Chance during trip dates: + {[ + [markerColors[3], "<5%"], + [markerColors[5], "10%"], + [markerColors[7], "40%"], + [markerColors[9], "80%+"], + ].map(([color, caption]) => ( + + {caption} + + ))} +
+ )}
{/* Prevents map from panning when close PWA on iOS with no home button */}
diff --git a/frontend/components/MarkerWithIcon.tsx b/frontend/components/MarkerWithIcon.tsx index 90f5c106..d57587ae 100644 --- a/frontend/components/MarkerWithIcon.tsx +++ b/frontend/components/MarkerWithIcon.tsx @@ -11,7 +11,7 @@ type Props = { highlight?: boolean; }; -export default function MarkerWithIcon({ icon, darkIcon, showStroke = true, className, highlight }: Props) { +export default function MarkerWithIcon({ icon, darkIcon, color, showStroke = true, className, highlight }: Props) { const iconData = markerIcons[icon]; if (!iconData) return null; return ( @@ -22,7 +22,7 @@ export default function MarkerWithIcon({ icon, darkIcon, showStroke = true, clas className )} style={{ - backgroundColor: iconData.color, + backgroundColor: color ?? iconData.color, }} > {highlight && ( diff --git a/frontend/components/SpeciesHero.tsx b/frontend/components/SpeciesHero.tsx index cb8990a2..d85d199b 100644 --- a/frontend/components/SpeciesHero.tsx +++ b/frontend/components/SpeciesHero.tsx @@ -142,7 +142,7 @@ export default function SpeciesHero({ name, scientificName, code, starred, mutua - Recent Reports Map + Show on Map } diff --git a/frontend/components/SpeciesMapOverlay.tsx b/frontend/components/SpeciesMapOverlay.tsx index 8d0edfc8..fe981587 100644 --- a/frontend/components/SpeciesMapOverlay.tsx +++ b/frontend/components/SpeciesMapOverlay.tsx @@ -1,37 +1,151 @@ import React from "react"; +import toast from "react-hot-toast"; import MapBox from "components/Mapbox"; import MapOverlay from "components/MapOverlay"; +import SegmentedControl from "components/SegmentedControl"; +import MapButton from "components/MapButton"; +import Icon from "components/Icon"; import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; +import useFetchSpeciesObs from "hooks/useFetchSpeciesObs"; +import useSpeciesHotspotRankings from "hooks/useSpeciesHotspotRankings"; +import { buildFrequencyLayer, filterLayer, frequencyColorIndex, markerColors } from "lib/helpers"; +import MarkerWithIcon from "components/MarkerWithIcon"; +import { useMapPreferences } from "stores/mapPreferences"; import { Button } from "components/ui/button"; +type MapMode = "trip" | "recent"; + type Props = { onOutsideClick: (e: React.MouseEvent) => void; - onHotspotClick: (id: string) => void; - obsLayer: React.ComponentProps["obsLayer"]; }; -export default function SpeciesMapOverlay({ onOutsideClick, onHotspotClick, obsLayer }: Props) { +export default function SpeciesMapOverlay({ onOutsideClick }: Props) { const { trip, selectedSpecies, setSelectedSpecies } = useTrip(); + const { open } = useModal(); + const [mode, setMode] = React.useState("trip"); + const showPersonalLocations = useMapPreferences((state) => state.showPersonalLocations); + const setShowPersonalLocations = useMapPreferences((state) => state.setShowPersonalLocations); + const [savedHotspotsOnly, setSavedHotspotsOnly] = React.useState(false); + const [prevCode, setPrevCode] = React.useState(selectedSpecies?.code); + + if (selectedSpecies?.code !== prevCode) { + setPrevCode(selectedSpecies?.code); + setSavedHotspotsOnly(false); + } + + const savedHotspots = trip?.hotspots ?? []; + const savedIds = new Set(savedHotspots.map((it) => it.id)); + + const { obs, obsLayer } = useFetchSpeciesObs({ region: trip?.region, code: selectedSpecies?.code }); + const regionHotspots = useSpeciesHotspotRankings(selectedSpecies?.code); + const savedRanked = useSpeciesHotspotRankings( + mode === "trip" ? selectedSpecies?.code : undefined, + savedHotspots.map((it) => it.id) + ); + if (!selectedSpecies) return null; + const savedFrequency = new Map(savedRanked.map((it) => [it.id, it.frequency])); + const regionLayer = buildFrequencyLayer(regionHotspots, savedIds); + const recentLayer = filterLayer( + obsLayer, + (it) => !savedIds.has(it.id) && (showPersonalLocations || it.isPersonal !== "true") + ); + const layer = savedHotspotsOnly ? null : mode === "trip" ? regionLayer : recentLayer; + + const reportedIds = new Set(obs.map((it) => it.id)); + + const markers = savedHotspots.map((it) => { + const frequency = savedFrequency.get(it.id); + const hasDataForMode = mode === "trip" ? frequency != null : reportedIds.has(it.id); + return { + id: it.id, + lat: it.lat, + lng: it.lng, + color: mode === "trip" ? markerColors[frequency == null ? 0 : frequencyColorIndex(frequency)] : undefined, + faded: !hasDataForMode, + }; + }); + + const personalDisabled = mode === "trip" || savedHotspotsOnly; + + const subtitle = + mode === "trip" + ? "Hotspots shaded by how often the species is reported during your trip dates." + : "Reports from the last 30 days."; + + const handleClick = (id: string) => { + const observation = obs.find((it) => it.id === id); + const target = savedHotspots.find((it) => it.id === id) || observation || regionHotspots.find((it) => it.id === id); + if (!target) return toast.error("Location not found"); + open(observation?.isPersonal ? "personalLocation" : "hotspot", { + hotspot: target, + speciesCode: selectedSpecies.code, + speciesName: selectedSpecies.name, + }); + }; + return (
- setSelectedSpecies(undefined)} title={selectedSpecies.name}> - Showing reports over the last 30 days.{" "} - -
{trip?.bounds && ( - + )} +
+ setSelectedSpecies(undefined)} + closeVariant="back" + title={selectedSpecies.name} + className="relative left-0 top-0 w-full max-w-none translate-x-0" + > + {subtitle}{" "} + + + + value={mode} + onChange={setMode} + options={[ + { value: "trip", label: "Trip dates" }, + { value: "recent", label: "Recent sightings" }, + ]} + /> + setSavedHotspotsOnly(!savedHotspotsOnly)} + tooltip={savedHotspotsOnly ? "Show all hotspots" : "Show only saved hotspots"} + active={savedHotspotsOnly} + > + + + setShowPersonalLocations(!showPersonalLocations)} + tooltip={ + personalDisabled + ? "Personal locations are only in recent sightings" + : showPersonalLocations + ? "Hide personal locations" + : "Show personal locations" + } + active={showPersonalLocations && !personalDisabled} + disabled={personalDisabled} + > + + +
); diff --git a/frontend/hooks/useCloseOnOutsideClick.ts b/frontend/hooks/useCloseOnOutsideClick.ts index 595b7b23..ab33f0ee 100644 --- a/frontend/hooks/useCloseOnOutsideClick.ts +++ b/frontend/hooks/useCloseOnOutsideClick.ts @@ -9,7 +9,8 @@ export default function useCloseOnOutsideClick() { !target.closest("button") && !target.closest("a") && !target.closest('[role="button"]') && - !target.closest(".mapboxgl-canvas") + !target.closest(".mapboxgl-canvas") && + !target.closest(".mapboxgl-marker") ) { close(); } diff --git a/frontend/hooks/useSpeciesHotspotRankings.ts b/frontend/hooks/useSpeciesHotspotRankings.ts new file mode 100644 index 00000000..aac42f17 --- /dev/null +++ b/frontend/hooks/useSpeciesHotspotRankings.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { OPENBIRDING_API_URL } from "lib/config"; +import { getMonthRange } from "lib/targets"; +import { useTrip } from "hooks/useTrip"; +import type { OpenBirdingHotspotRankingResponse } from "@birdplan/shared"; + +const HOTSPOT_LIMIT = 500; + +export default function useSpeciesHotspotRankings(code?: string, locationIds?: string[]) { + const { trip } = useTrip(); + const months = trip ? getMonthRange(trip.startMonth, trip.endMonth) : []; + const scope = locationIds ? locationIds.join(",") : trip?.region; + + const { data } = useQuery({ + queryKey: ["openbirding-trip-hotspots", code, scope, months.join(",")], + queryFn: async () => { + const res = await fetch(`${OPENBIRDING_API_URL}/api/v1/hotspots/species/${code}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...(locationIds ? { locationIds } : { region: trip?.region, limit: HOTSPOT_LIMIT }), + months, + sortBy: "best", + }), + }); + if (!res.ok) throw new Error("Failed to fetch hotspot rankings"); + return res.json(); + }, + enabled: !!code && !!OPENBIRDING_API_URL && (locationIds ? locationIds.length > 0 : !!trip?.region), + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + return data?.items ?? []; +} diff --git a/frontend/lib/helpers.ts b/frontend/lib/helpers.ts index 99435dd7..d408918f 100644 --- a/frontend/lib/helpers.ts +++ b/frontend/lib/helpers.ts @@ -126,6 +126,42 @@ export const getMarkerColorIndex = (count: number) => { return markerColors.indexOf(color); }; +export const filterLayer = (layer: any, keep: (properties: any) => boolean) => + layer && { ...layer, features: layer.features.filter((it: any) => keep(it.properties ?? {})) }; + +export const layerHasFrequency = (layer?: any) => + !!layer?.features?.some((it: any) => it.properties?.hasFrequency === "true"); + +export const frequencyColorIndex = (frequency: number) => { + if (frequency >= 80) return 9; + if (frequency >= 60) return 8; + if (frequency >= 40) return 7; + if (frequency >= 20) return 6; + if (frequency >= 10) return 5; + if (frequency >= 5) return 4; + return 3; +}; + +export const buildFrequencyLayer = ( + hotspots: { id: string; lat: number; lng: number; frequency: number }[], + savedIds: Set +): GeoJSON.FeatureCollection | null => { + const unsaved = hotspots.filter((it) => !savedIds.has(it.id)); + if (unsaved.length === 0) return null; + return { + type: "FeatureCollection", + features: unsaved.map((hotspot) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [hotspot.lng, hotspot.lat] }, + properties: { + id: hotspot.id, + hasFrequency: "true", + colorIndex: frequencyColorIndex(hotspot.frequency), + }, + })), + }; +}; + export const buildHotspotsLayer = ( hotspots: eBirdHotspot[], savedHotspots: Hotspot[] diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index a9c1ecd5..f12676b9 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -5,6 +5,8 @@ export type Marker = { lng: number; id: string; shade?: number; + color?: string; + faded?: boolean; }; export type CustomMarker = { diff --git a/frontend/modals/Hotspot.tsx b/frontend/modals/Hotspot.tsx index ecbe3c95..421ccf03 100644 --- a/frontend/modals/Hotspot.tsx +++ b/frontend/modals/Hotspot.tsx @@ -129,11 +129,11 @@ export default function Hotspot({ hotspot }: Props) { const hasSpecies = !!modalSpecies && location.pathname.includes("targets"); React.useEffect(() => { - if (hasSpecies) { + if (isSaved) { + setHalo(undefined); + } else if (hasSpecies) { setHalo({ lat, lng, color: "#ce0d02" }); - } else if (isSaved) { - setSelectedMarkerId(id); - } else if (!isSaved) { + } else { setHalo({ lat, lng, color: getMarkerColor(species || 0) }); } setSelectedMarkerId(id); diff --git a/frontend/pages/[tripId]/targets.tsx b/frontend/pages/[tripId]/targets.tsx index 2d90af8c..d95292dc 100644 --- a/frontend/pages/[tripId]/targets.tsx +++ b/frontend/pages/[tripId]/targets.tsx @@ -1,8 +1,5 @@ import React from "react"; -import { useModal } from "stores/modals"; -import useFetchSpeciesObs from "hooks/useFetchSpeciesObs"; import useCloseOnOutsideClick from "hooks/useCloseOnOutsideClick"; -import toast from "react-hot-toast"; import { useTrip } from "hooks/useTrip"; import SpeciesMapOverlay from "components/SpeciesMapOverlay"; import { Card } from "components/ui/card"; @@ -27,13 +24,8 @@ import { Download } from "lucide-react"; const PAGE_SIZE = 100; export default function TripTargets() { - const { open } = useModal(); - const { trip, selectedSpecies, canEdit } = useTrip(); + const { trip, canEdit } = useTrip(); const handleContainerClick = useCloseOnOutsideClick(); - const { obs, obsLayer } = useFetchSpeciesObs({ - region: trip?.region, - code: selectedSpecies?.code, - }); // Filter options const [search, setSearch] = React.useState(""); @@ -94,23 +86,6 @@ export default function TripTargets() { const truncatedTargets = filteredTargets?.slice(0, showCount); - const obsClick = (id: string) => { - const observation = obs.find((it) => it.id === id); - if (!observation) return toast.error("Observation not found"); - if (observation.isPersonal) { - open("personalLocation", { - hotspot: observation, - speciesCode: selectedSpecies?.code, - speciesName: selectedSpecies?.name, - }); - } else { - open("hotspot", { - hotspot: observation, - speciesName: selectedSpecies?.name, - }); - } - }; - return ( <> {trip && {`${trip.name} | BirdPlan.app`}} @@ -270,7 +245,7 @@ export default function TripTargets() { - + ); } diff --git a/frontend/pages/[tripId]/targets/[speciesCode].tsx b/frontend/pages/[tripId]/targets/[speciesCode].tsx index df4a7df7..42d47fc8 100644 --- a/frontend/pages/[tripId]/targets/[speciesCode].tsx +++ b/frontend/pages/[tripId]/targets/[speciesCode].tsx @@ -1,6 +1,5 @@ import React from "react"; import { useParams } from "react-router-dom"; -import toast from "react-hot-toast"; import TextareaAutosize from "react-textarea-autosize"; import { useQuery } from "@tanstack/react-query"; import { useDebounceCallback } from "usehooks-ts"; @@ -62,7 +61,7 @@ export default function SpeciesDetail() { }), }); - const { obs, obsLayer } = useFetchSpeciesObs({ region: trip?.region, code: speciesCode }); + const { obs } = useFetchSpeciesObs({ region: trip?.region, code: speciesCode }); const regionCode = trip?.region.split(",")[0] || ""; const lastSeenByLocId: Record = {}; @@ -202,16 +201,6 @@ export default function SpeciesDetail() { setSelectedSpecies({ code: speciesCode, name: speciesName || speciesCode }); }; - const obsClick = (id: string) => { - const observation = obs.find((it) => it.id === id); - if (!observation) return toast.error("Observation not found"); - open(observation.isPersonal ? "personalLocation" : "hotspot", { - hotspot: observation, - speciesCode, - speciesName, - }); - }; - return ( <> {trip && speciesName && {`${speciesName} | ${trip.name} | BirdPlan.app`}} @@ -306,7 +295,7 @@ export default function SpeciesDetail() { - + ); } diff --git a/frontend/stores/mapPreferences.ts b/frontend/stores/mapPreferences.ts new file mode 100644 index 00000000..578c2c2a --- /dev/null +++ b/frontend/stores/mapPreferences.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +type MapPreferencesState = { + showPersonalLocations: boolean; + setShowPersonalLocations: (showPersonalLocations: boolean) => void; +}; + +export const useMapPreferences = create()( + persist( + (set) => ({ + showPersonalLocations: true, + setShowPersonalLocations: (showPersonalLocations) => set({ showPersonalLocations }), + }), + { + name: "map-preferences", + storage: createJSONStorage(() => localStorage), + partialize: ({ showPersonalLocations }) => ({ showPersonalLocations }), + } + ) +);