// The fabric map: switches in their tiers, adapters under their leaves, every // cable between them, and the cluster's own adapters live. // // Plain SVG and no library, like every other chart here. The layout is the // shape a fat tree already has — cores over spines over leaves over hosts — // so it is computed rather than simulated, and it is the same every time the // page is opened: a map that rearranges itself on each refresh is one nobody // can learn. (function () { "use strict"; const root = document.getElementById("fabric-map"); if (!root) return; const seed = document.getElementById("fabric-data"); let data = seed ? JSON.parse(seed.textContent) : null; const url = root.dataset.src; const svgNS = "http://www.w3.org/2000/svg"; const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const state = { mode: location.hash === "#rails" ? "rails" : "speed", traffic: !reduced, problems: false, query: "", selected: null, view: { x: 0, y: 0, k: 1 } }; const canvas = root.querySelector(".fm-canvas"); const tip = root.querySelector(".fm-tip"); const panel = root.querySelector(".fm-panel"); const stats = root.querySelector(".fm-stats"); // --- words ------------------------------------------------------------- // What the map says comes from the page, in the page's language: the // server writes its catalogue's sentences into #fabric-i18n, with {0}, {1} // where a figure goes. English is kept here for a page that has none, so // the map still speaks rather than printing keys. const EN = { decimal: ".", map_failed: "The map could not be made: {0}", not_mapped: "Not mapped yet: the first scan runs within five minutes of a node on this fabric connecting.", "tier.leaf": "leaf", "tier.spine": "spine", "tier.core": "core", "tier.supercore": "super-core", "tier.n": "tier {0}", adapters_row: "adapters", ports_used: "{0}/{1} ports", hosts: "{0} hosts", cables_n: "{0} cables, ", below_rate: "trained below the {0} Gb/s the rest of its kind run at", adapters_n: "{0} adapters", degraded_n: "{0} degraded cable(s)", rail: "rail {0}", not_ours: "not one of this cluster's nodes", "dt.tier": "tier", "dt.ports": "ports", "dt.adapters": "adapters", "dt.adapter": "adapter", "dt.rail": "rail", "dt.leaf": "leaf", "dt.state": "state", "dt.rate": "rate", "dt.sending": "sending", "dt.receiving": "receiving", cabled: "{0} of {1} cabled", degraded_cables: "Degraded cables", all_good: "Every cable at its expected rate.", port_to: "port {0} → {1}: {2}", close: "close", open_node: "open {0} →", adapter: "adapter", "stat.switches": "switches", "stat.adapters": "adapters", "stat.cables": "cables", "stat.degraded": "degraded", "stat.rails": "rails", "stat.aligned": "aligned", "stat.crossed": "crossed", "stat.sent": "sent now", }; const S = (() => { const e = document.getElementById("fabric-i18n"); try { return e ? JSON.parse(e.textContent) : {}; } catch (_) { return {}; } })(); const T = (k, ...a) => String(S[k] || EN[k] || k).replace(/\{(\d)\}/g, (_, i) => a[+i]); const fixed = (v, n) => v.toFixed(n).replace(".", S.decimal || EN.decimal); // --- colours --------------------------------------------------------- const speedClass = (g) => g >= 800 ? "xdr" : g >= 400 ? "ndr" : g >= 200 ? "hdr" : g >= 100 ? "edr" : "slow"; const railPalette = ["#38bdf8", "#a78bfa", "#34d399", "#fbbf24", "#f472b6", "#fb7185", "#2dd4bf", "#c084fc"]; const fmtBps = (b) => { const bits = b * 8; if (bits >= 1e9) return fixed(bits / 1e9, 1) + " Gb/s"; if (bits >= 1e6) return fixed(bits / 1e6, 1) + " Mb/s"; if (bits >= 1e3) return fixed(bits / 1e3, 0) + " kb/s"; return fixed(bits, 0) + " b/s"; }; const el = (name, attrs, parent) => { const e = document.createElementNS(svgNS, name); for (const k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.appendChild(e); return e; }; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); // --- layout ---------------------------------------------------------- function layout(map, width) { const tiers = Math.max(1, map.tiers || 1); // Rows are placed once the width is known; see below. let gap = 150; const rowY = (t) => 70 + (tiers - t) * gap; const pos = {}; const byGUID = {}; map.switches.forEach((s) => (byGUID[s.guid] = s)); const leaves = map.switches.filter((s) => s.tier === 1).sort((a, b) => a.name.localeCompare(b.name)); // Hosts under their leaves, leaf by leaf, a little gap between groups. const groups = new Map(leaves.map((l) => [l.guid, []])); const loose = []; map.hosts.forEach((h) => (groups.has(h.leaf) ? groups.get(h.leaf) : loose).push(h)); const hostGap = map.hosts.length > 120 ? 16 : map.hosts.length > 48 ? 24 : 34; // The width decides the height: the drawing is fitted to the page, and a // fabric four times wider than the page would be a thin strip at the // height of a small one. const hostCount = map.hosts.length; const estWidth = Math.max(width, 60 + hostCount * hostGap + leaves.length * 90); gap = Math.max(130, (estWidth * 0.42 - 180) / tiers); const hostY = rowY(1) + gap; // Room on the left for the tier names, at the size they will be drawn. let x = 40 + 30 * Math.max(1, estWidth / width); const leafX = {}; for (const l of leaves) { const hs = groups.get(l.guid); if (!hs.length) { leafX[l.guid] = x + 50; x += 130; continue; } const start = x; hs.forEach((h) => { pos[h.guid] = { x, y: hostY }; x += hostGap; }); leafX[l.guid] = (start + x - hostGap) / 2; x += Math.max(40, 130 - hs.length * hostGap / 2); } loose.forEach((h) => { pos[h.guid] = { x, y: hostY }; x += hostGap; }); const span = Math.max(x + 20, width); // Leaves too close for their boxes are spread, keeping their order. let last = -Infinity; for (const l of leaves) { const want = Math.max(leafX[l.guid], last + 124); pos[l.guid] = { x: want, y: rowY(1) }; last = want; } // Each tier above: ordered by where the switches they connect to sit, // which keeps cables from crossing more than they must, then spread // evenly across the width. const adj = {}; map.links.forEach((k) => { (adj[k.from] = adj[k.from] || []).push(k.to); (adj[k.to] = adj[k.to] || []).push(k.from); }); for (let t = 2; t <= tiers; t++) { const row = map.switches.filter((s) => s.tier === t); row.forEach((s) => { const xs = (adj[s.guid] || []).map((g) => pos[g]).filter((p) => p && p.y > rowY(t)).map((p) => p.x); s._bx = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : span / 2; }); row.sort((a, b) => a._bx - b._bx || a.name.localeCompare(b.name)); const lo = Math.min(...leaves.map((l) => pos[l.guid].x), span * 0.15); const hi = Math.max(...leaves.map((l) => pos[l.guid].x), span * 0.85); row.forEach((s, i) => { pos[s.guid] = { x: row.length === 1 ? (lo + hi) / 2 : lo + (hi - lo) * i / (row.length - 1), y: rowY(t) }; }); } map.switches.forEach((s) => { if (!pos[s.guid]) pos[s.guid] = { x: span / 2, y: rowY(s.tier || 1) }; }); // How much to enlarge switches and adapters so they read at the size // they would on a page as wide as the fabric — as far as their spacing // lets them without touching. const fit = Math.max(1, Math.max(span, width) / width); let leafGap = Infinity; for (let i = 1; i < leaves.length; i++) leafGap = Math.min(leafGap, pos[leaves[i].guid].x - pos[leaves[i - 1].guid].x); const swScale = Math.min(fit, leafGap / 118, 2.4); const hostScale = Math.min(fit, hostGap / 13, 2.4); return { pos, width: Math.max(span, width), height: hostY + 110 * Math.max(1, hostScale), rowY, tiers, byGUID, gap, swScale: Math.max(1, swScale), hostScale: Math.max(1, hostScale), fit }; } // --- drawing --------------------------------------------------------- let svg, viewport, flowScale = 1; function draw() { canvas.textContent = ""; if (!data || !data.map) { const msg = data && data.error ? T("map_failed", data.error) : T("not_mapped"); canvas.innerHTML = '

' + esc(msg) + "

"; fillStats(); return; } const map = data.map; const width = canvas.clientWidth || 1000; const L = layout(map, width); // Fitted to the width to start with — the whole fabric at once — and // zoomed from there for detail. svg = el("svg", { class: "fm-svg mode-" + state.mode + (state.problems ? " only-problems" : ""), viewBox: `0 0 ${L.width} ${L.height}`, preserveAspectRatio: "xMidYMin meet" }); svg.style.height = Math.max(380, Math.min(780, L.height * width / L.width)) + "px"; const defs = el("defs", {}, svg); defs.innerHTML = ` `; el("rect", { x: -5000, y: -5000, width: 20000, height: 20000, fill: "url(#fm-grid)" }, svg); viewport = el("g", { class: "fm-viewport" }, svg); // Tier labels down the left. const names = { 1: T("tier.leaf"), 2: T("tier.spine"), 3: T("tier.core"), 4: T("tier.supercore") }; // Tier names down the left edge, turned on their side so they take no // room from the leftmost switch. const label = (y, text) => el("text", { class: "fm-tier", transform: `translate(${14 * L.fit},${y}) rotate(-90) scale(${L.fit})` }, viewport).textContent = text; for (let t = L.tiers; t >= 1; t--) label(L.rowY(t), names[t] || T("tier.n", t)); label(L.rowY(1) + L.gap, T("adapters_row")); // Cables, parallel ones bundled. const bundles = new Map(); for (const k of map.links) { const key = k.from + "|" + k.to; const b = bundles.get(key) || { from: k.from, to: k.to, links: [] }; b.links.push(k); bundles.set(key, b); } const linkLayer = el("g", { class: "fm-links" }, viewport); const flowLayer = el("g", { class: "fm-flows" }, viewport); flowScale = L.hostScale; const touches = {}; for (const b of bundles.values()) { const a = L.pos[b.from], z = L.pos[b.to]; if (!a || !z) continue; const my = (a.y + z.y) / 2; const d = `M${a.x},${a.y + 17 * L.swScale} C${a.x},${my} ${z.x},${my} ${z.x},${z.y - (b.links[0].host ? 8 * L.hostScale : 17 * L.swScale)}`; const bad = b.links.some((k) => k.degraded); const g = Math.min(...b.links.map((k) => k.gbps || 0)); const host = b.links[0].host; const hostInfo = host ? map.hosts.find((h) => h.guid === b.to) : null; const path = el("path", { d, class: "fm-link sp-" + speedClass(g) + (bad ? " degraded" : "") + (host ? " host" : ""), "stroke-width": host ? 1.4 : 1.2 + Math.log2(b.links.length) * 1.4 }, linkLayer); if (host && hostInfo) path.style.setProperty("--rail", railPalette[hostInfo.rail % railPalette.length]); path.dataset.ends = b.from + " " + b.to; path.addEventListener("mouseenter", (e) => showTip(e, linkTip(b, map))); path.addEventListener("mouseleave", hideTip); (touches[b.from] = touches[b.from] || []).push(path); (touches[b.to] = touches[b.to] || []).push(path); if (bad) { (touches[b.from].bad = (touches[b.from].bad || 0) + 1); const mid = pointAt(a, z, my); const w = el("g", { class: "fm-warn", transform: `translate(${mid.x},${mid.y}) scale(${L.swScale})` }, viewport); el("circle", { r: 7 }, w); el("text", { y: 3.5 }, w).textContent = "!"; w.addEventListener("mouseenter", (e) => showTip(e, linkTip(b, map))); w.addEventListener("mouseleave", hideTip); } // Traffic: particles that run as fast as the adapter is busy — up // the cable for what it sends, down for what it receives. if (host && state.traffic && data.live && data.live[b.to]) { const live = data.live[b.to]; flow(flowLayer, d, live.tx_bps, "up", live.gbps); flow(flowLayer, d, live.rx_bps, "down", live.gbps); } } // Switches. const nodeLayer = el("g", { class: "fm-nodes" }, viewport); for (const s of map.switches) { const p = L.pos[s.guid]; const tierClass = s.tier >= 3 ? "core" : s.tier === 2 ? "spine" : "leaf"; const g = el("g", { class: "fm-switch t-" + tierClass + (touches[s.guid] && touches[s.guid].bad ? " has-bad" : ""), transform: `translate(${p.x},${p.y}) scale(${L.swScale})`, tabindex: 0 }, nodeLayer); el("rect", { x: -56, y: -17, width: 112, height: 34, rx: 10, fill: `url(#fm-g-${tierClass})` }, g); el("text", { class: "fm-name", y: -1 }, g).textContent = s.name.length > 16 ? s.name.slice(0, 15) + "…" : s.name; el("text", { class: "fm-sub", y: 11 }, g).textContent = T("ports_used", s.used, s.ports) + (s.hosts ? " · " + T("hosts", s.hosts) : ""); // Port use as an arc along the bottom edge. const frac = s.ports ? Math.min(1, s.used / s.ports) : 0; el("line", { x1: -46, y1: 15.5, x2: -46 + 92 * frac, y2: 15.5, class: "fm-use" }, g); if (touches[s.guid] && touches[s.guid].bad) { const bdg = el("g", { class: "fm-badge", transform: "translate(52,-15)" }, g); el("circle", { r: 8 }, bdg); el("text", { y: 3.5 }, bdg).textContent = touches[s.guid].bad; } wire(g, s.guid, () => switchTip(s, map, touches), () => switchPanel(s, map)); } // Adapters. const showLabels = map.hosts.length <= 96; for (const h of map.hosts) { const p = L.pos[h.guid]; if (!p) continue; const live = data.live && data.live[h.guid]; const down = live && live.state && live.state.toUpperCase() !== "ACTIVE"; const errs = live && live.errors && Object.keys(live.errors).length; const cls = "fm-host" + (h.node ? " known" : "") + (down ? " down" : "") + (errs ? " errs" : "") + (state.query && matches(h) ? " found" : ""); const g = el("g", { class: cls, transform: `translate(${p.x},${p.y}) scale(${L.hostScale})`, tabindex: 0 }, nodeLayer); g.style.setProperty("--rail", railPalette[h.rail % railPalette.length]); el("circle", { r: 7.5, class: "fm-halo" }, g); el("circle", { r: 5.5 }, g); if (showLabels) { const label = (h.node || (h.desc || "").split(" ")[0] || h.guid.slice(-6)) + (h.hca ? " " + h.hca.replace("mlx5_", "#") : ""); el("text", { class: "fm-hlabel", transform: "translate(3,14) rotate(55)" }, g).textContent = label; } wire(g, h.guid, () => hostTip(h, live), () => hostPanel(h, live, map)); } canvas.appendChild(svg); applyView(); enablePanZoom(svg, L); if (state.selected) highlight(state.selected, touches); svg._touches = touches; fillStats(); } function pointAt(a, z, my) { // The middle of the cubic: its two control points share a height. return { x: (a.x + z.x) / 2, y: my }; } function flow(layer, d, bps, dir, gbps) { if (!bps || bps < 1e3) return; const cap = (gbps || 100) * 1e9 / 8; const load = Math.min(1, bps / cap); const count = 1 + Math.round(load * 4); const dur = Math.max(0.7, 4.5 - Math.log10(bps) * 0.38); for (let i = 0; i < count; i++) { const c = el("circle", { r: 2.4 * flowScale, class: "fm-particle " + dir }, layer); const m = el("animateMotion", { dur: dur + "s", repeatCount: "indefinite", path: d, begin: (-(dur / count) * i).toFixed(2) + "s" }, c); if (dir === "up") { m.setAttribute("keyPoints", "1;0"); m.setAttribute("keyTimes", "0;1"); m.setAttribute("calcMode", "linear"); } } } // --- interaction ------------------------------------------------------ function wire(g, guid, tipFn, panelFn) { g._guid = guid; g.addEventListener("mouseenter", (e) => { highlight(guid, svg._touches); showTip(e, tipFn()); }); g.addEventListener("mousemove", moveTip); g.addEventListener("mouseleave", () => { hideTip(); if (!state.selected) unhighlight(); else highlight(state.selected, svg._touches); }); const open = (e) => { e.stopPropagation(); state.selected = guid; highlight(guid, svg._touches); panel.innerHTML = panelFn(); panel.hidden = false; }; g.addEventListener("click", open); g.addEventListener("keydown", (e) => { if (e.key === "Enter") open(e); }); } function highlight(guid, touches) { if (!svg) return; svg.classList.add("focus"); svg.querySelectorAll(".hot").forEach((n) => n.classList.remove("hot")); (touches[guid] || []).forEach((p) => { p.classList.add("hot"); p.dataset.ends.split(" ").forEach((g) => { svg.querySelectorAll(".fm-nodes > g").forEach((n) => { if (n._guid === g) n.classList.add("hot"); }); }); }); svg.querySelectorAll(".fm-nodes > g").forEach((n) => { if (n._guid === guid) n.classList.add("hot"); }); } function unhighlight() { if (!svg) return; svg.classList.remove("focus"); svg.querySelectorAll(".hot").forEach((n) => n.classList.remove("hot")); } function showTip(e, html) { tip.innerHTML = html; tip.hidden = false; moveTip(e); } function moveTip(e) { const r = root.getBoundingClientRect(); tip.style.left = Math.min(e.clientX - r.left + 14, r.width - 260) + "px"; tip.style.top = (e.clientY - r.top + 14) + "px"; } function hideTip() { tip.hidden = true; } function linkTip(b, map) { const n = (g) => { const s = map.switches.find((x) => x.guid === g); const h = map.hosts.find((x) => x.guid === g); return s ? s.name : h ? (h.node ? h.node + " " + (h.hca || "") : h.desc) : g; }; const k = b.links[0]; const exp = k.host ? map.expected_host_gbps : map.expected_switch_gbps; return `${esc(n(b.from))} ↔ ${esc(n(b.to))}
${b.links.length > 1 ? esc(T("cables_n", b.links.length)) : ""}${esc(k.rate)} · ${k.gbps} Gb/s` + (b.links.some((x) => x.degraded) ? `
${esc(T("below_rate", exp))}` : ""); } const tierName = (s) => s.tier >= 3 ? T("tier.core") : s.tier === 2 ? T("tier.spine") : T("tier.leaf"); function switchTip(s, map, touches) { const bad = touches[s.guid] && touches[s.guid].bad; return `${esc(s.name)}
${esc(tierName(s))} · ${esc(T("ports_used", s.used, s.ports))}` + (s.hosts ? " · " + esc(T("adapters_n", s.hosts)) : "") + (s.lid ? ` · LID ${s.lid}` : "") + (bad ? `
${esc(T("degraded_n", bad))}` : ""); } function hostTip(h, live) { let t = `${esc(h.node || h.desc || h.guid)}${h.hca ? " · " + esc(h.hca) : ""}
${esc(T("rail", h.rail))}`; if (live) { t += ` · ${esc(live.state)}${live.gbps ? " · " + live.gbps + " Gb/s" : ""}`; if (live.tx_bps || live.rx_bps) t += `
↑ ${fmtBps(live.tx_bps)} · ↓ ${fmtBps(live.rx_bps)}`; if (live.errors) t += `
${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join(", ")}`; } else if (!h.node) { t += `
${esc(T("not_ours"))}`; } return t; } function switchPanel(s, map) { const cables = map.links.filter((k) => k.from === s.guid || k.to === s.guid); const bad = cables.filter((k) => k.degraded); const rows = bad.map((k) => { const o = k.from === s.guid ? k.to : k.from; const on = map.switches.find((x) => x.guid === o) || map.hosts.find((x) => x.guid === o); return `
  • ${esc(T("port_to", k.from === s.guid ? k.from_port : k.to_port, on ? (on.name || on.node || on.desc) : o, k.rate))}
  • `; }).join(""); return `

    ${esc(s.name)}

    ${esc(T("dt.tier"))}
    ${esc(tierName(s))}
    GUID
    ${esc(s.guid)}
    LID
    ${s.lid || "—"}
    ${esc(T("dt.ports"))}
    ${esc(T("cabled", s.used, s.ports))}
    ${esc(T("dt.adapters"))}
    ${s.hosts}
    ` + (bad.length ? `

    ${esc(T("degraded_cables"))}

    ` : `

    ${esc(T("all_good"))}

    `); } function hostPanel(h, live, map) { const leaf = map.switches.find((x) => x.guid === h.leaf); let body = `
    ${esc(T("dt.adapter"))}
    ${esc(h.hca || h.desc)}
    GUID
    ${esc(h.guid)}
    ${esc(T("dt.rail"))}
    ${h.rail}
    ${esc(T("dt.leaf"))}
    ${esc(leaf ? leaf.name : "—")}
    `; if (live) { body += `
    ${esc(T("dt.state"))}
    ${esc(live.state)} (${esc(live.phys_state)})
    ${esc(T("dt.rate"))}
    ${live.gbps} Gb/s
    ${esc(T("dt.sending"))}
    ${fmtBps(live.tx_bps)}
    ${esc(T("dt.receiving"))}
    ${fmtBps(live.rx_bps)}
    `; } body += "
    "; if (live && live.errors) body += `

    ${Object.entries(live.errors).map(([k, v]) => esc(k) + " " + v).join("
    ")}

    `; const link = h.node ? `${esc(T("open_node", h.node))}` : ""; return `

    ${esc(h.node || h.desc || T("adapter"))}

    ${body}${link}`; } panel.addEventListener("click", (e) => { if (e.target.classList.contains("fm-close")) { panel.hidden = true; state.selected = null; unhighlight(); } }); canvas.addEventListener("click", () => { panel.hidden = true; state.selected = null; unhighlight(); }); function matches(h) { const q = state.query.toLowerCase(); return (h.node || "").toLowerCase().includes(q) || (h.desc || "").toLowerCase().includes(q) || (h.hca || "").toLowerCase().includes(q); } // --- pan and zoom ------------------------------------------------------ function applyView() { if (viewport) viewport.setAttribute("transform", `translate(${state.view.x},${state.view.y}) scale(${state.view.k})`); } function enablePanZoom(s) { let drag = null; s.addEventListener("wheel", (e) => { e.preventDefault(); const r = s.getBoundingClientRect(); const scale = s.viewBox.baseVal.width / r.width; const mx = (e.clientX - r.left) * scale, my = (e.clientY - r.top) * scale; const k = Math.min(4, Math.max(0.25, state.view.k * (e.deltaY < 0 ? 1.12 : 1 / 1.12))); state.view.x = mx - (mx - state.view.x) * (k / state.view.k); state.view.y = my - (my - state.view.y) * (k / state.view.k); state.view.k = k; applyView(); }, { passive: false }); s.addEventListener("pointerdown", (e) => { if (e.target.closest(".fm-nodes > g")) return; drag = { x: e.clientX, y: e.clientY, vx: state.view.x, vy: state.view.y }; s.setPointerCapture(e.pointerId); s.classList.add("dragging"); }); s.addEventListener("pointermove", (e) => { if (!drag) return; const r = s.getBoundingClientRect(); const scale = s.viewBox.baseVal.width / r.width; state.view.x = drag.vx + (e.clientX - drag.x) * scale; state.view.y = drag.vy + (e.clientY - drag.y) * scale; applyView(); }); s.addEventListener("pointerup", () => { drag = null; s.classList.remove("dragging"); }); s.addEventListener("dblclick", () => { state.view = { x: 0, y: 0, k: 1 }; applyView(); }); } // --- the strip above ---------------------------------------------------- function fillStats() { if (!stats) return; const m = data && data.map; const live = data && data.live ? Object.values(data.live) : []; const tx = live.reduce((a, l) => a + (l.tx_bps || 0), 0); const cells = []; if (m) { cells.push([m.switches.length, T("stat.switches")], [m.hosts.length, T("stat.adapters")], [m.links.length, T("stat.cables")], [m.degraded, T("stat.degraded"), m.degraded ? "bad" : "ok"], [m.rails.length > 1 ? (m.rail_aligned ? T("stat.aligned") : T("stat.crossed")) : "—", T("stat.rails"), m.rails.length > 1 && !m.rail_aligned ? "bad" : ""]); } cells.push([fmtBps(tx), T("stat.sent")]); stats.innerHTML = cells.map(([v, k, c]) => `
    ${esc(v)}${esc(k)}
    `).join(""); } // --- toolbar ------------------------------------------------------------- root.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("on", b.dataset.mode === state.mode)); root.querySelectorAll("[data-mode]").forEach((b) => b.addEventListener("click", () => { state.mode = b.dataset.mode; history.replaceState(null, "", state.mode === "rails" ? "#rails" : location.pathname); root.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === b)); draw(); })); const tbtn = root.querySelector("[data-toggle=traffic]"); if (tbtn) { tbtn.classList.toggle("on", state.traffic); tbtn.addEventListener("click", () => { state.traffic = !state.traffic; tbtn.classList.toggle("on", state.traffic); draw(); }); } const pbtn = root.querySelector("[data-toggle=problems]"); if (pbtn) pbtn.addEventListener("click", () => { state.problems = !state.problems; pbtn.classList.toggle("on", state.problems); draw(); }); const search = root.querySelector(".fm-search"); if (search) search.addEventListener("input", () => { state.query = search.value.trim(); draw(); }); const reset = root.querySelector("[data-reset]"); if (reset) reset.addEventListener("click", () => { state.view = { x: 0, y: 0, k: 1 }; applyView(); }); draw(); window.addEventListener("resize", () => { clearTimeout(draw._t); draw._t = setTimeout(draw, 150); }); // Live: adapters' state and traffic every fifteen seconds, the map itself // when a new scan has made one. if (url) setInterval(async () => { if (document.visibilityState !== "visible") return; try { const r = await fetch(url, { headers: { Accept: "application/json" } }); if (!r.ok) return; data = await r.json(); draw(); } catch (_) { /* the next one */ } }, 15000); })();