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 (
{bm &&
{geo.basemap.attribution}
}
{geo.basemap && }
);
}