ev-grid-oracle / web /src /map /MapView.ts
NITISHRG15102007's picture
sync: push from tools/sync_space_to_hub.py (no artifacts/)
fc065ee verified
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 },
});
}
}