Skip to content

Commit ec56990

Browse files
committed
Add cache from SVG generate by graphviz
Fixes #197 and working on super big graphs
1 parent 4ec0239 commit ec56990

10 files changed

+258
-91
lines changed

package-lock.json

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@playwright/test": "1.32.0",
5757
"@svgr/webpack": "6.5.1",
5858
"@types/commonmark": "0.27.5",
59-
"@types/node": "18.15.5",
59+
"@types/node": "20.4.8",
6060
"@types/react": "18.0.26",
6161
"@types/react-dom": "18.0.10",
6262
"@types/webpack-node-externals": "^3.0.0",

src/components/GraphViewport.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { Component } from 'react';
22

3-
import { SVGRender, TypeGraph, Viewport } from './../graph/';
3+
import { renderSvg, TypeGraph, Viewport } from './../graph/';
44
import LoadingAnimation from './utils/LoadingAnimation';
55
import { VoyagerDisplayOptions } from './Voyager';
66

7-
const svgRenderer = new SVGRender();
8-
97
interface GraphViewportProps {
108
typeGraph: TypeGraph | null;
119
displayOptions: VoyagerDisplayOptions;
@@ -108,8 +106,7 @@ export default class GraphViewport extends Component<
108106
this._currentDisplayOptions = displayOptions;
109107

110108
const { onSelectNode, onSelectEdge } = this.props;
111-
svgRenderer
112-
.renderSvg(typeGraph, displayOptions)
109+
renderSvg(typeGraph, displayOptions)
113110
.then((svg) => {
114111
if (
115112
typeGraph !== this._currentTypeGraph ||
@@ -128,11 +125,14 @@ export default class GraphViewport extends Component<
128125
);
129126
this.setState({ svgViewport });
130127
})
131-
.catch((error) => {
128+
.catch((rawError) => {
132129
this._currentTypeGraph = null;
133130
this._currentDisplayOptions = null;
134131

135-
error.message = error.message || 'Unknown error';
132+
const error =
133+
rawError instanceof Error
134+
? rawError
135+
: new Error('Unknown error: ' + String(rawError));
136136
this.setState(() => {
137137
throw error;
138138
});

src/graph/graphviz-worker.ts

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
RenderRequest,
3+
RenderResponse,
4+
RenderResult,
5+
VizWorkerHash,
6+
VizWorkerSource,
7+
// eslint-disable-next-line import/no-unresolved
8+
} from '../../worker/voyager.worker';
9+
import { computeHash } from '../utils/compute-hash';
10+
import { LocalStorageLRUCache } from '../utils/local-storage-lru-cache';
11+
12+
export class VizWorker {
13+
private _cache = new LocalStorageLRUCache({
14+
localStorageKey: 'VoyagerSVGCache',
15+
maxSize: 10,
16+
});
17+
private _worker: Worker;
18+
private _listeners: Array<(result: RenderResult) => void> = [];
19+
20+
constructor() {
21+
const blob = new Blob([VizWorkerSource], {
22+
type: 'application/javascript',
23+
});
24+
const url = URL.createObjectURL(blob);
25+
26+
this._worker = new Worker(url, { name: 'graphql-voyager-worker' });
27+
this._worker.addEventListener('message', (event) => {
28+
const { id, result } = event.data as RenderResponse;
29+
30+
this._listeners[id](result);
31+
delete this._listeners[id];
32+
});
33+
}
34+
35+
async renderString(dot: string): Promise<string> {
36+
const dotHash = await computeHash(dot);
37+
const cacheKey = `worker:${VizWorkerHash}:dot:${dotHash}`;
38+
39+
try {
40+
const cachedSVG = this._cache.get(cacheKey);
41+
if (cachedSVG != null) {
42+
console.log('SVG cached');
43+
return decompressFromDataURL(cachedSVG);
44+
}
45+
} catch (err) {
46+
console.warn('Can not read graphql-voyager cache: ', err);
47+
}
48+
49+
console.time('Rendering SVG');
50+
const svg = await this._renderString(dot);
51+
console.timeEnd('Rendering SVG');
52+
53+
try {
54+
this._cache.set(cacheKey, await compressToDataURL(svg));
55+
} catch (err) {
56+
console.warn('Can not write graphql-voyager cache: ', err);
57+
}
58+
return svg;
59+
}
60+
61+
_renderString(src: string): Promise<string> {
62+
return new Promise((resolve, reject) => {
63+
const id = this._listeners.length;
64+
65+
this._listeners.push(function (result): void {
66+
if ('error' in result) {
67+
const { error } = result;
68+
const e = new Error(error.message);
69+
if (error.fileName) (e as any).fileName = error.fileName;
70+
if (error.lineNumber) (e as any).lineNumber = error.lineNumber;
71+
if (error.stack) (e as any).stack = error.stack;
72+
return reject(e);
73+
}
74+
resolve(result.value);
75+
});
76+
77+
const renderRequest: RenderRequest = { id, src };
78+
this._worker.postMessage(renderRequest);
79+
});
80+
}
81+
}
82+
83+
async function decompressFromDataURL(dataURL: string): Promise<string> {
84+
const response = await fetch(dataURL);
85+
const blob = await response.blob();
86+
switch (blob.type) {
87+
case 'application/gzip': {
88+
// @ts-expect-error DecompressionStream is missing from DOM types
89+
const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
90+
const decompressedBlob = await streamToBlob(stream, 'text/plain');
91+
return decompressedBlob.text();
92+
}
93+
case 'text/plain':
94+
return blob.text();
95+
default:
96+
throw new Error('Can not convert data url with MIME type:' + blob.type);
97+
}
98+
}
99+
100+
async function compressToDataURL(str: string): Promise<string> {
101+
try {
102+
const blob = new Blob([str], { type: 'text/plain' });
103+
// @ts-expect-error CompressionStream is missing from DOM types
104+
const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
105+
const compressedBlob = await streamToBlob(stream, 'application/gzip');
106+
return blobToDataURL(compressedBlob);
107+
} catch (err) {
108+
console.warn('Can not compress string: ', err);
109+
return `data:text/plain;charset=utf-8,${encodeURIComponent(str)}`;
110+
}
111+
}
112+
113+
function blobToDataURL(blob: Blob): Promise<string> {
114+
const fileReader = new FileReader();
115+
116+
return new Promise((resolve, reject) => {
117+
try {
118+
fileReader.onload = function (event) {
119+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
120+
const dataURL = event.target!.result!.toString();
121+
resolve(dataURL);
122+
};
123+
fileReader.readAsDataURL(blob);
124+
} catch (err) {
125+
reject(err);
126+
}
127+
});
128+
}
129+
130+
function streamToBlob(stream: ReadableStream, mimeType: string): Promise<Blob> {
131+
const response = new Response(stream, {
132+
headers: { 'Content-Type': mimeType },
133+
});
134+
return response.blob();
135+
}

src/graph/svg-renderer.ts

+13-68
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,26 @@
1-
// eslint-disable-next-line import/no-unresolved
2-
import VizWorker from '../../worker/voyager.worker.js';
31
import { VoyagerDisplayOptions } from '../components/Voyager';
42
import { stringToSvg } from '../utils/';
53
import { getDot } from './dot';
4+
import { VizWorker } from './graphviz-worker';
65
import { TypeGraph } from './type-graph';
76

7+
const vizWorker = new VizWorker();
8+
9+
export async function renderSvg(
10+
typeGraph: TypeGraph,
11+
displayOptions: VoyagerDisplayOptions,
12+
) {
13+
const dot = getDot(typeGraph, displayOptions);
14+
const rawSVG = await vizWorker.renderString(dot);
15+
const svg = preprocessVizSVG(rawSVG);
16+
return svg;
17+
}
18+
819
const RelayIconSvg = require('!!svg-as-symbol-loader?id=RelayIcon!../components/icons/relay-icon.svg');
920
const DeprecatedIconSvg = require('!!svg-as-symbol-loader?id=DeprecatedIcon!../components/icons/deprecated-icon.svg');
1021
const svgNS = 'http://www.w3.org/2000/svg';
1122
const xlinkNS = 'http://www.w3.org/1999/xlink';
1223

13-
interface SerializedError {
14-
message: string;
15-
lineNumber?: number;
16-
fileName?: string;
17-
stack?: string;
18-
}
19-
20-
type RenderRequestListener = (result: RenderResult) => void;
21-
22-
interface RenderRequest {
23-
id: number;
24-
src: string;
25-
}
26-
27-
interface RenderResponse {
28-
id: number;
29-
result: RenderResult;
30-
}
31-
type RenderResult = { error: SerializedError } | { value: string };
32-
33-
export class SVGRender {
34-
private _worker: Worker;
35-
36-
private _listeners: Array<RenderRequestListener> = [];
37-
constructor() {
38-
this._worker = VizWorker;
39-
40-
this._worker.addEventListener('message', (event) => {
41-
const { id, result } = event.data as RenderResponse;
42-
43-
this._listeners[id](result);
44-
delete this._listeners[id];
45-
});
46-
}
47-
48-
async renderSvg(typeGraph: TypeGraph, displayOptions: VoyagerDisplayOptions) {
49-
console.time('Rendering Graph');
50-
const dot = getDot(typeGraph, displayOptions);
51-
const rawSVG = await this._renderString(dot);
52-
const svg = preprocessVizSVG(rawSVG);
53-
console.timeEnd('Rendering Graph');
54-
return svg;
55-
}
56-
57-
_renderString(src: string): Promise<string> {
58-
return new Promise((resolve, reject) => {
59-
const id = this._listeners.length;
60-
61-
this._listeners.push(function (result): void {
62-
if ('error' in result) {
63-
const { error } = result;
64-
const e = new Error(error.message);
65-
if (error.fileName) (e as any).fileName = error.fileName;
66-
if (error.lineNumber) (e as any).lineNumber = error.lineNumber;
67-
if (error.stack) (e as any).stack = error.stack;
68-
return reject(e);
69-
}
70-
resolve(result.value);
71-
});
72-
73-
const renderRequest: RenderRequest = { id, src };
74-
this._worker.postMessage(renderRequest);
75-
});
76-
}
77-
}
78-
7924
function preprocessVizSVG(svgString: string) {
8025
//Add Relay and Deprecated icons
8126
svgString = svgString.replace(/<svg [^>]*>/, '$&' + RelayIconSvg);

src/middleware/render-voyager-page.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default function renderVoyagerPage(options: MiddlewareOptions) {
5555
GraphQLVoyager.init(document.getElementById('voyager'), {
5656
introspection,
5757
displayOptions: ${JSON.stringify(displayOptions)},
58-
})
58+
});
5959
})
6060
</script>
6161
</body>

src/utils/compute-hash.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const textEncoder = new TextEncoder();
2+
3+
export async function computeHash(str: string): Promise<string> {
4+
const data = textEncoder.encode(str);
5+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
6+
7+
const hashArray = Array.from(new Uint8Array(hashBuffer));
8+
const hashHex = hashArray
9+
.map((b) => b.toString(16).padStart(2, '0'))
10+
.join('');
11+
12+
return hashHex;
13+
}

src/utils/local-storage-lru-cache.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export class LocalStorageLRUCache {
2+
private _localStorageKey;
3+
private _maxSize;
4+
5+
constructor(options: { localStorageKey: string; maxSize: number }) {
6+
this._localStorageKey = options.localStorageKey;
7+
this._maxSize = options.maxSize;
8+
}
9+
10+
public set(key: string, value: string): void {
11+
const lru = this.readLRU();
12+
lru.delete(key);
13+
lru.set(key, value);
14+
this.writeLRU(lru);
15+
}
16+
17+
public get(key: string): string | null {
18+
const lru = this.readLRU();
19+
const cachedValue = lru.get(key);
20+
if (cachedValue === undefined) {
21+
return null;
22+
}
23+
24+
lru.delete(key);
25+
lru.set(key, cachedValue);
26+
this.writeLRU(lru);
27+
return cachedValue;
28+
}
29+
30+
private readLRU(): Map<string, string> {
31+
const rawData = localStorage.getItem(this._localStorageKey);
32+
const data = JSON.parse(rawData ?? '{}');
33+
return new Map(Array.isArray(data) ? data : []);
34+
}
35+
36+
private writeLRU(lru: Map<string, string>): void {
37+
let maxSize = this._maxSize;
38+
for (;;) {
39+
try {
40+
const trimmedPairs = Array.from(lru).slice(-maxSize);
41+
const rawData = JSON.stringify(trimmedPairs);
42+
localStorage.setItem(this._localStorageKey, rawData);
43+
this._maxSize = maxSize;
44+
break;
45+
} catch (error) {
46+
if (maxSize <= 1) {
47+
throw error;
48+
}
49+
console.warn(
50+
`Can't write LRU cache with ${maxSize} entries. Retrying...`,
51+
);
52+
maxSize -= 1;
53+
}
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)