From f7659df864465190d6b6e47cbd297e9620dfba55 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 09:14:42 -0400 Subject: [PATCH 1/2] feat: export graph as PNG image or JSON data Adds two buttons to the graph toolbar (next to zoom-to-fit): - PNG: composites every canvas in the graph container onto a single white-backed canvas and downloads it via toBlob, so it works with the current d3 canvas renderer and any future layered renderer. - JSON: serializes the GraphParser node/edge Maps to plain JSON. Edge endpoints may be uid strings or node objects resolved in place by d3-force - both shapes export as uids, keeping the output free of circular references. lib/exportGraph.js is covered by 8 unit tests. Verified in a real browser against a live Dgraph cluster: both buttons download valid files (the PNG renders the graph, the JSON carries correct uid endpoints). Co-Authored-By: Claude Fable 5 --- client/src/components/GraphContainer.js | 27 +++++ client/src/lib/exportGraph.js | 115 ++++++++++++++++++++ client/src/lib/exportGraph.test.js | 138 ++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 client/src/lib/exportGraph.js create mode 100644 client/src/lib/exportGraph.test.js diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index 583b0736..2d2d696d 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -12,6 +12,8 @@ import PartialRenderInfo from 'components/PartialRenderInfo' import D3Graph from 'components/D3Graph' import MovablePanel from 'components/MovablePanel' +import { downloadJSON, downloadPNG } from '../lib/exportGraph' + import '../assets/css/Graph.scss' export default ({ @@ -140,6 +142,31 @@ export default ({ + + {/* Node/edge count indicator */} diff --git a/client/src/lib/exportGraph.js b/client/src/lib/exportGraph.js new file mode 100644 index 00000000..6d9eb2c5 --- /dev/null +++ b/client/src/lib/exportGraph.js @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Serializes the GraphParser datasets to a plain JSON structure. + * + * Edge source/target may be either uid strings or node objects (the + * renderer resolves them to objects in place), so both shapes are + * handled. Node objects contain a Set and back-references, which JSON + * cannot represent — only the meaningful fields are exported. + */ +export function graphToJSON(nodesMap, edgesMap) { + const endpointUid = (endpoint) => + endpoint && typeof endpoint === 'object' ? endpoint.id : endpoint + + const nodes = [] + if (nodesMap) { + nodesMap.forEach((node) => + nodes.push({ + uid: node.uid || node.id, + label: node.name || node.label || '', + group: node.group, + attrs: (node.properties && node.properties.attrs) || {}, + facets: (node.properties && node.properties.facets) || {}, + }), + ) + } + + const edges = [] + if (edgesMap) { + edgesMap.forEach((edge) => + edges.push({ + source: endpointUid(edge.source), + target: endpointUid(edge.target), + predicate: edge.predicate, + facets: edge.facets || {}, + }), + ) + } + + return { nodes, edges } +} + +// Composites every canvas inside container (in DOM order) onto a single +// white-backed canvas, so layered renderers export as one image. +export function compositeCanvases(container) { + const canvases = Array.from(container.querySelectorAll('canvas')) + if (!canvases.length) { + return null + } + + const width = Math.max(...canvases.map((c) => c.width)) + const height = Math.max(...canvases.map((c) => c.height)) + if (!width || !height) { + return null + } + + const out = document.createElement('canvas') + out.width = width + out.height = height + const ctx = out.getContext('2d') + if (!ctx) { + return null + } + + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + canvases.forEach((c) => ctx.drawImage(c, 0, 0, width, height)) + return out +} + +export function exportFileName(extension, now = new Date()) { + const pad = (n) => String(n).padStart(2, '0') + const stamp = [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + ].join('-') + const time = [pad(now.getHours()), pad(now.getMinutes())].join('') + return `ratel-graph-${stamp}-${time}.${extension}` +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +export function downloadJSON(nodesMap, edgesMap) { + const json = JSON.stringify(graphToJSON(nodesMap, edgesMap), null, 2) + downloadBlob( + new Blob([json], { type: 'application/json' }), + exportFileName('json'), + ) +} + +export function downloadPNG(container) { + const canvas = compositeCanvases(container) + if (!canvas) { + return false + } + canvas.toBlob((blob) => { + if (blob) { + downloadBlob(blob, exportFileName('png')) + } + }, 'image/png') + return true +} diff --git a/client/src/lib/exportGraph.test.js b/client/src/lib/exportGraph.test.js new file mode 100644 index 00000000..170c78b6 --- /dev/null +++ b/client/src/lib/exportGraph.test.js @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + compositeCanvases, + exportFileName, + graphToJSON, +} from './exportGraph' + +describe('graphToJSON', () => { + const node = (uid, extra = {}) => ({ + id: uid, + uid, + label: `short-${uid}`, + name: `full-${uid}`, + group: 'friend', + properties: { attrs: { name: `full-${uid}` }, facets: { since: 2020 } }, + expansionParents: new Set(['root']), + ...extra, + }) + + it('handles empty/missing datasets', () => { + expect(graphToJSON(null, null)).toEqual({ nodes: [], edges: [] }) + expect(graphToJSON(new Map(), new Map())).toEqual({ nodes: [], edges: [] }) + }) + + it('exports node fields without circular references', () => { + const result = graphToJSON(new Map([['0x1', node('0x1')]]), new Map()) + + expect(result.nodes).toEqual([ + { + uid: '0x1', + label: 'full-0x1', + group: 'friend', + attrs: { name: 'full-0x1' }, + facets: { since: 2020 }, + }, + ]) + // The whole point: must be serializable. + expect(() => JSON.stringify(result)).not.toThrow() + }) + + it('exports edges with uid endpoints when endpoints are strings', () => { + const edges = new Map([ + [ + 'e1', + { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }, + ], + ]) + const result = graphToJSON(new Map(), edges) + expect(result.edges).toEqual([ + { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }, + ]) + }) + + it('exports edges with uid endpoints when endpoints are node objects', () => { + // After rendering, the graph renderer resolves edge endpoints to node + // objects in place — export must not produce circular JSON. + const a = node('0x1') + const b = node('0x2') + const edges = new Map([ + ['e1', { source: a, target: b, predicate: 'friend', facets: {} }], + ]) + + const result = graphToJSON( + new Map([ + ['0x1', a], + ['0x2', b], + ]), + edges, + ) + + expect(result.edges[0].source).toBe('0x1') + expect(result.edges[0].target).toBe('0x2') + expect(() => JSON.stringify(result)).not.toThrow() + }) +}) + +describe('exportFileName', () => { + it('builds a timestamped name', () => { + const fixed = new Date(2026, 5, 12, 9, 5) + expect(exportFileName('png', fixed)).toBe('ratel-graph-2026-06-12-0905.png') + expect(exportFileName('json', fixed)).toBe( + 'ratel-graph-2026-06-12-0905.json', + ) + }) +}) + +describe('compositeCanvases', () => { + it('returns null when the container has no canvases', () => { + const div = document.createElement('div') + expect(compositeCanvases(div)).toBe(null) + }) + + it('returns null for zero-sized canvases', () => { + const div = document.createElement('div') + const canvas = document.createElement('canvas') + canvas.width = 0 + canvas.height = 0 + div.appendChild(canvas) + expect(compositeCanvases(div)).toBe(null) + }) + + it('composites canvases onto a single canvas of the max dimensions', () => { + const div = document.createElement('div') + const sizes = [ + [100, 50], + [200, 40], + ] + const drawn = [] + sizes.forEach(([w, h]) => { + const c = document.createElement('canvas') + c.width = w + c.height = h + div.appendChild(c) + }) + + // jsdom has no real canvas; provide a minimal 2d context. + const ctx = { + fillRect: jest.fn(), + drawImage: jest.fn((img) => drawn.push(img)), + } + jest + .spyOn(window.HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(ctx) + + const out = compositeCanvases(div) + + expect(out.width).toBe(200) + expect(out.height).toBe(50) + expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 200, 50) + expect(drawn.length).toBe(2) + + window.HTMLCanvasElement.prototype.getContext.mockRestore() + }) +}) From 121fd9bb99ac5759e617408e4fe6bad01b0c0c30 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 13:45:55 -0400 Subject: [PATCH 2/2] style: apply trunk formatting Co-Authored-By: Claude Fable 5 --- client/src/lib/exportGraph.test.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/client/src/lib/exportGraph.test.js b/client/src/lib/exportGraph.test.js index 170c78b6..df3c3ded 100644 --- a/client/src/lib/exportGraph.test.js +++ b/client/src/lib/exportGraph.test.js @@ -3,11 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - compositeCanvases, - exportFileName, - graphToJSON, -} from './exportGraph' +import { compositeCanvases, exportFileName, graphToJSON } from './exportGraph' describe('graphToJSON', () => { const node = (uid, extra = {}) => ({ @@ -44,10 +40,7 @@ describe('graphToJSON', () => { it('exports edges with uid endpoints when endpoints are strings', () => { const edges = new Map([ - [ - 'e1', - { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }, - ], + ['e1', { source: '0x1', target: '0x2', predicate: 'friend', facets: {} }], ]) const result = graphToJSON(new Map(), edges) expect(result.edges).toEqual([