import { parseCsvGraph, parseCsvTable, type CsvIssue, type CsvValues } from '@graphora/adapter-csv' import { createGraphEngine } from '@graphora/engine' import { createCircularLayout } from '@graphora/layouts' import { CanvasGraphRenderer, createViewport, fitBounds } from '@graphora/renderer' import './style.css' function required(selector: string): T { const element = document.querySelector(selector) if (!element) throw new Error(`Missing import element: ${selector}`) return element } const graph = required('#graph') const nodes = required('#nodes') const edges = required('#edges') const separator = required('#delimiter') const status = required('#status') const diagnostics = required('#issues') const button = required('#import') const select = (id: string): HTMLSelectElement => required(`#${id}`) const options = () => ({ delimiter: separator.value === 'tab' ? '\t' : separator.value }) nodes.value = 'id,label\napi,API\nworker,Worker\ndb,Database' edges.value = 'source,target\napi,worker\nworker,db' const engine = createGraphEngine({ graph: { nodes: [] }, renderer: new CanvasGraphRenderer(), layout: createCircularLayout({ radius: 120 }), viewport: createViewport({ width: graph.clientWidth, height: graph.clientHeight }), theme: { backgroundColor: '#f8fafc', node: { radius: 18, fill: '#2563eb' }, label: { fontSize: 13 } } }) engine.mount(graph) function recordState(): void { graph.dataset.version = String(engine.store.getVersion()) graph.dataset.nodeIds = JSON.stringify( engine.store.getNodes().map((node) => node.id) ) } function showIssues(issues: readonly CsvIssue[], truncated = false): void { diagnostics.replaceChildren( ...issues.map((issue) => { const item = document.createElement('li') item.textContent = `${issue.table ?? 'Table'}${issue.line === undefined ? '' : ` line ${issue.line}`}${issue.column === undefined ? '' : ` (${issue.column})`}: ${issue.message}` return item }) ) status.value = `Import rejected. Current graph retained.${truncated ? ' Further diagnostics omitted.' : ''}` recordState() } function populate( id: string, columns: readonly string[], optional: boolean, fallback: string ): void { const element = select(id) const previous = element.value element.replaceChildren( ...[...(optional ? [''] : []), ...columns].map( (column) => new Option(column || 'None', column) ) ) element.value = columns.includes(previous) ? previous : columns.includes(fallback) ? fallback : optional ? '' : (columns[0] ?? '') } function preview(): void { const issues: CsvIssue[] = [] const parsedNodes = parseCsvTable(nodes.value, options()) if (parsedNodes.ok) { populate('node-id', parsedNodes.table.columns, false, 'id') populate('node-label', parsedNodes.table.columns, true, 'label') } else issues.push( ...parsedNodes.issues.map((issue) => ({ ...issue, table: 'nodes' as const })) ) if (edges.value.trim()) { const parsedEdges = parseCsvTable(edges.value, options()) if (parsedEdges.ok) { for (const name of ['source', 'target', 'id', 'label']) populate( `edge-${name}`, parsedEdges.table.columns, name === 'id' || name === 'label', name ) } else issues.push( ...parsedEdges.issues.map((issue) => ({ ...issue, table: 'edges' as const })) ) } else for (const name of ['source', 'target', 'id', 'label']) populate(`edge-${name}`, [], true, '') if (issues.length) showIssues(issues) else { diagnostics.replaceChildren() status.value = 'Columns ready. Review mappings, then import.' } } const optional = (id: string): string | undefined => select(id).value || undefined async function importGraph(): Promise { const result = parseCsvGraph( { nodes: { text: nodes.value, columns: { id: select('node-id').value, label: optional('node-label') } }, ...(edges.value.trim() ? { edges: { text: edges.value, columns: { source: select('edge-source').value, target: select('edge-target').value, id: optional('edge-id'), label: optional('edge-label') } } } : {}) }, options() ) if (!result.ok) { showIssues(result.issues, result.issuesTruncated) return } button.disabled = true diagnostics.replaceChildren() try { engine.loadGraph(result.graph) await engine.runLayout({ fitToView: { padding: 56 } }) status.value = `Imported ${engine.store.getNodes().length} nodes and ${engine.store.getEdges().length} ${engine.store.getEdges().length === 1 ? 'edge' : 'edges'}.` } catch (error) { status.value = `Graph display failed: ${error instanceof Error ? error.message : String(error)}` } finally { recordState() button.disabled = false } } required('#preview').addEventListener('click', preview) required('#import-form').addEventListener('submit', (event) => { event.preventDefault() if (!button.disabled) void importGraph() }) const observer = new ResizeObserver(() => { engine.resize({ width: graph.clientWidth, height: graph.clientHeight }) engine.setViewport( fitBounds(engine.getViewport(), engine.store.getBounds(), { padding: 56 }) ) }) observer.observe(graph) preview() void importGraph() window.addEventListener( 'pagehide', () => { observer.disconnect() engine.destroy() }, { once: true } )