// Main DGAC Playground app. const { useState, useEffect, useMemo, useRef } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "alpha": 0.9, "beta": 0.5, "topLayers": 5, "attrLayers": 5, "cpropLayers": 1, "dataset": "hetero", "showAttrGraph": true, "animateIter": true }/*EDITMODE-END*/; // Smooth transition ease curve reused for nodes + edges when step changes. const SMOOTH_T = "cubic-bezier(0.4, 0, 0.2, 1)"; const NODE_XITION = { transition: `transform 0.6s ${SMOOTH_T}` }; const LINE_XITION = { transition: `x1 0.6s ${SMOOTH_T}, y1 0.6s ${SMOOTH_T}, x2 0.6s ${SMOOTH_T}, y2 0.6s ${SMOOTH_T}, stroke 0.3s, stroke-width 0.3s, opacity 0.3s`, }; // ============================================================ // CorrectionTheater — plays out the "C-prop fixes a wrong node" // story. Picks one k-means-mis-assigned node whose neighbors // majority-vote the right cluster; every ~2.8s cycle: // (1) orange warning ring flashes around protagonist // (2) neighbor edges brighten + thicken // (3) colored particles fly from each neighbor to protagonist // (particle color = neighbor's current k-means color) // (4) vote-tally bar below protagonist fills up // (5) protagonist fill animates from wrong color → truth color // (6) green "rescue" ring flashes to celebrate the fix // Entirely SMIL-driven (no React timers), so it stays buttery // smooth even when other parts re-render. // ============================================================ function CorrectionTheater({ wrongNode, neighbors, posOf, kmColor, flipColor, nbColors, nbKmClusters, nbTruthClusters, nbIsWrong, protagonistTruthK, voteMaxK, clusterColors }) { const [wx, wy] = posOf(wrongNode); const DUR = 2.8; // full cycle seconds const tFlashIn = 0.06; // warning ring appears const tFlashPeak = 0.18; // warning ring at max const tEdgesOn = 0.20; // neighbor edges highlighted const tParticle0 = 0.28; // first particle leaves const particleGap = 0.06; // between particles const tTravel = 0.26; // particle travel duration fraction const tTallyStart = 0.55; const tTallyFull = 0.78; const tFlipStart = 0.80; const tFlipEnd = 0.86; const tRescuePk = 0.90; const tRescueEnd = 0.96; const totalNb = neighbors.length; // vote-bar geometry (below protagonist) const BAR_W = 54, BAR_H = 10; const barX = wx - BAR_W/2, barY = wy + 15; return ( {/* (2) neighbor edges — brighten during active phase */} {neighbors.map((nb, i) => { const [nx, ny] = posOf(nb); return ( ); })} {/* (1) orange warning ring around protagonist */} {/* (3) color particles flowing from each neighbor → protagonist */} {neighbors.map((nb, i) => { const [nx, ny] = posOf(nb); const delay = tParticle0 + i * particleGap; // safe: delay + tTravel stays < tTallyFull const arrive = Math.min(delay + tTravel, 0.77); return ( ); })} {/* (4) vote-tally — ONE CELL PER NEIGHBOR (not one wedge per cluster). Each cell shows that neighbor's current k-means color; a diagonal hatch overlay flags neighbors whose k-means color ≠ their truth (they're "wrong neighbors" whose vote still counts). Same-color cells are kept adjacent for visual clustering. */} {(() => { const cellGap = 0.8; const cellW = (BAR_W - cellGap*(totalNb-1)) / totalNb; // Sort neighbors: winner km-cluster cells first, then by km-cluster index const order = neighbors.map((_, i) => i).sort((a, b) => { const ka = nbKmClusters[a], kb = nbKmClusters[b]; if (ka === voteMaxK && kb !== voteMaxK) return -1; if (kb === voteMaxK && ka !== voteMaxK) return 1; return ka - kb; }); const segs = []; order.forEach((i, idx) => { const k = nbKmClusters[i]; const truthK = nbTruthClusters[i]; const isWinner = k === voteMaxK; const isWrong = nbIsWrong[i]; // "supportive" = neighbor truly in same cluster as protagonist // (regardless of how km labeled it) const isSupportive = truthK === protagonistTruthK; const x = barX + idx * (cellW + cellGap); const dotCx = x + cellW/2; const dotCy = barY + BAR_H + 3.2; segs.push( {/* vote cell: fill = neighbor's km color (= its vote) */} {/* diagonal hatch — only for the rare wrong-km neighbor (km label disagrees with its truth); stays hidden on demos where every neighbor is correctly km-labeled. */} {isWrong && [0.22, 0.50, 0.78].map((off, j) => ( ))} {/* truth dot below cell — colored by neighbor's TRUE cluster. Same color as cell = km correct; cross-protagonist color = heterophilic edge. A darker ring marks dots matching the protagonist's truth cluster (the "supportive" neighbors). */} ); }); return segs; })()} {/* (5) protagonist node — fill animates wrong → truth */} {/* (6) green rescue ring after flip */} ); } // === Graph view: shows nodes, topology edges, optional attribute edges === function GraphView({ stepId, tweaks, iter, dgac }) { const G = window.DEMO_GRAPHS[tweaks.dataset] || window.DEMO_GRAPHS.hetero; const W = 420, H = 360; // Determine what to show at this step const showEdges = true; const showAttrEdges = tweaks.showAttrGraph && (stepId==="attribute" || stepId==="encode" || stepId==="fusion"); // 2D projection from the active embedding at this step const proj = useMemo(() => { let src = dgac.H0; if (stepId==="topology") src = dgac.Ht; else if (stepId==="attribute") src = dgac.Ha; else if (stepId==="fusion" || stepId==="kmeans" || stepId==="loss") src = dgac.H; else if (stepId==="cprop" || stepId==="output") src = dgac.H; return window.DGAC_MATH.project2D(src); }, [dgac, stepId]); // per-step cluster prediction for coloring const predAssign = (() => { if (stepId==="kmeans") return dgac.km.assign; if (stepId==="cprop" || stepId==="output" || stepId==="loss") return dgac.refined.assign; return null; })(); // Match predicted labels onto truth colors (via best permutation, same as acc) const colorPerm = useMemo(() => { if (!predAssign) return [0,1,2,3]; // find best perm mapping pred → truth const K = 4; const perms = []; const h = (a, k)=>{ if(k===a.length){ perms.push(a.slice()); return;} for(let i=k;ibv){bv=c; best=p;} } return best; }, [predAssign, dgac.truth]); const clusterColors = G.clusters.map(c => c.color); const colorMode = (() => { if (["input","encode"].includes(stepId)) return "truth"; if (["topology","attribute","fusion"].includes(stepId)) return "neutral"; if (stepId==="kmeans") return "pred"; return "pred"; })(); const nodeFill = (n) => { if (colorMode==="truth") return n.color; if (colorMode==="neutral") return "#d9d2c3"; if (colorMode==="pred" && predAssign) return clusterColors[colorPerm[predAssign[n.id]]]; return "#ccc"; }; const isWrongNode = (n) => { if (!predAssign) return false; return colorPerm[predAssign[n.id]] !== n.cluster; }; // blend base anchor position with H-projection offset const posOf = (n) => { const ax = n.tx, ay = n.ty; const [dx, dy] = proj[n.id]; // strength of H-driven displacement grows with pipeline progress const k = ["input","encode"].includes(stepId) ? 0 : ["topology","attribute","fusion","kmeans"].includes(stepId) ? 0.10 : 0.08; return [ (ax + dx*k) * W, (ay + dy*k) * H ]; }; // diffusion halos — SMIL-driven smooth pulse (independent of React re-render) // Note: cprop step has its own CorrectionTheater, so we skip halo there to avoid visual clutter const showHalo = tweaks.animateIter && (stepId==="topology" || stepId==="attribute"); const haloColor = stepId==="topology" ? "oklch(0.55 0.13 250)" : stepId==="attribute" ? "oklch(0.58 0.13 35)" : "oklch(0.55 0.13 150)"; // k-means' own best color permutation (independent of predAssign's step-based choice) // used for the CorrectionTheater: we want to show how k-means MIS-colored nodes get fixed. const kmColorPerm = useMemo(() => { const K = 4; const perms = []; const h = (a, k) => { if (k === a.length) { perms.push(a.slice()); return; } for (let i = k; i < a.length; i++) { [a[k],a[i]]=[a[i],a[k]]; h(a, k+1); [a[k],a[i]]=[a[i],a[k]]; } }; h([0,1,2,3], 0); let best = perms[0], bv = -1; for (const p of perms) { let c = 0; for (let i = 0; i < dgac.km.assign.length; i++) if (p[dgac.km.assign[i]] === dgac.truth[i]) c++; if (c > bv) { bv = c; best = p; } } return best; }, [dgac.km.assign, dgac.truth]); // Pick one "demo-friendly" wrong node for C-prop theater: // - was mis-assigned by k-means // - has ≥ 2 neighbors // - majority of neighbors' k-means colors point to the TRUTH cluster // (so the animation shows a clear win, not ambiguous voting) const demoWrong = useMemo(() => { if (stepId !== "cprop") return null; const K = 4; const kmAssign = dgac.km.assign; const truth = dgac.truth; const wrongIds = []; for (let i = 0; i < G.nodes.length; i++) { if (kmColorPerm[kmAssign[i]] !== truth[i]) wrongIds.push(i); } if (wrongIds.length === 0) return null; const rated = wrongIds.map(id => { const nbIds = []; for (const [a, b] of G.edges) { if (a === id) nbIds.push(b); else if (b === id) nbIds.push(a); } const votes = new Array(K).fill(0); for (const nb of nbIds) votes[kmColorPerm[kmAssign[nb]]]++; let maj = 0; for (let k = 1; k < K; k++) if (votes[k] > votes[maj]) maj = k; return { id, nbIds, votes, maj, count: nbIds.length }; }).filter(r => r.count >= 2 && r.maj === truth[r.id]); if (rated.length === 0) return null; // prefer ~3 neighbors (visual clarity), tie-break by more correct neighbors rated.sort((a, b) => { const aDist = Math.abs(a.count - 3), bDist = Math.abs(b.count - 3); if (aDist !== bDist) return aDist - bDist; return b.votes[b.maj]/b.count - a.votes[a.maj]/a.count; }); const r = rated[0]; const nbKmClusters = r.nbIds.map(id => kmColorPerm[dgac.km.assign[id]]); const nbTruthClusters = r.nbIds.map(id => dgac.truth[id]); return { node: G.nodes[r.id], neighbors: r.nbIds.map(id => G.nodes[id]), nbColors: r.nbIds.map((_, i) => clusterColors[nbKmClusters[i]]), nbKmClusters, nbTruthClusters, nbIsWrong: r.nbIds.map((id, i) => nbKmClusters[i] !== dgac.truth[id]), protagonistTruthK: dgac.truth[r.id], kmColor: clusterColors[kmColorPerm[dgac.km.assign[r.id]]], flipColor: clusterColors[r.maj], voteMaxK: r.maj, }; }, [stepId, G, dgac.km.assign, dgac.truth, kmColorPerm, clusterColors]); return ( {/* topology edges — CSS transition smooths position changes when step shifts */} {showEdges && G.edges.map(([a,b],i)=>{ const [x1,y1]=posOf(G.nodes[a]), [x2,y2]=posOf(G.nodes[b]); const sameClu = G.nodes[a].cluster===G.nodes[b].cluster; return ; })} {/* attribute edges (dashed) */} {showAttrEdges && dgac.attrEdges.map(([a,b],i)=>{ const [x1,y1]=posOf(G.nodes[a]), [x2,y2]=posOf(G.nodes[b]); return ; })} {/* nodes — wrapped in so CSS transition smooths them when step changes. The C-prop protagonist is skipped here — CorrectionTheater paints it instead. */} {G.nodes.map(n => { if (demoWrong && demoWrong.node.id === n.id) return null; const [cx, cy] = posOf(n); const wrong = isWrongNode(n); return ( {showHalo && ( )} {wrong && ( )} ); })} {/* C-prop correction theater — plays on cprop step */} {demoWrong && ( )} {/* legend / caption */} {colorMode==="truth" && "● 真实簇标签 (仅此处展示)"} {colorMode==="neutral" && "○ 节点随 H 位移 → 观察 H = αÂH+H₀ 收敛"} {colorMode==="pred" && stepId==="kmeans" && "● kmeans 初分配, ⬤ 标记误分配节点"} {colorMode==="pred" && stepId==="cprop" && "● 上格=邻居 km 投票色 · 下点=邻居真实簇(深圈=与中心同簇的支持邻居)· Eq.16"} {colorMode==="pred" && stepId!=="kmeans" && stepId!=="cprop" && "● C-prop 平滑后的聚类结果, ⬤ 误分配"} ); } // === Step scrubber === function Scrubber({ step, steps, idx, setIdx, playing, setPlaying }) { return (
{idx+1} / {steps.length}
{/* dots */}
{steps.map((s,i)=>(
step {String(idx+1).padStart(2,"0")} {step.title} {step.subtitle}

{step.desc}

); } // === Header === function Header(){ return (
WWW 2025 · INTERACTIVE WALK-THROUGH

DGAC · Diffusion-based Graph-agnostic Clustering

github.com/Haili321/dgac-playground
双分支扩散 · 无监督 · 同质/异质图通吃
); } // === Summary strip === function BottomStrip() { const items = [ { k:"12", v:"基准图数据集" }, { k:"4", v:"损失项 (自监督)" }, { k:"0", v:"需要的标签数" }, { k:"≤ O(N·L)", v:"扩散复杂度 (稀疏)" }, ]; return (
{items.map((x,i)=>(
{x.k}
{x.v}
))}
); } // === App === function App() { const [tweaks, setTweaks] = useState(TWEAK_DEFAULTS); const [idx, setIdx] = useState(()=>{ const saved = localStorage.getItem("dgac_step"); return saved ? Math.min(parseInt(saved), window.STEPS.length-1) : 0; }); const [playing, setPlaying] = useState(false); const [tweaksVisible, setTweaksVisible] = useState(false); const [iter, setIter] = useState(0); const steps = window.STEPS; const step = steps[idx]; const activeSet = useMemo(()=>new Set(step.active), [step]); // REAL DGAC pipeline result — recomputes on every tweak change. const dgac = useMemo(()=>{ const G = window.DEMO_GRAPHS[tweaks.dataset]; return window.DGAC_MATH.runDGAC(G, tweaks); }, [tweaks.dataset, tweaks.alpha, tweaks.beta, tweaks.topLayers, tweaks.attrLayers, tweaks.cpropLayers]); useEffect(()=>{ localStorage.setItem("dgac_step", idx); }, [idx]); // autoplay removed — steps only advance via user action. // iteration ticker for halo animation useEffect(()=>{ const t = setInterval(()=>setIter(i=>i+1), 600); return ()=>clearInterval(t); }, []); // edit-mode wiring useEffect(()=>{ const handler = (e)=>{ if (e.data?.type === "__activate_edit_mode") setTweaksVisible(true); if (e.data?.type === "__deactivate_edit_mode") setTweaksVisible(false); }; window.addEventListener("message", handler); window.parent.postMessage({type:"__edit_mode_available"}, "*"); return ()=>window.removeEventListener("message", handler); }, []); const setTweak = (k, v)=>{ setTweaks(prev=>{ const next = {...prev, [k]: v}; window.parent.postMessage({type:"__edit_mode_set_keys", edits:{[k]:v}}, "*"); return next; }); }; return (
{/* Full-width architecture diagram */}
架构流程图 · ARCHITECTURE
{ const i = steps.findIndex(s=>s.id===id); if (i>=0) setIdx(i); }}/>
{/* Inline Tweaks bar — lives right under the architecture, above the grid */} {/* Main grid */}
{/* Left column: graph view + loss */}
示例图 · 20 节点 / 4 簇 / 异质
iter {iter%(tweaks.topLayers+1)} / {tweaks.topLayers}
kmeans 准确率 {(dgac.accKm*100).toFixed(1)}% C-prop 后 {(dgac.accFinal*100).toFixed(1)}% Δ = +{((dgac.accFinal-dgac.accKm)*100).toFixed(1)} pp
{/* Right column: formulas */}
公式 · FORMULAS
{/* Scrubber full width */}
DGAC DGAC Playground · interactive walkthrough
); } ReactDOM.createRoot(document.getElementById("root")).render();