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()
+ })
+})