gridsearch 1/2
This commit is contained in:
parent
1b5bf73320
commit
844bbc5170
@ -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];
|
||||
|
||||
@ -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 ...
|
||||
|
||||
|
||||
3
packages/ui/src/AppNoop.tsx
Normal file
3
packages/ui/src/AppNoop.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function AppNoop() {
|
||||
return <>Helo!</>
|
||||
}
|
||||
10
packages/ui/src/mainNoop.tsx
Normal file
10
packages/ui/src/mainNoop.tsx
Normal 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');
|
||||
@ -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}
|
||||
|
||||
@ -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++;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
36
packages/ui/src/modules/places/components/RulerButton.tsx
Normal file
36
packages/ui/src/modules/places/components/RulerButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
198
packages/ui/src/modules/places/hooks/useRulerTool.ts
Normal file
198
packages/ui/src/modules/places/hooks/useRulerTool.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user