Skip to content
50 changes: 42 additions & 8 deletions client/src/components/map/sketch/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react";

import dynamic from "next/dynamic";

import { geodesicBuffer } from "@arcgis/core/geometry/geometryEngine";
import * as geometryEngineAsync from "@arcgis/core/geometry/geometryEngineAsync";
import Graphic from "@arcgis/core/Graphic";
import GraphicsLayer from "@arcgis/core/layers/GraphicsLayer";
import SketchViewModel from "@arcgis/core/widgets/Sketch/SketchViewModel";
Expand Down Expand Up @@ -60,12 +60,15 @@ export default function Sketch({
const sketchViewModelRef = useRef<SketchViewModel | null>(null);
const sketchViewModelOnCreateRef = useRef<IHandle | null>(null);
const sketchViewModelOnUpdateRef = useRef<IHandle | null>(null);
const bufferDrawTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Monotonic token so a slow/stale async buffer can't overwrite a newer one.
const bufferDrawTokenRef = useRef(0);

const drawBuffer = useCallback(
(l: __esri.Graphic) => {
async (l: __esri.Graphic) => {
if (!l) return;

bufferRef.current.removeAll();
const token = ++bufferDrawTokenRef.current;

const buffer = new Graphic({
symbol: BUFFER_SYMBOL,
Expand All @@ -76,11 +79,22 @@ export default function Sketch({
location?.type !== "search"
? location?.buffer || BUFFERS[l.geometry.type]
: BUFFERS[l.geometry.type];
const g = geodesicBuffer(l.geometry, b, "kilometers");

buffer.geometry = Array.isArray(g) ? g[0] : g;
// Async/off-thread: buffering a long polyline densifies into thousands of
// vertices and blocks the main thread when done synchronously.
try {
const g = await geometryEngineAsync.geodesicBuffer(l.geometry, b, "kilometers");
buffer.geometry = Array.isArray(g) ? g[0] : g;
} catch {
// Worker error/cancel: keep the current buffer instead of clearing it.
return;
}
}

// A newer draw started while we awaited — let it win, don't clobber.
if (token !== bufferDrawTokenRef.current) return;

// Clear the previous buffer only once the new one is ready, to avoid flicker.
bufferRef.current.removeAll();
if (buffer.geometry) {
bufferRef.current.add(buffer);
}
Expand Down Expand Up @@ -118,7 +132,15 @@ export default function Sketch({
if (onUpdateChange) onUpdateChange(e);

if (e.state === "active") {
drawBuffer(e.graphics[0].clone());
// Reshape fires "active" on every pointer move; debounce so we don't queue a
// buffer computation per move when dragging a long polyline.
const graphic = e.graphics[0].clone();
if (bufferDrawTimeoutRef.current) {
clearTimeout(bufferDrawTimeoutRef.current);
}
bufferDrawTimeoutRef.current = setTimeout(() => {
drawBuffer(graphic);
}, 150);
}

if (e.state === "complete" && e.graphics.length) {
Expand Down Expand Up @@ -213,14 +235,18 @@ export default function Sketch({

useEffect(() => {
layerRef.current.removeAll();
bufferRef.current.removeAll();

if (LOCATION) {
const L = LOCATION.clone();
L.symbol = SYMBOLS[L.geometry.type];
layerRef.current.add(L);

// drawBuffer owns the buffer layer: it clears the old graphic only once the
// new (async) buffer is ready, so the buffer never blinks out mid-recompute.
drawBuffer(L);
} else {
// Nothing to draw — clear any leftover buffer.
bufferRef.current.removeAll();
}

handleListeners();
Expand All @@ -238,6 +264,14 @@ export default function Sketch({
}
}, [layerRef, enabled]);

useEffect(() => {
return () => {
if (bufferDrawTimeoutRef.current) {
clearTimeout(bufferDrawTimeoutRef.current);
}
};
}, []);

return (
<>
<Layer layer={bufferRef.current} index={99} />
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/map/sketch/upload-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default function UploadDialog({ open, onOpenChange }: UploadDialogProps)
});

if (esriGeometry) {
const bufferedGeometry = getGeometryWithBuffer(
const bufferedGeometry = await getGeometryWithBuffer(
esriGeometry,
BUFFERS[arcgisGeometry.type] || 0,
);
Expand Down
8 changes: 4 additions & 4 deletions client/src/containers/map/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default function MapEditContainer({
}, [GEOMETRY, desktop]);

const handleCreate = useCallback(
(graphic: __esri.Graphic) => {
async (graphic: __esri.Graphic) => {
setSketch({ enabled: undefined, type: undefined });

setLocation({
Expand All @@ -86,7 +86,7 @@ export default function MapEditContainer({
buffer: BUFFERS[graphic.geometry.type],
});

const g = getGeometryWithBuffer(graphic.geometry, BUFFERS[graphic.geometry.type]);
const g = await getGeometryWithBuffer(graphic.geometry, BUFFERS[graphic.geometry.type]);
if (g) {
setTmpBbox(g.extent);
}
Expand Down Expand Up @@ -114,7 +114,7 @@ export default function MapEditContainer({
}, [setSketch, setSketchAction]);

const handleUpdate = useCallback(
(graphic: __esri.Graphic) => {
async (graphic: __esri.Graphic) => {
if (!location) return;
const b = location.type !== "search" ? location.buffer : BUFFERS[graphic.geometry.type];
// Update the location state with the updated geometry
Expand All @@ -125,7 +125,7 @@ export default function MapEditContainer({
});

// Optionally update the bounding box based on the updated geometry
const g = getGeometryWithBuffer(graphic.geometry, b);
const g = await getGeometryWithBuffer(graphic.geometry, b);
if (g) {
setTmpBbox(g.extent);
}
Expand Down
8 changes: 4 additions & 4 deletions client/src/containers/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default function MapContainer({
}, 500);

const handleCreate = useCallback(
(graphic: __esri.Graphic) => {
async (graphic: __esri.Graphic) => {
setSketch({ enabled: undefined, type: undefined });

setLocation({
Expand All @@ -84,7 +84,7 @@ export default function MapContainer({
buffer: BUFFERS[graphic.geometry.type],
});

const g = getGeometryWithBuffer(graphic.geometry, BUFFERS[graphic.geometry.type]);
const g = await getGeometryWithBuffer(graphic.geometry, BUFFERS[graphic.geometry.type]);
if (g) {
setTmpBbox(g.extent);
}
Expand Down Expand Up @@ -112,7 +112,7 @@ export default function MapContainer({
}, [setSketch, setSketchAction]);

const handleUpdate = useCallback(
(graphic: __esri.Graphic) => {
async (graphic: __esri.Graphic) => {
if (!location) return;
const b = location.type !== "search" ? location.buffer : BUFFERS[graphic.geometry.type];
// Update the location state with the updated geometry
Expand All @@ -123,7 +123,7 @@ export default function MapContainer({
});

// Optionally update the bounding box based on the updated geometry
const g = getGeometryWithBuffer(graphic.geometry, b);
const g = await getGeometryWithBuffer(graphic.geometry, b);
if (g) {
setTmpBbox(g.extent);
}
Expand Down
4 changes: 2 additions & 2 deletions client/src/containers/map/layer-manager/grid-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ export default function GridLayer() {
setAlert(info);
}, []);

const handleConfirmAlert = useCallback(() => {
const handleConfirmAlert = useCallback(async () => {
if (!alert?.coordinate) return;

const cell = latLngToCell(alert.coordinate[1], alert.coordinate[0], 6);
Expand All @@ -552,7 +552,7 @@ export default function GridLayer() {
buffer: BUFFERS.point,
});

const gWithBuffer = getGeometryWithBuffer(g, BUFFERS.point);
const gWithBuffer = await getGeometryWithBuffer(g, BUFFERS.point);
if (gWithBuffer) {
setTmpBbox(gWithBuffer.extent);
}
Expand Down
4 changes: 2 additions & 2 deletions client/src/containers/report/grid/table/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const GridTableItem = (
});
}, [gridDatasets, queryMeta.data, rest]);

const handleClick = () => {
const handleClick = async () => {
const latLng = cellToLatLng(cell);

const p = new Point({ x: latLng[1], y: latLng[0], spatialReference: { wkid: 4326 } });
Expand All @@ -94,7 +94,7 @@ export const GridTableItem = (

setLocation({ type: "point", geometry: g.toJSON(), buffer: BUFFERS.point });

const gWithBuffer = getGeometryWithBuffer(g, BUFFERS.point);
const gWithBuffer = await getGeometryWithBuffer(g, BUFFERS.point);
if (gWithBuffer) {
setTmpBbox(gWithBuffer.extent);
}
Expand Down
93 changes: 65 additions & 28 deletions client/src/containers/report/location/confirm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";

import ReactMarkdown from "react-markdown";

Expand All @@ -9,11 +9,10 @@ import { useSetAtom } from "jotai";
import { useTranslations } from "next-intl";

import { formatNumber } from "@/lib/formats";
import { useDebounce } from "@/lib/hooks";
import {
getGeometryWithBuffer,
useLocation,
useLocationGeometry,
useLocationGeometryWithStatus,
useLocationTitle,
} from "@/lib/location";

Expand All @@ -23,6 +22,7 @@ import { BUFFERS } from "@/constants/map";

import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Spinner } from "@/components/ui/spinner";

export default function Confirm({ onConfirm }: { onConfirm: () => void }) {
const t = useTranslations();
Expand All @@ -32,34 +32,68 @@ export default function Confirm({ onConfirm }: { onConfirm: () => void }) {
const [location, setLocation] = useSyncLocation();
const TITLE = useLocationTitle(location);
const LOCATION = useLocation(location);
const GEOMETRY = useLocationGeometry(location);

const onValueChangeDebounced = useDebounce(() => {
if (!location || (location.type !== "point" && location.type !== "polyline")) return;
const gWithBuffer = getGeometryWithBuffer(GEOMETRY, location.buffer);

if (gWithBuffer) {
setTmpBbox(gWithBuffer.extent);
}
}, 500);
const { geometry: GEOMETRY, isCalculating } = useLocationGeometryWithStatus(location);

const AREA = useMemo(() => {
if (!GEOMETRY) return 0;
return geodesicArea(GEOMETRY, "square-kilometers");
}, [GEOMETRY]);

// Local buffer value drives the slider/label live while dragging. We commit it to
// the (shared) location state once per drag instead of once per pointer-move tick.
const committedBuffer =
(location && location.type !== "search" ? location.buffer : undefined) ||
BUFFERS[LOCATION?.geometry.type || "point"];
const [bufferValue, setBufferValue] = useState(committedBuffer);

// Latest dragged value, captured synchronously so the commit never reads a stale
// React-state value. Radix only fires onValueCommit when its (controlled) value
// differs from the slide-start value; on a fast drag the controlled value hasn't
// flushed by pointer-up, so onValueCommit gets skipped and the buffer never updates.
// A trailing debounce off onValueChange guarantees the final value commits.
const pendingBufferRef = useRef(committedBuffer);
const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Sync local slider state when the committed buffer changes from outside (reset,
// upload, edit-location).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBufferValue(committedBuffer);
pendingBufferRef.current = committedBuffer;
}, [committedBuffer]);

useEffect(() => {
return () => {
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
};
}, []);

const commitBuffer = async () => {
const value = pendingBufferRef.current;
setLocation((prev) => (prev ? { ...prev, buffer: value } : prev));

if (!location || !LOCATION || (location.type !== "point" && location.type !== "polyline")) {
return;
}

const gWithBuffer = await getGeometryWithBuffer(LOCATION.geometry, value);
if (gWithBuffer) {
setTmpBbox(gWithBuffer.extent);
}
};

const onValueChange = (value: number[]) => {
setLocation((prev) => {
if (prev) {
return {
...prev,
buffer: value[0],
};
}
return prev;
});

onValueChangeDebounced();
pendingBufferRef.current = value[0];
setBufferValue(value[0]);
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
commitTimeoutRef.current = setTimeout(commitBuffer, 120);
};

// Radix fires this on release for slow drags / track clicks. Commit immediately and
// cancel the pending debounce; reads pendingBufferRef (not the possibly-stale arg).
const onValueCommit = () => {
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
commitBuffer();
};

if (!location || !LOCATION) return null;
Expand All @@ -71,7 +105,8 @@ export default function Confirm({ onConfirm }: { onConfirm: () => void }) {
<div className="text-sm font-semibold uppercase leading-none text-muted-foreground">
{TITLE}
</div>
<div className="text-xs font-bold leading-none text-foreground">
<div className="flex items-center gap-1 text-xs font-bold leading-none text-foreground">
{isCalculating && <Spinner className="size-3 text-muted-foreground" />}
{formatNumber(AREA, {
maximumFractionDigits: 0,
})}{" "}
Expand Down Expand Up @@ -108,18 +143,20 @@ export default function Confirm({ onConfirm }: { onConfirm: () => void }) {
<div className="text-sm font-semibold leading-none text-blue-500">
{t("grid-sidebar-report-location-buffer-size")}
</div>
<div className="text-xs leading-none text-foreground">
{`${location.buffer || BUFFERS[LOCATION?.geometry.type || "point"]} km`}
<div className="flex items-center gap-1 text-xs leading-none text-foreground">
{isCalculating && <Spinner className="size-3 text-muted-foreground" />}
{`${bufferValue} km`}
</div>
</div>
<div className="space-y-1 px-1">
<Slider
min={1}
max={100}
step={1}
value={[location.buffer || BUFFERS[LOCATION?.geometry.type || "point"]]}
value={[bufferValue]}
minStepsBetweenThumbs={1}
onValueChange={onValueChange}
onValueCommit={onValueCommit}
/>

<div className="flex w-full justify-between text-2xs font-bold text-muted-foreground">
Expand Down
Loading
Loading