Skip to content
This repository was archived by the owner on Sep 20, 2026. It is now read-only.
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
122 changes: 100 additions & 22 deletions client/src/components/bushland-map.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useQueryClient } from "@tanstack/react-query";
import { latLng, latLngBounds, Polygon as LeafletPolygon } from "leaflet";
import { ExternalLink, LocateFixed, SlidersHorizontal, X } from "lucide-react";
import {
CalendarDays,
ExternalLink,
LocateFixed,
SlidersHorizontal,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Circle,
Expand Down Expand Up @@ -252,7 +259,6 @@ function MapOverlay({
onLocationStatusChange,
}: MapOverlayProps) {
const map = useMap();
const hasRequestedLocation = useRef(false);

useMapEvents({
locationerror() {
Expand All @@ -269,22 +275,6 @@ function MapOverlay({
},
});

useEffect(() => {
if (hasRequestedLocation.current) {
return;
}

hasRequestedLocation.current = true;
onClose();
onLocationStatusChange("locating");
map.locate({
enableHighAccuracy: true,
maximumAge: 30_000,
setView: false,
timeout: 10_000,
});
}, [map, onClose, onLocationStatusChange]);

useEffect(() => {
if (events.length > 0 && !currentLocation) {
map.fitBounds(
Expand Down Expand Up @@ -371,17 +361,72 @@ function MapOverlay({
{event ? (
<EventPreviewSheet event={event} onClose={onClose} />
) : bushland ? (
<article className="pointer-events-auto relative rounded-lg border border-border bg-card p-4 text-card-foreground shadow-lg md:max-w-[380px]">
<article
className="pointer-events-auto relative max-h-[70vh] overflow-y-auto overscroll-contain rounded-xl border border-border bg-card p-4 text-card-foreground shadow-lg duration-300 animate-in fade-in slide-in-from-right-8 md:max-w-[380px]"
key={bushland.objectid}
>
<CloseButton onClose={onClose} />
<span className="inline-flex rounded-full bg-[#234D3B] px-2 py-1 text-xs font-medium text-white">
BUSH FOREVER SITE {bushland.bf_sites}
</span>
<h2 className="mt-2 pr-8 text-lg font-semibold leading-tight">
{bushland.name}
</h2>
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{bushland.description}
</p>
<section className="mt-4 border-t border-border pt-3">
<h3 className="text-xs font-semibold text-foreground">
Site Description
</h3>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{bushland.description}
</p>
</section>
<section
className="mt-4 border-t border-border pt-3"
aria-labelledby="sighting-history-title"
>
<div className="flex items-center gap-2">
<CalendarDays
aria-hidden="true"
className="text-[#234D3B]"
size={14}
strokeWidth={1.8}
/>
<h3
className="text-xs font-semibold text-foreground"
id="sighting-history-title"
>
Recent Sightings
</h3>
</div>
{bushland.reportedSightings?.length ? (
<ol className="mt-3 flex flex-col gap-2">
{bushland.reportedSightings.slice(0, 3).map((sighting) => (
<li
className="rounded-lg bg-muted p-2.5"
key={`${sighting.date}-${sighting.species}`}
>
<p className="text-[10px] text-muted-foreground">
{new Date(`${sighting.date}T00:00:00`).toLocaleDateString(
"en-AU",
{ day: "numeric", month: "short", year: "numeric" },
)}
</p>
<p className="mt-1 text-[11px] font-semibold leading-tight text-foreground">
{sighting.species} reported
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{sighting.count}{" "}
{sighting.count === 1 ? "report" : "reports"}
</p>
</li>
))}
</ol>
) : (
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
No weed sightings have been reported here yet.
</p>
)}
</section>
{bushland.distanceMetres !== undefined ? (
<p className="mt-2 text-xs font-semibold text-[#234D3B]">
{formatBushlandDistance(bushland.distanceMetres)}
Expand Down Expand Up @@ -429,6 +474,7 @@ export default function BushlandMap({
onLocationChange,
onLocationStatusChange,
}: BushlandMapProps) {
const queryClient = useQueryClient();
const [selectedEvent, setSelectedEvent] = useState<MapEvent | null>(null);
const [selectedBushland, setSelectedBushland] =
useState<BushlandProperties | null>(null);
Expand All @@ -439,6 +485,38 @@ export default function BushlandMap({
const { data } = useEvents();
const events = useMemo(() => data?.events ?? [], [data?.events]);

useEffect(() => {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (!backendUrl || typeof window === "undefined") {
return;
}

const source = new EventSource(`${backendUrl}/weeds/stream/`);
source.onmessage = (message) => {
const update = JSON.parse(message.data) as {
bushland_id: number | null;
sighting: NonNullable<BushlandProperties["reportedSightings"]>[number];
};
queryClient.invalidateQueries({ queryKey: ["bushlands"] });
if (update.bushland_id === null) {
return;
}
setSelectedBushland((current) =>
current?.objectid === update.bushland_id
? {
...current,
reportedSightings: [
update.sighting,
...(current.reportedSightings ?? []),
].slice(0, 3),
}
: current,
);
};

return () => source.close();
}, [queryClient]);

useEffect(() => {
if (!nearestBushland) {
return;
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/event-preview-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export function EventPreviewSheet({ event, onClose }: EventPreviewSheetProps) {

return (
<div className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-4">
<div className="pointer-events-auto w-full max-w-md rounded-xl border border-border bg-card p-4 shadow-lg sm:max-w-2xl">
<div
className="pointer-events-auto w-full max-w-md rounded-xl border border-border bg-card p-4 shadow-lg duration-300 animate-in fade-in slide-in-from-right-8 sm:max-w-2xl"
key={event.id}
>
<div className="flex items-start justify-between gap-3">
<div className="w-full">
<EventPreviewCard event={event} from="map" linkToDetails={false} />
Expand Down
6 changes: 4 additions & 2 deletions client/src/components/weed-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ export function WeedResults({
if (!data) return null;

const topCandidate = data.candidates[0];
const isUnknown = data.candidates.length === 0;
const hasNoConfidentMatch = !data.candidates.some(
(candidate) => candidate.confidence > 0.2,
);
const isLowConfidence = topCandidate?.confidence_level === "low";

return (
Expand All @@ -318,7 +320,7 @@ export function WeedResults({
<p className="text-xs text-muted-foreground">{data.disclaimer}</p>
</div>

{isUnknown ? (
{hasNoConfidentMatch ? (
<div className="flex flex-col gap-3 rounded-md border border-input p-6 text-center">
<p className="font-medium text-foreground">
No confident match found
Expand Down
7 changes: 7 additions & 0 deletions client/src/hooks/bushlands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ export type BushlandProperties = {
name: string;
description: string;
sourceUrl: string;
reportedSightings?: ReportedWeedSighting[];
distanceMetres?: number;
};

export type ReportedWeedSighting = {
date: string;
species: string;
count: number;
};

export type BushlandCollection = FeatureCollection<
Polygon | MultiPolygon,
BushlandProperties
Expand Down
54 changes: 54 additions & 0 deletions client/src/hooks/weeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,60 @@ export interface IdentifyWeedVariables {
longitude?: number;
}

export interface ReportSightingVariables {
bushlandId?: number;
observedOn: string;
abundance: string;
latitude?: number;
longitude?: number;
notes?: string;
confirmedSpecies: string;
topScientificName?: string;
topCommonName?: string;
topConfidence?: number;
modelId?: string;
}

export interface ReportSightingResponse {
id: number;
ticket_id: string;
confirmed_species: string;
observed_on: string | null;
abundance: string;
created_at: string;
}

export const useReportSighting = (
args?: Omit<
UseMutationOptions<
ReportSightingResponse,
unknown,
ReportSightingVariables
>,
"mutationFn"
>,
) => {
return useMutation({
...args,
mutationFn: async (variables: ReportSightingVariables) => {
const res = await api.post<ReportSightingResponse>("/weeds/report/", {
bushland_id: variables.bushlandId,
observed_on: variables.observedOn,
abundance: variables.abundance,
latitude: variables.latitude,
longitude: variables.longitude,
notes: variables.notes,
confirmed_species: variables.confirmedSpecies,
top_scientific_name: variables.topScientificName,
top_common_name: variables.topCommonName,
top_confidence: variables.topConfidence,
model_id: variables.modelId,
});
return res.data;
},
});
};

/**
* Backend for `POST /api/weeds/identify/` (issue #9) isn't implemented yet.
* In non-production builds, if the real request can't reach the server at
Expand Down
Loading
Loading