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.entity_type)} \u00B7 ${esc(tr('common.papersCount', { count: e.paper_count }))} \u00B7 ${esc(tr('common.mentionsCount', { count: e.mention_count }))}
${esc(tr('empty.entities'))}
`; - const relHtml = (gs.top_relations || []).slice(0, 6).map(r => - `${esc(r.predicate)} \u00B7 ${esc(tr('common.papersCount', { count: r.paper_count }))}
${esc(tr('empty.relations'))}
`; - - html += `${esc(tr('empty.entities'))}
`; + return null; + } + return window.DGGraphRenderer.renderNetwork(container, model, { + height: (opts && opts.height) || 360, + theme: graphTheme(), + tooltip: dgTooltip, + nodeTooltipHtml: (n) => `