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..df3c3ded --- /dev/null +++ b/client/src/lib/exportGraph.test.js @@ -0,0 +1,131 @@ +/* + * 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() + }) +})