From 844bbc5170e3eadfdd8ee4c9fa76b4da1fcd8a88 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 24 Mar 2026 21:01:11 +0100 Subject: [PATCH] gridsearch 1/2 --- .../src/products/places/grid-generator.ts | 2 + packages/ui/src/App.tsx | 125 +++++++---- packages/ui/src/AppNoop.tsx | 3 + packages/ui/src/mainNoop.tsx | 10 + .../src/modules/places/CompetitorsMapView.tsx | 5 +- .../modules/places/CompetitorsThumbView.tsx | 10 +- packages/ui/src/modules/places/TypeCell.tsx | 11 +- .../places/components/GridSearchMap.tsx | 2 + .../modules/places/components/RulerButton.tsx | 36 ++++ .../modules/places/gadm-picker/GadmPicker.tsx | 59 +++++- .../simulator/hooks/useGridSimulatorState.ts | 32 ++- .../src/modules/places/hooks/useRulerTool.ts | 198 ++++++++++++++++++ 12 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 packages/ui/src/AppNoop.tsx create mode 100644 packages/ui/src/mainNoop.tsx create mode 100644 packages/ui/src/modules/places/components/RulerButton.tsx create mode 100644 packages/ui/src/modules/places/hooks/useRulerTool.ts diff --git a/packages/ui/shared/src/products/places/grid-generator.ts b/packages/ui/shared/src/products/places/grid-generator.ts index 5066878d..1822d9a2 100644 --- a/packages/ui/shared/src/products/places/grid-generator.ts +++ b/packages/ui/shared/src/products/places/grid-generator.ts @@ -326,6 +326,7 @@ async function generateCenterCells( const skippedCells: SimulatorGridCell[] = []; const acceptedCenters: Feature[] = []; + console.log(`[GHS Centers] Total features: ${features.length}`); for (let i = 0; i < features.length; i++) { const f = features[i]; const props = f.properties || {}; @@ -355,6 +356,7 @@ async function generateCenterCells( } const uniqueCenters = Array.from(centersMap.values()); + console.log(`[GHS Centers] Feature ${i} (${props.NAME_2 || props.NAME_1 || props.name || '?'}): popCenter=${props.ghsPopCenter ? 'yes' : 'no'}, builtCenter=${props.ghsBuiltCenter ? 'yes' : 'no'}, popCenters=${Array.isArray(props.ghsPopCenters) ? props.ghsPopCenters.length : 'none'}, builtCenters=${Array.isArray(props.ghsBuiltCenters) ? props.ghsBuiltCenters.length : 'none'}, unique=${uniqueCenters.length}`); for (let j = 0; j < uniqueCenters.length; j++) { const { coord, popWeight, builtWeight } = uniqueCenters[j]; diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2a1a7d84..fb5b87be 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -38,31 +38,59 @@ const LogsPage = React.lazy(() => import("./components/logging/LogsPage")); const Wizard = React.lazy(() => import("./pages/Wizard")); const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings")); -const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); -const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM")); -const VideoPlayerPlayground = React.lazy(() => import("./pages/VideoPlayerPlayground")); -const VideoFeedPlayground = React.lazy(() => import("./pages/VideoFeedPlayground")); -const VideoPlayerPlaygroundIntern = React.lazy(() => import("./pages/VideoPlayerPlaygroundIntern")); const NotFound = React.lazy(() => import("./pages/NotFound")); const AdminPage = React.lazy(() => import("./pages/AdminPage")); -const PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages")); -const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); -const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); -const GridSearchPlayground = React.lazy(() => import("./modules/places/GridSearchPlayground")); -const GridSearch = React.lazy(() => import("./modules/places/gridsearch/GridSearch")); -const PlacesModule = React.lazy(() => import("./modules/places/index")); -const LocationDetail = React.lazy(() => import("./modules/places/LocationDetail")); -const PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas")); -const TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); -const VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); -const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); -const FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); -const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); -const PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); -const SupportChat = React.lazy(() => import("./pages/SupportChat")); + +const enablePlaygrounds = import.meta.env.VITE_ENABLE_PLAYGROUNDS === 'true'; + +let PlaygroundEditor: any; +let PlaygroundEditorLLM: any; +let VideoPlayerPlayground: any; +let VideoFeedPlayground: any; +let VideoPlayerPlaygroundIntern: any; +let PlaygroundImages: any; +let PlaygroundImageEditor: any; +let VideoGenPlayground: any; +let GridSearchPlayground: any; +let PlaygroundCanvas: any; +let TypesPlayground: any; +let VariablePlayground: any; +let I18nPlayground: any; +let PlaygroundChat: any; +let GridSearch: any; +let PlacesModule: any; +let LocationDetail: any; +let Tetris: any; + +let FileBrowser: any; +let SupportChat: any; + +GridSearch = React.lazy(() => import("./modules/places/gridsearch/GridSearch")); + +if (enablePlaygrounds) { + PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); + PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM")); + VideoPlayerPlayground = React.lazy(() => import("./pages/VideoPlayerPlayground")); + VideoFeedPlayground = React.lazy(() => import("./pages/VideoFeedPlayground")); + VideoPlayerPlaygroundIntern = React.lazy(() => import("./pages/VideoPlayerPlaygroundIntern")); + PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages")); + PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); + VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); + GridSearchPlayground = React.lazy(() => import("./modules/places/GridSearchPlayground")); + PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas")); + TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); + VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); + I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); + PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); + PlacesModule = React.lazy(() => import("./modules/places/index")); + LocationDetail = React.lazy(() => import("./modules/places/LocationDetail")); + Tetris = React.lazy(() => import("./apps/tetris/Tetris")); + FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); + SupportChat = React.lazy(() => import("./pages/SupportChat")); +} + const VersionMap = React.lazy(() => import("./pages/VersionMap")); const UserCollections = React.lazy(() => import("./pages/UserCollections")); - const Collections = React.lazy(() => import("./pages/Collections")); const NewCollection = React.lazy(() => import("./pages/NewCollection")); const UserPage = React.lazy(() => import("./modules/pages/UserPage")); @@ -156,37 +184,48 @@ const AppWrapper = () => { } /> Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> + + {enablePlaygrounds && ( + <> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + + )} {/* Admin Routes */} Loading...}>} /> - {/* Playground Routes */} - Loading...}>} /> Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> - Loading...}>} /> + {enablePlaygrounds && Loading...}>} />} + {enablePlaygrounds && Loading...}>} />} + + {/* Playground Routes */} + {enablePlaygrounds && ( + <> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + Loading...}>} /> + + )} + + {enablePlaygrounds && Loading...}>} />} {/* Logs */} Loading...}>} /> {/* Apps */} - Loading...}>} /> - Loading...}>} /> + {enablePlaygrounds && Loading...}>} />} + {enablePlaygrounds && Loading...}>} />} {/* Ecommerce Routes */} {(ecommerce) && ( @@ -226,8 +265,6 @@ import { StreamInvalidator } from "@/components/StreamInvalidator"; import { ActionProvider } from "@/actions/ActionProvider"; import { HelmetProvider } from 'react-helmet-async'; -import Tracker from '@openreplay/tracker'; -import trackerAssist from '@openreplay/tracker-assist'; // ... previous imports ... diff --git a/packages/ui/src/AppNoop.tsx b/packages/ui/src/AppNoop.tsx new file mode 100644 index 00000000..b99fad9b --- /dev/null +++ b/packages/ui/src/AppNoop.tsx @@ -0,0 +1,3 @@ +export default function AppNoop() { + return <>Helo! +} \ No newline at end of file diff --git a/packages/ui/src/mainNoop.tsx b/packages/ui/src/mainNoop.tsx new file mode 100644 index 00000000..0ec0b524 --- /dev/null +++ b/packages/ui/src/mainNoop.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import App from "./AppNoop.tsx"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + +); + +// Enable CSS animations after initial render (prevents FOUC) +document.body.classList.add('app-init'); diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index dd0071eb..463857c9 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid, X } from 'lucide-react'; +import { RulerButton } from './components/RulerButton'; import { type CompetitorFull } from '@polymech/shared'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -72,7 +73,8 @@ const renderPopupHtml = (competitor: CompetitorFull) => { // Business Types (slice to avoid overflow) if (competitor.types) { competitor.types.slice(0, 3).forEach(t => { - tags.push(`${t.replace(/_/g, ' ')}`); + if (t == null || typeof t === 'object') return; + tags.push(`${String(t).replace(/_/g, ' ')}`); }); } // Categories @@ -549,6 +551,7 @@ export const CompetitorsMapView: React.FC = ({ competit showCenters={showCenters} onToggleCenters={setShowCenters} /> + ); displayedCount++; diff --git a/packages/ui/src/modules/places/TypeCell.tsx b/packages/ui/src/modules/places/TypeCell.tsx index 23a5f2d0..df97be57 100644 --- a/packages/ui/src/modules/places/TypeCell.tsx +++ b/packages/ui/src/modules/places/TypeCell.tsx @@ -29,8 +29,11 @@ export const TypeCell: React.FC = ({ types, excludedTypes = [], o if (!types || types.length === 0) return null; - const displayTypes = types.slice(0, 2); - const hasMore = types.length > 2; + // Sanitize: filter out any non-string entries (objects, nulls, numbers) + const safeTypes = types.filter((t): t is string => typeof t === 'string'); + + const displayTypes = safeTypes.slice(0, 2); + const hasMore = safeTypes.length > 2; const renderBadge = (type: string) => { const isExcluded = excludedTypes.includes(type); @@ -73,13 +76,13 @@ export const TypeCell: React.FC = ({ types, excludedTypes = [], o - +{types.length - 2} + +{safeTypes.length - 2} Other Types - {types.slice(2).map(type => { + {safeTypes.slice(2).map(type => { const isExcluded = excludedTypes.includes(type); return ( { const terrain = m.getTerrain(); @@ -349,6 +350,7 @@ export function GridSearchMap({ showCenters={showCenters} onToggleCenters={onToggleCenters} /> + + {active && pointCount > 0 && ( + + )} + + ); +} diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index 51326c85..ebfe979a 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -37,6 +37,7 @@ export interface GadmRegion { level: number; stats?: any; raw?: any; + parents?: { name: string; gid: string; level: number }[]; } @@ -49,7 +50,7 @@ function createMarkerEl(): HTMLElement { } const MAX_DISPLAY_LEVEL: Record = { - 0: 2, + 0: 1, 1: 3, 2: 4, 3: 5, @@ -356,6 +357,8 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className if (!map || !active) return; const handleMapClick = async (e: maplibregl.MapMouseEvent) => { + // Skip if ruler tool is active + if (map.getCanvas().dataset.rulerActive) return; const ctrlKey = e.originalEvent.ctrlKey || e.originalEvent.metaKey; if (ctrlKey && isFetchingSelectionRef.current) return; const lat = e.lngLat.lat; @@ -437,10 +440,29 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className const targetLevel = (forceLevel !== undefined) ? forceLevel : (resolutionOption > realGadmLevel ? resolutionOption : realGadmLevel); + // Build parent chain for breadcrumb + const parents: { name: string; gid: string; level: number }[] = []; + // Try inspectedHierarchy first (available during inspector ctrl+click) + if (inspectedHierarchy) { + for (const row of inspectedHierarchy) { + if (row.level < realGadmLevel && row.gadmName && row.gid) { + parents.push({ name: row.gadmName, gid: row.gid, level: row.level }); + } + } + } + // Fallback: try raw GADM NAME_0/GID_0 fields (available from search results) + if (parents.length === 0) { + for (let l = 0; l < realGadmLevel; l++) { + const n = region[`NAME_${l}`]; + const g = region[`GID_${l}`]; + if (n && g) parents.push({ name: n, gid: g, level: l }); + } + } + let isDuplicate = false; setSelectedRegions(prev => { if (prev.some(r => r.gid === gid)) { isDuplicate = true; return prev; } - return [...prev, { gid, gadmName: name, level: targetLevel, raw: region }]; + return [...prev, { gid, gadmName: name, level: targetLevel, raw: region, parents: parents.length > 0 ? parents : undefined }]; }); if (isDuplicate) { @@ -577,6 +599,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className }; const handleClearAll = () => { + isFetchingSelectionRef.current = false; queuedInspectionsRef.current.clear(); processingGids.current.clear(); loadingBoundaryIdsRef.current.clear(); @@ -744,6 +767,38 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className + {!loading && (() => { + const crumbs = region.parents; + if (!crumbs || crumbs.length === 0) return null; + return ( +
+ {crumbs.map((c, i) => ( + + {i > 0 && } + + + ))} +
+ ); + })()} {region.stats && !loading && (
Built: {region.stats.ghsBuiltWeight ? Math.round(region.stats.ghsBuiltWeight).toLocaleString() : 'N/A'} diff --git a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts index 7b60cdac..6502adf6 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts @@ -149,18 +149,28 @@ export function useGridSimulatorState({ return; } - // Fetch precisely the correct targetLevel resolution boundaries - // instead of relying on pickerPolygons which might be capped for display. + // For GHS Centers mode, use pickerPolygons directly — they're already fetched + // at the effective level and enriched with GHS data. Re-fetching at region.level + // can merge all features into one when the requested level exceeds available GADM depth + // (e.g., L5 requested but country only has data through L4). const features: GridFeature[] = []; - for (const region of pickerRegions) { - const geojson = await fetchRegionBoundary( - region.gid, - region.gadmName || '', - region.level, - true // enrich - ); - if (geojson && geojson.features) { - features.push(...(geojson.features as GridFeature[])); + if (gridMode === 'centers' && pickerPolygons && pickerPolygons.length > 0) { + for (const fc of pickerPolygons) { + if (fc && fc.features) { + features.push(...(fc.features as GridFeature[])); + } + } + } else { + for (const region of pickerRegions) { + const geojson = await fetchRegionBoundary( + region.gid, + region.gadmName || '', + region.level, + true // enrich + ); + if (geojson && geojson.features) { + features.push(...(geojson.features as GridFeature[])); + } } } diff --git a/packages/ui/src/modules/places/hooks/useRulerTool.ts b/packages/ui/src/modules/places/hooks/useRulerTool.ts new file mode 100644 index 00000000..50afce69 --- /dev/null +++ b/packages/ui/src/modules/places/hooks/useRulerTool.ts @@ -0,0 +1,198 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; + +const RULER_SOURCE = 'ruler-source'; +const RULER_LINE_LAYER = 'ruler-line-layer'; +const RULER_POINT_LAYER = 'ruler-point-layer'; +const RULER_LABEL_LAYER = 'ruler-label-layer'; + +/** Haversine distance between two [lng, lat] points in km */ +function haversineKm(a: [number, number], b: [number, number]): number { + const R = 6371; + const dLat = ((b[1] - a[1]) * Math.PI) / 180; + const dLon = ((b[0] - a[0]) * Math.PI) / 180; + const lat1 = (a[1] * Math.PI) / 180; + const lat2 = (b[1] * Math.PI) / 180; + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(h)); +} + +function formatDistance(km: number): string { + if (km < 1) return `${Math.round(km * 1000)} m`; + if (km < 100) return `${km.toFixed(2)} km`; + return `${Math.round(km).toLocaleString()} km`; +} + +function buildGeoJSON(points: [number, number][]) { + const features: any[] = []; + + // Line segments with segment distance labels + if (points.length >= 2) { + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: points }, + properties: {} + }); + } + + // Point markers with cumulative distance + let cumulative = 0; + points.forEach((p, i) => { + if (i > 0) cumulative += haversineKm(points[i - 1], p); + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: p }, + properties: { + index: i, + label: i === 0 ? 'Start' : formatDistance(cumulative) + } + }); + }); + + return { type: 'FeatureCollection', features }; +} + +export function useRulerTool(map: maplibregl.Map | null) { + const [active, setActive] = useState(false); + const [totalDistance, setTotalDistance] = useState(0); + const pointsRef = useRef<[number, number][]>([]); + const activeRef = useRef(false); + + const updateSource = useCallback(() => { + if (!map) return; + const src = map.getSource(RULER_SOURCE) as maplibregl.GeoJSONSource | undefined; + if (!src) return; + const gj = buildGeoJSON(pointsRef.current); + src.setData(gj as any); + + // Compute total + let total = 0; + const pts = pointsRef.current; + for (let i = 1; i < pts.length; i++) total += haversineKm(pts[i - 1], pts[i]); + setTotalDistance(total); + }, [map]); + + const ensureLayers = useCallback(() => { + if (!map) return; + if (map.getSource(RULER_SOURCE)) return; + + map.addSource(RULER_SOURCE, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + + map.addLayer({ + id: RULER_LINE_LAYER, + type: 'line', + source: RULER_SOURCE, + filter: ['==', '$type', 'LineString'], + paint: { + 'line-color': '#f59e0b', + 'line-width': 2.5, + 'line-dasharray': [3, 2] + } + }); + + map.addLayer({ + id: RULER_POINT_LAYER, + type: 'circle', + source: RULER_SOURCE, + filter: ['==', '$type', 'Point'], + paint: { + 'circle-radius': 5, + 'circle-color': '#f59e0b', + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2 + } + }); + + map.addLayer({ + id: RULER_LABEL_LAYER, + type: 'symbol', + source: RULER_SOURCE, + filter: ['==', '$type', 'Point'], + layout: { + 'text-field': ['get', 'label'], + 'text-size': 11, + 'text-offset': [0, -1.5], + 'text-anchor': 'bottom', + 'text-allow-overlap': true, + 'text-font': ['Open Sans Bold'] + }, + paint: { + 'text-color': '#92400e', + 'text-halo-color': '#ffffff', + 'text-halo-width': 1.5 + } + }); + }, [map]); + + const removeLayers = useCallback(() => { + if (!map) return; + try { + [RULER_LABEL_LAYER, RULER_POINT_LAYER, RULER_LINE_LAYER].forEach(id => { + if (map.getLayer(id)) map.removeLayer(id); + }); + if (map.getSource(RULER_SOURCE)) map.removeSource(RULER_SOURCE); + } catch (_) { /* map already destroyed */ } + }, [map]); + + const handleClick = useCallback((e: maplibregl.MapMouseEvent) => { + if (!activeRef.current) return; + pointsRef.current = [...pointsRef.current, [e.lngLat.lng, e.lngLat.lat]]; + updateSource(); + }, [updateSource]); + + const reset = useCallback(() => { + pointsRef.current = []; + setTotalDistance(0); + updateSource(); + }, [updateSource]); + + const toggle = useCallback(() => { + setActive(prev => { + const next = !prev; + activeRef.current = next; + if (next && map) { + ensureLayers(); + map.getCanvas().style.cursor = 'crosshair'; + map.getCanvas().dataset.rulerActive = 'true'; + map.on('click', handleClick); + } else if (map) { + map.off('click', handleClick); + map.getCanvas().style.cursor = ''; + delete map.getCanvas().dataset.rulerActive; + removeLayers(); + pointsRef.current = []; + setTotalDistance(0); + } + return next; + }); + }, [map, ensureLayers, removeLayers, handleClick]); + + // Re-add layers after style change (MapLibre loses them) + useEffect(() => { + if (!map || !active) return; + const handler = () => { + ensureLayers(); + updateSource(); + }; + map.on('style.load', handler); + return () => { map.off('style.load', handler); }; + }, [map, active, ensureLayers, updateSource]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (map) { + try { + map.off('click', handleClick); + removeLayers(); + map.getCanvas().style.cursor = ''; + } catch (_) { /* map already destroyed */ } + } + }; + }, [map, handleClick, removeLayers]); + + return { active, totalDistance, toggle, reset, formattedTotal: formatDistance(totalDistance), pointCount: pointsRef.current.length }; +}