Spaces:
Sleeping
Sleeping
| import maplibregl, { type LngLatLike, type Map as MapLibreMap } from "maplibre-gl"; | |
| import { MapboxOverlay } from "@deck.gl/mapbox"; | |
| import { PathLayer, IconLayer, ScatterplotLayer } from "@deck.gl/layers"; | |
| import { staticAssetUrl } from "../paths"; | |
| import { cartoDarkStyle } from "./basemap"; | |
| type Station = { station_id: string; lat: number; lng: number; total_slots: number }; | |
| function ensureLngLat(poly: any[]): [number, number][] { | |
| // Server returns [lat,lng]. Deck expects [lng,lat]. | |
| // Do NOT use heuristics here: Bangalore values (12.xx, 77.xx) can look valid in both orders. | |
| return Array.isArray(poly) ? (poly as any).map(([lat, lng]: any) => [lng, lat]) : []; | |
| } | |
| /** Uniform resample cap: huge OSM polylines slow Deck; keep shape + endpoints. */ | |
| function simplifyPathLngLat(path: [number, number][], maxPts: number): [number, number][] { | |
| if (path.length <= 2 || path.length <= maxPts) return path; | |
| const out: [number, number][] = []; | |
| const last = path.length - 1; | |
| const step = last / (maxPts - 1); | |
| let prevIdx = -1; | |
| for (let k = 0; k < maxPts - 1; k++) { | |
| let idx = Math.min(last, Math.floor(k * step)); | |
| if (idx <= prevIdx) idx = Math.min(last, prevIdx + 1); | |
| prevIdx = idx; | |
| out.push([path[idx][0], path[idx][1]]); | |
| } | |
| out.push([path[last][0], path[last][1]]); | |
| return out; | |
| } | |
| function dropNearDuplicates(path: [number, number][], minMeters: number): [number, number][] { | |
| if (path.length <= 2) return path; | |
| const out: [number, number][] = []; | |
| out.push([path[0][0], path[0][1]]); | |
| const R = 6371000; | |
| const hav = (a: [number, number], b: [number, number]) => { | |
| const lat1 = (a[1] * Math.PI) / 180; | |
| const lat2 = (b[1] * Math.PI) / 180; | |
| const dLat = ((b[1] - a[1]) * Math.PI) / 180; | |
| const dLng = ((b[0] - a[0]) * Math.PI) / 180; | |
| const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; | |
| return 2 * R * Math.asin(Math.sqrt(h)); | |
| }; | |
| for (let i = 1; i < path.length; i++) { | |
| const p = path[i]; | |
| const last = out[out.length - 1]; | |
| if (hav(last, p) >= minMeters) out.push([p[0], p[1]]); | |
| } | |
| const end = path[path.length - 1]; | |
| const last = out[out.length - 1]; | |
| if (last[0] !== end[0] || last[1] !== end[1]) out.push([end[0], end[1]]); | |
| return out.length >= 2 ? out : path; | |
| } | |
| // Light polyline smoothing to reduce harsh angles (keeps endpoints). | |
| function chaikinSmooth(path: [number, number][], iterations: number): [number, number][] { | |
| if (path.length < 3 || iterations <= 0) return path; | |
| let cur = path; | |
| for (let it = 0; it < iterations; it++) { | |
| const out: [number, number][] = []; | |
| out.push([cur[0][0], cur[0][1]]); | |
| for (let i = 0; i < cur.length - 1; i++) { | |
| const a = cur[i]; | |
| const b = cur[i + 1]; | |
| const q: [number, number] = [a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]; | |
| const r: [number, number] = [a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]; | |
| out.push(q, r); | |
| } | |
| out.push([cur[cur.length - 1][0], cur[cur.length - 1][1]]); | |
| cur = out; | |
| } | |
| return cur; | |
| } | |
| // Canvas atlas containing: car | bike | station | |
| // (data-URL SVG atlases can fail to load in some deck.gl environments) | |
| function buildIconAtlas(): HTMLCanvasElement { | |
| const c = document.createElement("canvas"); | |
| c.width = 192; | |
| c.height = 64; | |
| const ctx = c.getContext("2d")!; | |
| ctx.clearRect(0, 0, c.width, c.height); | |
| const drawGlow = (x: number, y: number, w: number, h: number) => { | |
| ctx.save(); | |
| ctx.globalAlpha = 0.28; | |
| ctx.fillStyle = "#23e7ff"; | |
| ctx.shadowColor = "#23e7ff"; | |
| ctx.shadowBlur = 10; | |
| ctx.fillRect(x, y, w, h); | |
| ctx.restore(); | |
| }; | |
| const drawCar = (ox: number) => { | |
| // Exact emoji only (requested): no outline/glow/fallback styling. | |
| ctx.save(); | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| ctx.font = "34px \"Segoe UI Emoji\", \"Apple Color Emoji\", \"Noto Color Emoji\", system-ui"; | |
| ctx.globalAlpha = 1; | |
| ctx.fillText("🚗", ox + 32, 34); | |
| ctx.restore(); | |
| }; | |
| const drawBike = (ox: number) => { | |
| ctx.save(); | |
| ctx.strokeStyle = "#ffffff"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.arc(ox + 22, 40, 7, 0, Math.PI * 2); | |
| ctx.arc(ox + 42, 40, 7, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| ctx.lineCap = "round"; | |
| ctx.lineJoin = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(ox + 26, 40); | |
| ctx.lineTo(ox + 33, 26); | |
| ctx.lineTo(ox + 40, 40); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(ox + 33, 26); | |
| ctx.lineTo(ox + 28, 26); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(ox + 33, 26); | |
| ctx.lineTo(ox + 38, 22); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| }; | |
| const drawStation = (ox: number) => { | |
| drawGlow(ox + 24, 14, 18, 28); | |
| ctx.fillStyle = "#ffffff"; | |
| roundRect(ctx, ox + 24, 14, 18, 28, 4); | |
| ctx.fill(); | |
| ctx.fillStyle = "#0b0d14"; | |
| roundRect(ctx, ox + 28, 18, 10, 8, 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#ffffff"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(ox + 42, 24); | |
| ctx.bezierCurveTo(50 + ox, 28, 50 + ox, 38, ox + 42, 42); | |
| ctx.stroke(); | |
| ctx.strokeStyle = "#0b0d14"; | |
| ctx.lineWidth = 3; | |
| ctx.beginPath(); | |
| ctx.moveTo(ox + 32, 28); | |
| ctx.lineTo(ox + 28, 36); | |
| ctx.lineTo(ox + 34, 36); | |
| ctx.lineTo(ox + 30, 44); | |
| ctx.stroke(); | |
| }; | |
| const roundRect = (cx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) => { | |
| const rr = Math.min(r, w / 2, h / 2); | |
| cx.beginPath(); | |
| cx.moveTo(x + rr, y); | |
| cx.arcTo(x + w, y, x + w, y + h, rr); | |
| cx.arcTo(x + w, y + h, x, y + h, rr); | |
| cx.arcTo(x, y + h, x, y, rr); | |
| cx.arcTo(x, y, x + w, y, rr); | |
| cx.closePath(); | |
| }; | |
| drawCar(0); | |
| drawBike(64); | |
| drawStation(128); | |
| return c; | |
| } | |
| const ICON_ATLAS = buildIconAtlas() as any; | |
| const ICON_MAPPING = { | |
| car: { x: 0, y: 0, width: 64, height: 64, mask: true, anchorY: 52 }, | |
| bike: { x: 64, y: 0, width: 64, height: 64, mask: true, anchorY: 52 }, | |
| station: { x: 128, y: 0, width: 64, height: 64, mask: true, anchorY: 52 }, | |
| } as const; | |
| type VehicleKind = "car" | "bike"; | |
| type Vehicle = { | |
| id: string; | |
| kind: VehicleKind; | |
| color: [number, number, number, number]; | |
| route: [number, number][]; // [lng,lat] | |
| cumM: number[]; | |
| totalM: number; | |
| progM: number; | |
| pos: [number, number]; | |
| headingDeg: number; | |
| lastSeenTs: number; | |
| arrivedTs?: number | null; | |
| segMq?: number[] | null; // traffic multiplier per segment (q=1000 => 1.0) | |
| speedMps?: number; // smoothed instantaneous speed | |
| }; | |
| export class MapView { | |
| private map: MapLibreMap; | |
| private overlay: MapboxOverlay; | |
| private side: "baseline" | "oracle" = "oracle"; | |
| private stations: Station[] = []; | |
| private activeRoute: [number, number][] = []; | |
| private follow = false; | |
| private heroVehicleId: string | null = null; | |
| private raf: number | null = null; | |
| private lastTs: number | null = null; | |
| private staticLayers: any[] = []; | |
| private vehicles: Map<string, Vehicle> = new Map(); | |
| private heroRemainingPath(): [number, number][] { | |
| if (!this.heroVehicleId) return this.activeRoute; | |
| const v = this.vehicles.get(this.heroVehicleId); | |
| if (!v || v.route.length < 2 || v.cumM.length !== v.route.length) return this.activeRoute; | |
| return this.splitRouteAtProgress(v.route, v.cumM, v.totalM, v.progM).remaining; | |
| } | |
| private nearestProgMOnRoute(pos: [number, number], route: [number, number][], cumM: number[]) { | |
| if (!route.length || cumM.length !== route.length) return 0; | |
| let bestI = 0; | |
| let bestD = 1e18; | |
| for (let i = 0; i < route.length; i++) { | |
| const p = route[i]; | |
| const d = this.haversineM(pos[1], pos[0], p[1], p[0]); | |
| if (d < bestD) { | |
| bestD = d; | |
| bestI = i; | |
| } | |
| } | |
| return Number(cumM[bestI] || 0); | |
| } | |
| constructor(mount: HTMLElement) { | |
| // MapLibre requires a real element size; ensure mount is empty. | |
| mount.innerHTML = ""; | |
| mount.style.position = "relative"; | |
| const mapEl = document.createElement("div"); | |
| mapEl.style.position = "absolute"; | |
| mapEl.style.inset = "0"; | |
| mount.appendChild(mapEl); | |
| this.map = new maplibregl.Map({ | |
| container: mapEl, | |
| style: cartoDarkStyle(), | |
| center: [77.60, 12.97] as LngLatLike, | |
| zoom: 11.5, | |
| pitch: 45, | |
| bearing: -18, | |
| attributionControl: { compact: true }, | |
| cooperativeGestures: true, | |
| }); | |
| this.map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); | |
| this.overlay = new MapboxOverlay({ interleaved: true, layers: [] }); | |
| this.map.addControl(this.overlay as any); | |
| // Make sure assets are CORS-safe for WebGL | |
| (this.map as any).getCanvas().setAttribute("crossorigin", "anonymous"); | |
| } | |
| destroy() { | |
| try { | |
| if (this.raf != null) cancelAnimationFrame(this.raf); | |
| this.map.remove(); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| setSide(side: "baseline" | "oracle") { | |
| this.side = side; | |
| this.renderStatic(); | |
| } | |
| setFollowVehicle(on: boolean) { | |
| this.follow = on; | |
| } | |
| async bindSession(_sessionId: string, station_nodes: Station[]) { | |
| this.stations = station_nodes; | |
| // Fit to stations bbox (area-wise, not whole city) | |
| const lngs = station_nodes.map((s) => s.lng); | |
| const lats = station_nodes.map((s) => s.lat); | |
| const bounds: [[number, number], [number, number]] = [ | |
| [Math.min(...lngs), Math.min(...lats)], | |
| [Math.max(...lngs), Math.max(...lats)], | |
| ]; | |
| this.map.fitBounds(bounds as any, { padding: 60, duration: 600, maxZoom: 13.8 }); | |
| // Load simplified render paths (much smaller than GeoJSON). | |
| // Fallback to GeoJSON only if render file is missing. | |
| const paths: { path: [number, number][]; highway: string }[] = []; | |
| try { | |
| const renderUrl = staticAssetUrl("maps/bangalore_roads_render.json"); | |
| const rows = (await fetch(renderUrl).then((r) => r.json())) as any[]; | |
| if (Array.isArray(rows)) { | |
| for (const row of rows) { | |
| const hw = String(row?.highway || ""); | |
| const coords = row?.path; | |
| if (!Array.isArray(coords) || coords.length < 2) continue; | |
| paths.push({ path: coords as [number, number][], highway: hw }); | |
| } | |
| } | |
| } catch { | |
| const roadsUrl = staticAssetUrl("maps/bangalore_roads_full.geojson"); | |
| const gj = await fetch(roadsUrl).then((r) => r.json()); | |
| const feats = Array.isArray(gj?.features) ? gj.features : []; | |
| for (const f of feats) { | |
| if (f?.geometry?.type !== "LineString") continue; | |
| const hw = String(f?.properties?.highway || ""); | |
| const coords = f?.geometry?.coordinates; | |
| if (!Array.isArray(coords) || coords.length < 2) continue; | |
| paths.push({ path: coords as [number, number][], highway: hw }); | |
| } | |
| } | |
| (this as any)._roads = paths; | |
| this.renderStatic(); | |
| } | |
| async playExternalEvent(event: any) { | |
| if (!event || event.type !== "route" || !Array.isArray(event.polyline)) return; | |
| const poly = ensureLngLat(event.polyline); | |
| if (poly.length < 2) return; | |
| this.activeRoute = poly; | |
| const segMq = Array.isArray(event?.traffic_seg_m_q) ? (event.traffic_seg_m_q as number[]) : null; | |
| // Decide vehicle type from persona if present (taxi/corp/private/emergency -> car; delivery -> bike). | |
| const persona = String(event?.persona || ""); | |
| const kind: VehicleKind = /Delivery/i.test(persona) ? "bike" : "car"; | |
| const id = String(event?.ev_id || `ev-${Math.random().toString(16).slice(2)}`); | |
| const now = performance.now(); | |
| this.heroVehicleId = id; | |
| const baseColor: [number, number, number, number] = | |
| this.side === "oracle" ? ([35, 231, 255, 210] as any) : ([255, 90, 138, 190] as any); | |
| const color: [number, number, number, number] = | |
| /Emergency/i.test(persona) ? ([255, 72, 72, 220] as any) : baseColor; | |
| const cumM = [0]; | |
| let acc = 0; | |
| for (let i = 1; i < poly.length; i++) { | |
| const a = poly[i - 1]; | |
| const b = poly[i]; | |
| acc += this.haversineM(a[1], a[0], b[1], b[0]); // lat,lng | |
| cumM.push(acc); | |
| } | |
| // If we already have this vehicle, update its route but keep its current position/progress | |
| // so the animation feels continuous (no teleport to the start of the new polyline). | |
| const prev = this.vehicles.get(id); | |
| if (prev) { | |
| prev.kind = kind; | |
| prev.color = color; | |
| prev.route = poly; | |
| prev.cumM = cumM; | |
| prev.totalM = acc; | |
| prev.segMq = segMq; | |
| prev.lastSeenTs = now; | |
| // Anchor progress to the closest point on the new route. | |
| const anchored = this.nearestProgMOnRoute(prev.pos, poly, cumM); | |
| prev.progM = Math.max(0, Math.min(acc, anchored)); | |
| // Update heading based on the next point (if any). | |
| const p = this.pointAtOn(prev.route, prev.cumM, prev.totalM, prev.progM); | |
| if (p) { | |
| prev.pos = p.pos; | |
| prev.headingDeg = p.headingDeg; | |
| } | |
| } else { | |
| const v: Vehicle = { | |
| id, | |
| kind, | |
| color, | |
| route: poly, | |
| cumM, | |
| totalM: acc, | |
| progM: 0, | |
| pos: poly[0], | |
| headingDeg: this.headingDeg(poly[0], poly[1]), | |
| lastSeenTs: now, | |
| segMq, | |
| speedMps: undefined, | |
| }; | |
| this.vehicles.set(id, v); | |
| } | |
| // Keep the map clean: cap number of vehicles (oldest removed). | |
| const maxVehicles = 90; | |
| if (this.vehicles.size > maxVehicles) { | |
| const oldest = [...this.vehicles.values()].sort((a, b) => a.lastSeenTs - b.lastSeenTs)[0]; | |
| if (oldest) this.vehicles.delete(oldest.id); | |
| } | |
| this.renderStatic(); | |
| const fitPoly = this.side === "baseline" ? this.heroRemainingPath() : poly; | |
| this.fitViewToRoute(fitPoly); | |
| this.kickAnim(); | |
| } | |
| /** Tight camera frame around the active route so Step feels like Ola/Uber navigation, not a whole-city mess. */ | |
| private fitViewToRoute(poly: [number, number][]) { | |
| if (!poly.length) return; | |
| let minLng = poly[0][0]; | |
| let maxLng = poly[0][0]; | |
| let minLat = poly[0][1]; | |
| let maxLat = poly[0][1]; | |
| for (const [lng, lat] of poly) { | |
| minLng = Math.min(minLng, lng); | |
| maxLng = Math.max(maxLng, lng); | |
| minLat = Math.min(minLat, lat); | |
| maxLat = Math.max(maxLat, lat); | |
| } | |
| const padLng = Math.max(0.002, (maxLng - minLng) * 0.12); | |
| const padLat = Math.max(0.002, (maxLat - minLat) * 0.12); | |
| const sw: [number, number] = [minLng - padLng, minLat - padLat]; | |
| const ne: [number, number] = [maxLng + padLng, maxLat + padLat]; | |
| const isBaseline = this.side === "baseline"; | |
| try { | |
| this.map.fitBounds([sw, ne] as any, { | |
| padding: isBaseline ? { top: 36, bottom: 36, left: 36, right: 36 } : { top: 56, bottom: 56, left: 56, right: 56 }, | |
| duration: isBaseline ? 520 : 700, | |
| maxZoom: isBaseline ? 16.6 : 15.2, | |
| minZoom: isBaseline ? 13.2 : 11.2, | |
| }); | |
| } catch { | |
| // ignore map timing errors | |
| } | |
| } | |
| private kickAnim() { | |
| if (this.raf != null) cancelAnimationFrame(this.raf); | |
| this.lastTs = null; | |
| const tick = (ts: number) => { | |
| if (this.lastTs == null) this.lastTs = ts; | |
| const dt = Math.max(0, Math.min(0.05, (ts - this.lastTs) / 1000)); | |
| this.lastTs = ts; | |
| // Simple speed model (can be upgraded to per-road-type later). | |
| const baseSpeedMps = this.side === "oracle" ? 34.0 : 30.0; // faster demo motion | |
| const now = performance.now(); | |
| // TTL: fade out old vehicles so the map doesn't become messy. | |
| const ttlMs = 120_000; | |
| const lingerAfterArriveMs = 25_000; | |
| for (const [id, v] of this.vehicles) { | |
| if (v.arrivedTs != null) { | |
| if (now - v.arrivedTs > lingerAfterArriveMs) { | |
| this.vehicles.delete(id); | |
| continue; | |
| } | |
| } else if (now - v.lastSeenTs > ttlMs) { | |
| this.vehicles.delete(id); | |
| continue; | |
| } | |
| const base = v.kind === "bike" ? baseSpeedMps * 0.92 : baseSpeedMps; | |
| const m = this.multAt(v); | |
| const targetSpeed = base / Math.max(0.35, Math.min(1.15, m)); | |
| v.speedMps = v.speedMps == null ? targetSpeed : v.speedMps * 0.84 + targetSpeed * 0.16; | |
| const nextProg = Math.min(v.totalM, v.progM + (v.speedMps || targetSpeed) * dt); | |
| v.progM = nextProg; | |
| // Keep active trips alive; don't delete mid-route just because they were spawned earlier. | |
| if (v.progM < v.totalM - 1e-3) { | |
| v.lastSeenTs = now; | |
| } else if (v.arrivedTs == null) { | |
| v.arrivedTs = now; | |
| } | |
| const p = this.pointAtOn(v.route, v.cumM, v.totalM, v.progM); | |
| if (p) { | |
| v.pos = p.pos; | |
| v.headingDeg = p.headingDeg; | |
| } | |
| } | |
| // Follow the most recently updated vehicle (if enabled) | |
| if (this.follow) { | |
| const latest = [...this.vehicles.values()].sort((a, b) => b.lastSeenTs - a.lastSeenTs)[0]; | |
| if (latest?.pos) this.map.easeTo({ center: latest.pos, duration: 120 }); | |
| } | |
| this.renderVehicleOnly(); | |
| if (this.vehicles.size > 0) { | |
| this.raf = requestAnimationFrame(tick); | |
| } else { | |
| this.raf = null; | |
| } | |
| }; | |
| this.raf = requestAnimationFrame(tick); | |
| } | |
| private pointAtOn( | |
| route: [number, number][], | |
| cumM: number[], | |
| totalM: number, | |
| m: number | |
| ): { pos: [number, number]; headingDeg: number } | null { | |
| if (!route.length || cumM.length !== route.length) return null; | |
| if (m <= 0) { | |
| const a = route[0]; | |
| const b = route[1]; | |
| return { pos: a, headingDeg: this.headingDeg(a, b) }; | |
| } | |
| if (m >= totalM) { | |
| const n = route.length; | |
| const a = route[n - 2]; | |
| const b = route[n - 1]; | |
| return { pos: b, headingDeg: this.headingDeg(a, b) }; | |
| } | |
| let i = 1; | |
| while (i < cumM.length && cumM[i] < m) i++; | |
| const i0 = Math.max(1, i); | |
| const a = route[i0 - 1]; | |
| const b = route[i0]; | |
| const m0 = cumM[i0 - 1]; | |
| const m1 = cumM[i0]; | |
| const t = m1 <= m0 ? 0 : (m - m0) / (m1 - m0); | |
| const lng = a[0] + (b[0] - a[0]) * t; | |
| const lat = a[1] + (b[1] - a[1]) * t; | |
| return { pos: [lng, lat], headingDeg: this.headingDeg(a, b) }; | |
| } | |
| private multAt(v: Vehicle): number { | |
| const segMq = v.segMq; | |
| if (!segMq || segMq.length < 1) return 1.0; | |
| let i = 1; | |
| while (i < v.cumM.length && v.cumM[i] < v.progM) i++; | |
| const segIdx = Math.max(0, Math.min(segMq.length - 1, i - 1)); | |
| const q = Number(segMq[segIdx] || 1000); | |
| return q / 1000.0; | |
| } | |
| private headingDeg(a: [number, number], b: [number, number]) { | |
| const dx = b[0] - a[0]; | |
| const dy = b[1] - a[1]; | |
| return (Math.atan2(dy, dx) * 180) / Math.PI; | |
| } | |
| private haversineM(lat1: number, lng1: number, lat2: number, lng2: number) { | |
| const R = 6371000; | |
| const dLat = ((lat2 - lat1) * Math.PI) / 180; | |
| const dLng = ((lng2 - lng1) * Math.PI) / 180; | |
| const sLat1 = (lat1 * Math.PI) / 180; | |
| const sLat2 = (lat2 * Math.PI) / 180; | |
| const a = Math.sin(dLat / 2) ** 2 + Math.cos(sLat1) * Math.cos(sLat2) * Math.sin(dLng / 2) ** 2; | |
| return 2 * R * Math.asin(Math.sqrt(a)); | |
| } | |
| private nearLngLat(a: [number, number], b: [number, number]) { | |
| return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9; | |
| } | |
| /** Split active polyline at hero progress for Uber-style “done vs ahead” styling. */ | |
| private splitRouteAtProgress( | |
| route: [number, number][], | |
| cumM: number[], | |
| totalM: number, | |
| progM: number | |
| ): { traveled: [number, number][]; remaining: [number, number][] } { | |
| if (route.length < 2) return { traveled: [...route], remaining: [...route] }; | |
| const m = Math.max(0, Math.min(progM, totalM)); | |
| const cut = this.pointAtOn(route, cumM, totalM, m); | |
| if (!cut) return { traveled: [[route[0][0], route[0][1]]], remaining: [...route] }; | |
| const pos = cut.pos; | |
| let i = 1; | |
| while (i < cumM.length && cumM[i] < m) i++; | |
| const traveled: [number, number][] = []; | |
| for (let j = 0; j < i; j++) traveled.push([route[j][0], route[j][1]]); | |
| const lastT = traveled[traveled.length - 1]; | |
| if (!lastT || !this.nearLngLat(lastT, pos)) traveled.push([pos[0], pos[1]]); | |
| const remaining: [number, number][] = []; | |
| if (!this.nearLngLat(pos, route[i - 1])) remaining.push([pos[0], pos[1]]); | |
| for (let j = i; j < route.length; j++) { | |
| if (j === i && this.nearLngLat(pos, route[j])) continue; | |
| remaining.push([route[j][0], route[j][1]]); | |
| } | |
| if (remaining.length < 2) { | |
| const end = route[route.length - 1]; | |
| remaining.push([end[0], end[1]]); | |
| } | |
| return { | |
| traveled: chaikinSmooth(dropNearDuplicates(simplifyPathLngLat(traveled, 220), 2.0), 1), | |
| remaining: chaikinSmooth(dropNearDuplicates(simplifyPathLngLat(remaining, 360), 2.0), 1), | |
| }; | |
| } | |
| /** Route is updated every animation frame; roads stay in `staticLayers`. */ | |
| private makeRouteProgressLayers(): PathLayer[] { | |
| if (!this.activeRoute.length) return []; | |
| const hero = this.heroVehicleId ? this.vehicles.get(this.heroVehicleId) : undefined; | |
| let traveled: [number, number][] = []; | |
| let remaining: [number, number][] = []; | |
| if (hero && hero.route.length >= 2 && hero.cumM.length === hero.route.length && hero.totalM > 1e-6) { | |
| const sp = this.splitRouteAtProgress(hero.route, hero.cumM, hero.totalM, hero.progM); | |
| traveled = sp.traveled; | |
| remaining = sp.remaining; | |
| } else { | |
| remaining = simplifyPathLngLat(this.activeRoute, 360); | |
| } | |
| const traveledCore: [number, number, number, number] = | |
| this.side === "oracle" ? [60, 160, 175, 170] : [200, 200, 220, 150]; | |
| const routeCore = this.side === "oracle" ? [55, 240, 255, 255] : [240, 244, 255, 250]; | |
| const routeCasing = [8, 10, 18, 235]; | |
| const routeHalo = this.side === "oracle" ? [35, 200, 230, 55] : [200, 210, 245, 45]; | |
| const layers: PathLayer[] = []; | |
| if (traveled.length >= 2) { | |
| layers.push( | |
| new PathLayer({ | |
| id: `route-traveled-${this.side}`, | |
| data: [{ path: traveled }], | |
| getPath: (d: any) => d.path, | |
| getColor: traveledCore as any, | |
| getWidth: 2.2, | |
| widthUnits: "pixels", | |
| capRounded: true, | |
| jointRounded: true, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }) | |
| ); | |
| } | |
| if (remaining.length >= 2) { | |
| layers.push( | |
| new PathLayer({ | |
| id: `route-halo-${this.side}`, | |
| data: [{ path: remaining }], | |
| getPath: (d: any) => d.path, | |
| getColor: routeHalo as any, | |
| getWidth: 7, | |
| widthUnits: "pixels", | |
| capRounded: false, | |
| jointRounded: false, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }), | |
| new PathLayer({ | |
| id: `route-casing-${this.side}`, | |
| data: [{ path: remaining }], | |
| getPath: (d: any) => d.path, | |
| getColor: routeCasing as any, | |
| getWidth: 5, | |
| widthUnits: "pixels", | |
| capRounded: false, | |
| jointRounded: false, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }), | |
| new PathLayer({ | |
| id: `route-core-${this.side}`, | |
| data: [{ path: remaining }], | |
| getPath: (d: any) => d.path, | |
| getColor: routeCore as any, | |
| getWidth: 2.75, | |
| widthUnits: "pixels", | |
| capRounded: true, | |
| jointRounded: true, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }) | |
| ); | |
| } | |
| return layers; | |
| } | |
| private renderVehicleOnly() { | |
| const vehicleDotLayer = this.makeVehicleDotLayer(); | |
| const stationLayer = this.makeStationIconLayer(); | |
| const routeLayers = this.makeRouteProgressLayers(); | |
| this.overlay.setProps({ | |
| layers: [...this.staticLayers, ...routeLayers, stationLayer, vehicleDotLayer], | |
| }); | |
| } | |
| private renderStatic() { | |
| const roads: { path: [number, number][]; highway: string }[] = (this as any)._roads || []; | |
| const roadColor = (hw: string) => { | |
| if (hw === "motorway" || hw === "trunk" || hw === "primary") return [230, 235, 255, 55] as any; | |
| if (hw === "secondary") return [200, 210, 245, 28] as any; | |
| return [160, 170, 210, 16] as any; | |
| }; | |
| const roadWidth = (hw: string) => (hw === "primary" ? 2.4 : hw === "secondary" ? 1.8 : 1.2); | |
| const roadLayer = new PathLayer({ | |
| id: `roads-${this.side}`, | |
| data: roads, | |
| getPath: (d: any) => d.path, | |
| getColor: (d: any) => roadColor(d.highway), | |
| getWidth: (d: any) => roadWidth(d.highway), | |
| widthUnits: "pixels", | |
| rounded: true, | |
| capRounded: true, | |
| jointRounded: true, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }); | |
| this.staticLayers = [roadLayer]; | |
| this.renderVehicleOnly(); | |
| } | |
| private makeVehicleDotLayer() { | |
| return new ScatterplotLayer({ | |
| id: `vehicle-dots-${this.side}`, | |
| data: [...this.vehicles.values()], | |
| getPosition: (d: any) => d.pos, | |
| radiusUnits: "pixels", | |
| getRadius: (d: any) => (d.kind === "bike" ? 6.5 : 8.0), | |
| getFillColor: (d: any) => d.color, | |
| getLineColor: [0, 0, 0, 190] as any, | |
| lineWidthUnits: "pixels", | |
| lineWidthMinPixels: 2, | |
| stroked: true, | |
| filled: true, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }); | |
| } | |
| private makeStationIconLayer() { | |
| return new IconLayer({ | |
| id: `station-icons-${this.side}`, | |
| data: this.stations, | |
| iconAtlas: ICON_ATLAS as any, | |
| iconMapping: ICON_MAPPING as any, | |
| getIcon: () => "station", | |
| sizeUnits: "pixels", | |
| getSize: 24, | |
| getPosition: (d: Station) => [d.lng, d.lat], | |
| getColor: this.side === "oracle" ? ([35, 231, 255, 170] as any) : ([232, 236, 255, 120] as any), | |
| billboard: true, | |
| pickable: false, | |
| parameters: { depthTest: false }, | |
| }); | |
| } | |
| } | |