← Mike Higgins Home
Custom Code Experiment •

Knowledge Graph

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Knowledge Graph</title>
<style>
  /* ============================================================
     SOPHISTICATED DARK MODE (Refined Retro Aesthetic)
     ============================================================ */
  :root {
    --bg: #0f1115;
    --panel-bg: #16191f;
    --panel-header: #1e2229;
    --text-main: #e2e8f0;
    --text-muted: #94a3b8;
    --border-light: #334155;
    --border-dark: #0b0d10;
    --accent: #38bdf8;
    --accent-hover: #0ea5e9;
    --accent-glow: rgba(56, 189, 248, 0.2);
  }

  * { box-sizing: border-box; }

  html, body {
    margin: 0; padding: 0; height: 100%;
    background: var(--bg); color: var(--text-main);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    font-size: 14px; overflow: hidden;
  }

  #app { display: flex; flex-direction: column; height: 100vh; }

  /* Header */
  header {
    display: flex; align-items: center; gap: 16px;
    padding: 10px 20px; flex: 0 0 auto;
    background: linear-gradient(to bottom, #1e2229, #111418);
    border-bottom: 1px solid var(--border-dark);
    box-shadow: 0 4px 12px rgba(0,0,0,0.4);
    z-index: 10;
  }
  header .menu-btn {
    background: linear-gradient(to bottom, #334155, #1e293b);
    border: 1px solid var(--border-light);
    border-radius: 4px;
    padding: 6px 8px; cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    color: var(--text-main);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
  }
  header .menu-btn:hover { background: linear-gradient(to bottom, #475569, #334155); }
  header .menu-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,0.5); }

  header .title-area { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 15px; color: #f8fafc; letter-spacing: 0.3px; }
  header .title-area .icon { color: var(--accent); }

  header .spacer { flex: 1; }

  header .actions { display: flex; align-items: center; gap: 12px; }
  header .profile-icon {
    width: 30px; height: 30px;
    background: linear-gradient(to bottom, var(--accent), var(--accent-hover));
    color: #0f1115; border: 1px solid #f8fafc;
    border-radius: 50%; display: flex; align-items: center; justify-content: center;
    font-weight: bold; font-size: 13px; cursor: pointer;
    box-shadow: 0 2px 6px rgba(0,0,0,0.3);
  }
  header .counter {
    background: #1e293b; color: var(--accent);
    border: 1px solid var(--border-light); padding: 4px 10px;
    border-radius: 12px; font-size: 11px; font-weight: 600;
    letter-spacing: 0.5px;
  }

  /* Main Layout */
  main { flex: 1; display: flex; position: relative; min-height: 0; }

  /* Left Sidebar */
  #left-panel {
    width: 340px; flex: 0 0 auto; display: flex; flex-direction: column;
    background: var(--panel-bg);
    border-right: 1px solid var(--border-dark);
    box-shadow: 4px 0 15px rgba(0,0,0,0.5);
    position: relative; transition: margin-left 0.3s ease;
    z-index: 5;
  }
  #left-panel.hidden { margin-left: -340px; }

  .panel-toolbar {
    display: flex; gap: 6px; padding: 10px;
    background: var(--panel-header);
    border-bottom: 1px solid var(--border-dark);
  }
  .toolbar-btn {
    display: flex; align-items: center; gap: 6px;
    background: linear-gradient(to bottom, #2a303c, #1e2229);
    border: 1px solid var(--border-light);
    padding: 6px 10px; border-radius: 4px; font-size: 12px;
    color: var(--text-main); cursor: pointer; font-weight: 500;
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
  }
  .toolbar-btn:hover { background: linear-gradient(to bottom, #334155, #2a303c); color: #fff; }
  .toolbar-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,0.4); }
  .toolbar-btn svg { width: 14px; height: 14px; color: var(--accent); }

  .input-area {
    flex: 1; display: flex; flex-direction: column; padding: 14px;
    overflow-y: auto;
  }
  .input-header {
    display: flex; justify-content: space-between; align-items: center;
    margin-bottom: 10px; font-size: 11px; color: var(--text-muted);
    text-transform: uppercase; letter-spacing: 1px; font-weight: 600;
  }
  .input-header .close-btn {
    background: #2a303c; border: 1px solid var(--border-light);
    width: 22px; height: 22px; border-radius: 4px; cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    color: var(--text-muted); font-size: 14px; font-weight: bold;
  }
  .input-header .close-btn:hover { background: #ef4444; color: #fff; border-color: #ef4444; }

  textarea {
    width: 100%; flex: 1; min-height: 150px; resize: none;
    background: #0f1115; color: #e2e8f0;
    border: 1px solid var(--border-light);
    box-shadow: inset 0 2px 6px rgba(0,0,0,0.6);
    border-radius: 6px;
    padding: 12px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
    font-size: 13px; line-height: 1.6; outline: none;
    transition: border-color 0.2s, box-shadow 0.2s;
  }
  textarea:focus {
    border-color: var(--accent);
    box-shadow: inset 0 2px 6px rgba(0,0,0,0.6), 0 0 0 3px var(--accent-glow);
  }

  .summarize-btn {
    margin: 14px; padding: 12px;
    background: linear-gradient(to bottom, var(--accent), var(--accent-hover));
    border: 1px solid #7dd3fc; border-radius: 6px;
    font-weight: 600; color: #0f1115; cursor: pointer;
    display: flex; align-items: center; justify-content: center; gap: 8px;
    box-shadow: 0 4px 10px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.4);
    font-family: inherit; font-size: 14px;
    transition: all 0.2s;
  }
  .summarize-btn:hover { background: linear-gradient(to bottom, #7dd3fc, #38bdf8); box-shadow: 0 6px 15px rgba(0,0,0,0.4); }
  .summarize-btn:active { box-shadow: inset 0 2px 6px rgba(0,0,0,0.4); transform: translateY(1px); }

  /* Graph Area */
  #graph-container {
    flex: 1; position: relative; background: radial-gradient(circle at center, #161a22 0%, #0b0d10 100%);
    overflow: hidden;
  }
  #graph-container svg { display: block; width: 100%; height: 100%; }

  /* Floating elements */
  .zoom-controls {
    position: absolute; right: 20px; top: 20px;
    display: flex; flex-direction: column; gap: 4px;
    background: linear-gradient(to bottom, #2a303c, #1e2229);
    border: 1px solid var(--border-light);
    border-radius: 6px; padding: 4px;
    box-shadow: 0 4px 15px rgba(0,0,0,0.6);
    z-index: 10;
  }
  .zoom-controls button {
    background: transparent; border: none; width: 32px; height: 32px;
    font-size: 18px; font-weight: 400; cursor: pointer; color: var(--text-muted);
    border-radius: 4px; display: flex; align-items: center; justify-content: center;
  }
  .zoom-controls button:hover { background: #334155; color: #fff; }
  .zoom-controls button:active { background: #1e293b; }

  /* Custom Tooltip */
  .tooltip {
    position: absolute; pointer-events: none; opacity: 0;
    background: rgba(15, 17, 21, 0.95);
    border: 1px solid var(--accent);
    border-radius: 6px; padding: 10px 14px; font-size: 12px;
    box-shadow: 0 8px 20px rgba(0,0,0,0.6);
    transform: translate(-50%, -100%); transition: opacity 0.15s;
    z-index: 100; max-width: 220px;
    color: #cbd5e1;
    backdrop-filter: blur(4px);
  }
  .tt-title { font-weight: 600; font-size: 14px; margin-bottom: 8px; color: #f8fafc; border-bottom: 1px solid #334155; padding-bottom: 4px; }
  .tt-row { display: flex; justify-content: space-between; gap: 16px; color: var(--text-muted); margin-bottom: 4px; }
  .tt-row b { color: var(--accent); font-weight: 600; }

  /* Scrollbar */
  ::-webkit-scrollbar { width: 8px; height: 8px; }
  ::-webkit-scrollbar-track { background: #0f1115; border-left: 1px solid var(--border-dark); }
  ::-webkit-scrollbar-thumb { background: #334155; border: 1px solid #1e2229; border-radius: 4px; }
  ::-webkit-scrollbar-thumb:hover { background: #475569; }

  /* ============================================================
     RESPONSIVE / MOBILE FRIENDLY
     ============================================================ */
  @media (max-width: 900px) {
    #left-panel {
      position: absolute; top: 0; bottom: 0; left: 0;
      width: 300px; z-index: 20;
    }
    #left-panel.hidden { margin-left: -300px; }
  }

  @media (max-width: 600px) {
    header { padding: 8px 12px; }
    header .title-area span { font-size: 13px; }
    header .actions .counter { display: none; }

    #left-panel { width: 100%; max-width: 100%; }
    #left-panel.hidden { margin-left: -100%; }

    .zoom-controls { top: 12px; right: 12px; }
  }
</style>
</head>
<body>
<div id="app">
  <!-- Header -->
  <header>
    <button class="menu-btn" id="toggle-left">
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
    </button>
    <div class="title-area">
      <span id="doc-title">knowledge_graph</span>
      <svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
    </div>
    <div class="spacer"></div>
    <div class="actions">
      <div style="font-weight: 600; font-size: 14px; margin: 0 10px; color: #f8fafc; letter-spacing: 0.5px;">GraphView</div>
      <div class="profile-icon">U</div>
      <div class="counter">0/5</div>
    </div>
  </header>

  <main>
    <!-- Left Sidebar -->
    <aside id="left-panel">
      <div class="panel-toolbar">
        <button class="toolbar-btn" id="hide-content-btn">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
          Hide Panel
        </button>
      </div>

      <div class="input-area">
        <div class="input-header">
          <span>Source text</span>
          <button class="close-btn" id="clear-btn">×</button>
        </div>
        <textarea id="text-input" placeholder="Paste or type any text here to generate a knowledge graph..."></textarea>
      </div>

      <button class="summarize-btn" id="generate-btn">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
        Generate Graph
      </button>
    </aside>

    <!-- Graph Area -->
    <section id="graph-container">
      <div class="zoom-controls">
        <button id="zoom-in" title="Zoom in">+</button>
        <button id="zoom-out" title="Zoom out">−</button>
        <button id="zoom-reset" title="Reset view">⌂</button>
      </div>
    </section>
  </main>
</div>

<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script>
/* ============================================================
   CORE LOGIC & GRAPH RENDERING
   ============================================================ */

const STOPWORDS = new Set(`
a about above after again against all am an and any are aren't as at be because been before being below
between both but by can't cannot could couldn't did didn't do does doesn't doing don't down during each
few for from further had hadn't has hasn't have haven't having he he'd he'll he's her here here's hers
herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself let's me
more most mustn't my myself no nor not of off on once only or other ought our ours ourselves out over own
same shan't she she'd she'll she's should shouldn't so some such than that that's the their theirs them
themselves then there there's these they they'd they'll they're they've this those through to too under
until up very was wasn't we we'd we'll we're we've were weren't what what's when when's where where's
which while who who's whom why why's with won't would wouldn't you you'd you'll you're you've your yours
yourself yourselves also may might must shall will would just now even still ever never always often
sometimes upon within without amongst among across although though yet
`.trim().split(/\s+/).filter(Boolean));

// Sophisticated, muted jewel tones for nodes
const PALETTE = [
  '#3b82f6', // Blue
  '#8b5cf6', // Violet
  '#ec4899', // Pink
  '#f43f5e', // Rose
  '#f59e0b', // Amber
  '#10b981', // Emerald
  '#06b6d4', // Cyan
  '#6366f1', // Indigo
  '#d946ef', // Fuchsia
  '#84cc16'  // Lime
];

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, c =>
    ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function linkKey(a, b) { return a < b ? a + '|' + b : b + '|' + a; }

function stem(w) {
  if (w.length <= 3) return w;
  if (w.endsWith('ies') && w.length > 4) return w.slice(0, -3) + 'y';
  if (w.endsWith('sses')) return w.slice(0, -2);
  if (w.endsWith('ses') && w.length > 4) return w.slice(0, -1);
  if (w.endsWith('s') && !w.endsWith('ss') && !w.endsWith('us') && !w.endsWith('is') && w.length > 3)
    return w.slice(0, -1);
  return w;
}

function analyze(text, opts) {
  const { windowSize, minFreq, minEdgeWeight, maxNodes } = opts;
  const rawSentences = text.split(/[.!?;:\n\r]+/);
  const sentences = [];
  for (const raw of rawSentences) {
    const tokens = raw.toLowerCase().replace(/[’']s\b/g, '').replace(/[^a-z\s\-]/g, ' ')
      .split(/\s+/).filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
    if (tokens.length) sentences.push(tokens);
  }
  const stemFreq = new Map();
  const displayForms = new Map();
  const tokenized = [];
  for (const toks of sentences) {
    const stems = [];
    for (const w of toks) {
      const s = stem(w);
      if (s.length < 3) continue;
      stems.push(s);
      stemFreq.set(s, (stemFreq.get(s) || 0) + 1);
      if (!displayForms.has(s)) displayForms.set(s, new Map());
      displayForms.get(s).set(w, (displayForms.get(s).get(w) || 0) + 1);
    }
    if (stems.length >= 2) tokenized.push(stems);
  }
  if (tokenized.length === 0 || stemFreq.size < 2) return { error: 'Not enough text.' };

  const displayName = new Map();
  for (const [s, df] of displayForms) {
    let best = s, bestC = -1;
    for (const [w, c] of df) {
      if (c > bestC || (c === bestC && w.length < best.length)) { best = w; bestC = c; }
    }
    displayName.set(s, best);
  }

  const cooc = new Map();
  for (const toks of tokenized) {
    const L = toks.length;
    for (let i = 0; i < L; i++) {
      const a = toks[i];
      const end = Math.min(L, i + 1 + windowSize);
      for (let j = i + 1; j < end; j++) {
        const b = toks[j];
        if (a === b) continue;
        const key = linkKey(a, b);
        cooc.set(key, (cooc.get(key) || 0) + 1);
      }
    }
  }

  let candidates = [...stemFreq.entries()].filter(([, f]) => f >= minFreq);
  if (candidates.length < 5) candidates = [...stemFreq.entries()];
  candidates.sort((a, b) => b[1] - a[1]);
  const selected = candidates.slice(0, maxNodes);
  const selectedSet = new Set(selected.map(x => x[0]));

  let edges = [];
  for (const [key, w] of cooc) {
    const [a, b] = key.split('|');
    if (!selectedSet.has(a) || !selectedSet.has(b)) continue;
    if (w < minEdgeWeight) continue;
    edges.push({ source: a, target: b, weight: w });
  }
  if (edges.length === 0 && minEdgeWeight > 1) {
    for (const [key, w] of cooc) {
      const [a, b] = key.split('|');
      if (!selectedSet.has(a) || !selectedSet.has(b)) continue;
      edges.push({ source: a, target: b, weight: w });
    }
  }
  if (edges.length > 1500) {
    edges.sort((a, b) => b.weight - a.weight);
    edges = edges.slice(0, 1500);
  }

  const nodes = selected.map(([s, f]) => ({
    id: s, label: displayName.get(s) || s, freq: f,
    degree: 0, betweenness: 0, community: 0,
  }));
  const nodeIndex = new Map(nodes.map((n, i) => [n.id, i]));
  return { nodes, edges, nodeIndex, error: null };
}

function louvain(n, edges) {
  if (n === 0) return [];
  let adj = Array.from({ length: n }, () => new Map());
  let self = new Array(n).fill(0);
  let totalWeight2 = 0;
  for (const { u, v, w } of edges) {
    if (u === v) { self[u] += w; totalWeight2 += 2 * w; continue; }
    adj[u].set(v, (adj[u].get(v) || 0) + w);
    adj[v].set(u, (adj[v].get(u) || 0) + w);
    totalWeight2 += 2 * w;
  }
  if (totalWeight2 === 0) return Array.from({ length: n }, (_, i) => i);
  let curN = n, curSelf = self, mapping = Array.from({ length: n }, (_, i) => i);
  for (let level = 0; level < 20; level++) {
    const k = new Array(curN).fill(0);
    for (let i = 0; i < curN; i++) {
      let s = 2 * curSelf[i];
      for (const w of adj[i].values()) s += w;
      k[i] = s;
    }
    const comm = Array.from({ length: curN }, (_, i) => i);
    const tot = k.slice();
    let improved = true, iters = 0;
    while (improved && iters < 100) {
      improved = false; iters++;
      for (let i = 0; i < curN; i++) {
        const ci = comm[i], ki = k[i];
        const nc = new Map();
        for (const [j, w] of adj[i]) {
          const cj = comm[j];
          nc.set(cj, (nc.get(cj) || 0) + w);
        }
        tot[ci] -= ki;
        let bestC = ci, bestGain = (nc.get(ci) || 0) - tot[ci] * ki / totalWeight2;
        for (const [cj, w] of nc) {
          const gain = w - tot[cj] * ki / totalWeight2;
          if (gain > bestGain + 1e-10) { bestGain = gain; bestC = cj; }
        }
        comm[i] = bestC;
        tot[bestC] += ki;
        if (bestC !== ci) improved = true;
      }
    }
    for (let i = 0; i < n; i++) mapping[i] = comm[mapping[i]];
    const uniq = [...new Set(comm)];
    const renum = new Map(uniq.map((c, i) => [c, i]));
    const newN = uniq.length;
    for (let i = 0; i < n; i++) mapping[i] = renum.get(mapping[i]);
    if (newN === curN) break;
    const newAdj = Array.from({ length: newN }, () => new Map());
    const newSelf = new Array(newN).fill(0);
    for (let i = 0; i < curN; i++) {
      const ci = renum.get(comm[i]);
      newSelf[ci] += curSelf[i];
      for (const [j, w] of adj[i]) {
        if (i >= j) continue;
        const cj = renum.get(comm[j]);
        if (ci === cj) newSelf[ci] += w;
        else {
          newAdj[ci].set(cj, (newAdj[ci].get(cj) || 0) + w);
          newAdj[cj].set(ci, (newAdj[cj].get(ci) || 0) + w);
        }
      }
    }
    adj = newAdj; curSelf = newSelf; curN = newN;
  }
  const uniq = [...new Set(mapping)];
  const renum = new Map(uniq.map((c, i) => [c, i]));
  return mapping.map(x => renum.get(x));
}

function betweenness(n, edges) {
  const adj = Array.from({ length: n }, () => []);
  for (const { u, v } of edges) {
    if (u === v) continue;
    adj[u].push(v); adj[v].push(u);
  }
  const CB = new Array(n).fill(0);
  for (let s = 0; s < n; s++) {
    const S = [], P = Array.from({ length: n }, () => []);
    const sigma = new Array(n).fill(0); sigma[s] = 1;
    const d = new Array(n).fill(-1); d[s] = 0;
    const Q = [s]; let qi = 0;
    while (qi < Q.length) {
      const v = Q[qi++]; S.push(v);
      for (const w of adj[v]) {
        if (d[w] < 0) { d[w] = d[v] + 1; Q.push(w); }
        if (d[w] === d[v] + 1) { sigma[w] += sigma[v]; P[w].push(v); }
      }
    }
    const delta = new Array(n).fill(0);
    for (let i = S.length - 1; i >= 0; i--) {
      const w = S[i];
      for (const v of P[w]) delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]);
      if (w !== s) CB[w] += delta[w];
    }
  }
  for (let i = 0; i < n; i++) CB[i] /= 2;
  return CB;
}

let simulation = null, svgRoot = null, zoomBehavior = null;
let currentData = null;

function runAnalysis() {
  const text = document.getElementById('text-input').value;
  if (!text.trim()) return;

  const firstWords = text.trim().split(/\s+/).slice(0, 4).join('_').toLowerCase().replace(/[^a-z0-9_]/g, '');
  document.getElementById('doc-title').textContent = firstWords || 'knowledge_graph';

  const result = analyze(text, { windowSize: 4, minFreq: 2, minEdgeWeight: 1, maxNodes: 60 });
  if (result.error) return;

  const { nodes, edges, nodeIndex } = result;
  const edgeIdx = edges.map(e => ({ u: nodeIndex.get(e.source), v: nodeIndex.get(e.target), w: e.weight }));
  const comm = louvain(nodes.length, edgeIdx);

  const commSizes = new Map();
  for (const c of comm) commSizes.set(c, (commSizes.get(c) || 0) + 1);
  const sortedComms = [...commSizes.entries()].sort((a, b) => b[1] - a[1]).map(x => x[0]);
  const commRenum = new Map(sortedComms.map((c, i) => [c, i]));
  nodes.forEach((n, i) => { n.community = commRenum.get(comm[i]); });

  for (const n of nodes) n.degree = 0;
  for (const e of edges) {
    nodes[nodeIndex.get(e.source)].degree += e.weight;
    nodes[nodeIndex.get(e.target)].degree += e.weight;
  }

  const bw = betweenness(nodes.length, edgeIdx);
  nodes.forEach((n, i) => { n.betweenness = bw[i]; });

  currentData = { nodes, edges, nodeIndex };
  renderGraph(currentData);
}

function renderGraph(data) {
  const container = document.getElementById('graph-container');
  d3.select(container).selectAll('svg').remove();
  d3.select(container).selectAll('.tooltip').remove();

  const width = container.clientWidth || 800;
  const height = container.clientHeight || 600;

  const svg = d3.select(container).append('svg')
    .attr('width', width).attr('height', height);
  svgRoot = svg;

  const zoomLayer = svg.append('g');
  const g = zoomLayer.append('g');

  zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on('zoom', e => zoomLayer.attr('transform', e.transform));
  svg.call(zoomBehavior);

  const color = d3.scaleOrdinal(PALETTE);

  const maxDeg = d3.max(data.nodes, d => d.degree) || 1;
  const minDeg = d3.min(data.nodes, d => d.degree) || 0;
  const sizeScale = d3.scaleSqrt().domain([minDeg, maxDeg]).range([12, 32]);

  const nodes = data.nodes.map(n => ({ ...n }));
  const links = data.edges.map(e => ({ ...e }));

  const commOf = new Map(nodes.map(n => [n.id, n.community]));
  links.forEach(l => {
    l.sourceComm = commOf.get(l.source);
    l.targetComm = commOf.get(l.target);
  });

  // Sophisticated curved paths
  const linkSel = g.append('g').attr('class', 'links')
    .selectAll('path').data(links).join('path')
    .attr('fill', 'none')
    .attr('stroke', l => l.sourceComm === l.targetComm ? color(l.sourceComm) : '#475569')
    .attr('stroke-opacity', l => l.sourceComm === l.targetComm ? 0.4 : 0.2)
    .attr('stroke-width', l => 1 + Math.sqrt(l.weight) * 0.8)
    .attr('stroke-dasharray', l => l.sourceComm === l.targetComm ? null : '4 4');

  const nodeSel = g.append('g').attr('class', 'nodes')
    .selectAll('g').data(nodes).join('g')
    .attr('class', 'node')
    .style('cursor', 'pointer')
    // Subtle shadow instead of harsh neon glow
    .style('filter', 'drop-shadow(0px 3px 6px rgba(0,0,0,0.4))')
    .call(d3.drag()
      .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y; })
      .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
      .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }));

  // Glossy square nodes
  nodeSel.append('rect')
    .attr('class', 'main')
    .attr('width', d => sizeScale(d.degree))
    .attr('height', d => sizeScale(d.degree))
    .attr('x', d => -sizeScale(d.degree) / 2)
    .attr('y', d => -sizeScale(d.degree) / 2)
    .attr('rx', 4).attr('ry', 4)
    .attr('fill', d => color(d.community))
    .attr('fill-opacity', 0.85)
    .attr('stroke', d => d3.color(color(d.community)).brighter(0.5).toString())
    .attr('stroke-width', 1.5);

  // Highlight top surface for subtle gloss effect
  nodeSel.append('rect')
    .attr('width', d => sizeScale(d.degree) * 0.85)
    .attr('height', d => sizeScale(d.degree) * 0.4)
    .attr('x', d => -sizeScale(d.degree) * 0.425)
    .attr('y', d => -sizeScale(d.degree) * 0.425)
    .attr('rx', 3).attr('ry', 3)
    .attr('fill', 'rgba(255,255,255,0.15)')
    .attr('pointer-events', 'none');

  nodeSel.append('text')
    .attr('class', 'label')
    .text(d => d.label)
    .attr('x', d => sizeScale(d.degree) / 2 + 6)
    .attr('dy', 5)
    .attr('font-size', d => Math.max(11, Math.min(15, sizeScale(d.degree) * 0.8)))
    .attr('fill', d => d3.color(color(d.community)).brighter(1.2).toString())
    .attr('pointer-events', 'none')
    .style('font-weight', '600')
    .style('font-family', 'Verdana, sans-serif')
    .style('text-shadow', '0 2px 4px rgba(0,0,0,0.8)'); // Strong text shadow for readability

  const tooltip = d3.select(container).append('div').attr('class', 'tooltip');

  nodeSel
    .on('mouseenter', function(e, d) {
      d3.select(this).select('.main').attr('stroke', '#f8fafc').attr('stroke-width', 2);
      const cSize = nodes.filter(n => n.community === d.community).length;
      tooltip.html(`
        <div class="tt-title">${escapeHtml(d.label)}</div>
        <div class="tt-row"><span>Frequency</span><b>${d.freq}</b></div>
        <div class="tt-row"><span>Degree</span><b>${d.degree.toFixed(1)}</b></div>
        <div class="tt-row"><span>Betweenness</span><b>${d.betweenness.toFixed(2)}</b></div>
        <div class="tt-row"><span>Cluster Size</span><b>${cSize} terms</b></div>
      `).style('opacity', 1);
    })
    .on('mousemove', function(e) {
      const rect = container.getBoundingClientRect();
      const x = e.clientX - rect.left, y = e.clientY - rect.top;
      tooltip.style('left', x + 'px').style('top', y - 12 + 'px');
    })
    .on('mouseleave', function(e, d) {
      d3.select(this).select('.main')
        .attr('stroke', d3.color(color(d.community)).brighter(0.5).toString())
        .attr('stroke-width', 1.5);
      tooltip.style('opacity', 0);
    });

  simulation = d3.forceSimulation(nodes)
    .force('link', d3.forceLink(links).id(d => d.id)
      .distance(d => 50 + 80 / (d.weight + 1))
      .strength(d => Math.min(0.8, 0.1 + d.weight * 0.05)))
    .force('charge', d3.forceManyBody().strength(d => -100 - sizeScale(d.degree) * 5))
    .force('center', d3.forceCenter(width / 2, height / 2))
    .force('collide', d3.forceCollide(d => sizeScale(d.degree) + 15).iterations(2))
    .on('tick', () => {
      linkSel.attr('d', d => {
        const dx = d.target.x - d.source.x;
        const dy = d.target.y - d.source.y;
        const dr = Math.sqrt(dx * dx + dy * dy) * 1.8; // More pronounced curve
        return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
      });
      nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
    });
}

/* ============================================================
   MAIN APP LOGIC
   ============================================================ */
document.getElementById('generate-btn').addEventListener('click', runAnalysis);
document.getElementById('clear-btn').addEventListener('click', () => {
  document.getElementById('text-input').value = '';
  document.getElementById('doc-title').textContent = 'untitled';
});

document.getElementById('toggle-left').addEventListener('click', () => {
  document.getElementById('left-panel').classList.toggle('hidden');
});

document.getElementById('hide-content-btn').addEventListener('click', () => {
  document.getElementById('left-panel').classList.add('hidden');
});

document.getElementById('zoom-in').addEventListener('click', () => {
  if (svgRoot && zoomBehavior) svgRoot.transition().duration(250).call(zoomBehavior.scaleBy, 1.3);
});
document.getElementById('zoom-out').addEventListener('click', () => {
  if (svgRoot && zoomBehavior) svgRoot.transition().duration(250).call(zoomBehavior.scaleBy, 1 / 1.3);
});
document.getElementById('zoom-reset').addEventListener('click', () => {
  if (svgRoot && zoomBehavior) svgRoot.transition().duration(400).call(zoomBehavior.transform, d3.zoomIdentity);
});

window.addEventListener('resize', () => {
  if (!simulation || !svgRoot) return;
  const container = document.getElementById('graph-container');
  const w = container.clientWidth, h = container.clientHeight;
  svgRoot.attr('width', w).attr('height', h);
  simulation.force('center', d3.forceCenter(w / 2, h / 2));
  simulation.alpha(0.1).restart();
});

window.addEventListener('load', () => {
  // Initialize with the requested British English example text
  document.getElementById('text-input').value = "Graph visualisation is a powerful tool for understanding complex data relationships. By mapping concepts to nodes and connections to edges, we reveal the hidden structure within text.";
  runAnalysis();
});
</script>
</body>
</html>