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.
695 lines
21 KiB
Vue
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>
|