Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions client/src/components/GraphContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -140,6 +142,31 @@ export default ({
<path d='M1 1h5v1.5H2.5V5H1V1zm9 0h5v4h-1.5V2.5H10V1zM1 11h1.5v2.5H5V15H1v-4zm12.5 2.5V11H15v4h-4v-1.5h2.5z' />
</svg>
</button>
<button
className='graph-control-btn'
onClick={(e) => {
const container = e.currentTarget.closest('.graph-container')
if (container) {
downloadPNG(container)
}
}}
title='Download graph as PNG'
>
<svg width='16' height='16' viewBox='0 0 16 16' fill='currentColor'>
<path d='M10.5 8.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z' />
<path d='M2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4H2zm.5 2a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 2.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z' />
</svg>
</button>
<button
className='graph-control-btn'
onClick={() => downloadJSON(nodesDataset, edgesDataset)}
title='Download nodes and edges as JSON'
>
<svg width='16' height='16' viewBox='0 0 16 16' fill='currentColor'>
<path d='M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z' />
<path d='M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z' />
</svg>
</button>
</div>

{/* Node/edge count indicator */}
Expand Down
115 changes: 115 additions & 0 deletions client/src/lib/exportGraph.js
Original file line number Diff line number Diff line change
@@ -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
}
131 changes: 131 additions & 0 deletions client/src/lib/exportGraph.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading