diff --git a/packages/ui/shared/src/products/places/grid-generator.ts b/packages/ui/shared/src/products/places/grid-generator.ts index 793cb4b4..35cc281d 100644 --- a/packages/ui/shared/src/products/places/grid-generator.ts +++ b/packages/ui/shared/src/products/places/grid-generator.ts @@ -1,11 +1,51 @@ import * as turf from '@turf/turf'; +import type { Feature, Polygon, MultiPolygon, Point } from 'geojson'; + +export interface GridSearchHop { + step: number; + lng: number; + lat: number; + radius_km?: number; +} + +export interface GridFeatureProperties { + areaSqKm?: number; + avgElevation?: number; + population?: number; + ghsPopulation?: number; + ghsBuiltWeight?: number; + ghsPopCenter?: [number, number]; + ghsBuiltCenter?: [number, number]; + [key: string]: any; +} + +export interface SimulatorGridCellProperties extends GridFeatureProperties { + sim_status: 'pending' | 'processed' | 'skipped'; + sim_region_idx: number; + _reason?: string; + search_center?: [number, number] | null; + search_radius_km?: number; + sim_index?: number; + is_center_cell?: boolean; + sim_skip_reason?: string; +} + +export type GridFeature = Feature; +export type SimulatorGridCell = Feature; + +export interface GridGeneratorProgressStats { + current: number; + total: number; + validCells: SimulatorGridCell[]; + skippedCells: SimulatorGridCell[]; +} export interface GridGeneratorOptions { - features: any[]; // The selected region polygons + features: GridFeature[]; gridMode: 'hex' | 'square' | 'admin' | 'centers'; cellSize: number; - cellOverlap?: number; // e.g. 0.1 for 10% overlap - centroidOverlap?: number; // e.g. 0.5 for 50% max allowable overlap between centers + cellOverlap?: number; + centroidOverlap?: number; maxCellsLimit?: number; maxElevation: number; minDensity: number; @@ -15,464 +55,140 @@ export interface GridGeneratorOptions { groupByRegion: boolean; ghsFilterMode?: 'AND' | 'OR'; allowMissingGhs?: boolean; - onFilterCell?: (cell: any) => boolean; - skipPolygons?: any[]; + onFilterCell?: (cell: Feature) => boolean; + skipPolygons?: Feature[]; bypassFilters?: boolean; } export interface GridGeneratorResult { - validCells: any[]; - skippedCells: any[]; + validCells: SimulatorGridCell[]; + skippedCells: SimulatorGridCell[]; + waypoints: GridSearchHop[]; error?: string; } -export async function generateGridSearchCells( - options: GridGeneratorOptions, - onProgress?: (stats: { current: number, total: number, validCells: any[], skippedCells: any[] }) => Promise -): Promise { - const { - features, gridMode, cellSize, maxElevation, - minDensity, minGhsPop = 0, minGhsBuilt = 0, pathOrder, groupByRegion, onFilterCell, - cellOverlap = 0, centroidOverlap = 0.5, ghsFilterMode = 'AND', maxCellsLimit = 15000, skipPolygons, allowMissingGhs = false - } = options; +// ============================================================================ +// Core Utility Functions +// ============================================================================ - let validCells: any[] = []; - let skippedCells: any[] = []; - if (gridMode === 'admin') { - // MODE: Admin Regions (GADM native polygons directly act as grid cells) - for (let i = 0; i < features.length; i++) { - const f = features[i]; +export function extractGridWaypoints(cells: SimulatorGridCell[]): GridSearchHop[] { + return cells.map((cell, i) => { + const pt = turf.centroid(cell).geometry.coordinates; + return { + step: cell.properties.sim_index !== undefined ? cell.properties.sim_index + 1 : i + 1, + lng: Number(pt[0].toFixed(6)), + lat: Number(pt[1].toFixed(6)), + radius_km: cell.properties.search_radius_km ? Number(cell.properties.search_radius_km.toFixed(2)) : undefined + }; + }); +} - if (i % 50 === 0 && onProgress) { - const shouldContinue = await onProgress({ - current: i, - total: features.length, - validCells, - skippedCells - }); - if (!shouldContinue) { - break; - } - } +function checkCellFilters(props: GridFeatureProperties, options: GridGeneratorOptions, areaSqKm: number): { allowed: boolean, reason: string } { + const { maxElevation, minDensity, minGhsPop = 0, minGhsBuilt = 0, ghsFilterMode = 'AND', allowMissingGhs = false, bypassFilters } = options; + let allowed = true; + let reason = ''; - const props = f.properties || {}; - let allowed = true; - let reason = ''; - const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); - if (props.avgElevation !== undefined && maxElevation > 0) { - if (props.avgElevation > maxElevation) { - allowed = false; - reason = `Elevation ${Math.round(props.avgElevation)}m > ${maxElevation}m`; - } - } + if (bypassFilters) return { allowed, reason }; - if (allowed && props.population !== undefined && areaSqKm > 0 && minDensity > 0) { - const density = props.population / areaSqKm; - if (density < minDensity) { - allowed = false; - reason = `Density ${density.toFixed(1)} < ${minDensity}`; - } - } + if (props.avgElevation !== undefined && maxElevation > 0 && props.avgElevation > maxElevation) { + return { allowed: false, reason: `Elevation ${Math.round(props.avgElevation)}m > ${maxElevation}m` }; + } - if (allowed) { - const checkPop = minGhsPop > 0; - const checkBuilt = minGhsBuilt > 0; - if (checkPop || checkBuilt) { - const hasPopData = props.ghsPopulation !== undefined; - const hasBuiltData = props.ghsBuiltWeight !== undefined; - const ghsPop = props.ghsPopulation || 0; - const ghsBuilt = props.ghsBuiltWeight || 0; - const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); - const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); - - if (ghsFilterMode === 'OR') { - if (checkPop && checkBuilt && !popPass && !builtPass) { - allowed = false; reason = `GHS (OR) Pop < ${minGhsPop} & Built < ${minGhsBuilt}`; - } else if (checkPop && !checkBuilt && !popPass) { - allowed = false; reason = `GHS Pop < ${minGhsPop}`; - } else if (checkBuilt && !checkPop && !builtPass) { - allowed = false; reason = `GHS Built < ${minGhsBuilt}`; - } - } else { - if (checkPop && !popPass) { - allowed = false; reason = `GHS Pop ${Math.round(ghsPop)} < ${minGhsPop}`; - } else if (checkBuilt && !builtPass) { - allowed = false; reason = `GHS Built ${Math.round(ghsBuilt)} < ${minGhsBuilt}`; - } - } - } - } - - if (allowed && onFilterCell && !onFilterCell(f)) allowed = false; - - let center = null; - let radiusKm = 0; - if (gridMode === 'admin') { - center = turf.centroid(f).geometry.coordinates; - const b = turf.bbox(f); - radiusKm = turf.distance(center, [b[2], b[3]], { units: 'kilometers' }); - } - - if (allowed && center && skipPolygons && skipPolygons.length > 0) { - const pt = turf.point(center); - for (const oldPoly of skipPolygons) { - if (turf.booleanPointInPolygon(pt, oldPoly)) { - allowed = false; - reason = 'Already Simulated'; - break; - } - } - } - - if (allowed) { - validCells.push({ - ...f, - properties: { - ...props, - sim_status: 'pending', - sim_region_idx: i, - _reason: reason, - search_center: center, - search_radius_km: radiusKm - } - }); - } else { - skippedCells.push({ - ...f, - properties: { - ...props, - sim_status: 'skipped', - sim_region_idx: i, - _reason: reason, - search_center: center, - search_radius_km: radiusKm - } - }); - } + if (props.population !== undefined && areaSqKm > 0 && minDensity > 0) { + const density = props.population / areaSqKm; + if (density < minDensity) { + return { allowed: false, reason: `Density ${density.toFixed(1)} < ${minDensity}` }; } - } else if (gridMode === 'centers') { - // MODE: GHS Centers - generate a cell exactly at ghsPopCenter and ghsBuiltCenter - const acceptedCenters: any[] = []; + } - for (let i = 0; i < features.length; i++) { - const f = features[i]; - const props = f.properties || {}; + const checkPop = minGhsPop > 0; + const checkBuilt = minGhsBuilt > 0; + + if (checkPop || checkBuilt) { + const hasPopData = props.ghsPopulation !== undefined; + const hasBuiltData = props.ghsBuiltWeight !== undefined; + const ghsPop = typeof props.ghsPopulation === 'number' ? props.ghsPopulation : 0; + const ghsBuilt = typeof props.ghsBuiltWeight === 'number' ? props.ghsBuiltWeight : 0; + const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); + const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); - if (i % 50 === 0 && onProgress) { - const shouldContinue = await onProgress({ current: i, total: features.length, validCells, skippedCells }); - if (!shouldContinue) break; + if (ghsFilterMode === 'OR') { + if (checkPop && checkBuilt && !popPass && !builtPass) { + return { allowed: false, reason: `GHS (OR) Pop < ${minGhsPop} & Built < ${minGhsBuilt}` }; + } else if (checkPop && !checkBuilt && !popPass) { + return { allowed: false, reason: `GHS Pop < ${minGhsPop}` }; + } else if (checkBuilt && !checkPop && !builtPass) { + return { allowed: false, reason: `GHS Built < ${minGhsBuilt}` }; } - - const centers: [number, number][] = []; - if (props.ghsPopCenter && Array.isArray(props.ghsPopCenter)) centers.push(props.ghsPopCenter as [number, number]); - if (props.ghsBuiltCenter && Array.isArray(props.ghsBuiltCenter)) centers.push(props.ghsBuiltCenter as [number, number]); - - // Deduplicate centers - const uniqueCenters: [number, number][] = []; - for (const c of centers) { - if (!uniqueCenters.some(uc => uc[0] === c[0] && uc[1] === c[1])) { - uniqueCenters.push(c); - } - } - - for (let j = 0; j < uniqueCenters.length; j++) { - const center = uniqueCenters[j]; - const pt = turf.point(center); - - let allowed = true; - let reason = ''; - const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); - - if (!options.bypassFilters) { - if (props.avgElevation !== undefined && maxElevation > 0 && props.avgElevation > maxElevation) { - allowed = false; reason = `elevation > ${maxElevation}`; - } else if (props.population !== undefined && minDensity > 0 && (props.population / areaSqKm) < minDensity) { - allowed = false; reason = `density < ${minDensity}`; - } else { - const checkPop = minGhsPop > 0; - const checkBuilt = minGhsBuilt > 0; - if (checkPop || checkBuilt) { - const hasPopData = props.ghsPopulation !== undefined; - const hasBuiltData = props.ghsBuiltWeight !== undefined; - const ghsPop = typeof props.ghsPopulation === 'number' ? props.ghsPopulation : 0; - const ghsBuilt = typeof props.ghsBuiltWeight === 'number' ? props.ghsBuiltWeight : 0; - const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); - const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); - - if (ghsFilterMode === 'OR') { - if (checkPop && checkBuilt && !popPass && !builtPass) { - allowed = false; reason = `ghs (OR) < min`; - } else if (checkPop && !checkBuilt && !popPass) { - allowed = false; reason = `ghsPop < ${minGhsPop}`; - } else if (checkBuilt && !checkPop && !builtPass) { - allowed = false; reason = `ghsBuilt < ${minGhsBuilt}`; - } - } else { - if (checkPop && !popPass) { allowed = false; reason = `ghsPop < ${minGhsPop}`; } - if (checkBuilt && !builtPass) { allowed = false; reason = `ghsBuilt < ${minGhsBuilt}`; } - } - } - } - } - - // Check overlap with ALREADY processed center cells - if (allowed && acceptedCenters.length > 0) { - const minAllowedDistance = cellSize * (1 - centroidOverlap); - for (const existingPt of acceptedCenters) { - const dist = turf.distance(pt, existingPt, { units: 'kilometers' }); - if (dist < minAllowedDistance) { - allowed = false; - reason = `overlaps another centroid (${dist.toFixed(2)}km < ${minAllowedDistance.toFixed(2)}km)`; - break; - } - } - } - - if (allowed && skipPolygons && skipPolygons.length > 0) { - for (const oldPoly of skipPolygons) { - if (turf.booleanPointInPolygon(pt, oldPoly)) { - allowed = false; - reason = `already processed`; - break; - } - } - } - - // Create a hexagon using buffer with 6 steps around the center point - const cell = turf.buffer(pt, cellSize / 2, { units: 'kilometers', steps: 6 }); - - if (cell) { - // Add parent properties - cell.properties = { ...props, is_center_cell: true }; - - if (onFilterCell && !onFilterCell(cell) && allowed) { - allowed = false; reason = `custom filter`; - } - - cell.properties!.sim_region_idx = i; - - if (allowed) { - cell.properties!.sim_status = 'pending'; - validCells.push(cell); - acceptedCenters.push(pt); - } else { - cell.properties!.sim_status = 'skipped'; - cell.properties!.sim_skip_reason = reason; - skippedCells.push(cell); - } - } - } - } - } else { - // MODE: Hex Grid Projection - const fc = turf.featureCollection(features); - - // 2. Get Bounding Box - const bbox = turf.bbox(fc); - - // Safety Check: Estimate cell count - const width = turf.distance([bbox[0], bbox[1]], [bbox[2], bbox[1]], { units: 'kilometers' }); - const height = turf.distance([bbox[0], bbox[1]], [bbox[0], bbox[3]], { units: 'kilometers' }); - const area = width * height; - const approxCellArea = cellSize * cellSize * 2.6; // hexagon area approx - const approxCells = Math.ceil(area / approxCellArea); - if (approxCells > maxCellsLimit) { - return { - validCells: [], - skippedCells: [], - error: `Grid is too massive (approx ${approxCells.toLocaleString()} cells). Please increase the cell size or select a smaller region to prevent your browser from freezing.` - }; - } - - // 3. Generate Grid - let grid; - if (gridMode === 'square') { - grid = turf.squareGrid(bbox, cellSize, { units: 'kilometers' }); } else { - grid = turf.hexGrid(bbox, cellSize, { units: 'kilometers' }); + if (checkPop && !popPass) { + return { allowed: false, reason: `GHS Pop ${Math.round(ghsPop)} < ${minGhsPop}` }; + } else if (checkBuilt && !builtPass) { + return { allowed: false, reason: `GHS Built ${Math.round(ghsBuilt)} < ${minGhsBuilt}` }; + } } + } + return { allowed, reason }; +} - // Apply overlap scaling if necessary - if (cellOverlap > 0) { - turf.featureEach(grid, (cell) => { - const scaled = turf.transformScale(cell, 1 + cellOverlap); - cell.geometry = scaled.geometry; +function checkSkipPolygons(pt: Feature, skipPolygons?: Feature[]): boolean { + if (!skipPolygons || skipPolygons.length === 0) return false; + for (const oldPoly of skipPolygons) { + if (turf.booleanPointInPolygon(pt, oldPoly)) { + return true; + } + } + return false; +} + +function sortGridCells( + validCells: SimulatorGridCell[], + pathOrder: GridGeneratorOptions['pathOrder'], + cellSize: number, + groupByRegion: boolean, + featuresLength: number +): SimulatorGridCell[] { + let cellsToSort = [...validCells]; + + const sortArray = (cells: SimulatorGridCell[]) => { + const rowTolerance = Math.min((cellSize / 111.32) * 0.5, 0.5); + + if (pathOrder === 'zigzag' || pathOrder === 'snake') { + cells.sort((a, b) => { + const centA = turf.centroid(a).geometry.coordinates; + const centB = turf.centroid(b).geometry.coordinates; + if (Math.abs(centA[1] - centB[1]) > rowTolerance) { + return centB[1] - centA[1]; + } + return centA[0] - centB[0]; }); - } - // 4. Intersect and Filter - const totalFeatures = grid.features.length; - for (let idx = 0; idx < totalFeatures; idx++) { - const cell = grid.features[idx]; + if (pathOrder === 'snake') { + const rows: SimulatorGridCell[][] = []; + let currentRow: SimulatorGridCell[] = []; + let lastY = cells.length > 0 ? turf.centroid(cells[0]).geometry.coordinates[1] : 0; - // Chunk checking: yield every 50 iterations or every 100 valid cells if callback provided - if (idx % 50 === 0 && onProgress) { - const shouldContinue = await onProgress({ - current: idx, - total: totalFeatures, - validCells, - skippedCells + cells.forEach(cell => { + const y = turf.centroid(cell).geometry.coordinates[1]; + if (Math.abs(y - lastY) > rowTolerance) { + rows.push(currentRow); + currentRow = [cell]; + lastY = y; + } else { + currentRow.push(cell); + } }); - if (!shouldContinue) { - break; // Abort processing, keep what we have - } - } + if (currentRow.length > 0) rows.push(currentRow); - // Check if cell intersects any of the picker regions - let intersects = false; - let regionIndex = -1; - for (let i = 0; i < features.length; i++) { - const regionFeature = features[i]; - if (!regionFeature) continue; - if (turf.booleanIntersects(cell, regionFeature as any)) { - intersects = true; - regionIndex = i; - break; - } - } - - if (intersects) { - // Extract stats from the containing boundary - const regionFeature = features[regionIndex]; - const props = regionFeature?.properties || {}; - const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(regionFeature) / 1000000); - - let allowed = onFilterCell ? onFilterCell(cell) : true; - let reason = ''; - - if (allowed && props.avgElevation !== undefined && maxElevation > 0) { - if (props.avgElevation > maxElevation) { - allowed = false; - reason = `Elevation ${Math.round(props.avgElevation)}m > ${maxElevation}m`; - } - } - - if (allowed && props.population !== undefined && areaSqKm > 0 && minDensity > 0) { - const density = props.population / areaSqKm; - if (density < minDensity) { - allowed = false; - reason = `Density ${density.toFixed(1)} < ${minDensity}`; - } - } - - if (allowed) { - const checkPop = minGhsPop > 0; - const checkBuilt = minGhsBuilt > 0; - if (checkPop || checkBuilt) { - const hasPopData = props.ghsPopulation !== undefined; - const hasBuiltData = props.ghsBuiltWeight !== undefined; - const ghsPop = props.ghsPopulation || 0; - const ghsBuilt = props.ghsBuiltWeight || 0; - const popPass = checkPop && ((!hasPopData && allowMissingGhs) || ghsPop >= minGhsPop); - const builtPass = checkBuilt && ((!hasBuiltData && allowMissingGhs) || ghsBuilt >= minGhsBuilt); - - if (ghsFilterMode === 'OR') { - if (checkPop && checkBuilt && !popPass && !builtPass) { - allowed = false; reason = `GHS (OR) Pop < ${minGhsPop} & Built < ${minGhsBuilt}`; - } else if (checkPop && !checkBuilt && !popPass) { - allowed = false; reason = `GHS Pop < ${minGhsPop}`; - } else if (checkBuilt && !checkPop && !builtPass) { - allowed = false; reason = `GHS Built < ${minGhsBuilt}`; - } - } else { - if (checkPop && !popPass) { - allowed = false; reason = `GHS Pop ${Math.round(ghsPop)} < ${minGhsPop}`; - } else if (checkBuilt && !builtPass) { - allowed = false; reason = `GHS Built ${Math.round(ghsBuilt)} < ${minGhsBuilt}`; - } - } - } - } - - const center = turf.centroid(cell).geometry.coordinates; - const cellBbox = turf.bbox(cell); - const radiusKm = turf.distance(center, [cellBbox[2], cellBbox[3]], { units: 'kilometers' }); - - if (allowed && center && skipPolygons && skipPolygons.length > 0) { - const pt = turf.point(center); - for (const oldPoly of skipPolygons) { - if (turf.booleanPointInPolygon(pt, oldPoly)) { - allowed = false; - reason = 'Already Simulated'; - break; - } - } - } - - if (allowed) { - cell.properties = { - ...cell.properties, - sim_status: 'pending', - sim_region_idx: regionIndex, - _reason: reason, - search_center: center, - search_radius_km: radiusKm - }; - validCells.push(cell); - } else { - cell.properties = { - ...cell.properties, - sim_status: 'skipped', - sim_region_idx: regionIndex, - _reason: reason, - search_center: center, - search_radius_km: radiusKm - }; - skippedCells.push(cell); - } - } - } - } // end hex mode - - // 5. Apply Path Trajectory Sorting - if (validCells.length > 0) { - const sortArray = (cells: any[]) => { - const rowTolerance = Math.min((cellSize / 111.32) * 0.5, 0.5); - - if (pathOrder === 'zigzag' || pathOrder === 'snake') { - // First sort top-down, left-right - cells.sort((a, b) => { - const centA = turf.centroid(a).geometry.coordinates; // [lng, lat] - const centB = turf.centroid(b).geometry.coordinates; - // Sort by Y first - if (Math.abs(centA[1] - centB[1]) > rowTolerance) { - return centB[1] - centA[1]; - } - // Then sort by X - return centA[0] - centB[0]; + cells.length = 0; + rows.forEach((row, i) => { + if (i % 2 === 1) cells.push(...row.reverse()); + else cells.push(...row); }); + } - if (pathOrder === 'snake') { - // Group into rows and reverse the odd rows - const rows: any[][] = []; - let currentRow: any[] = []; - let lastY = turf.centroid(cells[0]).geometry.coordinates[1]; - - cells.forEach(cell => { - const y = turf.centroid(cell).geometry.coordinates[1]; - if (Math.abs(y - lastY) > rowTolerance) { - rows.push(currentRow); - currentRow = [cell]; - lastY = y; - } else { - currentRow.push(cell); - } - }); - if (currentRow.length > 0) rows.push(currentRow); - - // Reconstruct validCells array directly into the passed array reference by clearing and pushing - cells.length = 0; - rows.forEach((row, i) => { - if (i % 2 === 1) { - cells.push(...row.reverse()); - } else { - cells.push(...row); - } - }); - } - - } else if (pathOrder === 'spiral-out' || pathOrder === 'spiral-in') { - // Find geometric center of all valid cells + } else if (pathOrder === 'spiral-out' || pathOrder === 'spiral-in') { + if (cells.length > 0) { const fc = turf.featureCollection(cells); const center = turf.center(fc).geometry.coordinates; @@ -483,67 +199,327 @@ export async function generateGridSearchCells( const distB = turf.distance(center, centB); return pathOrder === 'spiral-out' ? distA - distB : distB - distA; }); - } else if (pathOrder === 'shortest') { - if (cells.length > 1) { - const sorted = []; - const pts = cells.map(c => ({ - cell: c, - pt: turf.centroid(c).geometry.coordinates - })); - - let current = pts.shift()!; - sorted.push(current.cell); - - while (pts.length > 0) { - let nearestIdx = 0; - let minDistSq = Infinity; - for (let i = 0; i < pts.length; i++) { - const dx = pts[i].pt[0] - current.pt[0]; - const dy = pts[i].pt[1] - current.pt[1]; - const distSq = dx * dx + dy * dy; // fast euclidean squared distance - if (distSq < minDistSq) { - minDistSq = distSq; - nearestIdx = i; - } - } - current = pts[nearestIdx]; - sorted.push(current.cell); - pts.splice(nearestIdx, 1); - } - - cells.length = 0; - cells.push(...sorted); - } } - }; + } else if (pathOrder === 'shortest') { + if (cells.length > 1) { + const sorted = []; + const pts = cells.map(c => ({ + cell: c, + pt: turf.centroid(c).geometry.coordinates + })); - if (groupByRegion && features.length > 1) { - const grouped: Record = {}; - validCells.forEach(c => { - const idx = c.properties.sim_region_idx; - if (!grouped[idx]) grouped[idx] = []; - grouped[idx].push(c); - }); + let current = pts.shift()!; + sorted.push(current.cell); - validCells = []; - Object.keys(grouped).sort((a, b) => Number(a) - Number(b)).forEach(k => { - const cells = grouped[Number(k)]; - sortArray(cells); - validCells.push(...cells); - }); - } else { - sortArray(validCells); + while (pts.length > 0) { + let nearestIdx = 0; + let minDistSq = Infinity; + for (let i = 0; i < pts.length; i++) { + const dx = pts[i].pt[0] - current.pt[0]; + const dy = pts[i].pt[1] - current.pt[1]; + const distSq = dx * dx + dy * dy; + if (distSq < minDistSq) { + minDistSq = distSq; + nearestIdx = i; + } + } + current = pts[nearestIdx]; + sorted.push(current.cell); + pts.splice(nearestIdx, 1); + } + + cells.length = 0; + cells.push(...sorted); + } + } + }; + + if (groupByRegion && featuresLength > 1) { + const grouped: Record = {}; + cellsToSort.forEach(c => { + const idx = c.properties.sim_region_idx; + if (!grouped[idx]) grouped[idx] = []; + grouped[idx].push(c); + }); + + cellsToSort = []; + Object.keys(grouped).sort((a, b) => Number(a) - Number(b)).forEach(k => { + const cells = grouped[Number(k)]; + sortArray(cells); + cellsToSort.push(...cells); + }); + } else { + sortArray(cellsToSort); + } + + return cellsToSort.map((c, i) => ({ + ...c, + properties: { ...c.properties, sim_index: i } + })); +} + +// ============================================================================ +// Generators by Mode +// ============================================================================ + +async function generateAdminCells( + options: GridGeneratorOptions, + onProgress?: (stats: GridGeneratorProgressStats) => Promise +): Promise { + const { features, onFilterCell, skipPolygons } = options; + const validCells: SimulatorGridCell[] = []; + const skippedCells: SimulatorGridCell[] = []; + + for (let i = 0; i < features.length; i++) { + const f = features[i]; + + if (i % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ current: i, total: features.length, validCells, skippedCells }); + if (!shouldContinue) break; } - // Assign sim_index after sorting - validCells = validCells.map((c, i) => ({ - ...c, - properties: { ...c.properties, sim_index: i } - })); + const props = f.properties || {}; + const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); + + let { allowed, reason } = checkCellFilters(props, options, areaSqKm); + if (allowed && onFilterCell && !onFilterCell(f)) { + allowed = false; + reason = 'Failed custom filter function'; + } + + const center = turf.centroid(f).geometry.coordinates; + const b = turf.bbox(f); + const radiusKm = turf.distance(center, [b[2], b[3]], { units: 'kilometers' }); + + if (allowed && checkSkipPolygons(turf.point(center), skipPolygons)) { + allowed = false; + reason = 'Already Simulated'; + } + + const cell = { + ...f, + properties: { + ...props, + sim_status: allowed ? 'pending' : 'skipped', + sim_region_idx: i, + _reason: reason, + search_center: center, + search_radius_km: radiusKm + } + } as SimulatorGridCell; + + if (allowed) validCells.push(cell); + else skippedCells.push(cell); + } + + return { validCells, skippedCells, waypoints: [] }; +} + +async function generateCenterCells( + options: GridGeneratorOptions, + onProgress?: (stats: GridGeneratorProgressStats) => Promise +): Promise { + const { features, cellSize, centroidOverlap = 0.5, onFilterCell, skipPolygons } = options; + const validCells: SimulatorGridCell[] = []; + const skippedCells: SimulatorGridCell[] = []; + const acceptedCenters: Feature[] = []; + + for (let i = 0; i < features.length; i++) { + const f = features[i]; + const props = f.properties || {}; + + if (i % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ current: i, total: features.length, validCells, skippedCells }); + if (!shouldContinue) break; + } + + const centers: [number, number][] = []; + if (props.ghsPopCenter && Array.isArray(props.ghsPopCenter)) centers.push(props.ghsPopCenter as [number, number]); + if (props.ghsBuiltCenter && Array.isArray(props.ghsBuiltCenter)) centers.push(props.ghsBuiltCenter as [number, number]); + + const uniqueCenters: [number, number][] = []; + for (const c of centers) { + if (!uniqueCenters.some(uc => uc[0] === c[0] && uc[1] === c[1])) uniqueCenters.push(c); + } + + for (let j = 0; j < uniqueCenters.length; j++) { + const center = uniqueCenters[j]; + const pt = turf.point(center); + const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(f) / 1000000); + + let { allowed, reason } = checkCellFilters(props, options, areaSqKm); + + if (allowed && acceptedCenters.length > 0) { + const minAllowedDistance = cellSize * (1 - centroidOverlap); + for (const existingPt of acceptedCenters) { + const dist = turf.distance(pt, existingPt, { units: 'kilometers' }); + if (dist < minAllowedDistance) { + allowed = false; + reason = `overlaps another centroid (${dist.toFixed(2)}km < ${minAllowedDistance.toFixed(2)}km)`; + break; + } + } + } + + if (allowed && checkSkipPolygons(pt, skipPolygons)) { + allowed = false; + reason = 'already processed'; + } + + const cell = turf.buffer(pt, cellSize / 2, { units: 'kilometers', steps: 6 }) as unknown as SimulatorGridCell; + if (cell) { + cell.properties = { ...props, is_center_cell: true } as SimulatorGridCellProperties; + if (allowed && onFilterCell && !onFilterCell(cell)) { + allowed = false; + reason = `custom filter`; + } + cell.properties!.sim_region_idx = i; + cell.properties!.sim_status = allowed ? 'pending' : 'skipped'; + cell.properties!._reason = reason; + + if (allowed) { + validCells.push(cell); + acceptedCenters.push(pt); + } else { + cell.properties!.sim_skip_reason = reason; + skippedCells.push(cell); + } + } + } + } + + return { validCells, skippedCells, waypoints: [] }; +} + +async function generatePolygonGrid( + options: GridGeneratorOptions, + onProgress?: (stats: GridGeneratorProgressStats) => Promise +): Promise { + const { features, gridMode, cellSize, maxCellsLimit = 15000, cellOverlap = 0, onFilterCell, skipPolygons } = options; + const validCells: SimulatorGridCell[] = []; + const skippedCells: SimulatorGridCell[] = []; + const fc = turf.featureCollection(features); + const bbox = turf.bbox(fc); + + const width = turf.distance([bbox[0], bbox[1]], [bbox[2], bbox[1]], { units: 'kilometers' }); + const height = turf.distance([bbox[0], bbox[1]], [bbox[0], bbox[3]], { units: 'kilometers' }); + const approxCellArea = cellSize * cellSize * 2.6; // approx area of hexagon + const approxCells = Math.ceil((width * height) / approxCellArea); + + if (approxCells > maxCellsLimit) { + return { + validCells: [], + skippedCells: [], + waypoints: [], + error: `Grid is too massive (approx ${approxCells.toLocaleString()} cells). Please increase the cell size or select a smaller region to prevent your browser from freezing.` + }; + } + + const grid = gridMode === 'square' + ? turf.squareGrid(bbox, cellSize, { units: 'kilometers' }) + : turf.hexGrid(bbox, cellSize, { units: 'kilometers' }); + + if (cellOverlap > 0) { + turf.featureEach(grid, (cell) => { + const scaled = turf.transformScale(cell, 1 + cellOverlap); + cell.geometry = scaled.geometry; + }); + } + + const totalFeatures = grid.features.length; + for (let idx = 0; idx < totalFeatures; idx++) { + const cell = grid.features[idx]; + + if (idx % 50 === 0 && onProgress) { + const shouldContinue = await onProgress({ current: idx, total: totalFeatures, validCells, skippedCells }); + if (!shouldContinue) break; + } + + let intersects = false; + let regionIndex = -1; + for (let i = 0; i < features.length; i++) { + const regionFeature = features[i]; + if (!regionFeature) continue; + if (turf.booleanIntersects(cell, regionFeature as any)) { + intersects = true; + regionIndex = i; + break; + } + } + + if (intersects) { + const regionFeature = features[regionIndex]; + const props = regionFeature?.properties || {}; + const areaSqKm = props.areaSqKm !== undefined ? props.areaSqKm : (turf.area(regionFeature) / 1000000); + + let { allowed, reason } = checkCellFilters(props, options, areaSqKm); + if (allowed && onFilterCell && !onFilterCell(cell)) { + allowed = false; + reason = 'Failed custom filter function'; + } + + const center = turf.centroid(cell).geometry.coordinates; + const cellBbox = turf.bbox(cell); + const radiusKm = turf.distance(center, [cellBbox[2], cellBbox[3]], { units: 'kilometers' }); + + if (allowed && checkSkipPolygons(turf.point(center), skipPolygons)) { + allowed = false; + reason = 'Already Simulated'; + } + + cell.properties = { + ...cell.properties, + sim_status: allowed ? 'pending' : 'skipped', + sim_region_idx: regionIndex, + _reason: reason, + search_center: center, + search_radius_km: radiusKm + }; + + if (allowed) validCells.push(cell as unknown as SimulatorGridCell); + else skippedCells.push(cell as unknown as SimulatorGridCell); + } + } + + return { validCells, skippedCells, waypoints: [] }; +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +export async function generateGridSearchCells( + options: GridGeneratorOptions, + onProgress?: (stats: GridGeneratorProgressStats) => Promise +): Promise { + + let result: GridGeneratorResult; + + if (options.gridMode === 'admin') { + result = await generateAdminCells(options, onProgress); + } else if (options.gridMode === 'centers') { + result = await generateCenterCells(options, onProgress); + } else { + result = await generatePolygonGrid(options, onProgress); + } + + if (result.error) { + return result; + } + + // Sort valid cells according to paths + if (result.validCells.length > 0) { + result.validCells = sortGridCells( + result.validCells, + options.pathOrder, + options.cellSize, + options.groupByRegion, + options.features.length + ); } return { - validCells, - skippedCells + ...result, + waypoints: extractGridWaypoints(result.validCells) }; } 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 994a558c..0572653e 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/hooks/useGridSimulatorState.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import * as turf from '@turf/turf'; -import { generateGridSearchCells } from '@polymech/shared'; +import { generateGridSearchCells, extractGridWaypoints, SimulatorGridCell, GridSearchHop, GridFeature } from '@polymech/shared'; import { GridSimulatorSettings, GridSearchSimulatorProps } from '../types'; function useLocalStorage(key: string, initialValue: T) { @@ -36,7 +36,7 @@ export function useGridSimulatorState({ setSimulatorScanner }: GridSearchSimulatorProps) { - const [gridCells, setGridCells] = useState([]); + const [gridCells, setGridCells] = useState([]); const [progressIndex, setProgressIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [speed, setSpeed] = useState(0.5); @@ -64,12 +64,12 @@ export function useGridSimulatorState({ const [isCalculating, setIsCalculating] = useState(false); const [calcStats, setCalcStats] = useState({ current: 0, total: 0, valid: 0 }); - const skippedCellsRef = useRef([]); + const skippedCellsRef = useRef([]); const stopRequestedRef = useRef(false); const reqRef = useRef(); const lastTickRef = useRef(0); - const globalProcessedHopsRef = useRef([]); + const globalProcessedHopsRef = useRef([]); const [ghsBounds, setGhsBounds] = useState({ minPop: 0, maxPop: 1000000, minBuilt: 0, maxBuilt: 1000000 }); @@ -105,18 +105,8 @@ export function useGridSimulatorState({ } }, [pickerPolygons]); - const getFinalHopList = () => { - return gridCells - .filter(c => c.properties.sim_status !== 'skipped') - .map((c, i) => { - const pt = turf.centroid(c).geometry.coordinates; - return { - step: i + 1, - lng: Number(pt[0].toFixed(6)), - lat: Number(pt[1].toFixed(6)), - radius_km: c.properties.search_radius_km ? Number(c.properties.search_radius_km.toFixed(2)) : undefined - }; - }); + const getFinalHopList = (): GridSearchHop[] => { + return extractGridWaypoints(gridCells.filter(c => c.properties.sim_status !== 'skipped')); }; const getCurrentSettings = (): GridSimulatorSettings => ({ @@ -134,10 +124,10 @@ export function useGridSimulatorState({ return; } - const features: any[] = []; + const features: GridFeature[] = []; pickerPolygons.forEach(fc => { if (fc && fc.features) { - features.push(...fc.features); + features.push(...(fc.features as GridFeature[])); } }); diff --git a/packages/ui/src/modules/places/gridsearch/simulator/types.ts b/packages/ui/src/modules/places/gridsearch/simulator/types.ts index a09a1357..28e7d97d 100644 --- a/packages/ui/src/modules/places/gridsearch/simulator/types.ts +++ b/packages/ui/src/modules/places/gridsearch/simulator/types.ts @@ -1,4 +1,5 @@ -import { GridGeneratorOptions } from '@polymech/shared'; +import { GridGeneratorOptions, GridFeature, SimulatorGridCell } from '@polymech/shared'; +import type { FeatureCollection, Polygon, MultiPolygon, Point, LineString, Feature } from 'geojson'; type SimulatorBaseConfig = Required>; @@ -11,9 +12,9 @@ export interface GridSimulatorSettings extends SimulatorBaseConfig { export interface GridSearchSimulatorProps { pickerRegions: any[]; - pickerPolygons: any[]; - onFilterCell?: (cell: any) => boolean; - setSimulatorData: (data: any) => void; - setSimulatorPath: (data: any) => void; - setSimulatorScanner: (data: any) => void; + pickerPolygons: FeatureCollection[]; + onFilterCell?: (cell: GridFeature | SimulatorGridCell | Feature) => boolean; + setSimulatorData: (data: FeatureCollection) => void; + setSimulatorPath: (data: FeatureCollection) => void; + setSimulatorScanner: (data: FeatureCollection) => void; }