Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions frontend/components/MapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -35,8 +36,14 @@ export default function MapOverlay({ onClose, title, className, children }: Prop
<h2 className="text-lg font-semibold">{title}</h2>
<div className="text-xs text-muted-foreground mt-1.5">{children}</div>
</div>
<Button variant="ghost" size="icon-lg" className="-mr-1 -mt-1 shrink-0" onClick={onClose} aria-label="Close">
<XIcon className="size-5" />
<Button
variant="ghost"
size="icon-lg"
className="-mr-1 -mt-1 shrink-0"
onClick={onClose}
aria-label={closeVariant === "back" ? "Back" : "Close"}
>
{closeVariant === "back" ? <ArrowLeftIcon className="size-5" /> : <XIcon className="size-5" />}
</Button>
</div>
</Card>
Expand Down
34 changes: 29 additions & 5 deletions frontend/components/Mapbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
},
};

Expand Down Expand Up @@ -171,7 +175,12 @@ export default function Mapbox({
handleHotspotClick(marker.id);
}}
>
<MarkerWithIcon icon="hotspot" highlight={marker.id === selectedMarkerId} />
<MarkerWithIcon
icon="hotspot"
color={marker.color}
className={marker.faded && marker.id !== selectedMarkerId ? "opacity-40" : undefined}
highlight={marker.id === selectedMarkerId}
/>
</Marker>
))}
{customMarkers?.map((marker) => (
Expand Down Expand Up @@ -214,7 +223,7 @@ export default function Mapbox({
</Marker>
)}
</Map>
{obsLayer && (
{obsLayer && !hasFrequencyData && (
<div className="flex absolute bottom-0 left-0 bg-white/90 py-1.5 pl-2 pr-3 text-xs items-center gap-2 z-10 rounded-tr-sm">
<span className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#555]" /> Personal Location
Expand All @@ -224,6 +233,21 @@ export default function Mapbox({
</span>
</div>
)}
{obsLayer && hasFrequencyData && (
<div className="flex flex-wrap absolute bottom-0 left-0 bg-white/90 py-1.5 pl-2 pr-3 text-xs items-center gap-x-3 gap-y-1 z-10 rounded-tr-sm">
<span className="text-gray-500">Chance during trip dates:</span>
{[
[markerColors[3], "<5%"],
[markerColors[5], "10%"],
[markerColors[7], "40%"],
[markerColors[9], "80%+"],
].map(([color, caption]) => (
<span key={caption} className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} /> {caption}
</span>
))}
</div>
)}
<div className="absolute bottom-0 left-16 right-16 h-4 sm:hidden">
{/* Prevents map from panning when close PWA on iOS with no home button */}
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/MarkerWithIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,7 +22,7 @@ export default function MarkerWithIcon({ icon, darkIcon, showStroke = true, clas
className
)}
style={{
backgroundColor: iconData.color,
backgroundColor: color ?? iconData.color,
}}
>
{highlight && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/SpeciesHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export default function SpeciesHero({ name, scientificName, code, starred, mutua
</DropdownMenuItem>
<DropdownMenuItem onClick={onShowMap}>
<Map className="text-gray-500" />
Recent Reports Map
Show on Map
</DropdownMenuItem>
<DropdownMenuItem
render={<a href={`https://ebird.org/species/${code}`} target="_blank" rel="noopener noreferrer" />}
Expand Down
146 changes: 130 additions & 16 deletions frontend/components/SpeciesMapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => void;
onHotspotClick: (id: string) => void;
obsLayer: React.ComponentProps<typeof MapBox>["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<MapMode>("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 (
<div className="absolute inset-0 z-10 flex flex-col" onClick={onOutsideClick}>
<MapOverlay onClose={() => setSelectedSpecies(undefined)} title={selectedSpecies.name}>
Showing reports over the last 30 days.{" "}
<Button
className="underline"
variant="link"
size="sm"
href={`https://ebird.org/map/${selectedSpecies.code}?env.minX=${trip?.bounds?.minX}&env.minY=${trip?.bounds?.minY}&env.maxX=${trip?.bounds?.maxX}&env.maxY=${trip?.bounds?.maxY}`}
target="_blank"
>
View on eBird
</Button>
</MapOverlay>
<div className="w-full grow relative">
{trip?.bounds && (
<MapBox key={trip._id} onHotspotClick={onHotspotClick} obsLayer={obsLayer} bounds={trip.bounds} />
<MapBox
key={trip._id}
onHotspotClick={handleClick}
markers={markers}
obsLayer={layer}
bounds={trip.bounds}
/>
)}
<div className="absolute top-3 left-3 right-3 sm:right-auto sm:w-[26rem] z-10 flex flex-col items-start gap-3">
<MapOverlay
onClose={() => setSelectedSpecies(undefined)}
closeVariant="back"
title={selectedSpecies.name}
className="relative left-0 top-0 w-full max-w-none translate-x-0"
>
{subtitle}{" "}
<Button
className="underline"
variant="link"
size="sm"
href={`https://ebird.org/map/${selectedSpecies.code}?env.minX=${trip?.bounds?.minX}&env.minY=${trip?.bounds?.minY}&env.maxX=${trip?.bounds?.maxX}&env.maxY=${trip?.bounds?.maxY}`}
target="_blank"
>
View on eBird
</Button>
</MapOverlay>
<SegmentedControl<MapMode>
value={mode}
onChange={setMode}
options={[
{ value: "trip", label: "Trip dates" },
{ value: "recent", label: "Recent sightings" },
]}
/>
<MapButton
onClick={() => setSavedHotspotsOnly(!savedHotspotsOnly)}
tooltip={savedHotspotsOnly ? "Show all hotspots" : "Show only saved hotspots"}
active={savedHotspotsOnly}
>
<MarkerWithIcon icon="hotspot" showStroke={false} className="scale-[0.7]" />
</MapButton>
<MapButton
onClick={() => setShowPersonalLocations(!showPersonalLocations)}
tooltip={
personalDisabled
? "Personal locations are only in recent sightings"
: showPersonalLocations
? "Hide personal locations"
: "Show personal locations"
}
active={showPersonalLocations && !personalDisabled}
disabled={personalDisabled}
>
<Icon name="user" />
</MapButton>
</div>
</div>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/hooks/useCloseOnOutsideClick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
35 changes: 35 additions & 0 deletions frontend/hooks/useSpeciesHotspotRankings.ts
Original file line number Diff line number Diff line change
@@ -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<OpenBirdingHotspotRankingResponse>({
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",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting by frequency seems to return less useful results: 100% of 19 checklists seems less useful for planning than 85% of 500. The species detail page also seems to default to it, so this makes the map agree with the list.

}),
});
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 ?? [];
}
36 changes: 36 additions & 0 deletions frontend/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
): 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[]
Expand Down
Loading