import { useEffect, useRef, useState } from "react"; import { useOnce } from "../api.js"; // Vehicles are drawn top-down at their real size in metres (SUMO defaults), with a minimum size on // screen so they stay visible when zoomed out. Shapes point along +x and are rotated to the heading. const VEHICLES = { car: { L: 4.5, W: 1.85, min: 9, fill: "#c9d1d8", label: "Car" }, moto: { L: 2.1, W: 0.8, min: 6, fill: "#f2c47a", label: "Motorcycle" }, bus: { L: 12, W: 2.55, min: 16, fill: "#2f7fd8", label: "Bus" }, ambulance: { L: 5.8, W: 2.1, min: 11, fill: "#ffffff", label: "Ambulance" }, police: { L: 4.8, W: 1.9, min: 10, fill: "#1f3fbf", label: "Police" }, fire: { L: 9, W: 2.5, min: 14, fill: "#d8261c", label: "Fire truck" }, incident: { L: 4.5, W: 1.85, min: 10, fill: "#ff3b30", label: "Stalled vehicle" }, }; // Paint mix of cars in Tunis traffic; "taxi" is the yellow city taxi. const CAR_PAINTS = [["#f4f4f2", 28], ["#b8bec4", 18], ["#6b7178", 11], ["#1d2126", 12], ["taxi", 13], ["#9e2a2b", 6], ["#2d4f8a", 6], ["#d8cdb4", 4], ["#3f6b4a", 2]]; const PAINT_TOTAL = CAR_PAINTS.reduce((a, [, w]) => a + w, 0); function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0; } function paint(id) { let r = hash(id) % PAINT_TOTAL; for (const [c, w] of CAR_PAINTS) { if ((r -= w) < 0) return c; } return CAR_PAINTS[0][0]; } // One vehicle in its own frame: x forward, metres. detail adds lights and markings when big enough to see. function VehicleShape({ v, kind, detail }) { const s = VEHICLES[kind] || VEHICLES.car; const h = s.L / 2, w = s.W / 2; const shadow = ; const lights = detail && <> ; if (kind === "moto") { return <> ; } if (kind === "bus" || kind === "fire") { return <> {shadow} {kind === "bus" && <> {detail && v.line && {v.line.replace("_R", "")}} } {kind === "fire" && [-2.4, -1, 0.4, 1.8].map((x) => ( ))} {kind === "fire" && } {lights} ; } const fill = kind === "car" ? paint(v.id) : s.fill; const body = fill === "taxi" ? "#f7c600" : fill; return <> {shadow} {/* windscreen (front) and rear window */} {fill === "taxi" && } {kind === "police" && <> } {kind === "ambulance" && <> } {lights} ; } // Positions arrive once a second; CSS glides each vehicle to its new place over that second. function Vehicle({ v, x, y, angle, focused, mpp }) { const kind = VEHICLES[v.kind] ? v.kind : "car"; const s = VEHICLES[kind]; const k = Math.max(1, (s.min * mpp) / s.L) * (focused ? 1.6 : 1); // never shorter than s.min px on screen const detail = (s.L * k) / mpp > 22; return ( {focused && } ); } export function Legend() { return (
Taxi {Object.entries(VEHICLES).map(([k, s]) => ( {s.label} ))} Pedestrian Slow walker Traffic light Crossing (green = walk) Bus stop
); } // Draws the Tunis network, junction states, vehicles and pedestrians. Zoom with the mouse wheel, // drag to pan, double-click or the reset button to fit the whole network again. // highlight: { incidents, floods, routeB, alt } (edge ids), focus: vehicle ids to emphasise const LINE_PALETTE = ["#4cc3ff", "#ff8a3d", "#b78cff", "#3ddc97", "#ff5c8a", "#ffd23f", "#5c9bea", "#e86bff", "#9be15d"]; // Same colour for both directions of a line; the return trip ("_R") is drawn dashed. export const lineColor = (id) => LINE_PALETTE[hash(id.replace(/_R$/, "")) % LINE_PALETTE.length]; // Street map under the simulation, already warped into SUMO x/y by scripts/build_basemap.py. // "dark" is the street map recoloured in the browser for the dark theme. const BASEMAPS = { dark: { style: "streets", label: "Dark streets", filter: "invert(1) hue-rotate(180deg) brightness(0.95) contrast(0.85)" }, streets: { style: "streets", label: "Streets" }, satellite: { style: "satellite", label: "Satellite" }, off: { label: "No map" }, }; // Live web-map tiles at the zoom that matches the view. Each tile is placed by the exact // net <-> web-map transform from scripts/build_basemap.py (affine, a few cm over the network), // so it stays sharp at every zoom. A coarser layer underneath fills gaps while sharp tiles load. function Tiles({ basemap, style, view, box, mpp, origin }) { const url = basemap.tiles?.[style], fwd = basemap.net_to_merc, inv = basemap.merc_to_net; if (!url || !fwd || !inv) return null; const [x0, y1, pad] = origin; const [A, B, C, D, E, F] = inv; // net x = A u + B v + C, y = D u + E v + F const w = box?.width ? box.width * mpp : view[2], h = box?.height ? box.height * mpp : view[3]; const cx = view[0] + view[2] / 2, cy = view[1] + view[3] / 2; const corners = [[cx - w / 2, cy - h / 2], [cx + w / 2, cy - h / 2], [cx - w / 2, cy + h / 2], [cx + w / 2, cy + h / 2]] .map(([sx, sy]) => [sx + x0 - pad, y1 + pad - sy]) // screen -> net x/y .map(([x, y]) => [fwd[0] * x + fwd[1] * y + fwd[2], fwd[3] * x + fwd[4] * y + fwd[5]]); const us = corners.map((c) => c[0]), vs = corners.map((c) => c[1]); const dpr = window.devicePixelRatio || 1; const range = (zz) => { const n = 2 ** zz; return [Math.floor(Math.min(...us) * n), Math.floor(Math.max(...us) * n), Math.floor(Math.min(...vs) * n), Math.floor(Math.max(...vs) * n)]; }; let z = Math.ceil(Math.log2(Math.hypot(A, D) / ((256 * mpp) / dpr))); z = Math.max(12, Math.min(basemap.max_zoom || 19, z)); while (z > 12) { const [a, b, c, d] = range(z); if ((b - a + 1) * (d - c + 1) <= 160) break; z -= 1; } const layer = (zz) => { const [ta, tb, tc, td] = range(zz), n = 2 ** zz, s = 1 / (n * 256), out = []; for (let tx = ta; tx <= tb; tx++) { for (let ty = tc; ty <= td; ty++) { const u0 = tx / n, v0 = ty / n; const m = [A * s, -D * s, B * s, -E * s, A * u0 + B * v0 + C - x0 + pad, y1 + pad - (D * u0 + E * v0 + F)]; out.push(); } } return out; }; return <>{z - 3 >= 12 && layer(z - 3)}{layer(z)}; } function savedBasemap() { try { return BASEMAPS[localStorage.getItem("basemap")] ? localStorage.getItem("basemap") : "dark"; } catch { return "dark"; } } export default function NetworkMap({ live, highlight = {}, focus = [], showCars = true, height = 420, lines = [] }) { const geo = useOnce(live.scenario ? "/network" : null, [live.scenario]); const svgRef = useRef(null); const [view, setView] = useState(null); // [x, y, w, h] of the visible area const [peds, setPeds] = useState(true); const [basemap, setBasemap] = useState(savedBasemap); const chooseBasemap = (b) => { setBasemap(b); try { localStorage.setItem("basemap", b); } catch { /* private window */ } }; const drag = useRef(null); const angles = useRef(new Map()); // last drawn heading per vehicle, unwrapped const pad = 60; const full = geo ? (() => { const [x0, y0, x1, y1] = geo.bounds; return [0, 0, x1 - x0 + 2 * pad, y1 - y0 + 2 * pad]; })() : null; useEffect(() => { if (full) setView(full); }, [geo]); useEffect(() => { // wheel zoom needs a non-passive listener const el = svgRef.current; if (!el) return; const onWheel = (e) => { e.preventDefault(); zoomAt(e.deltaY < 0 ? 0.8 : 1.25, e.clientX, e.clientY); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }); if (!geo || !view || live.status === "idle" || live.status === "offline") return
Start a scenario to see the map
; const [x0, , , y1] = geo.bounds; const X = (x) => x - x0 + pad, Y = (y) => y1 - y + pad; const toMap = (cx, cy) => { const r = svgRef.current.getBoundingClientRect(); const scale = Math.max(view[2] / r.width, view[3] / r.height); const offX = (r.width * scale - view[2]) / 2, offY = (r.height * scale - view[3]) / 2; return [view[0] - offX + (cx - r.left) * scale, view[1] - offY + (cy - r.top) * scale, scale]; }; function zoomAt(factor, cx, cy) { setView((v) => { if (!v) return v; const r = svgRef.current.getBoundingClientRect(); const [mx, my] = cx === undefined ? [v[0] + v[2] / 2, v[1] + v[3] / 2] : toMap(cx, cy); const w = Math.min(Math.max(v[2] * factor, full[2] / 20), full[2] * 1.5); const h = w * (v[3] / v[2]); const k = w / v[2]; return [mx - (mx - v[0]) * k, my - (my - v[1]) * k, w, h]; }); } const onDown = (e) => { drag.current = { x: e.clientX, y: e.clientY, v: view }; e.currentTarget.setPointerCapture?.(e.pointerId); }; const onMove = (e) => { if (!drag.current) return; const r = svgRef.current.getBoundingClientRect(); const scale = Math.max(view[2] / r.width, view[3] / r.height); const { x, y, v } = drag.current; setView([v[0] - (e.clientX - x) * scale, v[1] - (e.clientY - y) * scale, v[2], v[3]]); }; const onUp = () => { drag.current = null; }; const status = Object.fromEntries((live.junctions || []).map((j) => [j.id, j])); const inc = new Set(highlight.incidents || []), fl = new Set(highlight.floods || []); const rb = new Set(highlight.routeB || []), alt = new Set(highlight.alt || []); const color = (j) => !j ? "#5a6470" : j.preempt ? "#e5604d" : j.mode === "fixed" ? "#e6a23c" : j.mode === "actuated" ? "#c9b25a" : j.queue > 12 ? "#e6a23c" : "#2bc4b5"; const zoomedIn = view[2] < full[2] * 0.6; const btn = { padding: "2px 10px", fontSize: 16, lineHeight: "22px" }; const jr = Math.max(9, Math.min(30, view[2] / 55)); // junction markers shrink when zooming in const sr = Math.max(3.5, Math.min(9, view[2] / 170)); // traffic light size const br = Math.max(5, Math.min(13, view[2] / 120)); // bus stop size const walk = {}; (live.junctions || []).forEach((j) => j.crossings.forEach((c) => { walk[c.id] = c.walk; })); const lightColor = { G: "#35d07f", y: "#ffc83d", r: "#ff4a3a" }; const box = svgRef.current?.getBoundingClientRect(); const mpp = box && box.width ? Math.max(view[2] / box.width, view[3] / box.height) : view[2] / 800; // metres per px const heading = (v) => { // turn the short way round (359 -> 1 is +2, not -358) const a = v.angle ?? 90, last = angles.current.get(v.id); const next = last === undefined ? a : last + ((((a - last) % 360) + 540) % 360) - 180; angles.current.set(v.id, next); return next; }; if (angles.current.size > 2 * (live.vehicles?.length || 0) + 200) { // forget vehicles that left const here = new Set((live.vehicles || []).map((v) => v.id)); for (const id of angles.current.keys()) if (!here.has(id)) angles.current.delete(id); } const pr = Math.max(0.9, Math.min(3, view[2] / 550)); // pedestrian dot radius const bm = geo.basemap && BASEMAPS[basemap].style ? BASEMAPS[basemap] : null; const roadOpacity = bm ? 0.6 : 1; // let the real streets show through return (
setView(full)}> {bm && {geo.basemap.styles[bm.style] && (() => { // offline fallback, also shown while tiles load const [bx0, by0, bx1, by1] = geo.basemap.bounds; return ; })()} } {/* sidewalks, then each traffic lane (real widths in metres) */} {geo.edges.flatMap((e) => (e.sidewalks || []).map((sh, i) => ( `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke="#4b5462" strokeWidth={2.4} strokeLinecap="round" opacity={roadOpacity} /> )))} {geo.edges.flatMap((e) => (e.lane_shapes || [e.shape]).map((sh, i) => ( `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke="#2c3442" strokeWidth={e.lane_shapes ? 3.3 : 8 + e.lanes * 5} strokeLinecap="butt" opacity={roadOpacity} /> )))} {geo.edges.filter((e) => fl.has(e.id) || inc.has(e.id) || rb.has(e.id) || alt.has(e.id)).map((e) => { const stroke = fl.has(e.id) ? "#5c9bea" : inc.has(e.id) ? "#e5604d" : rb.has(e.id) ? "#2bc4b5" : "#e6a23c"; return `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke={stroke} strokeWidth={Math.max(4, e.lanes * 3.3)} opacity="0.7" strokeDasharray={fl.has(e.id) ? "14 8" : undefined} />; })} {geo.edges.filter((e) => e.bus_lane).map((e) => ( `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke="#8a2f3a" strokeWidth={5} strokeDasharray="10 6" /> ))} {lines.map((ln) => (geo.lines?.[ln] || []).map((eid) => { const e = geo.edges.find((x) => x.id === eid); return e && `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke={lineColor(ln)} strokeWidth={6} opacity="0.85" strokeDasharray={ln.endsWith("_R") ? "12 7" : undefined} />; }))} {(geo.crossings || []).map((c) => ( `${X(x)},${Y(y)}`).join(" ")} fill="none" stroke={walk[c.id] ? "#35d07f" : "#e8ecef"} strokeWidth={4} strokeDasharray="2.5 2.5" opacity={walk[c.id] ? 0.95 : 0.55} /> ))} {zoomedIn && geo.edges.filter((e) => e.name).map((e) => { const [[ax, ay], [bx, by]] = [e.shape[0], e.shape[e.shape.length - 1]]; if (e.id > e.id.split("_").reverse().join("_")) return null; // one label per road return {e.name}; })} {peds && (live.people || []).map(([x, y, slow], i) => ( ))} {showCars && (live.vehicles || []).map((v) => ( ))} {(geo.bus_stops || []).map((b) => ( B {zoomedIn && bus stop} ))} {(geo.signals || []).map((g) => { const c = live.signals?.[g.tls]?.[g.edge] || "r"; return ( ); })} {geo.nodes.filter((n) => n.signal).map((n) => ( {n.id} ))} {bm &&
{geo.basemap.attribution}
}
{geo.basemap && }
); }