gridloc / src /lib /twin /mockData.ts
lassanmonster's picture
GridLoc: Bengaluru parking congestion digital twin
db3dbe1
Raw
History Blame Contribute Delete
8.63 kB
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]
}