Spaces:
Sleeping
Sleeping
| 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<string, number> // 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<string, number> | |
| } | |
| /** | |
| * 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<Hex> { | |
| const cells = JSON.parse(scoredRaw) as Array<ScoredCell> | |
| 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<Hex>): Array<RoadSegment> { | |
| // 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<string, HexType>() | |
| for (const h of hexes) { | |
| if (h.type !== 'calm') cellType.set(h.h3, h.type) | |
| } | |
| const fc = JSON.parse(roadsRaw) as { features: Array<RoadFeature> } | |
| // Per-cell accumulator of candidate slices. | |
| type Slice = { coords: Array<[number, number]>; name: string; width: number } | |
| const byCell = new Map<string, Array<Slice>>() | |
| 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<RoadSegment> = [] | |
| 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<Hex> = 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<RoadSegment> = generateSegments(MOCK_HEXES) | |
| /** Road shapes passing through a given hex (capped at 5). */ | |
| export function segmentsForHex(hexId: string): Array<RoadSegment> { | |
| 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<string> { | |
| const seen = new Set<string>() | |
| 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] | |
| } | |