docling-studio/frontend/src/features/analysis/ui/GraphView.vue
Pier-Jean Malandrino 9ec64961fc feat(reasoning): bidirectional PDF ↔ graph focus + DocumentView mode
Propagate Docling `self_ref` through PageElement so bboxes and graph nodes
share a stable identity. Add a Document/Graph mode switch to the reasoning
workspace; selecting a node highlights its bbox (numbered badge, focus ring,
optional dim of non-visited) and clicking a bbox re-centers the graph.
2026-04-23 22:07:58 +02:00

695 lines
21 KiB
Vue

<template>
<div class="graph-view" data-e2e="graph-view">
<div v-if="loading" class="graph-placeholder">
<div class="spinner-large" />
<span>{{ t('results.graphLoading') }}</span>
</div>
<div v-else-if="error" class="graph-placeholder error" data-e2e="graph-error">
<span>{{ error }}</span>
<button class="retry-btn" @click="load">{{ t('results.retry') }}</button>
</div>
<div v-else-if="empty" class="graph-placeholder">
<span>{{ t('results.graphEmpty') }}</span>
</div>
<template v-else>
<div class="graph-toolbar">
<span class="graph-stats">
{{ payload?.node_count }} nodes · {{ payload?.edge_count }} edges ·
{{ payload?.page_count }} pages
</span>
<span class="graph-legend">
<button
v-for="chip in visibleChips"
:key="chip.key"
type="button"
class="legend-chip"
:class="[`legend-${chip.key}`, { 'legend-off': hiddenChips.has(chip.key) }]"
:data-e2e="`legend-${chip.key}`"
:title="
hiddenChips.has(chip.key) ? `Show ${chip.label} nodes` : `Hide ${chip.label} nodes`
"
:aria-pressed="!hiddenChips.has(chip.key)"
@click="toggleChip(chip.key)"
>
{{ chip.label }}
</button>
</span>
</div>
<div class="graph-body">
<div class="graph-canvas-wrap">
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
<!-- Hover tooltip, positioned inside the canvas wrap so its
coords match cy.renderedPosition() exactly. -->
<div
v-if="tooltip"
class="graph-tooltip"
data-e2e="graph-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
{{ tooltip.text }}
</div>
</div>
<NodeDetailsPanel
:node="selectedNode"
:contents="selectedNodeContents"
@close="closeDetails"
@navigate="navigateToNode"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
import { LEGEND_CHIPS, findChip } from '../legendFilters'
import { computeSectionParents, explicitParentMap, mergeParentMaps } from '../sectionParenting'
import NodeDetailsPanel from './NodeDetailsPanel.vue'
// `fetcher` is optional so Maintain can keep using the Neo4j-backed endpoint
// (`fetchDocumentGraph`, the default) while the reasoning-trace page can
// inject a SQLite-backed fetcher that returns the same `GraphPayload` shape
// without requiring Neo4j. Keeping this at the component boundary means the
// rendering pipeline below doesn't care where the graph came from.
const props = withDefaults(
defineProps<{
docId: string | null
fetcher?: (docId: string) => Promise<GraphPayload>
}>(),
{ fetcher: fetchDocumentGraph },
)
const emit = defineEmits<{
/** Emitted on node tap with the element's `self_ref` (null when the tap
* cleared the selection, or when the tapped node has no self_ref —
* Document / Page / Chunk). Consumers can mirror the selection elsewhere
* (e.g. the ReasoningWorkspace syncs it to the PDF viewer). */
nodeFocus: [selfRef: string | null]
}>()
const { t } = useI18n()
const containerRef = ref<HTMLDivElement | null>(null)
const payload = ref<GraphPayload | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const empty = ref(false)
// Exposed via defineExpose so parent components (e.g. the reasoning trace
// overlay) can read the live Cytoscape instance reactively. Null while the
// graph is loading / empty / unmounted.
const cy = ref<Core | null>(null)
// Legend-driven visibility: user clicks a chip → its key lands here and the
// matching nodes get the `.hidden` Cytoscape class. Reset to empty every time
// the doc changes (see the `watch(() => props.docId)` below) — kept as a Set
// because order doesn't matter and membership checks are the hot path.
const hiddenChips = ref<Set<string>>(new Set())
// Click → details panel. Null = panel hidden.
const selectedNode = ref<GraphNode | null>(null)
// Compound parenting map (childId → parentId), kept in sync with the
// Cytoscape render so the details panel can show "this section contains …".
// Updated at the end of `renderGraph` — before that it's empty.
const parentMap = ref<Map<string, string>>(new Map())
// Inverse index of parentMap: parentId → childId[]. Enables the
// NodeDetailsPanel "contents" section (click a section → see what's in it).
const childrenByParent = computed<Map<string, GraphNode[]>>(() => {
const out = new Map<string, GraphNode[]>()
const byId = new Map<string, GraphNode>()
for (const n of payload.value?.nodes ?? []) byId.set(n.id, n)
for (const [childId, parentId] of parentMap.value) {
const child = byId.get(childId)
if (!child) continue
if (!out.has(parentId)) out.set(parentId, [])
out.get(parentId)!.push(child)
}
return out
})
const selectedNodeContents = computed<GraphNode[]>(() => {
const id = selectedNode.value?.id
if (!id) return []
return childrenByParent.value.get(id) ?? []
})
// Only surface chips that actually have matching nodes in the current
// payload. Keeps the legend in sync with the source (e.g. Reasoning view
// never emits Chunk nodes, so the Chunk chip would dangle) without
// hardcoding per-view chip lists.
const visibleChips = computed(() => {
const nodes = payload.value?.nodes ?? []
if (nodes.length === 0) return []
return LEGEND_CHIPS.filter((chip) => nodes.some((n) => chip.match(n)))
})
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
const NODE_COLORS: Record<string, string> = {
document: '#1E293B',
SectionHeader: '#F97316',
Paragraph: '#3B82F6',
TextElement: '#3B82F6',
Table: '#8B5CF6',
Figure: '#22C55E',
ListItem: '#06B6D4',
Formula: '#EC4899',
Code: '#14B8A6',
Caption: '#EAB308',
Page: '#94A3B8',
Chunk: '#DC2626',
}
function nodeColor(n: GraphPayload['nodes'][number]): string {
if (n.group === 'document') return NODE_COLORS.document
if (n.group === 'page') return NODE_COLORS.Page
if (n.group === 'chunk') return NODE_COLORS.Chunk
return NODE_COLORS[n.label || 'TextElement'] || NODE_COLORS.TextElement
}
function nodeLabel(n: GraphPayload['nodes'][number]): string {
if (n.group === 'document') return n.title || n.id
if (n.group === 'page') return `p.${n.page_no}`
if (n.group === 'chunk') return `chunk #${n.chunk_index}`
const txt = (n.text || '').slice(0, 40)
return txt || n.label || n.docling_label || n.self_ref || n.id
}
async function load(): Promise<void> {
if (!props.docId) {
empty.value = true
return
}
loading.value = true
error.value = null
empty.value = false
try {
payload.value = await props.fetcher(props.docId)
if (!payload.value.nodes.length) {
empty.value = true
return
}
// Flip loading off so the canvas <div> mounts, then wait a tick before init.
loading.value = false
await nextTick()
await renderGraph()
// Re-apply any chips the user had toggled before this load (e.g. they
// hid Pages on doc A, then navigated to doc B — keep Pages hidden).
applyHiddenChips()
} catch (e) {
error.value = (e as Error).message || 'Failed to load graph'
console.error('Failed to load graph', e)
} finally {
loading.value = false
}
}
async function renderGraph(): Promise<void> {
if (!containerRef.value || !payload.value) return
// Dynamic imports keep the heavy Cytoscape bundle out of the main chunk.
const [{ default: cytoscape }, { default: dagre }, ecMod] = await Promise.all([
import('cytoscape'),
import('cytoscape-dagre'),
import('cytoscape-expand-collapse'),
])
const C = cytoscape as any
C.use(dagre)
// Plugin registration is idempotent — calling use() twice is a no-op.
C.use((ecMod as any).default ?? ecMod)
if (cy.value) {
cy.value.destroy()
cy.value = null
}
// Compute compound parenting: merge docling-native PARENT_OF with the
// synthetic section-scope parents so every non-root element sits inside
// its section visually (enables per-section collapse via the legend chips
// and the expand-collapse plugin). Also persisted on `parentMap` so the
// NodeDetailsPanel can list what a given section contains.
const computedParentMap = mergeParentMaps(
explicitParentMap(payload.value.edges),
computeSectionParents(payload.value.nodes, payload.value.edges),
)
parentMap.value = computedParentMap
const elements = [
...payload.value.nodes.map((n) => ({
data: {
id: n.id,
label: nodeLabel(n),
// `kindLabel` is the specific Neo4j label (SectionHeader, Paragraph,
// Figure, …) — kept as a data attribute so legend filters can match
// on it. `label` above is the human display string for Cytoscape.
kindLabel: n.label ?? null,
bg: nodeColor(n),
group: n.group,
// Compound-node parent: used by the expand-collapse plugin to
// fold/unfold a section's scope. `undefined` = this node is a root
// of the compound hierarchy (Documents, unparented sections, etc.).
parent: computedParentMap.get(n.id),
raw: n,
},
})),
...payload.value.edges.map((e) => ({
data: {
id: e.id,
source: e.source,
target: e.target,
type: e.type,
},
})),
]
cy.value = cytoscape({
container: containerRef.value,
elements,
style: [
{
selector: 'node',
style: {
'background-color': 'data(bg)',
label: 'data(label)',
color: '#0F172A',
'font-size': 10,
'text-wrap': 'ellipsis',
'text-max-width': '140px',
'text-valign': 'center',
'text-halign': 'center',
width: 28,
height: 28,
'border-width': 1,
'border-color': '#0F172A',
},
},
{
selector: 'node[group = "document"]',
style: { shape: 'round-rectangle', width: 60, height: 36, color: '#F8FAFC' },
},
{
selector: 'node[group = "page"]',
style: { shape: 'round-rectangle', width: 40, height: 24 },
},
{
selector: 'node[group = "chunk"]',
style: { shape: 'diamond', color: '#F8FAFC' },
},
{
selector: 'edge',
style: {
width: 1,
'line-color': '#94A3B8',
'target-arrow-color': '#94A3B8',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'font-size': 8,
color: '#64748B',
},
},
{
selector: 'edge[type = "PARENT_OF"]',
style: { 'line-color': '#1E293B', 'target-arrow-color': '#1E293B', width: 1.5 },
},
{
selector: 'edge[type = "NEXT"]',
style: { 'line-style': 'dashed', 'line-color': '#64748B' },
},
{
selector: 'edge[type = "ON_PAGE"]',
style: { 'line-color': '#CBD5E1', width: 1 },
},
{
selector: 'edge[type = "DERIVED_FROM"]',
style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' },
},
// Reasoning-trace overlay: visited-node class + synthetic REASONING_NEXT edges.
...reasoningOverlayStyles,
// Legend-driven visibility: chips toggle the `.hidden` class on
// matching nodes. `display: none` cascades to connected edges for free.
{ selector: 'node.hidden', style: { display: 'none' } },
// Compound nodes (section scope + explicit PARENT_OF containers). A
// node with :parent children is rendered as a bounding box here —
// visually groups the section's content together. Minimal padding so
// the layout stays compact.
{
selector: ':parent',
style: {
'background-opacity': 0.08,
'background-color': '#F97316',
'border-color': '#FDBA74',
'border-width': 1,
'padding-top': '12px',
'padding-bottom': '12px',
'padding-left': '12px',
'padding-right': '12px',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -4,
shape: 'round-rectangle',
},
},
// Click-selected node: stronger border so the user sees which one
// populated the details panel.
{
selector: 'node.nd-selected',
style: {
'border-width': 4,
'border-color': '#0EA5E9',
'overlay-color': '#0EA5E9',
'overlay-opacity': 0.12,
'overlay-padding': 4,
'z-index': 60,
},
},
],
layout: {
name: 'dagre',
rankDir: 'TB',
nodeSep: 30,
edgeSep: 10,
rankSep: 40,
} as any,
wheelSensitivity: 0.15,
})
// --- Plugin activation ---------------------------------------------------
// Expand/collapse on compound nodes. `layoutBy` re-runs dagre after each
// toggle so the graph stays tidy. Don't animate — on big docs the per-node
// tween is choppy and the user just wants the end state.
;(cy.value as any).expandCollapse({
layoutBy: { name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 40 },
fisheye: false,
animate: false,
undoable: false,
cueEnabled: true, // shows the +/- cue on compound nodes
expandCollapseCuePosition: 'top-left',
expandCollapseCueSize: 12,
})
// --- Interactions --------------------------------------------------------
cy.value.on('tap', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
selectedNode.value = raw
// Visual feedback — clear previous selection class first.
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
evt.target.addClass('nd-selected')
// Let the outer workspace mirror the selection (e.g. into the PDF view).
// Nodes without a `self_ref` (Document / Page / Chunk) emit `null` so
// the consumer can reset its focus.
emit('nodeFocus', raw.self_ref ?? null)
})
// Click on background → close details panel + clear cross-view focus.
cy.value.on('tap', (evt) => {
if (evt.target === cy.value) {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
emit('nodeFocus', null)
}
})
// Hover tooltip — shows the full node text (backend truncates to 200 chars).
cy.value.on('mouseover', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
const text = tooltipTextFor(raw)
if (!text) return
const pos = evt.target.renderedPosition()
tooltip.value = { x: pos.x, y: pos.y, text }
})
cy.value.on('mouseout', 'node', () => {
tooltip.value = null
})
cy.value.on('pan zoom', () => {
// Tooltip coordinates are in rendered space; on pan/zoom they're stale.
tooltip.value = null
})
}
/**
* What to show on hover: prefer the node's full text, then title, then ref.
* Keeps the tooltip useful across node kinds (Document/Page/Chunk too).
*/
function tooltipTextFor(n: GraphNode): string {
if (n.group === 'document') return (n.title as string | undefined) ?? n.id
if (n.group === 'page') return `Page ${n.page_no ?? '?'}`
if (n.group === 'chunk') {
const head = ((n.text as string | undefined) ?? '').slice(0, 160)
return head ? `chunk #${n.chunk_index ?? '?'}\n${head}` : `chunk #${n.chunk_index ?? '?'}`
}
const text = (n.text as string | undefined) ?? ''
const ref = n.self_ref ?? ''
if (text) return text
return ref || n.label || ''
}
function closeDetails(): void {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
}
/**
* Triggered when the user clicks a child row inside the NodeDetailsPanel
* (e.g. the "Contents" list of a section). Switch the selection, center the
* viewport on the target, and flash the node briefly so the eye can catch it.
*/
function navigateToNode(target: GraphNode): void {
selectedNode.value = target
if (!cy.value) return
cy.value.nodes('.nd-selected').removeClass('nd-selected')
const el = cy.value.getElementById(target.id)
if (el && el.length > 0) {
el.addClass('nd-selected')
cy.value.animate({ center: { eles: el }, duration: 250 })
}
}
/**
* Mirror an external selection (e.g. user clicked a bbox in the PDF view)
* onto the graph: select the matching node, scroll it into view, update
* the details panel. No-op if the element isn't in the current graph
* (common for a PDF-only element that the reasoning graph didn't emit).
*/
function selectBySelfRef(selfRef: string): void {
const node = payload.value?.nodes.find((n) => n.self_ref === selfRef) ?? null
if (!node) return
navigateToNode(node)
}
function disposeGraph(): void {
if (cy.value) {
cy.value.destroy()
cy.value = null
}
selectedNode.value = null
tooltip.value = null
}
/**
* Apply the current `hiddenChips` set to the Cytoscape instance — marks
* every node whose chip is in the set with the `.hidden` class, and clears
* the class from nodes whose chip is no longer hidden.
*
* Called after every re-render (so chip state survives a doc reload) and
* whenever the user toggles a chip.
*/
function applyHiddenChips(): void {
const c = cy.value
if (!c) return
c.nodes().forEach((n: any) => {
const raw = n.data('raw')
if (!raw) return
const hiddenByChip = [...hiddenChips.value].some((key) => {
const chip = findChip(key)
return chip?.match(raw) ?? false
})
if (hiddenByChip) n.addClass('hidden')
else n.removeClass('hidden')
})
}
function toggleChip(key: string): void {
const next = new Set(hiddenChips.value)
if (next.has(key)) next.delete(key)
else next.add(key)
hiddenChips.value = next
applyHiddenChips()
}
onMounted(load)
onBeforeUnmount(disposeGraph)
watch(
() => props.docId,
() => {
disposeGraph()
load()
},
)
// Let parent components observe the live Cytoscape instance (e.g. the
// reasoning-trace overlay reads it via `graphViewRef.value?.cy`).
defineExpose({ cy, load, selectBySelfRef })
</script>
<style scoped>
.graph-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.graph-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
gap: 12px;
flex-wrap: wrap;
}
.graph-stats {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.graph-legend {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.legend-chip {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
color: #f8fafc;
border: 0;
cursor: pointer;
font-family: inherit;
transition: opacity var(--transition);
}
.legend-chip:hover {
filter: brightness(1.12);
}
/* Inactive (user clicked the chip to hide that node kind). */
.legend-chip.legend-off {
opacity: 0.32;
text-decoration: line-through;
filter: saturate(0.4);
}
.legend-document {
background: #1e293b;
}
.legend-section {
background: #f97316;
}
.legend-paragraph {
background: #3b82f6;
}
.legend-table {
background: #8b5cf6;
}
.legend-figure {
background: #22c55e;
}
.legend-page {
background: #94a3b8;
color: #0f172a;
}
.legend-chunk {
background: #dc2626;
}
.graph-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
.graph-canvas-wrap {
flex: 1 1 auto;
min-width: 0;
position: relative;
overflow: hidden;
}
.graph-canvas {
position: absolute;
inset: 0;
background: var(--bg);
}
.graph-tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 14px));
max-width: 280px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.94);
color: #f8fafc;
font-size: 11px;
line-height: 1.4;
border-radius: var(--radius-sm);
pointer-events: none;
white-space: pre-wrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 20;
}
.graph-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
padding: 32px;
text-align: center;
}
.graph-placeholder.error {
color: var(--error);
}
.retry-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
}
.spinner-large {
width: 32px;
height: 32px;
border: 3px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>