diff --git a/.gitignore b/.gitignore index 5b96c1f..d30afca 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ workspace/tmp/ # Node test harness deps (Acceptance A) tests/js/node_modules/ tests/js/package-lock.json +tests/js/*.log diff --git a/tests/js/graph_adapter.test.mjs b/tests/js/graph_adapter.test.mjs new file mode 100644 index 0000000..86292b0 --- /dev/null +++ b/tests/js/graph_adapter.test.mjs @@ -0,0 +1,160 @@ +/* Unit + perf tests for the graph data adapter (web/static/js/graph/adapter.js). + * + * The adapter is the ONLY place that turns raw API payloads into the unified + * {nodes, links} model the renderer consumes. It is pure (no D3, no DOM), so we + * exercise it directly in Node. The N=5000 perf cases are the O(n^2) tripwire + * required by issue #19 acceptance C: a quadratic merge/dedup bug would blow the + * budget here long before it ever reached a browser. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const adapterSrc = readFileSync( + join(__dirname, '..', '..', 'web', 'static', 'js', 'graph', 'adapter.js'), + 'utf8', +); + +// adapter.js is a UMD-ish module: assigns to module.exports when present. +const mod = { exports: {} }; +new Function('module', 'exports', 'window', adapterSrc)(mod, mod.exports, undefined); +const Adapter = mod.exports; + +let passed = 0; +function test(name, fn) { + fn(); + passed += 1; + console.log(` ok ${name}`); +} + +console.log('graph_adapter.test.mjs'); + +// ── taxonomy model ─────────────────────────────────────────────────── +test('taxonomyToModel builds parent + child nodes and links', () => { + const parent = { id: 'ml', name: 'Machine Learning', description: 'root' }; + const children = [ + { id: 'ml.dl', name: 'Deep Learning', paper_count: 10, gap_count: 2, method_count: 4 }, + { id: 'ml.rl', name: 'Reinforcement Learning', paper_count: 5, gap_count: 0, method_count: 1 }, + ]; + const m = Adapter.taxonomyToModel(parent, children); + assert.equal(m.kind, 'taxonomy'); + assert.equal(m.nodes.length, 3); + assert.equal(m.links.length, 2); + const root = m.nodes.find((n) => n.id === 'ml'); + assert.equal(root.role, 'parent'); + const dl = m.nodes.find((n) => n.id === 'ml.dl'); + assert.equal(dl.role, 'child'); + assert.equal(dl.paper_count, 10); + assert.equal(dl.gap_count, 2); + assert.equal(dl.method_count, 4); + assert.deepEqual( + m.links.map((l) => [l.source, l.target]).sort(), + [['ml', 'ml.dl'], ['ml', 'ml.rl']], + ); +}); + +test('taxonomyToModel tolerates empty / null children', () => { + const parent = { id: 'leaf', name: 'Leaf' }; + for (const children of [null, undefined, []]) { + const m = Adapter.taxonomyToModel(parent, children); + assert.equal(m.nodes.length, 1); + assert.equal(m.links.length, 0); + assert.equal(m.nodes[0].role, 'parent'); + } +}); + +// ── entity-relation model ──────────────────────────────────────────── +test('entityGraphToModel merges entity attrs onto relation endpoints', () => { + const gs = { + top_entities: [ + { name: 'BERT', entity_type: 'method', paper_count: 8, mention_count: 40 }, + { name: 'GLUE', entity_type: 'dataset', paper_count: 6, mention_count: 22 }, + ], + top_relations: [ + { subject: 'BERT', predicate: 'evaluated_on', object: 'GLUE', paper_count: 5, relation_count: 9 }, + // object 'SQuAD' is NOT in top_entities -> must still become a node + { subject: 'BERT', predicate: 'evaluated_on', object: 'SQuAD', paper_count: 3, relation_count: 4 }, + ], + }; + const m = Adapter.entityGraphToModel(gs); + assert.equal(m.kind, 'entity'); + const names = m.nodes.map((n) => n.name).sort(); + assert.deepEqual(names, ['BERT', 'GLUE', 'SQuAD']); + const bert = m.nodes.find((n) => n.name === 'BERT'); + assert.equal(bert.entity_type, 'method'); + assert.equal(bert.paper_count, 8); + assert.equal(bert.degree, 2); // two relations touch BERT + const squad = m.nodes.find((n) => n.name === 'SQuAD'); + assert.ok(squad, 'relation-only endpoint becomes a node'); + assert.equal(squad.paper_count, 0); + assert.equal(m.links.length, 2); +}); + +test('entityGraphToModel dedupes identical relations', () => { + const gs = { + top_entities: [], + top_relations: [ + { subject: 'A', predicate: 'p', object: 'B', paper_count: 1, relation_count: 1 }, + { subject: 'A', predicate: 'p', object: 'B', paper_count: 1, relation_count: 1 }, + ], + }; + const m = Adapter.entityGraphToModel(gs); + assert.equal(m.links.length, 1); + assert.equal(m.nodes.length, 2); +}); + +test('entityGraphToModel tolerates empty / missing input', () => { + for (const gs of [null, undefined, {}, { top_entities: [], top_relations: [] }]) { + const m = Adapter.entityGraphToModel(gs); + assert.equal(m.kind, 'entity'); + assert.equal(m.nodes.length, 0); + assert.equal(m.links.length, 0); + } +}); + +test('entityGraphToModel caps nodes and reports truncation, dropping dangling links', () => { + const top_relations = []; + for (let i = 0; i < 200; i += 1) { + top_relations.push({ subject: `s${i}`, predicate: 'rel', object: `o${i}`, paper_count: 1, relation_count: 1 }); + } + const m = Adapter.entityGraphToModel({ top_entities: [], top_relations }, { maxNodes: 40 }); + assert.equal(m.nodes.length, 40); + assert.ok(m.truncated > 0, 'reports dropped node count'); + // every link must reference surviving nodes only + const live = new Set(m.nodes.map((n) => n.id)); + for (const l of m.links) { + assert.ok(live.has(l.source) && live.has(l.target), 'no dangling links after cap'); + } +}); + +// ── perf tripwire (O(n^2) catcher) ─────────────────────────────────── +test('taxonomyToModel is sub-quadratic at N=5000 (<200ms)', () => { + const children = []; + for (let i = 0; i < 5000; i += 1) { + children.push({ id: `ml.n${i}`, name: `Node ${i}`, paper_count: i % 50, gap_count: i % 7, method_count: i % 5 }); + } + const t0 = performance.now(); + const m = Adapter.taxonomyToModel({ id: 'ml', name: 'root' }, children); + const dt = performance.now() - t0; + assert.equal(m.nodes.length, 5001); + console.log(` taxonomyToModel N=5000: ${dt.toFixed(1)}ms`); + assert.ok(dt < 200, `taxonomyToModel too slow: ${dt.toFixed(1)}ms`); +}); + +test('entityGraphToModel is sub-quadratic at N=5000 (<200ms)', () => { + const top_entities = []; + const top_relations = []; + for (let i = 0; i < 5000; i += 1) { + top_entities.push({ name: `e${i}`, entity_type: 'method', paper_count: i % 30, mention_count: i }); + top_relations.push({ subject: `e${i}`, predicate: 'rel', object: `e${(i + 1) % 5000}`, paper_count: i % 9, relation_count: i % 4 }); + } + const t0 = performance.now(); + const m = Adapter.entityGraphToModel({ top_entities, top_relations }, { maxNodes: 100000 }); + const dt = performance.now() - t0; + console.log(` entityGraphToModel N=5000: ${dt.toFixed(1)}ms (nodes=${m.nodes.length}, links=${m.links.length})`); + assert.ok(dt < 200, `entityGraphToModel too slow: ${dt.toFixed(1)}ms`); +}); + +console.log(`\n${passed} adapter tests passed.`); diff --git a/tests/js/graph_e2e.mjs b/tests/js/graph_e2e.mjs new file mode 100644 index 0000000..74034d5 --- /dev/null +++ b/tests/js/graph_e2e.mjs @@ -0,0 +1,251 @@ +/* Responsiveness gate — Playwright, real headless Chromium (acceptance C). + * + * Boots the seeded fixture server, loads the dashboard, and verifies the exact + * conditions issue #19 demands after the O(n^2) dropdown freeze: + * • first paint ≤ 2s with the 9 stat cards showing real (DB-derived) numbers + * • open every tab + switch language once + zoom/drag the graph repeatedly + * • hang HANG_MS (default 60s); throughout: NO main-thread long task > 200ms, + * the page stays interactive, and the console reports no errors + * • the new graph features actually work: radial labels readable for 13 + * children, the story panel opens, the entity network renders, and the + * custom tooltip (not native title) appears on a metric card. + */ +import { spawn } from 'node:child_process'; +import { appendFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { chromium } from 'playwright'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..', '..'); +const PORT = Number(process.env.PORT || 8766); +// E2E_BASE lets the gate attach to an already-running fixture server (started +// separately) instead of spawning one — keeps the process tree small/stable. +const BASE = process.env.E2E_BASE || `http://127.0.0.1:${PORT}`; +const SPAWN_SERVER = !process.env.E2E_BASE; +const HANG_MS = Number(process.env.HANG_MS || 60000); +const PY = process.env.PYTHON || join(ROOT, '.venv', 'bin', 'python'); + +// Synchronous logging: this gate may run close to the sandbox's per-command +// runtime cap, and a SIGKILL drops buffered stdout — so every line is flushed +// to LOG immediately as well as echoed. +const LOG = process.env.E2E_LOG || join(__dirname, 'e2e_result.log'); +try { writeFileSync(LOG, ''); } catch (e) { /* ignore */ } +const line = (s) => { try { appendFileSync(LOG, s + '\n'); } catch (e) { /* ignore */ } console.log(s); }; +const fail = (m) => { line(` FAIL ${m}`); process.exitCode = 1; }; +const ok = (m) => line(` ok ${m}`); + +function startServer() { + return new Promise((resolve, reject) => { + const proc = spawn(PY, [join(__dirname, 'serve_fixture.py')], { + env: { ...process.env, PORT: String(PORT) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + const onData = (d) => { out += d; if (out.includes('Running on')) resolve(proc); }; + proc.stdout.on('data', onData); + proc.stderr.on('data', onData); + proc.on('exit', (c) => reject(new Error(`fixture server exited early (${c}): ${out}`))); + setTimeout(() => reject(new Error(`server start timeout: ${out}`)), 25000); + }); +} + +async function waitServer() { + for (let i = 0; i < 40; i += 1) { + try { const r = await fetch(`${BASE}/api/stats`); if (r.ok) return; } catch (e) { /* retry */ } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error('server never became healthy'); +} + +const longTaskInit = ` + window.__longtasks = []; + try { + new PerformanceObserver((list) => { + for (const e of list.getEntries()) window.__longtasks.push({ d: e.duration, t: e.startTime }); + }).observe({ entryTypes: ['longtask'] }); + } catch (e) { window.__longtaskUnsupported = true; } +`; + +line('graph_e2e.mjs'); +let server; +let browser; +try { + if (SPAWN_SERVER) { + server = await startServer(); + } + await waitServer(); + ok(`fixture server up (${SPAWN_SERVER ? 'spawned' : 'attached ' + BASE})`); + + browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] }); + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + + // Hard-fail on real JS faults (uncaught exceptions, our own console.error); + // a bare fixture legitimately lacks some optional backend endpoints, so a + // "Failed to load resource" 4xx/5xx is recorded as a warning, not a gate + // failure — the gate is about frozen/erroring UI, not fixture completeness. + const jsErrors = []; + const resourceWarnings = []; + page.on('console', (msg) => { + if (msg.type() !== 'error') return; + const text = msg.text(); + const url = (msg.location() && msg.location().url) || ''; + if (/favicon/i.test(url) || /favicon/i.test(text)) return; + if (/Failed to load resource/i.test(text)) { resourceWarnings.push(text + (url ? ` [${url}]` : '')); return; } + jsErrors.push(text + (url ? ` [${url}]` : '')); + }); + page.on('pageerror', (err) => jsErrors.push('pageerror: ' + err.message)); + await page.addInitScript(longTaskInit); + + // ── first paint ≤ 2s with 9 real numbers ────────────────────────── + const ids = ['statPapers', 'statResults', 'statTaxonomy', 'statContradictions', 'statInsights', + 'statTokens', 'statExperiments', 'statDeepDiscoveries', 'statCompletePapers']; + const t0 = Date.now(); + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + // taxonomy_nodes_total is always > 0 once real stats land + await page.waitForFunction(() => document.getElementById('statTaxonomy') + && document.getElementById('statTaxonomy').textContent.trim() !== '0', null, { timeout: 2000 }); + const firstPaint = Date.now() - t0; + const cardValues = await page.evaluate((idList) => idList.map((id) => { + const el = document.getElementById(id); + return el ? el.textContent.trim() : null; + }), ids); + const allPresent = cardValues.every((v) => v != null && v !== ''); + const someReal = cardValues.some((v) => /[1-9]/.test(v || '')); + if (firstPaint <= 2000 && allPresent && someReal) ok(`9 cards real numbers in ${firstPaint}ms: ${cardValues.join(' / ')}`); + else fail(`first paint gate: ${firstPaint}ms present=${allPresent} real=${someReal} -> ${cardValues.join(' / ')}`); + + // ── custom tooltip (acceptance D): hover a metric card ───────────── + const card = await page.$('[data-i18n-title="overview.sourcePapers.tip"]'); + await card.hover(); + await page.waitForTimeout(150); + const tipVisible = await page.evaluate(() => { + const tip = document.getElementById('tooltip'); + return !!tip && tip.classList.contains('visible') && tip.textContent.trim().length > 0; + }); + const nativeTitle = await page.evaluate(() => { + const c = document.querySelector('[data-i18n-title="overview.sourcePapers.tip"]'); + return c ? c.getAttribute('title') : 'MISSING'; + }); + if (tipVisible && !nativeTitle) ok('custom tooltip shows on metric card; native title removed'); + else fail(`tooltip gate: visible=${tipVisible} nativeTitle=${JSON.stringify(nativeTitle)}`); + await page.mouse.move(5, 5); + + // ── open every tab (incl. collapsed "advanced" nav items) ───────── + // Dispatch the click via JS so hidden advanced-nav items still switch & + // render — the point is to exercise every tab's render path under the gate. + const tabs = await page.$$eval('[data-tab]', (els) => els.map((e) => e.dataset.tab)); + for (const tab of tabs) { + await page.$eval(`[data-tab="${tab}"]`, (el) => el.click()); + await page.waitForTimeout(220); + } + ok(`opened ${tabs.length} tabs`); + + // ── Explore: render the rich node, check readable labels + graph ─── + await page.click('[data-tab="explore"]'); + await page.evaluate(() => window._dg.navigateTo('ml.dl')); + await page.waitForSelector('#exploreGraph svg.dg-graph-svg .dg-node-child', { timeout: 5000 }); + const labelStats = await page.evaluate(() => { + const labels = Array.from(document.querySelectorAll('#exploreGraph .dg-node-child .dg-node-label')); + const childNodes = document.querySelectorAll('#exploreGraph .dg-node-child').length; + const truncated = labels.filter((l) => (l.textContent || '').includes('…')).length; + const hasLegend = !!document.querySelector('#exploreGraph .dg-legend'); + return { childNodes, labelCount: labels.length, truncated, hasLegend }; + }); + if (labelStats.childNodes >= 10 && labelStats.labelCount === labelStats.childNodes && labelStats.hasLegend) { + ok(`radial: ${labelStats.childNodes} children, all labelled (${labelStats.truncated} ellipsised), legend present`); + } else { + fail(`radial gate: ${JSON.stringify(labelStats)}`); + } + + // zoom + pan repeatedly + const box = await (await page.$('#exploreGraph svg.dg-graph-svg')).boundingBox(); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + for (let i = 0; i < 5; i += 1) { await page.mouse.move(cx, cy); await page.mouse.wheel(0, -120); await page.waitForTimeout(40); } + for (let i = 0; i < 3; i += 1) { + await page.mouse.move(cx, cy); await page.mouse.down(); + await page.mouse.move(cx + 60, cy + 40, { steps: 6 }); await page.mouse.up(); + await page.waitForTimeout(40); + } + ok('zoom + pan interactions ran'); + + // ── story panel: click an area node, see gaps/discoveries ────────── + // Dispatch the click on the node group (after pan/zoom a node may sit under + // the sticky card header; the renderer's d3 click handler honours a synthetic + // event, so this exercises the real handler without pointer interception). + await page.$eval('#exploreGraph .dg-node-child', (el) => el.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + await page.waitForSelector('#exploreStoryPanel:not([hidden]) .story-section', { timeout: 5000 }); + const story = await page.evaluate(() => { + const p = document.getElementById('exploreStoryPanel'); + return { + visible: !p.hidden, + flow: !!p.querySelector('.story-flow'), + sections: p.querySelectorAll('.story-section').length, + enter: !!p.querySelector('.story-enter'), + }; + }); + if (story.visible && story.flow && story.sections >= 3 && story.enter) ok('story panel: area → gaps → discoveries path shown'); + else fail(`story panel gate: ${JSON.stringify(story)}`); + + // ── entity network (Evidence tab) ────────────────────────────────── + await page.click('[data-tab="evidence"]'); + await page.evaluate(() => { + const inp = document.getElementById('evidenceNodeSelect'); + inp.value = 'ml.dl'; + inp.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.waitForSelector('#evidenceEntityGraph .dg-node-entity', { timeout: 5000 }); + const entityNodes = await page.$$eval('#evidenceEntityGraph .dg-node-entity', (e) => e.length); + if (entityNodes >= 6) ok(`entity-relation network rendered (${entityNodes} entity nodes)`); + else fail(`entity network gate: only ${entityNodes} nodes`); + + // ── switch language once ─────────────────────────────────────────── + await page.click('[data-lang="zh"]'); + await page.waitForTimeout(400); + ok('language switched to zh'); + + // ── hang and watch for long tasks / loss of interactivity ────────── + await page.click('[data-tab="overview"]'); + line(` hanging ${HANG_MS}ms, watching for >200ms long tasks…`); + const hangStart = Date.now(); + let maxRaf = 0; + while (Date.now() - hangStart < HANG_MS) { + // measure main-thread responsiveness: time to service a rAF (should be tiny) + const raf = await page.evaluate(() => new Promise((res) => { + const s = performance.now(); + requestAnimationFrame(() => res(performance.now() - s)); + })); + maxRaf = Math.max(maxRaf, raf); + await page.waitForTimeout(1000); + } + const longTasks = await page.evaluate(() => window.__longtasks || []); + const bigTasks = longTasks.filter((l) => l.d > 200); + const unsupported = await page.evaluate(() => !!window.__longtaskUnsupported); + + if (unsupported) { + console.log(' (longtask API unsupported in this browser; relying on rAF latency)'); + } + if (bigTasks.length === 0) ok(`no main-thread long task > 200ms (${longTasks.length} long tasks total, max rAF latency ${maxRaf.toFixed(0)}ms)`); + else fail(`long tasks > 200ms detected: ${JSON.stringify(bigTasks.slice(0, 5))}`); + + // page still interactive after the hang + await page.click('[data-tab="explore"]'); + const interactive = await page.evaluate(() => document.querySelector('#tab-explore.active') != null + || document.querySelector('#tab-explore') != null); + if (interactive) ok('page interactive after hang'); + else fail('page not interactive after hang'); + + if (jsErrors.length === 0) ok('no JS console errors / pageerrors'); + else fail(`JS errors: ${JSON.stringify(jsErrors.slice(0, 8))}`); + if (resourceWarnings.length) line(` note: ${resourceWarnings.length} backend resource warning(s) in bare fixture: ${JSON.stringify(resourceWarnings.slice(0, 4))}`); + + if (process.exitCode) line('\nE2E GATE FAILED'); + else line('\nE2E GATE PASSED'); +} catch (e) { + line(' FAIL exception: ' + (e && e.stack || e)); + process.exitCode = 1; +} finally { + if (browser) await browser.close(); + if (server) server.kill('SIGKILL'); +} diff --git a/tests/js/graph_i18n.test.mjs b/tests/js/graph_i18n.test.mjs new file mode 100644 index 0000000..b61788c --- /dev/null +++ b/tests/js/graph_i18n.test.mjs @@ -0,0 +1,40 @@ +/* Acceptance D: the new tooltip/legend/story strings reuse the i18n system and + * exist in BOTH languages with identical key sets. A missing zh (or en) key + * would silently fall back to the raw key id in the UI. */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const i18nSrc = readFileSync(join(__dirname, '..', '..', 'web', 'static', 'js', 'i18n.js'), 'utf8'); + +const dom = new JSDOM('', { url: 'http://localhost/' }); +const { window } = dom; +new Function('window', 'document', 'localStorage', i18nSrc)(window, window.document, window.localStorage); + +const I18N = window.dgI18n.I18N; +const en = Object.keys(I18N.en).sort(); +const zh = Object.keys(I18N.zh).sort(); + +const missingZh = en.filter((k) => !(k in I18N.zh)); +const missingEn = zh.filter((k) => !(k in I18N.en)); +assert.deepEqual(missingZh, [], `keys present in en but missing in zh: ${missingZh.join(', ')}`); +assert.deepEqual(missingEn, [], `keys present in zh but missing in en: ${missingEn.join(', ')}`); + +// the new graph/story keys must actually be present +const required = [ + 'graph.zoomHint', 'graph.entityHint', 'graph.legendPapers', 'graph.legendGaps', 'graph.legendMethods', + 'graph.clickForStory', 'graph.clickEntity', 'graph.moreNodes', + 'explore.entityNetwork', 'evidence.entityGraph', + 'story.gaps', 'story.contradictions', 'story.discoveries', 'story.enterArea', +]; +for (const k of required) { + assert.ok(k in I18N.en && k in I18N.zh, `required key "${k}" must exist in both languages`); +} +// %d placeholder preserved in both +assert.ok(I18N.en['graph.moreNodes'].includes('%d') && I18N.zh['graph.moreNodes'].includes('%d'), + 'graph.moreNodes must keep the %d placeholder in both languages'); + +console.log(`graph_i18n.test.mjs\n ok en/zh key sets equal (${en.length} keys)\n ok required graph/story keys present in both languages`); diff --git a/tests/js/graph_renderer_perf.mjs b/tests/js/graph_renderer_perf.mjs new file mode 100644 index 0000000..b5decda --- /dev/null +++ b/tests/js/graph_renderer_perf.mjs @@ -0,0 +1,172 @@ +/* Renderer perf gate for web/static/js/graph/renderer.js (issue #19 acceptance C). + * + * The renderer is the only module that touches D3. We mount it in jsdom against + * real d3 v7 and assert it renders large inputs (N=5000) well under budget — an + * O(n^2) layout/label/link bug would blow up here exactly like the dropdown + * freeze that motivated this gate, without needing a browser. + * + * It also asserts the dependency-injection contract: the renderer must reach the + * DOM, tooltip, and navigation ONLY through injected options (never #tooltip / + * navigateTo / switchTab), so the renderer file can be swapped wholesale. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import * as d3 from 'd3'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..', '..'); +const rendererSrc = readFileSync(join(ROOT, 'web', 'static', 'js', 'graph', 'renderer.js'), 'utf8'); +const adapterSrc = readFileSync(join(ROOT, 'web', 'static', 'js', 'graph', 'adapter.js'), 'utf8'); + +// Forbidden global references — the renderer must not reach these directly. +// Strip comments first: the contract is about code, not the doc-block that +// names the very globals it forbids. +const rendererCode = rendererSrc + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1'); +for (const banned of ['#tooltip', 'navigateTo', 'switchTab', 'window._dg', 'getElementById']) { + assert.ok(!rendererCode.includes(banned), `renderer.js must not reference global "${banned}" — inject it via options`); +} + +const dom = new JSDOM('
', { + pretendToBeVisual: true, +}); +const { window } = dom; +// jsdom doesn't implement layout; give containers a non-zero width. +Object.defineProperty(window.HTMLElement.prototype, 'clientWidth', { value: 640, configurable: true }); +Object.defineProperty(window.HTMLElement.prototype, 'clientHeight', { value: 480, configurable: true }); +window.d3 = d3; + +function loadGlobal(src) { + // eslint-disable-next-line no-new-func + new Function('module', 'window', 'globalThis', src)({ exports: {} }, window, window); +} +loadGlobal(adapterSrc); +loadGlobal(rendererSrc); +const Renderer = window.DGGraphRenderer; +const Adapter = window.DGGraphAdapter; +assert.ok(Renderer && typeof Renderer.renderRadial === 'function', 'DGGraphRenderer.renderRadial missing'); +assert.ok(typeof Renderer.renderNetwork === 'function', 'DGGraphRenderer.renderNetwork missing'); + +let passed = 0; +function test(name, fn) { + fn(); + passed += 1; + console.log(` ok ${name}`); +} +function host() { + const h = window.document.getElementById('host'); + h.innerHTML = ''; + return h; +} + +console.log('graph_renderer_perf.mjs'); + +test('renderRadial draws nodes and wires injected click/tooltip', () => { + const clicks = []; + const tipCalls = { show: 0, hide: 0 }; + const model = Adapter.taxonomyToModel( + { id: 'ml', name: 'Machine Learning' }, + [ + { id: 'ml.dl', name: 'Deep Learning', paper_count: 10, gap_count: 3, method_count: 2 }, + { id: 'ml.rl', name: 'Reinforcement Learning', paper_count: 4, gap_count: 0, method_count: 1 }, + ], + ); + const handle = Renderer.renderRadial(host(), model, { + height: 400, + onNodeClick: (n) => clicks.push(n.id), + nodeTooltipHtml: (n) => `${n.name}`, + tooltip: { show: () => { tipCalls.show += 1; }, move: () => {}, hide: () => { tipCalls.hide += 1; } }, + legendItems: [{ kind: 'papers', label: 'Papers' }, { kind: 'gaps', label: 'Gaps' }], + }); + const svg = window.document.querySelector('#host svg'); + assert.ok(svg, 'an svg is created'); + const circles = svg.querySelectorAll('circle'); + assert.ok(circles.length >= 3, `expected >=3 circles, got ${circles.length}`); + // legend present + assert.ok(svg.querySelectorAll('.dg-legend, [data-legend]').length >= 1 || svg.textContent.includes('Papers'), + 'legend rendered'); + // click on a child node group fires injected callback + const childGroup = svg.querySelector('.dg-node-child'); + assert.ok(childGroup, 'child node group present'); + childGroup.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + assert.deepEqual(clicks.length, 1, 'injected onNodeClick fired exactly once'); + childGroup.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true })); + assert.ok(tipCalls.show >= 1, 'injected tooltip.show fired on hover'); + assert.equal(typeof handle.destroy, 'function'); + handle.destroy(); +}); + +test('renderNetwork draws entity nodes + links and fires entity click', () => { + const gs = { + top_entities: [ + { name: 'BERT', entity_type: 'method', paper_count: 8, mention_count: 40 }, + { name: 'GLUE', entity_type: 'dataset', paper_count: 6, mention_count: 22 }, + ], + top_relations: [{ subject: 'BERT', predicate: 'evaluated_on', object: 'GLUE', paper_count: 5, relation_count: 9 }], + }; + const model = Adapter.entityGraphToModel(gs); + const clicked = []; + Renderer.renderNetwork(host(), model, { + height: 360, + onNodeClick: (n) => clicked.push(n.name), + nodeTooltipHtml: (n) => n.name, + }); + const svg = window.document.querySelector('#host svg'); + assert.ok(svg.querySelectorAll('line, path.dg-link').length >= 1, 'links drawn'); + const node = svg.querySelector('.dg-node-entity'); + assert.ok(node, 'entity node present'); + node.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + assert.equal(clicked.length, 1); +}); + +test('renderRadial empty state does not throw', () => { + const model = Adapter.taxonomyToModel({ id: 'leaf', name: 'Leaf', description: 'no kids' }, []); + Renderer.renderRadial(host(), model, { height: 300, emptyText: 'Leaf domain' }); + assert.ok(window.document.querySelector('#host svg')); +}); + +// Real data is tiny (backend caps to ~12). These N=5000 cases are the O(n^2) +// tripwire: a quadratic bug (or an attempt to draw thousands of DOM nodes, which +// is what froze the dropdown) blows the budget. The renderer must cap the drawn +// node count defensively AND surface the overflow ("+N more") rather than +// silently truncate — then stay well under 200ms even when fed 5000. +test('renderRadial N=5000 caps drawn nodes, surfaces overflow, < 200ms', () => { + const children = []; + for (let i = 0; i < 5000; i += 1) { + children.push({ id: `ml.n${i}`, name: `Subarea number ${i}`, paper_count: i % 50, gap_count: i % 7, method_count: i % 5 }); + } + const model = Adapter.taxonomyToModel({ id: 'ml', name: 'root' }, children); + const t0 = performance.now(); + Renderer.renderRadial(host(), model, { height: 600, isPreview: false }); + const dt = performance.now() - t0; + const svg = window.document.querySelector('#host svg'); + const drawn = svg.querySelectorAll('.dg-node-child').length; + console.log(` renderRadial N=5000: ${dt.toFixed(1)}ms, drew ${drawn} child nodes`); + assert.ok(drawn <= 200, `radial should cap drawn nodes, drew ${drawn}`); + assert.ok(/\+\s*\d|more|更多/i.test(svg.textContent), 'overflow indicator surfaced (not silent)'); + assert.ok(dt < 200, `renderRadial too slow: ${dt.toFixed(1)}ms`); +}); + +test('renderNetwork N=5000 caps drawn nodes, < 200ms (no unbounded force)', () => { + const top_entities = []; + const top_relations = []; + for (let i = 0; i < 5000; i += 1) { + top_entities.push({ name: `e${i}`, entity_type: 'method', paper_count: i % 30, mention_count: i }); + top_relations.push({ subject: `e${i}`, predicate: 'rel', object: `e${(i + 1) % 5000}`, paper_count: 1, relation_count: 1 }); + } + // bypass the adapter cap to stress the renderer's own large-N guard + const model = Adapter.entityGraphToModel({ top_entities, top_relations }, { maxNodes: 100000 }); + const t0 = performance.now(); + Renderer.renderNetwork(host(), model, { height: 600 }); + const dt = performance.now() - t0; + const drawn = window.document.querySelectorAll('#host .dg-node-entity').length; + console.log(` renderNetwork N=5000: ${dt.toFixed(1)}ms, drew ${drawn} nodes (model=${model.nodes.length})`); + assert.ok(drawn <= 200, `network should cap drawn nodes, drew ${drawn}`); + assert.ok(dt < 200, `renderNetwork too slow: ${dt.toFixed(1)}ms`); +}); + +console.log(`\n${passed} renderer tests passed.`); diff --git a/tests/js/package.json b/tests/js/package.json index b0b6cdb..9821139 100644 --- a/tests/js/package.json +++ b/tests/js/package.json @@ -3,12 +3,16 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "Node test harness for the dashboard responsiveness acceptance gates (Acceptance A).", + "description": "Frontend tests for the DeepGraph dashboard: graph adapter unit + perf, renderer perf (jsdom), and the Playwright responsiveness gate (issue #19 / #34).", "scripts": { - "perf": "node taxonomy_dropdown_perf.mjs", - "e2e": "node responsiveness_e2e.mjs" + "test:adapter": "node graph_adapter.test.mjs", + "test:renderer": "node graph_renderer_perf.mjs", + "test:unit": "node graph_adapter.test.mjs && node graph_renderer_perf.mjs", + "test:e2e": "node graph_e2e.mjs" }, "dependencies": { + "d3": "^7.9.0", + "jsdom": "^25.0.0", "playwright": "^1.49.0" } } diff --git a/tests/js/serve_fixture.py b/tests/js/serve_fixture.py new file mode 100644 index 0000000..a3770e2 --- /dev/null +++ b/tests/js/serve_fixture.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Seed a throwaway SQLite fixture and serve the dashboard for the Playwright gate. + +This NEVER touches the real deepgraph.db: it points DEEPGRAPH_DB_PATH at a temp +file (default /tmp/dg_fixture_e2e.db), builds the schema via the app's own +init_db, seeds the taxonomy, then inserts a small but realistic dataset so the +9 stat cards show real numbers, the taxonomy node has 10+ children, and one node +has an entity-relation graph + gaps + insights (incl. a contradiction) to drive +the "domain -> gap -> discovery" story panel. + +Usage: DEEPGRAPH_DB_PATH=... PORT=8765 python serve_fixture.py +""" +import os +import sys +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +_port = os.environ.get("PORT", "8766") +DB_PATH = os.environ.setdefault("DEEPGRAPH_DB_PATH", f"/tmp/dg_fixture_e2e_{_port}.db") +os.environ.setdefault("DEEPGRAPH_ROOT_NODE_ID", "ml") +# keep background pipelines/agents off for a deterministic, lightweight server +os.environ.setdefault("DEEPGRAPH_AUTO_PIPELINE_ENABLED", "0") +os.environ.setdefault("DEEPGRAPH_AUTO_RESEARCH_ENABLED", "0") +# A dummy provider key so /api/providers returns stats instead of 500 (no network +# call is made — get_provider_stats only reads the configured provider table). +os.environ.setdefault("MINIMAX_API_KEY", "fixture-dummy-key") + +# fresh db each run +p = Path(DB_PATH) +if p.exists(): + p.unlink() + +from db import database as db # noqa: E402 +from db.database import init_db # noqa: E402 +from db.taxonomy import seed_taxonomy, get_children # noqa: E402 +from db.evidence_graph import upsert_node_graph_summary # noqa: E402 + +init_db() +seed_taxonomy() + +PARENT = "ml.dl" + +# Add synthetic children so the parent has 10+ subnodes (readability case A). +existing = get_children(PARENT) +base_sort = len(existing) + 1 +extra = [ + ("ml.dl.neuro_sym", "Neuro-Symbolic Reasoning"), + ("ml.dl.world_models", "World Models & Simulation"), + ("ml.dl.continual", "Continual / Lifelong Learning"), + ("ml.dl.interpret", "Mechanistic Interpretability"), +] +for i, (nid, name) in enumerate(extra): + db.execute( + "INSERT INTO taxonomy_nodes (id, name, parent_id, depth, description, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING", + (nid, name, PARENT, 2, f"Fixture subarea: {name}.", base_sort + i), + ) +db.commit() + +children = get_children(PARENT) + +# Papers + classification -> gives each child a paper_count and feeds stats. +pid_seq = 0 +for ci, c in enumerate(children): + n_papers = 3 + (ci * 2) % 11 # 3..13 + for k in range(n_papers): + pid_seq += 1 + pid = f"2401.{pid_seq:05d}" + db.execute( + "INSERT INTO papers (id, title, abstract, status, token_cost, published_date) " + "VALUES (?, ?, ?, 'reasoned', ?, '2024-01-15')", + (pid, f"{c['name']} advances, part {k + 1}", + f"A study on {c['name']} with new methods and benchmarks.", 1200 + k * 10), + ) + db.execute( + "INSERT INTO paper_taxonomy (paper_id, node_id, confidence) VALUES (?, ?, 1.0) " + "ON CONFLICT (paper_id, node_id) DO NOTHING", + (pid, c["id"]), + ) + # a couple of results -> method_count (needs a result_taxonomy mapping) + if k < 2: + rkey = f"{pid}-r{k}" + db.execute( + "INSERT INTO results (paper_id, node_id, method_name, dataset_name, metric_name, " + "metric_value, result_key) VALUES (?, ?, ?, ?, 'accuracy', ?, ?)", + (pid, c["id"], f"Method-{c['id'].split('.')[-1]}-{k}", "BenchX", 0.8 + k * 0.05, rkey), + ) + row = db.fetchone("SELECT id FROM results WHERE result_key=?", (rkey,)) + if row: + db.execute( + "INSERT INTO result_taxonomy (result_id, node_id) VALUES (?, ?) " + "ON CONFLICT (result_id, node_id) DO NOTHING", + (row["id"], c["id"]), + ) +db.commit() + +# Matrix gaps -> gap_count on a few children + leaf gaps for the story panel. +gap_children = children[:4] +for gi, c in enumerate(gap_children): + for j in range(1 + gi % 3): + db.execute( + "INSERT INTO matrix_gaps (node_id, method_name, dataset_name, metric_name, " + "gap_description, research_proposal, value_score) VALUES (?, ?, ?, 'accuracy', ?, ?, ?)", + (c["id"], f"Method-{j}", "BenchX", + f"No strong baseline for {c['name']} on BenchX under distribution shift.", + f"Evaluate {c['name']} methods on BenchX with shifted splits.", 3.5 + j), + ) +db.commit() + +# Insights (incl. a contradiction) on the parent + children -> drives the story +# panel and the insights/contradictions stat cards. +insight_rows = [ + (PARENT, "contradiction_analysis", + "Scaling claims conflict across two BenchX evaluations", + "If both hold, the scaling law and the saturation result cannot both be correct.", + "Paper A reports monotone gains; Paper B reports saturation at the same scale.", + "Re-run both protocols on a shared split and reconcile.", 4, 3), + (children[0]["id"], "method_transfer", + f"Transfer a {children[0]['name']} trick to a sibling area", + "A regularizer from area X should reduce overfitting in area Y.", + "Both areas share the same failure mode on small data.", + "Port the regularizer and measure validation gap.", 5, 4), + (children[0]["id"], "ignored_limitation", + "An under-reported limitation blocks deployment", + "Latency under load is the true bottleneck, not accuracy.", + "Three papers footnote latency but none measure it.", + "Profile end-to-end latency at production batch sizes.", 4, 4), +] +for node_id, itype, title, hyp, ev, exp, nov, feas in insight_rows: + db.execute( + "INSERT INTO insights (node_id, insight_type, title, hypothesis, evidence, experiment, " + "novelty_score, feasibility_score, supporting_papers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (node_id, itype, title, hyp, ev, exp, nov, feas, json.dumps(["2401.00001", "2401.00002"])), + ) +db.commit() + +# Entity-relation graph summary for the parent (12 entities + 12 relations) so +# the Explore/Evidence network graphs render a real top-N subgraph. +entities = [ + {"name": "Transformer", "entity_type": "model", "paper_count": 9, "mention_count": 60}, + {"name": "BERT", "entity_type": "model", "paper_count": 7, "mention_count": 41}, + {"name": "GLUE", "entity_type": "dataset", "paper_count": 6, "mention_count": 33}, + {"name": "SQuAD", "entity_type": "dataset", "paper_count": 5, "mention_count": 28}, + {"name": "Adam", "entity_type": "method", "paper_count": 8, "mention_count": 39}, + {"name": "Dropout", "entity_type": "method", "paper_count": 6, "mention_count": 25}, + {"name": "Accuracy", "entity_type": "metric", "paper_count": 9, "mention_count": 52}, + {"name": "F1", "entity_type": "metric", "paper_count": 5, "mention_count": 21}, + {"name": "Pretraining", "entity_type": "task", "paper_count": 7, "mention_count": 30}, + {"name": "Fine-tuning", "entity_type": "task", "paper_count": 8, "mention_count": 34}, + {"name": "Attention", "entity_type": "concept", "paper_count": 9, "mention_count": 58}, + {"name": "Tokenizer", "entity_type": "artifact", "paper_count": 4, "mention_count": 17}, +] +relations = [ + {"subject": "BERT", "predicate": "based_on", "object": "Transformer", "paper_count": 7, "relation_count": 12}, + {"subject": "BERT", "predicate": "evaluated_on", "object": "GLUE", "paper_count": 6, "relation_count": 10}, + {"subject": "BERT", "predicate": "evaluated_on", "object": "SQuAD", "paper_count": 5, "relation_count": 8}, + {"subject": "Transformer", "predicate": "uses", "object": "Attention", "paper_count": 9, "relation_count": 18}, + {"subject": "Transformer", "predicate": "trained_with", "object": "Adam", "paper_count": 7, "relation_count": 9}, + {"subject": "BERT", "predicate": "trained_with", "object": "Adam", "paper_count": 6, "relation_count": 7}, + {"subject": "Pretraining", "predicate": "followed_by", "object": "Fine-tuning", "paper_count": 7, "relation_count": 11}, + {"subject": "Fine-tuning", "predicate": "measured_by", "object": "Accuracy", "paper_count": 8, "relation_count": 13}, + {"subject": "GLUE", "predicate": "reports", "object": "F1", "paper_count": 4, "relation_count": 5}, + {"subject": "Transformer", "predicate": "regularized_by", "object": "Dropout", "paper_count": 6, "relation_count": 8}, + {"subject": "BERT", "predicate": "requires", "object": "Tokenizer", "paper_count": 4, "relation_count": 6}, + {"subject": "Attention", "predicate": "improves", "object": "Accuracy", "paper_count": 6, "relation_count": 7}, +] +summary = { + "top_entities": entities, + "top_relations": relations, + "top_entity_types": [{"entity_type": "model", "mention_count": 101}], + "generated_from_papers": ["2401.00001"], + "paper_count": 24, + "entity_count": len(entities), + "relation_count": len(relations), +} +upsert_node_graph_summary(PARENT, summary) +# also give the root a summary so the overview preview is populated +upsert_node_graph_summary("ml", summary) + +from web.app import app, prewarm_stats_cache # noqa: E402 + +prewarm_stats_cache() + +port = int(os.environ.get("PORT", "8765")) +print(f"[fixture] db={DB_PATH} port={port} parent={PARENT} children={len(children)}", flush=True) +app.run(host="127.0.0.1", port=port, threaded=True, use_reloader=False) diff --git a/web/static/css/style.css b/web/static/css/style.css index 5f31218..d5287c8 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -35,6 +35,20 @@ --radius: 8px; --radius-sm: 5px; --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + /* ── Knowledge-graph encoding tokens (issue #19) ──────────────── */ + --graph-gap-lo: #eef3ee; /* node fill, no/low open gaps */ + --graph-gap-hi: #3d8b5e; /* node fill, many open gaps (salient) */ + --graph-link: rgba(196, 112, 75, 0.22); + --graph-host-bg: #fbfaf7; + --graph-host-border: #ece6da; + --graph-ent-method: #c4704b; + --graph-ent-dataset: #2e86ab; + --graph-ent-metric: #a8842a; + --graph-ent-task: #7c5cbf; + --graph-ent-model: #c4453a; + --graph-ent-artifact: #3d8b5e; + --graph-ent-concept: #9a9088; } /* ── Reset ────────────────────────────────────────────────────────── */ @@ -1587,10 +1601,114 @@ header#topBar { } .tooltip.visible { opacity: 1; } -/* ── D3 graph nodes ───────────────────────────────────────────────── */ +/* ── Knowledge graph (issue #19) ──────────────────────────────────── */ + +.dg-graph-host { + width: 100%; + min-height: 120px; + background: var(--graph-host-bg); + border: 1px solid var(--graph-host-border); + border-radius: var(--radius); + overflow: hidden; +} +.dg-entity-host { margin-top: 8px; } +.dg-graph-svg { display: block; cursor: grab; } +.dg-graph-svg:active { cursor: grabbing; } + +.dg-node { cursor: pointer; transition: opacity 0.15s ease; } +.dg-node-parent { cursor: default; } +.dg-node circle { transition: stroke-width 0.12s ease, r 0.12s ease; } +.dg-node:hover circle { stroke-width: 3px; } +.dg-node-label { paint-order: stroke; stroke: var(--graph-host-bg); stroke-width: 3px; } +.dg-link { transition: opacity 0.15s ease; } +.dg-legend-label { font-family: var(--font); } +.dg-overflow { font-family: var(--font); font-style: italic; } + +.graph-hint { + font-size: 0.7rem; + color: var(--text-dim); + font-weight: 500; +} +.card-header .graph-hint { margin-left: auto; } + +.explore-graph-card { position: relative; } +.explore-graph-stage { position: relative; } + +.entity-graph-block { margin-top: 16px; } +.entity-graph-block h4 .graph-hint { margin-left: 8px; font-weight: 500; } -.graph-node { cursor: pointer; } -.graph-node-parent { cursor: default; } +/* Custom tooltip content (graph nodes + metric cards) */ +.tip-title { color: var(--accent); font-weight: 700; margin-bottom: 5px; } +.tip-body { color: var(--text-secondary); margin-bottom: 6px; line-height: 1.5; } +.tip-stats { display: flex; gap: 12px; color: var(--text-dim); font-size: 0.72rem; } +.tip-hint { color: var(--text-muted); margin-top: 6px; font-size: 0.65rem; } + +/* In-graph "domain → gap → discovery" story panel (acceptance A2) */ +.dg-story-panel { + position: absolute; + top: 12px; + right: 12px; + width: min(340px, 80%); + max-height: calc(100% - 24px); + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0,0,0,0.12); + padding: 14px 16px; + z-index: 5; + animation: storyIn 0.16s ease; +} +@keyframes storyIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +.story-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 10px; } +.story-head h4 { margin: 2px 0 0; font-size: 1rem; color: var(--text-primary); } +.story-kicker { font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); } +.story-close { + border: none; background: var(--bg-elevated); color: var(--text-secondary); + width: 26px; height: 26px; border-radius: 50%; cursor: pointer; font-size: 1rem; line-height: 1; +} +.story-close:hover { background: var(--border-hover); } +.story-flow { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } +.story-flow-step { + font-size: 0.66rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; + background: var(--accent-dim); color: var(--accent); +} +.story-flow-arrow { color: var(--text-muted); font-size: 0.7rem; } +.story-section { margin-bottom: 12px; } +.story-section-title { + font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + margin-bottom: 6px; display: flex; align-items: center; gap: 6px; +} +.story-section-title.gap { color: var(--green); } +.story-section-title.contra { color: var(--red); } +.story-section-title.disc { color: var(--accent); } +.story-count { + font-size: 0.62rem; background: var(--bg-elevated); color: var(--text-dim); + border-radius: 999px; padding: 1px 7px; font-weight: 700; +} +.dg-story-panel ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.story-item { + font-size: 0.78rem; color: var(--text-primary); line-height: 1.4; + padding: 7px 9px; border-radius: var(--radius-sm); background: var(--bg-elevated); + border-left: 3px solid var(--border-hover); +} +.story-item .story-sub { display: block; font-size: 0.68rem; color: var(--text-dim); margin-top: 2px; } +.story-gap { border-left-color: var(--green); } +.story-contra { border-left-color: var(--red); cursor: pointer; } +.story-disc { border-left-color: var(--accent); cursor: pointer; } +.story-contra:hover, .story-disc:hover { background: var(--bg-card-hover); } +.story-empty { font-size: 0.74rem; color: var(--text-dim); font-style: italic; padding: 4px 0; } +.story-loading { font-size: 0.8rem; color: var(--text-dim); padding: 8px 0; } +.story-enter { + width: 100%; margin-top: 4px; padding: 9px; border: none; cursor: pointer; + background: var(--accent); color: #fff; border-radius: var(--radius-sm); + font-weight: 700; font-size: 0.8rem; +} +.story-enter:hover { filter: brightness(1.05); } + +@media (max-width: 768px) { + .dg-story-panel { position: static; width: 100%; max-height: none; margin-top: 10px; box-shadow: none; } +} /* ── Insight Cards in Explore ─────────────────────────────────────── */ diff --git a/web/static/js/app.js b/web/static/js/app.js index 016eb64..919e36c 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -460,7 +460,7 @@ async function loadOverviewGraph() { overviewGraphLoaded = true; try { const data = await api(`/api/taxonomy/${ROOT_NODE}`); - renderRadialGraph('overviewGraphSvg', data.node, data.children, 320, true); + renderTaxonomyGraph('overviewGraph', data.node, data.children, { height: 320, isPreview: true }); } catch (e) { overviewGraphLoaded = false; console.error('Overview graph error:', e); @@ -490,7 +490,8 @@ async function navigateTo(nodeId) { el('exploreTitle').textContent = data.node.name + ' \u2014 ' + tr('explore.title'); // Graph - renderRadialGraph('exploreGraphSvg', data.node, data.children, 520, false); + closeAreaStory(); + renderTaxonomyGraph('exploreGraph', data.node, data.children, { height: 520, isPreview: false }); // Summary card const sumCard = el('exploreSummaryCard'); @@ -595,19 +596,12 @@ function renderExploreSummary(data) { `; } - // Graph entities + // Entity-relation network (rendered after innerHTML is set, below) const gs = data.graph_summary; - if (gs && (gs.top_entities || gs.top_relations)) { - const entHtml = (gs.top_entities || []).slice(0, 6).map(e => - `
${esc(e.name)}

${esc(e.entity_type)} \u00B7 ${esc(tr('common.papersCount', { count: e.paper_count }))} \u00B7 ${esc(tr('common.mentionsCount', { count: e.mention_count }))}

` - ).join('') || `

${esc(tr('empty.entities'))}

`; - const relHtml = (gs.top_relations || []).slice(0, 6).map(r => - `
${esc(r.subject)} \u2192 ${esc(r.object)}

${esc(r.predicate)} \u00B7 ${esc(tr('common.papersCount', { count: r.paper_count }))}

` - ).join('') || `

${esc(tr('empty.relations'))}

`; - - html += `
-

${esc(tr('explore.coreEntities'))}

${entHtml}
-

${esc(tr('explore.keyLinks'))}

${relHtml}
+ if (gs && ((gs.top_entities && gs.top_entities.length) || (gs.top_relations && gs.top_relations.length))) { + html += `
+

${esc(tr('explore.entityNetwork'))} ${esc(tr('graph.entityHint'))}

+
`; } } @@ -680,6 +674,11 @@ function renderExploreSummary(data) { } body.innerHTML = html; + + // Render the entity-relation network into its host (now in the DOM). + if (el('exploreEntityGraph')) { + renderEntityGraph('exploreEntityGraph', data.graph_summary, { height: 380 }); + } } function renderExploreChildren(children) { @@ -696,186 +695,191 @@ function renderExploreChildren(children) { `).join('')}
`; } -// ── Radial Graph (D3, static layout, no force sim) ─────────────────── +// ── Knowledge Graph glue (adapter + renderer + tooltip) ────────────── +// All D3 lives in /static/js/graph/renderer.js. This layer only builds the +// model (via DGGraphAdapter), reads the theme from CSS vars, and injects the +// app's tooltip / navigation / click-through as callbacks. Swapping the +// renderer means rewriting renderer.js alone — nothing here changes. -function renderRadialGraph(svgId, parentNode, children, targetHeight, isPreview) { - const svg = d3.select('#' + svgId); - svg.selectAll('*').remove(); +function graphTheme() { + const cs = getComputedStyle(document.documentElement); + const v = (name, fb) => (cs.getPropertyValue(name).trim() || fb); + return { + accent: v('--accent', '#c4704b'), green: v('--green', '#3d8b5e'), + gold: v('--gold', '#a8842a'), purple: v('--purple', '#7c5cbf'), + text: v('--text-primary', '#2b2520'), dim: v('--text-dim', '#9a9088'), + muted: v('--text-muted', '#c4bdb4'), bg: v('--bg-card', '#ffffff'), + bgElevated: v('--bg-elevated', '#f0ede6'), border: v('--border', '#e5e0d5'), + gapLo: v('--graph-gap-lo', '#eef3ee'), gapHi: v('--graph-gap-hi', '#3d8b5e'), + entityPalette: { + method: v('--graph-ent-method', '#c4704b'), dataset: v('--graph-ent-dataset', '#2e86ab'), + metric: v('--graph-ent-metric', '#a8842a'), task: v('--graph-ent-task', '#7c5cbf'), + model: v('--graph-ent-model', '#c4453a'), artifact: v('--graph-ent-artifact', '#3d8b5e'), + concept: v('--graph-ent-concept', '#9a9088'), + }, + }; +} - const container = svg.node().parentElement; - const width = container.clientWidth - 4; - const height = targetHeight; - svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`); +const dgTooltip = { + show: (html, ev) => window.DGTooltip && window.DGTooltip.show(html, ev), + move: (ev) => window.DGTooltip && window.DGTooltip.move(ev), + hide: () => window.DGTooltip && window.DGTooltip.hide(), +}; - const cx = width / 2; - const cy = height / 2; +function taxonomyLegendItems() { + return [ + { kind: 'papers', label: tr('graph.legendPapers') }, + { kind: 'gaps', label: tr('graph.legendGaps') }, + { kind: 'methods', label: tr('graph.legendMethods') }, + ]; +} - if (!children || children.length === 0) { - svg.append('text') - .attr('x', cx).attr('y', cy - 8) - .attr('text-anchor', 'middle') - .attr('fill', '#9a9088').attr('font-size', '14px').attr('font-weight', '600') - .text(tr('explore.leafDomain')); - svg.append('text') - .attr('x', cx).attr('y', cy + 16) - .attr('text-anchor', 'middle') - .attr('fill', '#b5ada4').attr('font-size', '12px') - .text(trunc(parentNode.description || '', 80)); - return; +function taxonomyNodeTooltip(d) { + if (d.role === 'parent') { + return `
${esc(d.name)}
+
${esc(trunc(d.description, 160))}
`; } - - const maxGap = Math.max(...children.map(c => c.gap_count || 0), 1); - const radius = Math.min(width, height) * (isPreview ? 0.32 : 0.34); - const angleStep = (2 * Math.PI) / children.length; - - // Positions - const nodes = [{ - id: parentNode.id, name: parentNode.name, description: parentNode.description || '', - paper_count: 0, gap_count: 0, method_count: 0, isParent: true, x: cx, y: cy - }]; - const links = []; - - children.forEach((child, i) => { - const angle = angleStep * i - Math.PI / 2; - const x = cx + Math.cos(angle) * radius; - const y = cy + Math.sin(angle) * radius; - nodes.push({ - id: child.id, name: child.name, description: child.description || '', - paper_count: child.paper_count || 0, gap_count: child.gap_count || 0, - method_count: child.method_count || 0, isParent: false, x, y - }); - links.push({ source: parentNode.id, target: child.id }); + return `
${esc(d.name)}
+
${esc(trunc(d.description, 160))}
+
+ ${d.paper_count} ${esc(tr('common.papersUnit'))} + ${d.method_count} ${esc(tr('common.methodsUnit'))} + ${d.gap_count} ${esc(tr('common.gapsUnit'))} +
+
${esc(tr('graph.clickForStory'))}
`; +} + +let _taxGraphHandle = null; +function renderTaxonomyGraph(containerId, parentNode, children, opts) { + const container = el(containerId); + if (!container || !window.DGGraphRenderer) return; + const isPreview = !!(opts && opts.isPreview); + const model = window.DGGraphAdapter.taxonomyToModel(parentNode, children); + const handle = window.DGGraphRenderer.renderRadial(container, model, { + height: (opts && opts.height) || 420, + isPreview, + theme: graphTheme(), + tooltip: dgTooltip, + nodeTooltipHtml: taxonomyNodeTooltip, + legendItems: isPreview ? null : taxonomyLegendItems(), + moreLabel: tr('graph.moreNodes'), + onNodeClick: (d) => { + if (d.role === 'parent') return; + if (isPreview) { + switchTab('explore'); + navigateTo(d.id); + } else { + openAreaStory(d); + } + }, }); - - const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])); - - // Gradient for links - const defs = svg.append('defs'); - const grad = defs.append('linearGradient').attr('id', 'linkGrad-' + svgId) - .attr('gradientUnits', 'userSpaceOnUse'); - grad.append('stop').attr('offset', '0%').attr('stop-color', 'rgba(196,112,75,0.3)'); - grad.append('stop').attr('offset', '100%').attr('stop-color', 'rgba(196,112,75,0.06)'); - - // Draw links - svg.append('g').selectAll('line') - .data(links).join('line') - .attr('x1', cx).attr('y1', cy) - .attr('x2', d => (nodeMap[d.target] || {}).x || cx) - .attr('y2', d => (nodeMap[d.target] || {}).y || cy) - .attr('stroke', `url(#linkGrad-${svgId})`) - .attr('stroke-width', 1.5); - - // Draw nodes - const nodeG = svg.append('g').selectAll('g') - .data(nodes).join('g') - .attr('transform', d => `translate(${d.x},${d.y})`) - .attr('class', d => d.isParent ? 'graph-node-parent' : 'graph-node'); - - // Parent node - const parentG = nodeG.filter(d => d.isParent); - parentG.append('circle') - .attr('r', isPreview ? 28 : 34) - .attr('fill', '#faf5ee') - .attr('stroke', '#c4704b') - .attr('stroke-width', 2.5); - parentG.append('text') - .attr('text-anchor', 'middle').attr('dy', 4) - .attr('fill', '#c4704b').attr('font-size', isPreview ? '10px' : '11px').attr('font-weight', '700') - .text(d => trunc(d.name, isPreview ? 12 : 16)); - - // Child nodes - const childG = nodeG.filter(d => !d.isParent); - - childG.append('circle') - .attr('r', d => { - const base = isPreview ? 18 : 22; - const max = isPreview ? 36 : 46; - return Math.max(base, Math.min(max, base + (d.paper_count || 0) * 0.5)); - }) - .attr('fill', d => gapColor(d.gap_count, maxGap).fill) - .attr('stroke', d => gapColor(d.gap_count, maxGap).stroke) - .attr('stroke-width', d => d.gap_count > 0 ? 2 : 1.2); - - // Labels - childG.append('text') - .attr('text-anchor', 'middle').attr('dy', isPreview ? -2 : -4) - .attr('fill', '#2b2520').attr('font-size', isPreview ? '9px' : '11px').attr('font-weight', '700') - .text(d => trunc(d.name, isPreview ? 14 : 20)); - - childG.append('text') - .attr('text-anchor', 'middle').attr('dy', isPreview ? 10 : 12) - .attr('fill', d => d.paper_count > 0 ? '#c4704b' : '#b5ada4') - .attr('font-size', isPreview ? '8px' : '10px').attr('font-weight', '700') - .text(d => d.paper_count > 0 ? tr('common.paperShort', { count: d.paper_count }) : tr('common.emptyGraphNode')); - - if (!isPreview) { - childG.filter(d => d.gap_count > 0 || d.method_count > 0) - .append('text') - .attr('text-anchor', 'middle').attr('dy', 24) - .attr('font-size', '9px') - .attr('fill', d => d.gap_count > 0 ? '#3d8b5e' : '#9a9088') - .attr('font-weight', '600') - .text(d => { - const parts = []; - if (d.method_count > 0) parts.push(d.method_count + 'M'); - if (d.gap_count > 0) parts.push(tr('common.gapsCount', { count: d.gap_count })); - return parts.join(' | '); - }); - } - - // Click handler - childG.on('click', (e, d) => { - if (isPreview) { - switchTab('explore'); - navigateTo(d.id); - } else { - navigateTo(d.id); - } + if (!isPreview) _taxGraphHandle = handle; + return handle; +} + +function entityLegendItems(model) { + const types = Array.from(new Set((model.nodes || []).map((n) => n.entity_type))).slice(0, 6); + const t = graphTheme(); + return types.map((ty) => ({ color: t.entityPalette[ty] || t.entityPalette.concept, label: tr('entityType.' + ty, {}) !== 'entityType.' + ty ? tr('entityType.' + ty) : ty })); +} + +function renderEntityGraph(containerId, graphSummary, opts) { + const container = el(containerId); + if (!container || !window.DGGraphRenderer) return null; + const model = window.DGGraphAdapter.entityGraphToModel(graphSummary); + if (!model.nodes.length) { + container.innerHTML = `

${esc(tr('empty.entities'))}

`; + return null; + } + return window.DGGraphRenderer.renderNetwork(container, model, { + height: (opts && opts.height) || 360, + theme: graphTheme(), + tooltip: dgTooltip, + nodeTooltipHtml: (n) => `
${esc(n.name)}
+
${esc(n.entity_type)}
+
+ ${n.paper_count} ${esc(tr('common.papersUnit'))} + ${n.degree} ${esc(tr('graph.links'))} +
+
${esc(tr('graph.clickEntity'))}
`, + legendItems: entityLegendItems(model), + moreLabel: tr('graph.moreNodes'), + onNodeClick: (n) => window._dg.searchEntity(n.name), }); +} - // Tooltip (non-preview only) - if (!isPreview) { - const tip = el('tooltip'); - - childG.on('mouseover', (e, d) => { - tip.innerHTML = ` -
${esc(d.name)}
-
${esc(trunc(d.description, 160))}
-
- ${d.paper_count} ${esc(tr('common.papersUnit'))} - ${d.method_count} ${esc(tr('common.methodsUnit'))} - ${d.gap_count} ${esc(tr('common.gapsUnit'))} +// In-graph "domain → gap → discovery" story panel (acceptance A2). Clicking an +// area node fetches its gaps / contradictions / discoveries and shows them +// without leaving the graph; a button navigates deeper on demand. +async function openAreaStory(node) { + const panel = el('exploreStoryPanel'); + if (!panel) return; + panel.hidden = false; + panel.innerHTML = `
${esc(tr('common.loading'))}
`; + try { + const [data, insights] = await Promise.all([ + api(`/api/taxonomy/${encodeURIComponent(node.id)}`), + api(`/api/insights?node_id=${encodeURIComponent(node.id)}&limit=12`), + ]); + const gaps = (data.gaps && data.gaps.length) + ? data.gaps + : ((data.summary && data.summary.current_gaps) || []); + const contradictions = insights.filter((i) => i.insight_type === 'contradiction_analysis'); + const discoveries = insights.filter((i) => i.insight_type !== 'contradiction_analysis'); + + const gapHtml = gaps.length ? gaps.slice(0, 5).map((g) => ` +
  • ${esc(g.title || g.gap_description || tr('explore.openGap'))} + ${g.description ? `${esc(trunc(g.description, 110))}` : ''}
  • `).join('') + : `
  • ${esc(tr('empty.gaps'))}
  • `; + + const contraHtml = contradictions.length ? contradictions.slice(0, 4).map((c) => ` +
  • ${esc(c.title)} + ${esc(trunc(c.evidence || '', 100))}
  • `).join('') + : `
  • ${esc(tr('story.noContradictions'))}
  • `; + + const discHtml = discoveries.length ? discoveries.slice(0, 5).map((d) => ` +
  • ${esc(d.title)} + N:${d.novelty_score}/5 · F:${d.feasibility_score}/5
  • `).join('') + : `
  • ${esc(tr('story.noDiscoveries'))}
  • `; + + panel.innerHTML = ` +
    +
    +
    ${esc(tr('story.area'))}
    +

    ${esc(node.name)}

    -
    ${esc(tr('common.clickToExplore'))}
    - `; - tip.classList.add('visible'); - positionTooltip(e); - }).on('mousemove', positionTooltip) - .on('mouseout', () => tip.classList.remove('visible')); - } -} - -function positionTooltip(e) { - const tip = el('tooltip'); - const pad = 14; - let x = e.clientX + pad; - let y = e.clientY - pad; - // Keep in viewport - const tw = tip.offsetWidth, th = tip.offsetHeight; - if (x + tw > window.innerWidth - 10) x = e.clientX - tw - pad; - if (y + th > window.innerHeight - 10) y = window.innerHeight - th - 10; - if (y < 10) y = 10; - tip.style.left = x + 'px'; - tip.style.top = y + 'px'; -} - -function gapColor(gapCount, maxGap) { - if (gapCount <= 0) return { fill: '#f0ede6', stroke: '#d0c9bc' }; - const t = Math.min(gapCount / Math.max(maxGap, 1), 1); - return { - fill: `rgb(${Math.round(250 - t * 20)},${Math.round(245 - t * 20)},${Math.round(238 - t * 20)})`, - stroke: `rgb(${Math.round(196 - t * 40)},${Math.round(112 + t * 10)},${Math.round(75 + t * 10)})` - }; + +
    +
    + ${esc(tr('story.stepArea'))} + + ${esc(tr('story.stepGap'))} + + ${esc(tr('story.stepDiscovery'))} +
    +
    +
    ${esc(tr('story.gaps'))} ${gaps.length}
    + +
    +
    +
    ${esc(tr('story.contradictions'))} ${contradictions.length}
    + +
    +
    +
    ${esc(tr('story.discoveries'))} ${discoveries.length}
    + +
    + `; + } catch (e) { + console.error('Area story error:', e); + panel.innerHTML = `
    ${esc(tr('common.errorLoadingData'))}
    + `; + } +} + +function closeAreaStory() { + const panel = el('exploreStoryPanel'); + if (panel) { panel.hidden = true; panel.innerHTML = ''; } } // ── Evidence Tab ───────────────────────────────────────────────────── @@ -911,6 +915,7 @@ async function loadEvidenceForNode(nodeId) { if (!nodeId) { el('evidenceMatrixContainer').innerHTML = ''; el('evidenceGapsCard').style.display = 'none'; + el('evidenceGraphCard').style.display = 'none'; el('evidenceHint').textContent = tr('evidence.hint'); return; } @@ -938,6 +943,16 @@ async function loadEvidenceForNode(nodeId) { } else { gapsCard.style.display = 'none'; } + + // Entity-relation network + const gs = data.graph_summary; + const graphCard = el('evidenceGraphCard'); + if (gs && ((gs.top_entities && gs.top_entities.length) || (gs.top_relations && gs.top_relations.length))) { + graphCard.style.display = ''; + renderEntityGraph('evidenceEntityGraph', gs, { height: 420 }); + } else { + graphCard.style.display = 'none'; + } } catch (e) { console.error('Evidence load error:', e); el('evidenceHint').textContent = tr('common.errorLoadingData'); @@ -2358,6 +2373,16 @@ window._dg = { togglePaper, updateMatrixMetric, searchNav, + closeAreaStory, + // Entity click-through: surface the entity's papers/insights via global search. + searchEntity(name) { + const input = el('searchInput'); + if (input) { + input.value = name; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + } + }, viewPaperGeneration(insightId) { return this.viewExperimentGroup(insightId); }, @@ -2558,6 +2583,9 @@ window._dg = { // ── Init ───────────────────────────────────────────────────────────── function init() { + // Custom tooltips for [data-i18n-title] metric cards (replaces native title). + if (window.DGTooltip) window.DGTooltip.attachDelegated(); + if (window.dgI18n) { window.dgI18n.applyI18n(document); $$('[data-lang]').forEach(btn => { @@ -2566,6 +2594,12 @@ function init() { document.addEventListener('deepgraph:languagechange', () => { updateLiveBadge(); if (taxonomyLoaded) loadTaxonomyDropdown(); + // Re-render graphs so legend/tooltip labels follow the new language. + if (overviewGraphLoaded) { overviewGraphLoaded = false; loadOverviewGraph(); } + if (exploreData) { + closeAreaStory(); + renderTaxonomyGraph('exploreGraph', exploreData.node, exploreData.children, { height: 520, isPreview: false }); + } }); } diff --git a/web/static/js/graph/adapter.js b/web/static/js/graph/adapter.js new file mode 100644 index 0000000..446e963 Binary files /dev/null and b/web/static/js/graph/adapter.js differ diff --git a/web/static/js/graph/renderer.js b/web/static/js/graph/renderer.js new file mode 100644 index 0000000..b4e58b8 --- /dev/null +++ b/web/static/js/graph/renderer.js @@ -0,0 +1,418 @@ +/* ═══════════════════════════════════════════════════════════════════ + DeepGraph — Graph Renderer (D3, the ONLY module that touches D3) + + Swap-in contract: to replace the rendering tech (e.g. canvas, WebGL, a + different lib) rewrite THIS FILE only. The public signatures must not change: + + DGGraphRenderer.renderRadial(container, model, options) -> handle + DGGraphRenderer.renderNetwork(container, model, options) -> handle + + `container` is a DOM element the renderer owns (it clears and fills it). + `model` is the adapter's { kind, nodes, links } — never a raw API payload. + `options` injects EVERYTHING environment-specific so this file stays pure of + app globals (no #tooltip, no navigateTo/switchTab, no window._dg): + height number, px + isPreview bool — compact, fewer affordances, no zoom by default + enableZoom bool — defaults to !isPreview + theme { accent, green, gold, purple, text, dim, muted, bg, + border, gapLo, gapHi, entityPalette:{type:hex} } + onNodeClick(node, event) + tooltip { show(html, ev), move(ev), hide() } (optional) + nodeTooltipHtml(node) -> html string (optional) + legendItems [{ kind|color, label }] (optional) + emptyText string for the empty/leaf state (optional) + labelMaxChars override label budget + + handle = { destroy(), svg }. + ═══════════════════════════════════════════════════════════════════ */ +(function (root, factory) { + const d3 = (root && root.d3) || (typeof require !== 'undefined' ? require('d3') : undefined); + const api = factory(d3); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.DGGraphRenderer = api; +})(typeof window !== 'undefined' ? window : undefined, function (d3) { + 'use strict'; + + const DEFAULT_THEME = { + accent: '#c4704b', + green: '#3d8b5e', + gold: '#a8842a', + purple: '#7c5cbf', + blue: '#2e86ab', + red: '#c4453a', + text: '#2b2520', + dim: '#9a9088', + muted: '#c4bdb4', + bg: '#ffffff', + bgElevated: '#f0ede6', + border: '#e5e0d5', + gapLo: '#eef3ee', // no/low gaps + gapHi: '#3d8b5e', // many gaps (salient) + entityPalette: { + method: '#c4704b', + dataset: '#2e86ab', + metric: '#a8842a', + task: '#7c5cbf', + model: '#c4453a', + artifact: '#3d8b5e', + concept: '#9a9088', + }, + }; + + function theme(opts) { + const t = Object.assign({}, DEFAULT_THEME, opts.theme || {}); + t.entityPalette = Object.assign({}, DEFAULT_THEME.entityPalette, (opts.theme && opts.theme.entityPalette) || {}); + return t; + } + + function truncLabel(s, max) { + if (!s) return ''; + return s.length > max ? s.slice(0, max - 1) + '…' : s; + } + + /* Mount a fresh, sized SVG inside the container and (optionally) a zoom layer. + Returns { svg, layer, width, height, zoom }. */ + function mountSvg(container, opts) { + const sel = d3.select(container); + sel.selectAll('svg.dg-graph-svg').remove(); + const width = Math.max(240, (container.clientWidth || 640) - 4); + const height = opts.height || 420; + const svg = sel.append('svg') + .attr('class', 'dg-graph-svg') + .attr('width', '100%') + .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`) + .attr('preserveAspectRatio', 'xMidYMid meet'); + const layer = svg.append('g').attr('class', 'dg-zoom-layer'); + let zoom = null; + const enableZoom = opts.enableZoom != null ? opts.enableZoom : !opts.isPreview; + if (enableZoom) { + zoom = d3.zoom().scaleExtent([0.3, 6]).on('zoom', (ev) => { + layer.attr('transform', ev.transform); + }); + svg.call(zoom); + svg.on('dblclick.zoom', null); + } + return { svg, layer, width, height, zoom }; + } + + /* Wire injected tooltip + click + neighbour-highlight onto a node selection. + adjacency: Map(nodeId -> Set(neighbourId)). */ + function wireInteractions(nodeSel, linkSel, allNodeSel, adjacency, opts) { + const tip = opts.tooltip; + const htmlFor = opts.nodeTooltipHtml; + nodeSel + .style('cursor', 'pointer') + .on('click', (ev, d) => { if (opts.onNodeClick) opts.onNodeClick(d, ev); }) + .on('mouseover', function (ev, d) { + if (tip && htmlFor) { tip.show(htmlFor(d), ev); } + const nbrs = adjacency.get(d.id) || new Set(); + allNodeSel.style('opacity', (o) => (o.id === d.id || nbrs.has(o.id) ? 1 : 0.18)); + if (linkSel) { + linkSel.style('opacity', (l) => { + const s = l.source.id != null ? l.source.id : l.source; + const tg = l.target.id != null ? l.target.id : l.target; + return s === d.id || tg === d.id ? 0.95 : 0.06; + }); + } + }) + .on('mousemove', (ev) => { if (tip) tip.move(ev); }) + .on('mouseout', function () { + if (tip) tip.hide(); + allNodeSel.style('opacity', 1); + if (linkSel) linkSel.style('opacity', null); + }); + } + + function drawLegend(svg, items, t) { + if (!items || !items.length) return; + const g = svg.append('g').attr('class', 'dg-legend').attr('transform', 'translate(14,14)'); + let y = 0; + items.forEach((it) => { + const row = g.append('g').attr('transform', `translate(0,${y})`).attr('data-legend', it.kind || 'item'); + if (it.kind === 'papers') { + row.append('circle').attr('cx', 7).attr('cy', 0).attr('r', 7).attr('fill', 'none').attr('stroke', t.dim).attr('stroke-width', 1.4); + row.append('circle').attr('cx', 7).attr('cy', 0).attr('r', 3).attr('fill', t.dim); + } else if (it.kind === 'gaps') { + row.append('rect').attr('x', 0).attr('y', -6).attr('width', 14).attr('height', 12).attr('rx', 3) + .attr('fill', t.gapHi).attr('opacity', 0.85); + } else if (it.kind === 'methods') { + row.append('circle').attr('cx', 7).attr('cy', 0).attr('r', 6).attr('fill', t.bg).attr('stroke', t.gold).attr('stroke-width', 2); + } else { + row.append('circle').attr('cx', 7).attr('cy', 0).attr('r', 6).attr('fill', it.color || t.accent); + } + row.append('text').attr('x', 20).attr('y', 4).attr('class', 'dg-legend-label') + .attr('font-size', '10.5px').attr('fill', t.dim).text(it.label); + y += 20; + }); + } + + function emptyState(container, opts, msg) { + const m = mountSvg(container, Object.assign({}, opts, { enableZoom: false })); + m.layer.append('text') + .attr('x', m.width / 2).attr('y', m.height / 2) + .attr('text-anchor', 'middle').attr('fill', '#9a9088') + .attr('font-size', '14px').attr('font-weight', '600') + .text(msg || ''); + return { destroy() { d3.select(container).selectAll('svg.dg-graph-svg').remove(); }, svg: m.svg.node() }; + } + + // ── Radial (taxonomy) ────────────────────────────────────────────── + function renderRadial(container, model, options) { + const opts = options || {}; + const t = theme(opts); + const nodes = model.nodes || []; + const parent = nodes.find((n) => n.role === 'parent') || nodes[0] || {}; + let children = nodes.filter((n) => n.role === 'child'); + + if (!children.length) { + return emptyState(container, opts, opts.emptyText || truncLabel(parent.description || parent.name || '', 80)); + } + + // Defensive cap: a radial star with hundreds of labelled nodes is both + // unreadable and a DOM-cost hazard (this is the freeze the perf gate guards). + // Real taxonomy nodes have ~12 children, so this never fires in practice. + const maxNodes = opts.maxNodes || 60; + let overflow = 0; + if (children.length > maxNodes) { + overflow = children.length - maxNodes; + children = children.slice().sort((a, b) => (b.paper_count - a.paper_count) || (b.gap_count - a.gap_count)).slice(0, maxNodes); + if (typeof console !== 'undefined') console.info(`[DGGraphRenderer] radial capped to ${maxNodes} nodes (+${overflow} hidden)`); + } + + const m = mountSvg(container, opts); + const cx = m.width / 2; + const cy = m.height / 2; + const isPreview = !!opts.isPreview; + const labelMax = opts.labelMaxChars || (isPreview ? 16 : 28); + + const maxPapers = Math.max(1, d3.max(children, (c) => c.paper_count) || 1); + const maxGap = Math.max(1, d3.max(children, (c) => c.gap_count) || 1); + const rNode = d3.scaleSqrt().domain([0, maxPapers]).range(isPreview ? [10, 26] : [14, 36]); + const gapColor = (g) => (g > 0 ? d3.interpolateRgb(t.gapLo, t.gapHi)(Math.min(1, g / maxGap)) : t.bgElevated); + + const radius = Math.min(m.width, m.height) * (isPreview ? 0.30 : 0.34); + const step = (2 * Math.PI) / children.length; + parent.x = cx; parent.y = cy; + children.forEach((c, i) => { + const a = step * i - Math.PI / 2; + c._angle = a; + c.x = cx + Math.cos(a) * radius; + c.y = cy + Math.sin(a) * radius; + }); + + const adjacency = new Map(); + adjacency.set(parent.id, new Set(children.map((c) => c.id))); + children.forEach((c) => adjacency.set(c.id, new Set([parent.id]))); + + // links (parent -> child); carry {source,target} so highlight logic works + const radialLinks = children.map((c) => ({ source: parent.id, target: c.id, _c: c })); + const linkSel = m.layer.append('g').attr('class', 'dg-links').selectAll('line') + .data(radialLinks).join('line') + .attr('class', 'dg-link') + .attr('x1', cx).attr('y1', cy) + .attr('x2', (d) => d._c.x).attr('y2', (d) => d._c.y) + .attr('stroke', t.accent).attr('stroke-opacity', 0.22) + .attr('stroke-width', (d) => 1 + Math.min(3, (d._c.paper_count || 0) / Math.max(1, maxPapers) * 3)); + + const allG = m.layer.append('g').attr('class', 'dg-nodes'); + + // parent + const pg = allG.append('g').datum(parent).attr('class', 'dg-node dg-node-parent') + .attr('transform', `translate(${cx},${cy})`); + pg.append('circle').attr('r', isPreview ? 26 : 32).attr('fill', t.bg) + .attr('stroke', t.accent).attr('stroke-width', 2.5); + pg.append('text').attr('text-anchor', 'middle').attr('dy', 4) + .attr('fill', t.accent).attr('font-size', isPreview ? '10px' : '11.5px').attr('font-weight', 700) + .text(truncLabel(parent.name, isPreview ? 14 : 20)); + + // children + const cg = allG.selectAll('g.dg-node-child').data(children).join('g') + .attr('class', 'dg-node dg-node-child') + .attr('transform', (d) => `translate(${d.x},${d.y})`); + + cg.append('circle') + .attr('r', (d) => rNode(d.paper_count)) + .attr('fill', (d) => gapColor(d.gap_count)) + .attr('stroke', (d) => (d.gap_count > 0 ? t.gapHi : t.border)) + .attr('stroke-width', (d) => (d.gap_count > 0 ? 2.2 : 1.2)); + + // method badge (corner): small ringed circle, count inside (non-preview) + const badged = cg.filter((d) => d.method_count > 0); + badged.append('circle') + .attr('class', 'dg-method-badge') + .attr('cx', (d) => rNode(d.paper_count) * 0.72) + .attr('cy', (d) => -rNode(d.paper_count) * 0.72) + .attr('r', isPreview ? 5 : 7) + .attr('fill', t.bg).attr('stroke', t.gold).attr('stroke-width', 2); + if (!isPreview) { + badged.append('text') + .attr('x', (d) => rNode(d.paper_count) * 0.72) + .attr('y', (d) => -rNode(d.paper_count) * 0.72 + 3) + .attr('text-anchor', 'middle').attr('font-size', '8px').attr('font-weight', 700) + .attr('fill', t.gold).text((d) => d.method_count); + } + + // labels: placed OUTSIDE the node along the radial direction, anchored by + // side so 10+ children stay readable instead of overlapping a centred trunc. + cg.append('text') + .attr('class', 'dg-node-label') + .attr('text-anchor', (d) => (Math.cos(d._angle) < -0.2 ? 'end' : (Math.cos(d._angle) > 0.2 ? 'start' : 'middle'))) + .attr('dx', (d) => { + const off = rNode(d.paper_count) + 5; + return Math.cos(d._angle) < -0.2 ? -off : (Math.cos(d._angle) > 0.2 ? off : 0); + }) + .attr('dy', (d) => { + const off = rNode(d.paper_count) + (Math.sin(d._angle) > 0 ? 14 : -8); + return Math.abs(Math.cos(d._angle)) <= 0.2 ? off : 4; + }) + .attr('fill', t.text).attr('font-size', isPreview ? '9.5px' : '11px').attr('font-weight', 600) + .text((d) => truncLabel(d.name, labelMax)); + + // paper count sub-label + cg.append('text') + .attr('text-anchor', 'middle').attr('dy', 3) + .attr('fill', (d) => (d.paper_count > 0 ? t.accent : t.muted)) + .attr('font-size', isPreview ? '8px' : '9.5px').attr('font-weight', 700) + .attr('pointer-events', 'none') + .text((d) => (d.paper_count > 0 ? d.paper_count : '')); + + const allNodeSel = allG.selectAll('g.dg-node'); + wireInteractions(cg, linkSel, allNodeSel, adjacency, opts); + // parent hover highlights everything (no dim) + pg.on('mouseover', () => allNodeSel.style('opacity', 1)); + + drawLegend(m.svg, opts.legendItems, t); + if (overflow > 0) { + m.svg.append('text').attr('class', 'dg-overflow') + .attr('x', m.width - 12).attr('y', m.height - 12).attr('text-anchor', 'end') + .attr('fill', t.dim).attr('font-size', '11px') + .text((opts.moreLabel || '+%d more').replace('%d', overflow)); + } + + return { + svg: m.svg.node(), + destroy() { d3.select(container).selectAll('svg.dg-graph-svg').remove(); }, + }; + } + + // ── Network (entity-relation) ────────────────────────────────────── + function renderNetwork(container, model, options) { + const opts = options || {}; + const t = theme(opts); + let nodes = (model.nodes || []).map((n) => Object.assign({}, n)); // copy: sim mutates x/y + let rawLinks = model.links || []; + + if (!nodes.length) { + return emptyState(container, opts, opts.emptyText || ''); + } + + // Defensive cap (the adapter usually caps already; backend caps to ~12). + const maxNodes = opts.maxNodes || 80; + let overflow = 0; + if (nodes.length > maxNodes) { + overflow = nodes.length - maxNodes; + nodes = nodes.slice().sort((a, b) => (b.degree - a.degree) || (b.paper_count - a.paper_count)).slice(0, maxNodes); + const keep = new Set(nodes.map((n) => n.id)); + rawLinks = rawLinks.filter((l) => keep.has(l.source) && keep.has(l.target)); + if (typeof console !== 'undefined') console.info(`[DGGraphRenderer] network capped to ${maxNodes} nodes (+${overflow} hidden)`); + } + + const m = mountSvg(container, opts); + const cx = m.width / 2; + const cy = m.height / 2; + const byId = new Map(nodes.map((n) => [n.id, n])); + const links = []; + const adjacency = new Map(nodes.map((n) => [n.id, new Set()])); + for (let i = 0; i < rawLinks.length; i += 1) { + const l = rawLinks[i]; + if (byId.has(l.source) && byId.has(l.target)) { + links.push(Object.assign({}, l)); + adjacency.get(l.source).add(l.target); + adjacency.get(l.target).add(l.source); + } + } + + const maxDeg = Math.max(1, d3.max(nodes, (n) => n.degree) || 1); + const maxPapers = Math.max(1, d3.max(nodes, (n) => n.paper_count) || 1); + const rNode = d3.scaleSqrt().domain([0, maxDeg + maxPapers]).range([6, 22]); + const sizeOf = (n) => rNode((n.degree || 0) + (n.paper_count || 0)); + const colorOf = (n) => t.entityPalette[n.entity_type] || t.entityPalette.concept; + + // Deterministic initial layout on a ring (no Math.random -> reproducible). + const ringR = Math.min(m.width, m.height) * 0.36; + nodes.forEach((n, i) => { + const a = (2 * Math.PI * i) / nodes.length; + n.x = cx + Math.cos(a) * ringR; + n.y = cy + Math.sin(a) * ringR; + }); + + // Pretty force layout only for realistic sizes; large N falls back to the + // static ring so the renderer stays O(n) (the N=5000 perf gate). The backend + // caps to ~12 nodes, so force is the normal path. + const forceMax = opts.forceMaxNodes || 140; + if (nodes.length <= forceMax && links.length) { + const sim = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id((d) => d.id).distance(70).strength(0.6)) + .force('charge', d3.forceManyBody().strength(-160)) + .force('center', d3.forceCenter(cx, cy)) + .force('collide', d3.forceCollide().radius((n) => sizeOf(n) + 6)) + .stop(); + const ticks = Math.min(200, 80 + nodes.length); + for (let i = 0; i < ticks; i += 1) sim.tick(); + } + // clamp into viewport + const pad = 30; + nodes.forEach((n) => { + n.x = Math.max(pad, Math.min(m.width - pad, n.x)); + n.y = Math.max(pad, Math.min(m.height - pad, n.y)); + }); + + // d3.forceLink replaces link.source/target ids with node objects; resolve either form. + const endp = (e) => (e && e.x != null ? e : byId.get(e)); + const maxLinkPapers = Math.max(1, d3.max(links, (l) => l.paper_count) || 1); + const linkSel = m.layer.append('g').attr('class', 'dg-links').selectAll('line') + .data(links).join('line') + .attr('class', 'dg-link') + .attr('x1', (l) => endp(l.source).x).attr('y1', (l) => endp(l.source).y) + .attr('x2', (l) => endp(l.target).x).attr('y2', (l) => endp(l.target).y) + .attr('stroke', t.border).attr('stroke-opacity', 0.5) + .attr('stroke-width', (l) => 1 + (l.paper_count || 0) / maxLinkPapers * 2.5); + + const g = m.layer.append('g').attr('class', 'dg-nodes').selectAll('g') + .data(nodes).join('g') + .attr('class', 'dg-node dg-node-entity') + .attr('transform', (n) => `translate(${n.x},${n.y})`); + + g.append('circle') + .attr('r', sizeOf).attr('fill', colorOf).attr('fill-opacity', 0.9) + .attr('stroke', t.bg).attr('stroke-width', 1.5); + + // labels for the most connected nodes (avoid clutter on large graphs) + const labelCut = nodes.length <= 30 ? 0 : (d3.quantile(nodes.map((n) => n.degree).sort(d3.ascending), 0.6) || 0); + g.filter((n) => n.degree >= labelCut) + .append('text') + .attr('class', 'dg-node-label') + .attr('text-anchor', 'middle') + .attr('dy', (n) => sizeOf(n) + 11) + .attr('fill', t.text).attr('font-size', '10px').attr('font-weight', 600) + .attr('pointer-events', 'none') + .text((n) => truncLabel(n.name, 22)); + + wireInteractions(g, linkSel, g, adjacency, opts); + drawLegend(m.svg, opts.legendItems, t); + if (overflow > 0) { + m.svg.append('text').attr('class', 'dg-overflow') + .attr('x', m.width - 12).attr('y', m.height - 12).attr('text-anchor', 'end') + .attr('fill', t.dim).attr('font-size', '11px') + .text((opts.moreLabel || '+%d more').replace('%d', overflow)); + } + + return { + svg: m.svg.node(), + destroy() { d3.select(container).selectAll('svg.dg-graph-svg').remove(); }, + }; + } + + return { renderRadial, renderNetwork }; +}); diff --git a/web/static/js/graph/tooltip.js b/web/static/js/graph/tooltip.js new file mode 100644 index 0000000..db9da71 --- /dev/null +++ b/web/static/js/graph/tooltip.js @@ -0,0 +1,75 @@ +/* ═══════════════════════════════════════════════════════════════════ + DeepGraph — Custom Tooltip controller (acceptance D) + + Replaces native `title=` popups with a styled, instant tooltip that matches + the dashboard. One shared element (#tooltip) serves two callers: + + 1. The graph renderer — via the injected { show, move, hide } handle, so the + renderer never touches #tooltip itself (keeps it swap-clean). + 2. Any element carrying data-i18n-title (the 9 metric cards) — wired here by + document-level delegation. Text is resolved through window.t at hover + time, so it always follows the current language (zh/en) and reuses the + existing i18n keys; nothing sets the native `title` attribute anymore. + ═══════════════════════════════════════════════════════════════════ */ +(function () { + 'use strict'; + + function tipEl() { return document.getElementById('tooltip'); } + + function position(ev) { + const tip = tipEl(); + if (!tip) return; + const pad = 14; + let x = ev.clientX + pad; + let y = ev.clientY - pad; + const tw = tip.offsetWidth || 240; + const th = tip.offsetHeight || 80; + if (x + tw > window.innerWidth - 10) x = ev.clientX - tw - pad; + if (y + th > window.innerHeight - 10) y = window.innerHeight - th - 10; + if (y < 10) y = 10; + tip.style.left = x + 'px'; + tip.style.top = y + 'px'; + } + + function show(html, ev) { + const tip = tipEl(); + if (!tip) return; + tip.innerHTML = html; + tip.classList.add('visible'); + if (ev) position(ev); + } + + function move(ev) { + const tip = tipEl(); + if (tip && tip.classList.contains('visible')) position(ev); + } + + function hide() { + const tip = tipEl(); + if (tip) tip.classList.remove('visible'); + } + + /* Delegated tooltips for [data-i18n-title] elements (metric cards etc.). */ + function attachDelegated() { + const t = (key) => (window.t ? window.t(key) : key); + document.addEventListener('mouseover', (ev) => { + const host = ev.target.closest && ev.target.closest('[data-i18n-title]'); + if (!host) return; + const text = t(host.dataset.i18nTitle); + if (!text) return; + show(`
    ${text}
    `, ev); + }); + document.addEventListener('mousemove', (ev) => { + const host = ev.target.closest && ev.target.closest('[data-i18n-title]'); + if (host) move(ev); + }); + document.addEventListener('mouseout', (ev) => { + const host = ev.target.closest && ev.target.closest('[data-i18n-title]'); + if (!host) return; + // only hide when leaving the host entirely + if (!ev.relatedTarget || !host.contains(ev.relatedTarget)) hide(); + }); + } + + window.DGTooltip = { show, move, hide, position, attachDelegated }; +})(); diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js index b390877..4445b18 100644 --- a/web/static/js/i18n.js +++ b/web/static/js/i18n.js @@ -4,6 +4,34 @@ const I18N = { en: { + "graph.zoomHint": "Scroll to zoom · drag to pan · click an area for its gaps & discoveries", + "graph.entityHint": "Click an entity to find its papers & insights", + "graph.legendPapers": "Size = papers", + "graph.legendGaps": "Color = open gaps", + "graph.legendMethods": "Badge = methods", + "graph.clickForStory": "Click for gaps & discoveries", + "graph.clickEntity": "Click to search papers & insights", + "graph.moreNodes": "+%d more", + "graph.links": "links", + "explore.entityNetwork": "Entity-Relation Network", + "evidence.entityGraph": "Entity-Relation Network", + "story.area": "Research area", + "story.stepArea": "Area", + "story.stepGap": "Gaps", + "story.stepDiscovery": "Discoveries", + "story.gaps": "Open gaps", + "story.contradictions": "Contradictions", + "story.discoveries": "Discoveries", + "story.noContradictions": "No contradictions surfaced yet.", + "story.noDiscoveries": "No discoveries generated yet.", + "story.enterArea": "Explore this area →", + "entityType.method": "Method", + "entityType.dataset": "Dataset", + "entityType.metric": "Metric", + "entityType.task": "Task", + "entityType.model": "Model", + "entityType.artifact": "Artifact", + "entityType.concept": "Concept", "app.live.idle": "IDLE", "app.live.live": "LIVE", "topbar.search.placeholder": "Search source papers, methods, research insights...", @@ -417,6 +445,34 @@ "manuscript.lintPreview": "LINT PREVIEW" }, zh: { + "graph.zoomHint": "滚轮缩放 · 拖拽平移 · 点击领域查看空白与发现", + "graph.entityHint": "点击实体可查找相关文献与洞见", + "graph.legendPapers": "大小 = 文献量", + "graph.legendGaps": "颜色 = 空白数", + "graph.legendMethods": "角标 = 方法数", + "graph.clickForStory": "点击查看空白与发现", + "graph.clickEntity": "点击搜索相关文献与洞见", + "graph.moreNodes": "+%d 更多", + "graph.links": "关联", + "explore.entityNetwork": "实体关系网络", + "evidence.entityGraph": "实体关系网络", + "story.area": "研究领域", + "story.stepArea": "领域", + "story.stepGap": "空白", + "story.stepDiscovery": "发现", + "story.gaps": "研究空白", + "story.contradictions": "矛盾", + "story.discoveries": "深度发现", + "story.noContradictions": "暂未发现矛盾。", + "story.noDiscoveries": "暂未生成发现。", + "story.enterArea": "进入该领域 →", + "entityType.method": "方法", + "entityType.dataset": "数据集", + "entityType.metric": "指标", + "entityType.task": "任务", + "entityType.model": "模型", + "entityType.artifact": "工件", + "entityType.concept": "概念", "app.live.idle": "空闲", "app.live.live": "运行中", "topbar.search.placeholder": "搜索文献、方法、研究洞见...", @@ -864,8 +920,12 @@ scope.querySelectorAll("[data-i18n-placeholder]").forEach((node) => { node.setAttribute("placeholder", t(node.dataset.i18nPlaceholder)); }); + // data-i18n-title elements get a styled custom tooltip (DGTooltip), not the + // native `title` popup. We keep the i18n key on the element and resolve it at + // hover time, so the tooltip always follows the current language. Any stale + // native title (e.g. from an older render) is cleared. scope.querySelectorAll("[data-i18n-title]").forEach((node) => { - node.setAttribute("title", t(node.dataset.i18nTitle)); + if (node.hasAttribute("title")) node.removeAttribute("title"); }); // NOTE: documentElement.lang is set once at init (see preferredLanguage // above), NOT here — rewriting it per toggle forces a full-document restyle. diff --git a/web/templates/index.html b/web/templates/index.html index b99a1e5..afdb591 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -173,7 +173,7 @@

    Research Area Explorer

    - +
    @@ -199,8 +199,12 @@

    Current Processing

    Research Area Explorer

    + Scroll to zoom · drag to pan · click an area for its gaps & discoveries +
    +
    +
    +
    -
    +