概要
グラフDBをブラウザ上で試してみた。
kuzu-shellに使われているkuzu-wasmを利用。
kuzu-wasmを動かす
vite設定
wasmを動かすために、excludeの設定と、serverのヘッダ設定の追加が必要であった。(SharedArrayBufferを利用しているため)
Prerequisite: Enable Cross-Origin-isolation
vite.config.ts
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
build: { target: 'esnext' },
optimizeDeps: {
exclude: ['@kuzu/kuzu-wasm'],
esbuildOptions: {
target: 'es2020',
},
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
型定義
typescriptの型定義が見つからなかったので暫定的に自分で定義した。
types.ts
declare module '@kuzu/kuzu-wasm' {
export default function kuzu_wasm(): Promise<Kuzu>;
export interface Kuzu {
Database: () => Promise<Database>;
Connection: (db: Database) => Promise<Connection>;
FS: {
writeFile: (path: string, content: string) => void;
};
}
export interface Database {
close: () => void;
}
export interface Connection {
execute: (query: string) => Promise<QueryResult>;
}
export interface QueryResult {
table: {
toString: () => string;
};
}
}
サンプルコード
csvファイルをDBに読み込むために、いったんwasm上に書き込む必要がある点に注意。
App.ts
import kuzu_wasm from '@kuzu/kuzu-wasm';
import { useEffect } from 'react';
function App() {
useEffect(() => {
(async () => {
const kuzu = await kuzu_wasm();
const db = await kuzu.Database();
const conn = await kuzu.Connection(db);
// Write files
kuzu.FS.writeFile('/follows.csv', await (await fetch('/data/follows.csv')).text());
kuzu.FS.writeFile('/city.csv', await (await fetch('/data/city.csv')).text());
kuzu.FS.writeFile('/lives-in.csv', await (await fetch('/data/lives-in.csv')).text());
kuzu.FS.writeFile('/user.csv', await (await fetch('/data/user.csv')).text());
// Create schema
await conn.execute('CREATE NODE TABLE User(name STRING, age INT64, PRIMARY KEY (name))');
await conn.execute('CREATE NODE TABLE City(name STRING, population INT64, PRIMARY KEY (name))');
await conn.execute('CREATE REL TABLE Follows(FROM User TO User, since INT64)');
await conn.execute('CREATE REL TABLE LivesIn(FROM User TO City)');
// Insert data
await conn.execute('COPY User FROM "/user.csv"');
await conn.execute('COPY City FROM "/city.csv"');
await conn.execute('COPY Follows FROM "/follows.csv"');
await conn.execute('COPY LivesIn FROM "/lives-in.csv"');
// Execute Cypher query
const response = await conn.execute(
`
MATCH (a:User)
RETURN *;
`,
);
const users = JSON.parse(response.table.toString());
console.log(users)
const relsResponse = await conn.execute(
`
MATCH (a:User)-[r:Follows]->(b:User)
RETURN *;
`,
);
const rels = JSON.parse(relsResponse.table.toString());
console.log(rels);
})();
}, []);
return (
<div style={{ width: '100%', height: 500 }}>
</div>
);
}
export default App;
実行結果
users
console.log(users)
[
{
"a": {
"_ID": {
"offset": "0",
"table": "0"
},
"_LABEL": "User",
"name": "Adam",
"age": "30"
}
},
{
"a": {
"_ID": {
"offset": "1",
"table": "0"
},
"_LABEL": "User",
"name": "Karissa",
"age": "40"
}
},
{
"a": {
"_ID": {
"offset": "2",
"table": "0"
},
"_LABEL": "User",
"name": "Zhang",
"age": "50"
}
},
{
"a": {
"_ID": {
"offset": "3",
"table": "0"
},
"_LABEL": "User",
"name": "Noura",
"age": "25"
}
}
]
rels
console.log(rels)
[
{
"a": {
"_ID": {
"offset": "0",
"table": "0"
},
"_LABEL": "User",
"name": "Adam",
"age": "30"
},
"b": {
"_ID": {
"offset": "1",
"table": "0"
},
"_LABEL": "User",
"name": "Karissa",
"age": "40"
},
"r": {
"_SRC": {
"offset": "0",
"table": "0"
},
"_DST": {
"offset": "1",
"table": "0"
},
"_LABEL": "Follows",
"_ID": {
"offset": "0",
"table": "2"
},
"since": "2020"
}
},
{
"a": {
"_ID": {
"offset": "0",
"table": "0"
},
"_LABEL": "User",
"name": "Adam",
"age": "30"
},
"b": {
"_ID": {
"offset": "2",
"table": "0"
},
"_LABEL": "User",
"name": "Zhang",
"age": "50"
},
"r": {
"_SRC": {
"offset": "0",
"table": "0"
},
"_DST": {
"offset": "2",
"table": "0"
},
"_LABEL": "Follows",
"_ID": {
"offset": "1",
"table": "2"
},
"since": "2020"
}
},
{
"a": {
"_ID": {
"offset": "1",
"table": "0"
},
"_LABEL": "User",
"name": "Karissa",
"age": "40"
},
"b": {
"_ID": {
"offset": "2",
"table": "0"
},
"_LABEL": "User",
"name": "Zhang",
"age": "50"
},
"r": {
"_SRC": {
"offset": "1",
"table": "0"
},
"_DST": {
"offset": "2",
"table": "0"
},
"_LABEL": "Follows",
"_ID": {
"offset": "2",
"table": "2"
},
"since": "2021"
}
},
{
"a": {
"_ID": {
"offset": "2",
"table": "0"
},
"_LABEL": "User",
"name": "Zhang",
"age": "50"
},
"b": {
"_ID": {
"offset": "3",
"table": "0"
},
"_LABEL": "User",
"name": "Noura",
"age": "25"
},
"r": {
"_SRC": {
"offset": "2",
"table": "0"
},
"_DST": {
"offset": "3",
"table": "0"
},
"_LABEL": "Follows",
"_ID": {
"offset": "3",
"table": "2"
},
"since": "2022"
}
}
]
可視化
npm i @neo4j-nvl/react layout-base
vite設定
vite.config.ts
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
build: { target: 'esnext' },
optimizeDeps: {
exclude: ['@kuzu/kuzu-wasm', '@neo4j-nvl/layout-workers'],
include: [
'@neo4j-nvl/layout-workers > cytoscape',
'@neo4j-nvl/layout-workers > cytoscape-cose-bilkent',
'@neo4j-nvl/layout-workers > @neo4j-bloom/dagre',
'@neo4j-nvl/layout-workers > bin-pack',
'@neo4j-nvl/layout-workers > graphlib',
'@segment/analytics-next',
'concaveman',
'layout-base',
],
esbuildOptions: {
target: 'es2020',
},
},
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
サンプルコード
App.ts
import kuzu_wasm from '@kuzu/kuzu-wasm';
import { InteractiveNvlWrapper } from '@neo4j-nvl/react';
import { useEffect, useState } from 'react';
function App() {
const [nodes, setNodes] = useState([]);
const [rels, setRels] = useState([]);
useEffect(() => {
(async () => {
const kuzu = await kuzu_wasm();
const db = await kuzu.Database();
const conn = await kuzu.Connection(db);
// get remote csv to wasm filesystem
kuzu.FS.writeFile('/follows.csv', await (await fetch('/data/follows.csv')).text());
kuzu.FS.writeFile('/city.csv', await (await fetch('/data/city.csv')).text());
kuzu.FS.writeFile('/lives-in.csv', await (await fetch('/data/lives-in.csv')).text());
kuzu.FS.writeFile('/user.csv', await (await fetch('/data/user.csv')).text());
// Create schema
await conn.execute('CREATE NODE TABLE User(name STRING, age INT64, PRIMARY KEY (name))');
await conn.execute('CREATE NODE TABLE City(name STRING, population INT64, PRIMARY KEY (name))');
await conn.execute('CREATE REL TABLE Follows(FROM User TO User, since INT64)');
await conn.execute('CREATE REL TABLE LivesIn(FROM User TO City)');
// Insert data
await conn.execute('COPY User FROM "/user.csv"');
await conn.execute('COPY City FROM "/city.csv"');
await conn.execute('COPY Follows FROM "/follows.csv"');
await conn.execute('COPY LivesIn FROM "/lives-in.csv"');
// Execute Cypher query
const response = await conn.execute(
`
MATCH (a:User)
RETURN *;
`,
);
const users = JSON.parse(response.table.toString());
type ID = { offset: string; table: string };
const getId = (id: ID): string => `id_${id.table}_${id.offset}`;
const nodes = users.map(({ a }: { a: { _ID: ID; name: string } }) => ({ id: getId(a._ID), caption: a.name }));
setNodes(nodes);
const relsResponse = await conn.execute(
`
MATCH (a:User)-[r:Follows]->(b:User)
RETURN *;
`,
);
const rels = JSON.parse(relsResponse.table.toString());
const relsData = rels.map(
({ r }: { a: { _ID: ID; name: string }; r: { _ID: ID; _SRC: ID; _DST: ID; since: string }; b: { _ID: ID; name: string } }) => ({
from: getId(r._SRC),
to: getId(r._DST),
id: getId(r._ID),
caption: r.since,
}),
);
setRels(relsData);
})();
}, []);
return (
<div style={{ width: '100%', height: 500 }}>
<InteractiveNvlWrapper
nvlOptions={{ useWebGL: false, initialZoom: 2.6 }}
nodes={nodes}
rels={rels}
mouseEventCallbacks={{
onZoom: true,
onPan: true,
}}
/>
</div>
);
}
export default App;
参考
kuzu
kuzu-wasm
shell-js
quarto-graph-experiments
@neo4j-nvl/react
無料で手軽にNeo4j aura で Graph DBを使ってみる
Cypher よく使うクエリ
Neo4jことはじめ
neo4j をインストールしてから Cytoscape と組み合わせるまでの話
Neo4jの可視化ライブラリまとめ
React + TypeScriptで使えるネットワークグラフ系ライブラリ3つ + α
ネットワークグラフ描画ライブラリ7個まとめ
antvis/G6