import * as h3 from 'h3-js' import * as turf from '@turf/turf' import roadsRaw from '../../data/bengaluru-roads.geojson?raw' import scoredRaw from '../../data/gridloc_scored.json?raw' import { H3_RESOLUTION } from './h3' // Bengaluru center. export const BLR_LAT = 12.9716 export const BLR_LNG = 77.5946 // Real model categories from gridloc_final_scored: // confirmed - certified chronic hotspot (high observed, statistically hot) // blind_spot - high expected demand but under-observed (likely under-enforced) // quirk - certified one-off anomaly (hot, but low ongoing priority) // calm - no signal export type HexType = 'confirmed' | 'blind_spot' | 'quirk' | 'calm' export type Hex = { h3: string observedTraffic: number // 0-100, from model O expectedTraffic: number // 0-100, from model E type: HexType zone: string // snapped road name (used to group the ranked list) center: [number, number] // [lat, lng] // Real model fields, surfaced in the detail panel. giZ: number // Getis-Ord hotspot z-score priority: number // 0-1 priority score (drives ranking) certified: boolean rawCount: number road: string reason: string timeBucket: string profile: Record // priority per time bucket } /** * A road slice inside a hex, as a full-carriageway-width polygon footprint. * These come from OpenStreetMap geometry (for road-SHAPE rendering only) - they * are NOT scored by the model, so they carry no per-road severity. `name` is the * real OSM street name; `type` is just the parent cell's classification, used to * tint the drawn shape, not a per-road measurement. */ export type RoadSegment = { id: number hexId: string // the H3 cell this segment belongs to name: string // real OSM street name ('Unnamed road' if OSM had none) type: HexType // parent cell's category - for tinting the shape only widthMeters: number polygon: Array<[number, number]> // [lng, lat] outer ring } // Shape of a record in the bundled scored dataset. type ScoredCell = { h3: string lat: number lng: number snapLat: number snapLng: number O: number E: number giZ: number priority: number category: HexType road: string certified: boolean rawCount: number timeBucket: string reason: string profile: Record } /** * Load the real scored model output (one representative - worst time-bucket - * row per H3 cell). Runs ONCE at module load. Pure + deterministic (the JSON is * a static build artifact of gridloc_final_scored.parquet). */ function buildHexData(): Array { const cells = JSON.parse(scoredRaw) as Array return cells.map((c) => ({ h3: c.h3, observedTraffic: Math.round(c.O * 100), expectedTraffic: Math.round(c.E * 100), type: c.category, zone: c.road && c.road !== 'Unnamed Road' ? c.road : 'Unnamed stretch', center: [c.lat, c.lng], giZ: c.giZ, priority: c.priority, certified: c.certified, rawCount: c.rawCount, road: c.road, reason: c.reason, timeBucket: c.timeBucket, profile: c.profile ?? {}, })) } /** * Turn a [lng,lat] polyline into a road-width polygon footprint via turf.buffer. * Wrapped so a malformed input logs instead of failing silently. */ function bufferLineToPolygon( line: Array<[number, number]>, widthMeters: number, id: number, ): Array<[number, number]> | null { try { const buffered = turf.buffer(turf.lineString(line), widthMeters / 2, { units: 'meters', }) if (!buffered) { console.error('[twin] buffer returned null for segment', id) return null } const geom = buffered.geometry if (geom.type === 'Polygon') { return geom.coordinates[0] as Array<[number, number]> } if (geom.type === 'MultiPolygon') { return geom.coordinates[0][0] as Array<[number, number]> } console.error('[twin] unexpected buffer geometry', (geom as { type: string }).type, 'seg', id) return null } catch (err) { console.error('[twin] buffer failed for segment', id, err) return null } } type RoadFeature = { properties: { name: string; highway: string; widthMeters: number } geometry: { type: 'LineString'; coordinates: Array<[number, number]> } } /** * Derive road segments from REAL cached OSM geometry (public Overpass fetched * once at build time → src/data/bengaluru-roads.geojson). Runs ONCE at module * load. For each patrol/check hex we keep up to 5 real road slices that fall * inside that exact H3 cell, so segments sit ON the actual streets. * * Pipeline: * 1. Build the set of patrol/check cells (calm hexes get no segments). * 2. Prune the 11k roads to those with at least one vertex in a target cell * (cheap latLngToCell test) before any expensive chunking. * 3. Chunk each kept road into ~250m slices with turf.lineChunk - this * PRESERVES original point order along the road (no reordering/interp). * 4. Assign each slice to a cell via its midpoint, h3.latLngToCell(lat,lng,res) * - note the (lat, lng) argument order. * 5. Buffer each slice to its carriageway-width polygon footprint. */ function generateSegments(hexes: Array): Array { // Target cells = the actionable categories (everything except calm). These // OSM slices are drawn purely to show the road SHAPES inside a cell - they're // not scored, so no per-road severity is computed. const cellType = new Map() for (const h of hexes) { if (h.type !== 'calm') cellType.set(h.h3, h.type) } const fc = JSON.parse(roadsRaw) as { features: Array } // Per-cell accumulator of candidate slices. type Slice = { coords: Array<[number, number]>; name: string; width: number } const byCell = new Map>() for (const feat of fc.features) { const coords = feat.geometry?.coordinates if (!Array.isArray(coords) || coords.length < 2) continue // Prune: does this road touch any target cell at all? let touches = false for (const [lng, lat] of coords) { if (cellType.has(h3.latLngToCell(lat, lng, H3_RESOLUTION))) { touches = true break } } if (!touches) continue // Chunk into ~250m order-preserving slices. let chunks try { chunks = turf.lineChunk(turf.lineString(coords), 0.25, { units: 'kilometers', }) } catch { continue } const width = feat.properties?.widthMeters ?? 8 const name = feat.properties?.name ?? 'Unnamed road' for (const chunk of chunks.features) { const cc = chunk.geometry.coordinates as Array<[number, number]> if (cc.length < 2) continue // Midpoint cell assignment (lat, lng order). const mid = cc[Math.floor(cc.length / 2)] const cell = h3.latLngToCell(mid[1], mid[0], H3_RESOLUTION) const type = cellType.get(cell) if (!type) continue const list = byCell.get(cell) ?? [] list.push({ coords: cc, name, width }) byCell.set(cell, list) } } // Emit up to 5 slices per cell as width-buffered polygons. Uniform styling - // these are road shapes, not measurements. const out: Array = [] let id = 0 for (const [cell, slices] of byCell) { const type = cellType.get(cell)! for (const slice of slices.slice(0, 5)) { const polygon = bufferLineToPolygon(slice.coords, slice.width, id) if (!polygon) continue out.push({ id: id++, hexId: cell, name: slice.name, type, widthMeters: slice.width, polygon, }) } } return out } /** Every scored H3 cell in the model output - loaded ONCE from the dataset. */ export const MOCK_HEXES: Array = buildHexData() // Backwards-compatible alias used elsewhere in the codebase. export const HEXES = MOCK_HEXES /** Real road segments for actionable hexes - generated ONCE. */ export const MOCK_SEGMENTS: Array = generateSegments(MOCK_HEXES) /** Road shapes passing through a given hex (capped at 5). */ export function segmentsForHex(hexId: string): Array { return MOCK_SEGMENTS.filter((s) => s.hexId === hexId).slice(0, 5) } /** * Distinct named OSM streets passing through a hex - for the honest "roads in * this cell" list. Dedupes the slice names and drops unnamed roads. */ export function roadNamesForHex(hexId: string): Array { const seen = new Set() for (const s of MOCK_SEGMENTS) { if (s.hexId !== hexId) continue if (!s.name || s.name === 'Unnamed road') continue seen.add(s.name) } return [...seen] }