(function () { const MOUNT_ID = "fig-how"; const DATA_URL = "assets/data/literature_clusters.json"; // 12 distinct hues — picked to read on the warm paper background. const CLUSTER_PALETTE = [ "#254EFF", "#C96A2E", "#7B93B8", "#A368DF", "#4DB78C", "#CD3572", "#00BDB6", "#FD8153", "#5366E0", "#681FB1", "#E18AAC", "#13553A", ]; const NOISE_COLOR = "#B5BDC8"; const INK = "#35302E"; const MUTED = "#6b6358"; const SVG_NS = "http://www.w3.org/2000/svg"; const MARGIN = { left: 20, right: 20, top: 20, bottom: 60 }; function el(name, attrs, children) { const e = document.createElementNS(SVG_NS, name); if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]); if (children) for (const c of children) { e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); } return e; } function starPath(cx, cy, r) { const pts = []; for (let i = 0; i < 10; i++) { const angle = -Math.PI / 2 + (i * Math.PI) / 5; const radius = i % 2 === 0 ? r : r * 0.42; pts.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]); } return "M" + pts.map(p => p.join(",")).join(" L") + " Z"; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'", })[c]); } // -------- tooltip -------- let tipEl = null; let hideTimer = null; let activeCluster = null; let svgRoot = null; function ensureTip() { if (tipEl) return tipEl; tipEl = document.createElement("div"); tipEl.className = "cluster-tooltip"; tipEl.setAttribute("role", "tooltip"); tipEl.addEventListener("mouseenter", () => clearTimeout(hideTimer)); tipEl.addEventListener("mouseleave", () => scheduleClear()); document.body.appendChild(tipEl); return tipEl; } function showCluster(cluster, color, evt) { clearTimeout(hideTimer); if (activeCluster === cluster.id) { positionTip(evt); return; } activeCluster = cluster.id; if (svgRoot) { svgRoot.classList.add("has-active"); svgRoot .querySelectorAll('[data-cluster]') .forEach(node => { const same = node.getAttribute("data-cluster") === String(cluster.id); node.classList.toggle("is-active", same); }); } const t = ensureTip(); const reps = (cluster.representatives || []) .map(r => { const year = r.year ? ` (${r.year})` : ""; const titleHtml = escapeHtml(r.title) + escapeHtml(year); return r.url ? `
  • ${titleHtml}
  • ` : `
  • ${titleHtml}
  • `; }) .join(""); const kwsHtml = (cluster.keywords || []) .slice(0, 4) .map(k => `${escapeHtml(k)}`) .join(""); t.innerHTML = `
    ` + `` + `cluster of ${cluster.size}` + `
    ` + `
    ${kwsHtml}
    ` + `
    representative papers
    ` + ``; t.style.borderColor = color; t.classList.add("is-visible"); positionTip(evt); } function scheduleClear() { clearTimeout(hideTimer); hideTimer = setTimeout(clearActive, 220); } function clearActive() { activeCluster = null; if (svgRoot) { svgRoot.classList.remove("has-active"); svgRoot .querySelectorAll('[data-cluster]') .forEach(n => n.classList.remove("is-active")); } if (tipEl) tipEl.classList.remove("is-visible"); } function positionTip(evt) { if (!tipEl) return; const pad = 14; const tw = tipEl.offsetWidth; const th = tipEl.offsetHeight; let x = evt.clientX + pad; let y = evt.clientY + pad; if (x + tw > window.innerWidth - 8) x = evt.clientX - tw - pad; if (y + th > window.innerHeight - 8) y = evt.clientY - th - pad; tipEl.style.left = x + "px"; tipEl.style.top = y + "px"; } // -------- render -------- function buildSVG(data, width) { const height = width; const innerW = width - MARGIN.left - MARGIN.right; const innerH = height - MARGIN.top - MARGIN.bottom; const { x_min, x_max, y_min, y_max } = data.bounds; const xPad = (x_max - x_min) * 0.04; const yPad = (y_max - y_min) * 0.04; const xScale = v => MARGIN.left + ((v - (x_min - xPad)) / ((x_max + xPad) - (x_min - xPad))) * innerW; const yScale = v => MARGIN.top + (1 - (v - (y_min - yPad)) / ((y_max + yPad) - (y_min - yPad))) * innerH; const svg = el("svg", { viewBox: `0 0 ${width} ${height}`, width: width, height: height, style: "display: block; max-width: 100%; height: auto;", }); // Cluster colour map const clusterIdToColor = {}; data.clusters.forEach((c, i) => { clusterIdToColor[c.id] = CLUSTER_PALETTE[i % CLUSTER_PALETTE.length]; }); // Noise points const noise = data.points.filter(p => p.cluster === -1); if (noise.length) { const g = el("g", { class: "cluster-noise" }); noise.forEach(p => { const cx = xScale(p.x), cy = yScale(p.y); if (p.is_deployment) { g.appendChild(el("path", { d: starPath(cx, cy, 5), fill: NOISE_COLOR, opacity: 0.55, })); } else { g.appendChild(el("circle", { cx, cy, r: 3, fill: NOISE_COLOR, opacity: 0.4, })); } }); svg.appendChild(g); } // Per-cluster groups (so hover targets one whole cluster at a time) data.clusters.forEach(cluster => { const color = clusterIdToColor[cluster.id]; const g = el("g", { class: "cluster-group", "data-cluster": String(cluster.id), }); // Invisible larger hit box per point so hover is forgiving. data.points.filter(p => p.cluster === cluster.id).forEach(p => { const cx = xScale(p.x), cy = yScale(p.y); if (p.is_deployment) { g.appendChild(el("path", { d: starPath(cx, cy, 7), fill: color, stroke: INK, "stroke-width": 0.5, opacity: 0.85, })); } else { g.appendChild(el("circle", { cx, cy, r: 4, fill: color, stroke: INK, "stroke-width": 0.3, opacity: 0.7, })); } }); svg.appendChild(g); }); // Cluster keyword labels at each centroid data.clusters.forEach(cluster => { const color = clusterIdToColor[cluster.id]; const cx = xScale(cluster.centroid[0]); const cy = yScale(cluster.centroid[1]); const kws = (cluster.keywords || []).slice(0, 2); if (!kws.length) return; const text = el("text", { x: cx, y: cy, "text-anchor": "middle", "dominant-baseline": "middle", "font-family": "Tomato Grotesk, sans-serif", "font-size": 11, "font-weight": 600, fill: color, class: "cluster-label", "data-cluster": String(cluster.id), "paint-order": "stroke", stroke: "#FFFFFF", "stroke-width": 3.5, "stroke-linejoin": "round", }); kws.forEach((k, i) => { text.appendChild(el("tspan", { x: cx, dy: i === 0 ? -((kws.length - 1) * 6) : 13, }, [`"${k}"`])); }); svg.appendChild(text); }); // Legend in the bottom-left const lg = el("g", { transform: `translate(${MARGIN.left + 4}, ${height - MARGIN.bottom + 18})` }); lg.appendChild(el("circle", { cx: 4, cy: 4, r: 4, fill: MUTED })); lg.appendChild(el("text", { x: 16, y: 8, "font-family": "Univers, sans-serif", "font-size": 11, fill: INK, }, ["Edge ML method"])); lg.appendChild(el("path", { d: starPath(4, 26, 6), fill: MUTED })); lg.appendChild(el("text", { x: 16, y: 30, "font-family": "Univers, sans-serif", "font-size": 11, fill: INK, }, ["Real-world deployment"])); svg.appendChild(lg); return { svg, clusterIdToColor }; } function wire(svg, data, clusterIdToColor) { svgRoot = svg; const clustersById = {}; data.clusters.forEach(c => { clustersById[c.id] = c; }); svg.querySelectorAll(".cluster-group").forEach(g => { const id = Number(g.getAttribute("data-cluster")); const cluster = clustersById[id]; if (!cluster) return; const color = clusterIdToColor[id]; g.style.cursor = "pointer"; g.addEventListener("mouseenter", e => showCluster(cluster, color, e)); g.addEventListener("mousemove", e => positionTip(e)); g.addEventListener("mouseleave", () => scheduleClear()); }); svg.querySelectorAll(".cluster-label").forEach(t => { const id = Number(t.getAttribute("data-cluster")); const cluster = clustersById[id]; if (!cluster) return; const color = clusterIdToColor[id]; t.style.cursor = "pointer"; t.addEventListener("mouseenter", e => showCluster(cluster, color, e)); t.addEventListener("mousemove", e => positionTip(e)); t.addEventListener("mouseleave", () => scheduleClear()); }); } function render(mount, data) { mount.innerHTML = ""; const width = Math.max(420, Math.floor(mount.clientWidth || mount.getBoundingClientRect().width)); const { svg, clusterIdToColor } = buildSVG(data, width); mount.appendChild(svg); wire(svg, data, clusterIdToColor); } async function boot() { const mount = document.getElementById(MOUNT_ID); if (!mount) return; try { const res = await fetch(DATA_URL, { cache: "no-cache" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); render(mount, data); let resizeTimer = null; window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => render(mount, data), 120); }); } catch (err) { console.error("fig-literature-clusters:", err); mount.innerHTML = '

    [ figure failed to load ]

    '; } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();