'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { length as turfLength, area as turfArea } from '@turf/turf' import { useMapContext } from '../context' import { createLineString, createPolygon, createFeatureCollection } from '../utils' /** Measure interaction mode. */ export type MeasureMode = 'distance' | 'area' export interface UseMeasureResult { /** Current measure mode (`'distance'` = path length, `'area'` = polygon area). */ mode: MeasureMode /** Switch the measure mode. Clears the in-progress vertices. */ setMode: (mode: MeasureMode) => void /** Vertices collected so far, as `[lng, lat]` pairs in click order. */ points: [number, number][] /** * The collected geometry as a FeatureCollection ready for `MapSource`. * A LineString in `'distance'` mode, a Polygon in `'area'` mode (empty * until enough vertices exist to form the geometry). */ geojson: GeoJSON.FeatureCollection /** * The running total — kilometres in `'distance'` mode, square metres in * `'area'` mode. `0` until at least two (distance) / three (area) points. */ total: number /** Human-readable total, e.g. `"1.24 km"`, `"3,420 m²"`, `"0.34 km²"`. */ totalLabel: string /** Remove all collected vertices. */ clear: () => void /** Whether the measure interaction is listening for map clicks. */ active: boolean /** Start / stop listening for map clicks. Stopping clears the vertices. */ setActive: (active: boolean) => void } const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) /** Format a distance in kilometres into a compact `m`/`km` label. */ function formatDistance(km: number): string { if (km <= 0) return '0 m' if (km < 1) return `${numberFormat.format(km * 1000)} m` return `${km.toFixed(2)} km` } /** Format an area in square metres into a compact `m²`/`km²` label. */ function formatArea(m2: number): string { if (m2 <= 0) return '0 m²' if (m2 < 1_000_000) return `${numberFormat.format(m2)} m²` return `${(m2 / 1_000_000).toFixed(2)} km²` } /** * Click-to-measure interaction for the Map tool. * * Subscribes to map clicks while `active` and appends each `[lng, lat]` to * the vertex list, recomputing a running total with turf: * - `'distance'` → a LineString, total via `turf.length` (km). * - `'area'` → a Polygon, total via `turf.area` (m²). * * Geometry is built with the tool's own `createLineString` / `createPolygon` * helpers so it matches house style and renders through the existing * `createLineLayer` / `createPolygonLayer` factories. * * Must be used inside a `MapProvider` (it reads `useMapContext`). */ export function useMeasure(initialMode: MeasureMode = 'distance'): UseMeasureResult { const { mapRef, isLoaded } = useMapContext() const [mode, setModeState] = useState(initialMode) const [active, setActiveState] = useState(false) const [points, setPoints] = useState<[number, number][]>([]) // Latest mode/active read inside the (re-subscribed only on isLoaded) click // handler, so changing mode/active never re-binds the underlying listener. const modeRef = useRef(mode) const activeRef = useRef(active) useEffect(() => { modeRef.current = mode }, [mode]) useEffect(() => { activeRef.current = active }, [active]) const clear = useCallback(() => { setPoints([]) }, []) const setMode = useCallback((next: MeasureMode) => { setModeState(next) setPoints([]) }, []) const setActive = useCallback((next: boolean) => { setActiveState(next) // Stopping the interaction discards the in-progress measurement. if (!next) setPoints([]) }, []) // Subscribe to map clicks. Listener is attached once per `isLoaded`; the // handler reads the latest mode/active via refs (same pattern as // `useMapEvents`). useEffect(() => { const map = mapRef.current?.getMap() if (!map || !isLoaded) return const handleClick = (event: maplibregl.MapMouseEvent) => { if (!activeRef.current) return const { lng, lat } = event.lngLat setPoints((prev) => [...prev, [lng, lat]]) } map.on('click', handleClick) return () => { map.off('click', handleClick) } }, [mapRef, isLoaded]) // Reflect the active state as a crosshair cursor on the map canvas. useEffect(() => { const map = mapRef.current?.getMap() if (!map || !isLoaded) return const canvas = map.getCanvas() if (!canvas) return const previous = canvas.style.cursor if (active) canvas.style.cursor = 'crosshair' return () => { canvas.style.cursor = previous } }, [mapRef, isLoaded, active]) const geojson = useMemo(() => { if (mode === 'distance') { if (points.length < 2) return createFeatureCollection() return createFeatureCollection([createLineString(points)]) } if (points.length < 3) return createFeatureCollection() return createFeatureCollection([createPolygon(points)]) }, [mode, points]) const total = useMemo(() => { if (mode === 'distance') { if (points.length < 2) return 0 return turfLength(createLineString(points), { units: 'kilometers' }) } if (points.length < 3) return 0 return turfArea(createPolygon(points)) }, [mode, points]) const totalLabel = useMemo( () => (mode === 'distance' ? formatDistance(total) : formatArea(total)), [mode, total] ) return { mode, setMode, points, geojson, total, totalLabel, clear, active, setActive, } }