gridsearch 1/2

This commit is contained in:
lovebird 2026-03-24 21:01:11 +01:00
parent 1b5bf73320
commit 844bbc5170
12 changed files with 427 additions and 66 deletions

View File

@ -326,6 +326,7 @@ async function generateCenterCells(
const skippedCells: SimulatorGridCell[] = [];
const acceptedCenters: Feature<Point>[] = [];
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];

View File

@ -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 = () => {
</React.Suspense>
} />
<Route path="/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} />
<Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} />
<Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
{enablePlaygrounds && (
<>
<Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} />
<Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} />
<Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} />
<Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} />
<Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
<Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} />
</>
)}
{/* Admin Routes */}
<Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
{/* Playground Routes */}
<Route path="/playground/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearchPlayground /></React.Suspense>} />
<Route path="/products/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearch /></React.Suspense>} />
<Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />
<Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} />
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} />
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} />
<Route path="/support-chat" element={<React.Suspense fallback={<div>Loading...</div>}><SupportChat /></React.Suspense>} />
{enablePlaygrounds && <Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />}
{enablePlaygrounds && <Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} />}
{/* Playground Routes */}
{enablePlaygrounds && (
<>
<Route path="/playground/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearchPlayground /></React.Suspense>} />
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
<Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} />
<Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} />
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
<Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} />
</>
)}
{enablePlaygrounds && <Route path="/support-chat" element={<React.Suspense fallback={<div>Loading...</div>}><SupportChat /></React.Suspense>} />}
{/* Logs */}
<Route path="/logs" element={<React.Suspense fallback={<div>Loading...</div>}><LogsPage /></React.Suspense>} />
{/* Apps */}
<Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} />
<Route path="/app/filebrowser/*" element={<React.Suspense fallback={<div>Loading...</div>}><FileBrowser /></React.Suspense>} />
{enablePlaygrounds && <Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} />}
{enablePlaygrounds && <Route path="/app/filebrowser/*" element={<React.Suspense fallback={<div>Loading...</div>}><FileBrowser /></React.Suspense>} />}
{/* 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 ...

View File

@ -0,0 +1,3 @@
export default function AppNoop() {
return <>Helo!</>
}

View File

@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import App from "./AppNoop.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<App />
);
// Enable CSS animations after initial render (prevents FOUC)
document.body.classList.add('app-init');

View File

@ -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(`<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 capitalize">${t.replace(/_/g, ' ')}</span>`);
if (t == null || typeof t === 'object') return;
tags.push(`<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 capitalize">${String(t).replace(/_/g, ' ')}</span>`);
});
}
// Categories
@ -549,6 +551,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
<RulerButton map={map.current} />
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}

View File

@ -75,16 +75,18 @@ export const CompetitorsThumbView: React.FC<CompetitorsThumbViewProps> = ({
// Add Business Types
for (const type of businessTypes) {
if (displayedCount < displayLimit) {
if (type == null || typeof type === 'object') continue;
const typeStr = String(type);
items.push(
<button
key={`type-${type}`}
onClick={(e) => { e.preventDefault(); toggleFilter(type); }}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize transition-colors ${filters.includes(type)
key={`type-${typeStr}`}
onClick={(e) => { e.preventDefault(); toggleFilter(typeStr); }}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize transition-colors ${filters.includes(typeStr)
? 'bg-blue-200 text-blue-900 ring-1 ring-blue-400'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
}`}
>
{type.replace(/_/g, ' ')}
{typeStr.replace(/_/g, ' ')}
</button>
);
displayedCount++;

View File

@ -29,8 +29,11 @@ export const TypeCell: React.FC<TypeCellProps> = ({ 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<TypeCellProps> = ({ types, excludedTypes = [], o
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Badge variant="outline" className="cursor-pointer text-xs px-1.5 py-0">
+{types.length - 2}
+{safeTypes.length - 2}
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Other Types</DropdownMenuLabel>
<DropdownMenuSeparator />
{types.slice(2).map(type => {
{safeTypes.slice(2).map(type => {
const isExcluded = excludedTypes.includes(type);
return (
<DropdownMenuItem

View File

@ -9,6 +9,7 @@ import { useMapControls } from '../hooks/useMapControls';
import { SimulatorLayers } from './map-layers/SimulatorLayers';
import { RegionLayers } from './map-layers/RegionLayers';
import { MapLayerToggles } from './MapLayerToggles';
import { RulerButton } from './RulerButton';
const safeSetStyle = (m: maplibregl.Map, style: any) => {
const terrain = m.getTerrain();
@ -349,6 +350,7 @@ export function GridSearchMap({
showCenters={showCenters}
onToggleCenters={onToggleCenters}
/>
<RulerButton map={map.current} />
<button
onClick={() => onPosterMode?.(!posterMode)}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Ruler, RotateCcw } from 'lucide-react';
import maplibregl from 'maplibre-gl';
import { useRulerTool } from '../hooks/useRulerTool';
export function RulerButton({ map }: { map: maplibregl.Map | null }) {
const { active, toggle, reset, formattedTotal, pointCount } = useRulerTool(map);
return (
<>
<button
onClick={toggle}
className={`p-1 flex items-center gap-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors font-medium ${
active
? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600'
: 'text-gray-500'
}`}
title={active ? 'Disable Ruler' : 'Measure Distance'}
>
<Ruler className="w-3.5 h-3.5" />
{active && pointCount >= 2 && (
<span className="text-[10px] font-mono font-bold">{formattedTotal}</span>
)}
</button>
{active && pointCount > 0 && (
<button
onClick={reset}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 hover:text-amber-600 transition-colors"
title="Clear Measurement"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</>
);
}

View File

@ -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<number, number> = {
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
<X className="w-3 h-3" />
</button>
</div>
{!loading && (() => {
const crumbs = region.parents;
if (!crumbs || crumbs.length === 0) return null;
return (
<div className="flex items-center flex-wrap gap-0.5 mt-1 pl-3.5 text-[10px] text-yellow-700/70 dark:text-yellow-400/60">
{crumbs.map((c, i) => (
<React.Fragment key={c.gid}>
{i > 0 && <ChevronRight className="w-2.5 h-2.5 opacity-50" />}
<button
onClick={async (e) => {
e.stopPropagation();
try {
const geo = await fetchRegionBoundary(c.gid, c.name, c.level, false);
if (geo?.features?.length) {
const centroid = turf.centroid(geo.features[0]);
const [lng, lat] = centroid.geometry.coordinates;
await performInspection(lat, lng);
}
} catch (err) {
console.error('Breadcrumb inspect failed', err);
}
}}
className="hover:text-yellow-900 dark:hover:text-yellow-200 hover:underline underline-offset-2 transition-colors cursor-pointer"
title={`Inspect ${c.name}`}
>
{c.name}
</button>
</React.Fragment>
))}
</div>
);
})()}
{region.stats && !loading && (
<div className="mt-1.5 text-xs text-yellow-700 dark:text-yellow-500 flex gap-4 opacity-90 border-t border-yellow-200/50 dark:border-yellow-800/50 pt-1.5 pl-3.5">
<span><strong className="font-semibold">Built:</strong> {region.stats.ghsBuiltWeight ? Math.round(region.stats.ghsBuiltWeight).toLocaleString() : 'N/A'}</span>

View File

@ -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[]));
}
}
}

View File

@ -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 };
}