Skip to content
Merged
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
78 changes: 78 additions & 0 deletions frontend/components/HotspotFilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from "react";
import { cn } from "lib/utils";
import Icon from "components/Icon";
import MapButton from "components/MapButton";
import MinStepper from "components/MinStepper";
import { Button } from "components/ui/button";
import { Switch } from "components/ui/switch";
import { useTrip, DEFAULT_HOTSPOT_FILTERS } from "hooks/useTrip";

const MIN_CHECKLIST_STEPS = [0, 1, 5, 10, 25, 50, 100, 250, 500, 1000];
const MIN_SPECIES_STEPS = [0, 25, 50, 100, 150, 200, 250, 300, 400];

export default function HotspotFilterMenu() {
const { showAllHotspots, setShowAllHotspots, hotspotFilters, setHotspotFilters } = useTrip();
const [open, setOpen] = React.useState(false);
const { minChecklists, minSpecies } = hotspotFilters;
const activeCount =
(showAllHotspots ? 0 : 1) +
(minChecklists !== DEFAULT_HOTSPOT_FILTERS.minChecklists ? 1 : 0) +
(minSpecies !== DEFAULT_HOTSPOT_FILTERS.minSpecies ? 1 : 0);

return (
<div className="relative">
<MapButton onClick={() => setOpen((o) => !o)} tooltip={open ? undefined : "Hotspots"} active={activeCount > 0}>
<Icon name="sliders" />
</MapButton>
{open && (
<>
<div onClick={() => setOpen(false)} className="fixed inset-0 z-30" />
<div className="absolute top-0 right-14 sm:left-14 sm:right-auto z-40 w-[300px] bg-card border rounded-xl shadow-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-semibold text-foreground">Filters</div>
<Button
variant="link"
onClick={() => {
setShowAllHotspots(true);
setHotspotFilters(DEFAULT_HOTSPOT_FILTERS);
setOpen(false);
}}
className={cn("text-[11px] font-semibold hover:opacity-80", activeCount === 0 && "invisible")}
>
Clear all
</Button>
</div>
<label className="flex items-center justify-between gap-3 mb-4 cursor-pointer">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold">
Show saved only
</span>
<Switch checked={!showAllHotspots} onCheckedChange={(checked) => setShowAllHotspots(!checked)} />
</label>
<div className={cn(!showAllHotspots && "opacity-40 pointer-events-none")}>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold mb-2">
Minimum checklists
</div>
<div className="mb-4">
<MinStepper
value={minChecklists}
onChange={(minChecklists) => setHotspotFilters({ minChecklists })}
steps={MIN_CHECKLIST_STEPS}
min={0}
/>
</div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold mb-2">
Minimum species
</div>
<MinStepper
value={minSpecies}
onChange={(minSpecies) => setHotspotFilters({ minSpecies })}
steps={MIN_SPECIES_STEPS}
min={0}
/>
</div>
</div>
</>
)}
</div>
);
}
32 changes: 29 additions & 3 deletions frontend/components/Mapbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { markerColors, getLatLngFromBounds } from "lib/helpers";
import MarkerWithIcon from "components/MarkerWithIcon";
import clsx from "clsx";
import { useModal } from "stores/modals";
import { useTrip } from "hooks/useTrip";
import { useTrip, HotspotFilters, DEFAULT_HOTSPOT_FILTERS } from "hooks/useTrip";

type Props = {
bounds: Trip["bounds"];
markers?: MarkerT[];
customMarkers?: CustomMarker[];
hotspotLayer?: any;
hotspotFilters?: HotspotFilters;
obsLayer?: any;
addingMarker?: boolean;
showSatellite?: boolean;
Expand All @@ -27,6 +28,7 @@ export default function Mapbox({
customMarkers,
onHotspotClick,
hotspotLayer,
hotspotFilters = DEFAULT_HOTSPOT_FILTERS,
obsLayer,
addingMarker,
showSatellite,
Expand Down Expand Up @@ -57,12 +59,36 @@ export default function Mapbox({
return window.innerWidth < 768;
}, []);

const visibleHotspotCount = (hotspotLayer?.features || []).filter(
(f: any) =>
f.properties.checklists >= hotspotFilters.minChecklists &&
f.properties.species >= hotspotFilters.minSpecies
).length;
const isSparse = visibleHotspotCount < 50;

const hsLayerStyle = {
id: "hotspots",
type: "circle",
filter: [
"all",
[">=", ["get", "checklists"], hotspotFilters.minChecklists],
[">=", ["get", "species"], hotspotFilters.minSpecies],
],
paint: {
"circle-radius": isMobile ? 8 : 7,
"circle-stroke-width": 0.75,
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
6,
isSparse
? ["interpolate", ["linear"], ["get", "species"], 0, 3.5, 300, 7]
: ["interpolate", ["linear"], ["get", "species"], 0, 3, 300, 5],
9,
["interpolate", ["linear"], ["get", "species"], 0, 4.5, 300, 9],
12,
["interpolate", ["linear"], ["get", "species"], 0, 7, 300, isMobile ? 10 : 9],
],
"circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 6, 0.3, 12, 0.75],
"circle-stroke-color": "#555",
"circle-color": [
"match",
Expand Down
39 changes: 39 additions & 0 deletions frontend/components/MinStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Button } from "components/ui/button";

type Props = {
value: number;
onChange: (n: number) => void;
steps: number[];
min: number;
};

export default function MinStepper({ value, onChange, steps, min }: Props) {
const next = steps.find((s) => s > value) ?? value + 100;
const below = steps.filter((s) => s < value);
const prev = below.length > 0 ? below[below.length - 1] : Math.max(min, value - 100);

return (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-lg text-xs text-secondary-foreground">
<span>Min</span>
<Button
variant="ghost"
size="icon"
onClick={() => onChange(prev)}
className="size-6 rounded text-secondary-foreground"
aria-label="Decrease"
>
</Button>
<span className="min-w-[22px] text-center font-bold text-foreground">{value}</span>
<Button
variant="ghost"
size="icon"
onClick={() => onChange(next)}
className="size-6 rounded text-secondary-foreground"
aria-label="Increase"
>
+
</Button>
</div>
);
}
42 changes: 7 additions & 35 deletions frontend/components/SpeciesHotspotToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Icon from "components/Icon";
import SelectDropdown from "components/SelectDropdown";
import SegmentedControl from "components/SegmentedControl";
import FilterChip from "components/FilterChip";
import MinStepper from "components/MinStepper";
import { Button } from "components/ui/button";

export type Scope = "saved" | "all";
export type SortKey = "best" | "freq";
Expand All @@ -15,17 +17,6 @@ const SORT_OPTIONS: { value: SortKey; label: string }[] = [

const MIN_OBS_STEPS = [1, 2, 5, 10, 25, 50, 100, 200, 300, 400];

function nextStep(n: number): number {
const found = MIN_OBS_STEPS.find((s) => s > n);
return found ?? n + 100;
}

function prevStep(n: number): number {
const below = MIN_OBS_STEPS.filter((s) => s < n);
if (below.length > 0) return below[below.length - 1];
return Math.max(1, n - 100);
}

type Props = {
scope: Scope;
setScope: (s: Scope) => void;
Expand Down Expand Up @@ -102,17 +93,17 @@ function MoreFiltersMenu({
<div className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold">
Last seen (days)
</div>
<button
type="button"
<Button
variant="link"
onClick={() => {
setMinObservations(1);
setRecentDays(null);
setOpen(false);
}}
className={cn("text-[11px] font-semibold text-link hover:opacity-80", activeCount === 0 && "invisible")}
className={cn("text-[11px] font-semibold hover:opacity-80", activeCount === 0 && "invisible")}
>
Clear all
</button>
</Button>
</div>
<SegmentedControl
className="mb-4 h-8"
Expand All @@ -129,26 +120,7 @@ function MoreFiltersMenu({
<div className="text-[11px] uppercase tracking-wide text-muted-foreground font-semibold mb-2">
Minimum observations
</div>
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-lg text-xs text-secondary-foreground">
<span>Min</span>
<button
type="button"
onClick={() => setMinObservations(prevStep(minObservations))}
className="w-6 h-6 rounded grid place-items-center text-secondary-foreground hover:bg-muted"
aria-label="Decrease"
>
</button>
<span className="min-w-[22px] text-center font-bold text-foreground">{minObservations}</span>
<button
type="button"
onClick={() => setMinObservations(nextStep(minObservations))}
className="w-6 h-6 rounded grid place-items-center text-secondary-foreground hover:bg-muted"
aria-label="Increase"
>
+
</button>
</div>
<MinStepper value={minObservations} onChange={setMinObservations} steps={MIN_OBS_STEPS} min={1} />
</div>
</>
)}
Expand Down
13 changes: 13 additions & 0 deletions frontend/hooks/useTrip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,26 @@ type HaloT = {

type SetState<T> = T | ((prev: T) => T);

export type HotspotFilters = {
minChecklists: number;
minSpecies: number;
};

export const DEFAULT_HOTSPOT_FILTERS: HotspotFilters = { minChecklists: 0, minSpecies: 0 };

type TripUiState = {
selectedSpecies?: SelectedSpecies;
selectedMarkerId?: string;
halo?: HaloT;
showAllHotspots: boolean;
showSatellite: boolean;
hotspotFilters: HotspotFilters;
setSelectedSpecies: (species?: SelectedSpecies) => void;
setSelectedMarkerId: (id?: string) => void;
setHalo: (data?: HaloT) => void;
setShowAllHotspots: (show: SetState<boolean>) => void;
setShowSatellite: (show: SetState<boolean>) => void;
setHotspotFilters: (filters: Partial<HotspotFilters>) => void;
};

const resolve = <T,>(value: SetState<T>, prev: T): T =>
Expand All @@ -40,11 +49,13 @@ const resolve = <T,>(value: SetState<T>, prev: T): T =>
const useTripUiStore = create<TripUiState>((set) => ({
showAllHotspots: false,
showSatellite: false,
hotspotFilters: DEFAULT_HOTSPOT_FILTERS,
setSelectedSpecies: (selectedSpecies) => set({ selectedSpecies }),
setSelectedMarkerId: (selectedMarkerId) => set({ selectedMarkerId }),
setHalo: (halo) => set({ halo }),
setShowAllHotspots: (show) => set((s) => ({ showAllHotspots: resolve(show, s.showAllHotspots) })),
setShowSatellite: (show) => set((s) => ({ showSatellite: resolve(show, s.showSatellite) })),
setHotspotFilters: (filters) => set((s) => ({ hotspotFilters: { ...s.hotspotFilters, ...filters } })),
}));

export const useClearSelectedSpeciesOnNavigate = () => {
Expand Down Expand Up @@ -105,11 +116,13 @@ export const useTrip = () => {
dateRangeLabel,
showAllHotspots: ui.showAllHotspots,
showSatellite: ui.showSatellite,
hotspotFilters: ui.hotspotFilters,
setSelectedSpecies: ui.setSelectedSpecies,
setSelectedMarkerId: ui.setSelectedMarkerId,
setHalo: ui.setHalo,
setShowAllHotspots: ui.setShowAllHotspots,
setShowSatellite: ui.setShowSatellite,
setHotspotFilters: ui.setHotspotFilters,
refetch,
};
};
5 changes: 3 additions & 2 deletions frontend/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,7 @@ export const getMarkerColor = (count: number) => {
if (count <= 250) return markerColors[6];
if (count <= 300) return markerColors[7];
if (count <= 400) return markerColors[8];
if (count <= 1000) return markerColors[9];
return markerColors[0];
return markerColors[9];

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.

Counts >1000 were returning markerColors[0], the same as 0 count.

};

export const getMarkerColorIndex = (count: number) => {
Expand All @@ -144,6 +143,8 @@ export const buildHotspotsLayer = (
},
properties: {
shade: getMarkerColorIndex(hotspot.species || 0),
species: hotspot.species || 0,
checklists: hotspot.checklists || 0,
id: hotspot.id,
},
})),
Expand Down
4 changes: 4 additions & 0 deletions frontend/lib/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ export const icons = {
viewbox: "0 0 640 512",
path: "M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312V248H440c-13.3 0-24-10.7-24-24s10.7-24 24-24h64V136c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24H552v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z",
},
sliders: {
viewbox: "0 0 640 640",
path: "M88 136C74.7 136 64 146.7 64 160C64 173.3 74.7 184 88 184L179.7 184C189.9 216.5 220.2 240 256 240C291.8 240 322.1 216.5 332.3 184L552 184C565.3 184 576 173.3 576 160C576 146.7 565.3 136 552 136L332.3 136C322.1 103.5 291.8 80 256 80C220.2 80 189.9 103.5 179.7 136L88 136zM88 296C74.7 296 64 306.7 64 320C64 333.3 74.7 344 88 344L339.7 344C349.9 376.5 380.2 400 416 400C451.8 400 482.1 376.5 492.3 344L552 344C565.3 344 576 333.3 576 320C576 306.7 565.3 296 552 296L492.3 296C482.1 263.5 451.8 240 416 240C380.2 240 349.9 263.5 339.7 296L88 296zM88 456C74.7 456 64 466.7 64 480C64 493.3 74.7 504 88 504L147.7 504C157.9 536.5 188.2 560 224 560C259.8 560 290.1 536.5 300.3 504L552 504C565.3 504 576 493.3 576 480C576 466.7 565.3 456 552 456L300.3 456C290.1 423.5 259.8 400 224 400C188.2 400 157.9 423.5 147.7 456L88 456zM224 512C206.3 512 192 497.7 192 480C192 462.3 206.3 448 224 448C241.7 448 256 462.3 256 480C256 497.7 241.7 512 224 512zM416 352C398.3 352 384 337.7 384 320C384 302.3 398.3 288 416 288C433.7 288 448 302.3 448 320C448 337.7 433.7 352 416 352zM224 160C224 142.3 238.3 128 256 128C273.7 128 288 142.3 288 160C288 177.7 273.7 192 256 192C238.3 192 224 177.7 224 160z",
},
speaker: {
viewbox: "0 0 576 512",
path: "M333.1 34.8C344.6 40 352 51.4 352 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L163.8 352H96c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L298.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zm172 72.2c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C507.3 341.3 528 301.1 528 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C466.1 199.1 480 225.9 480 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C425.1 284.4 432 271 432 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5z",
Expand Down
22 changes: 13 additions & 9 deletions frontend/pages/[tripId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ import { useTrip } from "hooks/useTrip";
import MapButton from "components/MapButton";
import MapOverlay from "components/MapOverlay";
import Icon from "components/Icon";
import HotspotFilterMenu from "components/HotspotFilterMenu";
import { Star, Utensils, MapPin } from "lucide-react";
import { Button } from "components/ui/button";

export default function Trip() {
const { open } = useModal();
const { trip, canEdit, setSelectedSpecies, showAllHotspots, setShowAllHotspots, showSatellite, setShowSatellite } =
useTrip();
const {
trip,
canEdit,
setSelectedSpecies,
showAllHotspots,
setShowAllHotspots,
showSatellite,
setShowSatellite,
hotspotFilters,
} = useTrip();
const [isAddingMarker, setIsAddingMarker] = React.useState(false);

const savedHotspots = trip?.hotspots || [];
Expand Down Expand Up @@ -58,13 +67,7 @@ export default function Trip() {
<>
{trip && <title>{`${trip.name} | BirdPlan.app`}</title>}
<div className="absolute top-4 right-4 sm:left-4 sm:right-auto flex flex-col gap-3 z-10">
<MapButton
onClick={() => setShowAllHotspots((prev) => !prev)}
tooltip={showAllHotspots ? "Hide hotspots" : "Show hotspots"}
active={showAllHotspots}
>
<Icon name="mapFlatPin" />
</MapButton>
<HotspotFilterMenu />
<MapButton onClick={() => setShowSatellite((prev) => !prev)} tooltip="Satellite view" active={showSatellite}>
<Icon name="layers" />
</MapButton>
Expand Down Expand Up @@ -104,6 +107,7 @@ export default function Trip() {
markers={markers}
customMarkers={customMarkers}
hotspotLayer={showAllHotspots && hotspotLayer}
hotspotFilters={hotspotFilters}
bounds={trip.bounds}
addingMarker={isAddingMarker}
onDisableAddingMarker={() => setIsAddingMarker(false)}
Expand Down