tablegraph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/assets/example.png +0 -0
- package/assets/logo.png +0 -0
- package/dist/cli/index.js +106 -0
- package/dist/core/core.js +108 -0
- package/dist/core/index.js +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/start-server.js +61 -0
- package/dist/server/utils.js +53 -0
- package/dist/web/assets/index-lXWmY9mA.css +1 -0
- package/dist/web/assets/index-wwhT77gD.js +15 -0
- package/dist/web/icons.svg +24 -0
- package/dist/web/index.html +14 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# TableGraph
|
|
2
|
+
|
|
3
|
+
Visualize your database schema as an interactive graph directly in your browser.
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<img src="./assets/logo.png" alt="TableGraph logo" width="250" />
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
TableGraph connects to your database, reads schema metadata, and opens a local web interface where you can inspect tables, columns, and relationships visually.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
### Global install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g tablegraph
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Or run with npx
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx tablegraph --driver postgres --url <connection-url>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
tablegraph --driver postgres --url postgresql://username:password@localhost:5432/db_name
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then open:
|
|
34
|
+
|
|
35
|
+
```txt
|
|
36
|
+
http://localhost:3001
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
tablegraph --driver postgres --url postgresql://postgres:postgres@localhost:5434/postgres
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Visualization
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<img src="./assets/example.png" alt="TableGraph web visualization" />
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
## Supported drivers
|
|
52
|
+
|
|
53
|
+
- postgres
|
|
54
|
+
|
|
55
|
+
## Notes
|
|
56
|
+
|
|
57
|
+
- Make sure your database is running and accessible
|
|
58
|
+
- Ensure your schema is initialized and contains tables
|
|
59
|
+
- TableGraph runs locally and serves the visualization in your browser
|
|
Binary file
|
package/assets/logo.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getDatabaseInfo, supportedDriversList, } from "../core/index.js";
|
|
3
|
+
import { startServer } from "../server/index.js";
|
|
4
|
+
const cliFlags = ["--driver", "--url", "--help"];
|
|
5
|
+
const cliFlagsSet = new Set(cliFlags);
|
|
6
|
+
const supportedDriversSet = new Set(supportedDriversList);
|
|
7
|
+
function invalidUrl(value) {
|
|
8
|
+
try {
|
|
9
|
+
new URL(value);
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function printHelp() {
|
|
17
|
+
console.log(`
|
|
18
|
+
Usage:
|
|
19
|
+
tablegraph [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--driver <postgres>
|
|
23
|
+
--url <connection-url>
|
|
24
|
+
--help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
tablegraph --driver postgres --url postgresql://username:password@localhost:5432/db_name
|
|
28
|
+
`.trim());
|
|
29
|
+
}
|
|
30
|
+
function parseArgs(args) {
|
|
31
|
+
const options = {};
|
|
32
|
+
if (args.includes("--help")) {
|
|
33
|
+
printHelp();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
if (args.length === 0) {
|
|
37
|
+
printHelp();
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (args.length % 2 !== 0) {
|
|
41
|
+
throw new Error("Flags must be provided as pairs: --flag value.");
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
44
|
+
const flag = args[i];
|
|
45
|
+
const value = args[i + 1];
|
|
46
|
+
if (!cliFlagsSet.has(flag)) {
|
|
47
|
+
throw new Error(`Unsupported flag "${flag}". Valid flags: ${cliFlags.join(", ")}.`);
|
|
48
|
+
}
|
|
49
|
+
if (flag === "--help") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!value || value.startsWith("--")) {
|
|
53
|
+
throw new Error(`Missing value for flag "${flag}".`);
|
|
54
|
+
}
|
|
55
|
+
if (flag === "--driver") {
|
|
56
|
+
if (!supportedDriversSet.has(value)) {
|
|
57
|
+
throw new Error(`Unsupported driver "${value}". Valid drivers: ${supportedDriversList.join(", ")}.`);
|
|
58
|
+
}
|
|
59
|
+
options.driver = value;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (flag === "--url") {
|
|
63
|
+
if (invalidUrl(value)) {
|
|
64
|
+
throw new Error(`Invalid URL "${value}".`);
|
|
65
|
+
}
|
|
66
|
+
options.url = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!options.driver) {
|
|
70
|
+
throw new Error('Missing required flag "--driver".');
|
|
71
|
+
}
|
|
72
|
+
if (!options.url) {
|
|
73
|
+
throw new Error('Missing required flag "--url".');
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
driver: options.driver,
|
|
77
|
+
url: options.url,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function main() {
|
|
81
|
+
const argList = process.argv.slice(2);
|
|
82
|
+
const options = parseArgs(argList);
|
|
83
|
+
const dbInfo = await getDatabaseInfo(options.driver, options.url);
|
|
84
|
+
if (dbInfo.tables.length === 0) {
|
|
85
|
+
console.warn([
|
|
86
|
+
"Warning: no tables were found in the database.",
|
|
87
|
+
"Make sure you are connecting to the correct database and that your schema has already been initialized.",
|
|
88
|
+
].join("\n"));
|
|
89
|
+
}
|
|
90
|
+
await startServer(dbInfo);
|
|
91
|
+
}
|
|
92
|
+
main().catch((error) => {
|
|
93
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
94
|
+
if (error instanceof Error) {
|
|
95
|
+
console.error(`Error: ${error.message}`);
|
|
96
|
+
if (isDev) {
|
|
97
|
+
console.error(error.stack);
|
|
98
|
+
}
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
console.error("An unexpected error occurred.");
|
|
102
|
+
if (isDev) {
|
|
103
|
+
console.error(error);
|
|
104
|
+
}
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
export const supportedDriversList = ["postgres"];
|
|
3
|
+
async function getTables(pool) {
|
|
4
|
+
const query = `
|
|
5
|
+
SELECT
|
|
6
|
+
table_schema,
|
|
7
|
+
table_name,
|
|
8
|
+
table_type
|
|
9
|
+
FROM information_schema.tables
|
|
10
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
11
|
+
AND table_type = 'BASE TABLE'
|
|
12
|
+
ORDER BY table_schema, table_name;
|
|
13
|
+
`;
|
|
14
|
+
const result = await pool.query(query);
|
|
15
|
+
return result.rows;
|
|
16
|
+
}
|
|
17
|
+
async function getColumns(pool) {
|
|
18
|
+
const query = `
|
|
19
|
+
SELECT
|
|
20
|
+
table_schema,
|
|
21
|
+
table_name,
|
|
22
|
+
column_name,
|
|
23
|
+
ordinal_position,
|
|
24
|
+
udt_name
|
|
25
|
+
FROM information_schema.columns
|
|
26
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
27
|
+
ORDER BY table_schema, table_name, ordinal_position;
|
|
28
|
+
`;
|
|
29
|
+
const result = await pool.query(query);
|
|
30
|
+
return result.rows;
|
|
31
|
+
}
|
|
32
|
+
async function getForeignKeys(pool) {
|
|
33
|
+
const query = `
|
|
34
|
+
SELECT
|
|
35
|
+
tc.constraint_name,
|
|
36
|
+
tc.table_schema AS source_schema,
|
|
37
|
+
tc.table_name AS source_table,
|
|
38
|
+
kcu.column_name AS source_column,
|
|
39
|
+
ccu.table_schema AS target_schema,
|
|
40
|
+
ccu.table_name AS target_table,
|
|
41
|
+
ccu.column_name AS target_column
|
|
42
|
+
FROM information_schema.table_constraints AS tc
|
|
43
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
44
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
45
|
+
AND tc.table_schema = kcu.table_schema
|
|
46
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
47
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
48
|
+
AND ccu.table_schema = tc.table_schema
|
|
49
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
50
|
+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
51
|
+
ORDER BY
|
|
52
|
+
source_schema,
|
|
53
|
+
source_table,
|
|
54
|
+
tc.constraint_name,
|
|
55
|
+
source_column;
|
|
56
|
+
`;
|
|
57
|
+
const result = await pool.query(query);
|
|
58
|
+
return result.rows;
|
|
59
|
+
}
|
|
60
|
+
function makeTableKey(schema, table) {
|
|
61
|
+
return `${schema}.${table}`;
|
|
62
|
+
}
|
|
63
|
+
export async function getDatabaseInfo(driver, url) {
|
|
64
|
+
switch (driver) {
|
|
65
|
+
case "postgres": {
|
|
66
|
+
const pool = new Pool({ connectionString: url });
|
|
67
|
+
try {
|
|
68
|
+
const [allTables, allColumns, allForeignKeys] = await Promise.all([
|
|
69
|
+
getTables(pool),
|
|
70
|
+
getColumns(pool),
|
|
71
|
+
getForeignKeys(pool),
|
|
72
|
+
]);
|
|
73
|
+
const columnsByTable = new Map();
|
|
74
|
+
for (const column of allColumns) {
|
|
75
|
+
const key = makeTableKey(column.table_schema, column.table_name);
|
|
76
|
+
const current = columnsByTable.get(key) ?? [];
|
|
77
|
+
current.push({
|
|
78
|
+
name: column.column_name,
|
|
79
|
+
position: Number(column.ordinal_position),
|
|
80
|
+
type: column.udt_name,
|
|
81
|
+
});
|
|
82
|
+
columnsByTable.set(key, current);
|
|
83
|
+
}
|
|
84
|
+
const tables = allTables.map((table) => {
|
|
85
|
+
const key = makeTableKey(table.table_schema, table.table_name);
|
|
86
|
+
return {
|
|
87
|
+
schema: table.table_schema,
|
|
88
|
+
name: table.table_name,
|
|
89
|
+
columns: columnsByTable.get(key) ?? [],
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const relations = allForeignKeys.map((fk) => ({
|
|
93
|
+
name: fk.constraint_name,
|
|
94
|
+
fromSchema: fk.source_schema,
|
|
95
|
+
fromTable: fk.source_table,
|
|
96
|
+
fromColumn: fk.source_column,
|
|
97
|
+
toSchema: fk.target_schema,
|
|
98
|
+
toTable: fk.target_table,
|
|
99
|
+
toColumn: fk.target_column,
|
|
100
|
+
}));
|
|
101
|
+
return { tables, relations };
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await pool.end();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getDatabaseInfo, supportedDriversList, } from "./core.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { startServer } from "./start-server.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sendJson, sendResponse, sendText, getContentType, readStaticFile, resolveStaticFilePath, } from "./utils.js";
|
|
5
|
+
export async function startServer(dbInfo, options = {}) {
|
|
6
|
+
const port = options.port ?? 3001;
|
|
7
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const webDir = path.resolve(__dirname, "../web");
|
|
11
|
+
const cachedResources = new Map();
|
|
12
|
+
const server = http.createServer(async (req, res) => {
|
|
13
|
+
const method = req.method ?? "GET";
|
|
14
|
+
const requestUrl = req.url ?? "/";
|
|
15
|
+
if (method !== "GET") {
|
|
16
|
+
sendText(res, 405, "Method Not Allowed");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (requestUrl === "/api/db-info") {
|
|
20
|
+
sendJson(res, 200, dbInfo);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (isDev) {
|
|
24
|
+
sendText(res, 404, "Frontend assets are served by the Vite dev server in development.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const filePath = resolveStaticFilePath(webDir, requestUrl);
|
|
28
|
+
if (filePath === null) {
|
|
29
|
+
sendText(res, 403, "Forbidden");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
let file = cachedResources.get(filePath);
|
|
34
|
+
if (!file) {
|
|
35
|
+
file = await readStaticFile(filePath);
|
|
36
|
+
cachedResources.set(filePath, file);
|
|
37
|
+
}
|
|
38
|
+
sendResponse(res, {
|
|
39
|
+
statusCode: 200,
|
|
40
|
+
contentType: getContentType(filePath),
|
|
41
|
+
body: file,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
sendText(res, 404, "Not Found");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
server.once("error", reject);
|
|
50
|
+
server.listen(port, () => {
|
|
51
|
+
if (isDev) {
|
|
52
|
+
console.log(`API server running at http://localhost:${port}`);
|
|
53
|
+
console.log("Frontend is served by the Vite dev server.");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(`View your database graph at http://localhost:${port}`);
|
|
57
|
+
}
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
export function sendResponse(res, options) {
|
|
4
|
+
const { statusCode, contentType = "text/plain; charset=utf-8", body = "", } = options;
|
|
5
|
+
res.statusCode = statusCode;
|
|
6
|
+
res.setHeader("Content-Type", contentType);
|
|
7
|
+
res.end(body);
|
|
8
|
+
}
|
|
9
|
+
export function sendText(res, statusCode, body = "") {
|
|
10
|
+
sendResponse(res, {
|
|
11
|
+
statusCode,
|
|
12
|
+
contentType: "text/plain; charset=utf-8",
|
|
13
|
+
body,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function sendJson(res, statusCode, data) {
|
|
17
|
+
sendResponse(res, {
|
|
18
|
+
statusCode,
|
|
19
|
+
contentType: "application/json; charset=utf-8",
|
|
20
|
+
body: JSON.stringify(data),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const MIME_TYPES = {
|
|
24
|
+
".html": "text/html; charset=utf-8",
|
|
25
|
+
".js": "text/javascript; charset=utf-8",
|
|
26
|
+
".css": "text/css; charset=utf-8",
|
|
27
|
+
".json": "application/json; charset=utf-8",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
};
|
|
35
|
+
export function getContentType(filePath) {
|
|
36
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
37
|
+
return MIME_TYPES[extension] ?? "application/octet-stream";
|
|
38
|
+
}
|
|
39
|
+
export function getPathname(requestUrl) {
|
|
40
|
+
return decodeURIComponent(requestUrl.split("?")[0] ?? "/");
|
|
41
|
+
}
|
|
42
|
+
export function resolveStaticFilePath(rootDir, requestUrl) {
|
|
43
|
+
const pathname = decodeURIComponent(requestUrl.split("?")[0] ?? "/");
|
|
44
|
+
const relativePath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
45
|
+
const filePath = path.resolve(rootDir, relativePath);
|
|
46
|
+
if (!filePath.startsWith(rootDir)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return filePath;
|
|
50
|
+
}
|
|
51
|
+
export async function readStaticFile(filePath) {
|
|
52
|
+
return fs.readFile(filePath);
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
html,body,#root{width:100%;height:100%;margin:0;font-family:Inter,system-ui,sans-serif}.react-flow{--xy-edge-stroke-default:#b1b1b7;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#555;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#ffffff80;--xy-minimap-background-color-default:#fff;--xy-minimap-mask-background-color-default:#f0f0f099;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#e2e2e2;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:transparent;--xy-background-pattern-dots-color-default:#91919a;--xy-background-pattern-lines-color-default:#eee;--xy-background-pattern-cross-color-default:#e2e2e2;background-color:var(--xy-background-color,var(--xy-background-color-default));--xy-node-color-default:inherit;--xy-node-border-default:1px solid #1a192b;--xy-node-background-color-default:#fff;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #00000014;--xy-node-boxshadow-selected-default:0 0 0 .5px #1a192b;--xy-node-border-radius-default:3px;--xy-handle-background-color-default:#1a192b;--xy-handle-border-color-default:#fff;--xy-selection-background-color-default:#0059dc14;--xy-selection-border-default:1px dotted #0059dccc;--xy-controls-button-background-color-default:#fefefe;--xy-controls-button-background-color-hover-default:#f4f4f4;--xy-controls-button-color-default:inherit;--xy-controls-button-color-hover-default:inherit;--xy-controls-button-border-color-default:#eee;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#fff;--xy-edge-label-color-default:inherit;--xy-resize-background-color-default:#3367d9;direction:ltr}.react-flow.dark{--xy-edge-stroke-default:#3e3e3e;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#727272;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#96969640;--xy-minimap-background-color-default:#141414;--xy-minimap-mask-background-color-default:#3c3c3c99;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#2b2b2b;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:#141414;--xy-background-pattern-dots-color-default:#777;--xy-background-pattern-lines-color-default:#777;--xy-background-pattern-cross-color-default:#777;--xy-node-color-default:#f8f8f8;--xy-node-border-default:1px solid #3c3c3c;--xy-node-background-color-default:#1e1e1e;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #ffffff14;--xy-node-boxshadow-selected-default:0 0 0 .5px #999;--xy-handle-background-color-default:#bebebe;--xy-handle-border-color-default:#1e1e1e;--xy-selection-background-color-default:#c8c8dc14;--xy-selection-border-default:1px dotted #c8c8dccc;--xy-controls-button-background-color-default:#2b2b2b;--xy-controls-button-background-color-hover-default:#3e3e3e;--xy-controls-button-color-default:#f8f8f8;--xy-controls-button-color-hover-default:#fff;--xy-controls-button-border-color-default:#5b5b5b;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#141414;--xy-edge-label-color-default:#f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props,var(--xy-background-color,var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width,var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke,var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width,var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{pointer-events:none;position:absolute;overflow:visible}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:.5s linear infinite dashdraw}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected,var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:.5s linear infinite dashdraw}svg.react-flow__connectionline{z-index:1001;position:absolute;overflow:visible}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{-webkit-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default;position:absolute}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:0 0;pointer-events:none}.react-flow__nodesselection-rect{pointer-events:all;cursor:grab;position:absolute}.react-flow__handle{pointer-events:none;background-color:var(--xy-handle-background-color,var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color,var(--xy-handle-border-color-default));border-radius:100%;width:6px;min-width:5px;height:6px;min-height:5px;position:absolute}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;bottom:0;left:50%;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{z-index:5;margin:15px;position:absolute}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px)translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px)translateY(-50%)}.react-flow__attribution{background:var(--xy-attribution-background-color,var(--xy-attribution-background-color-default));margin:0;padding:2px 3px;font-size:10px}.react-flow__attribution a{color:#999;text-decoration:none}@keyframes dashdraw{0%{stroke-dashoffset:10px}}.react-flow__edgelabel-renderer{pointer-events:none;-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__viewport-portal{-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__minimap{background:var(--xy-minimap-background-color-props,var(--xy-minimap-background-color,var(--xy-minimap-background-color-default)))}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var(--xy-minimap-mask-background-color-props,var(--xy-minimap-mask-background-color,var(--xy-minimap-mask-background-color-default)));stroke:var(--xy-minimap-mask-stroke-color-props,var(--xy-minimap-mask-stroke-color,var(--xy-minimap-mask-stroke-color-default)));stroke-width:var(--xy-minimap-mask-stroke-width-props,var(--xy-minimap-mask-stroke-width,var(--xy-minimap-mask-stroke-width-default)))}.react-flow__minimap-node{fill:var(--xy-minimap-node-background-color-props,var(--xy-minimap-node-background-color,var(--xy-minimap-node-background-color-default)));stroke:var(--xy-minimap-node-stroke-color-props,var(--xy-minimap-node-stroke-color,var(--xy-minimap-node-stroke-color-default)));stroke-width:var(--xy-minimap-node-stroke-width-props,var(--xy-minimap-node-stroke-width,var(--xy-minimap-node-stroke-width-default)))}.react-flow__background-pattern.dots{fill:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-dots-color-default)))}.react-flow__background-pattern.lines{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-lines-color-default)))}.react-flow__background-pattern.cross{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-cross-color-default)))}.react-flow__controls{box-shadow:var(--xy-controls-box-shadow,var(--xy-controls-box-shadow-default));flex-direction:column;display:flex}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{background:var(--xy-controls-button-background-color,var(--xy-controls-button-background-color-default));border:none;border-bottom:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)));width:26px;height:26px;color:var(--xy-controls-button-color-props,var(--xy-controls-button-color,var(--xy-controls-button-color-default)));cursor:pointer;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;padding:4px;display:flex}.react-flow__controls-button svg{fill:currentColor;width:100%;max-width:12px;max-height:12px}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{border-radius:var(--xy-node-border-radius,var(--xy-node-border-radius-default));width:150px;color:var(--xy-node-color,var(--xy-node-color-default));text-align:center;border:var(--xy-node-border,var(--xy-node-border-default));background-color:var(--xy-node-background-color,var(--xy-node-background-color-default));padding:10px;font-size:12px}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover,var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected,var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color,var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color,var(--xy-selection-background-color-default));border:var(--xy-selection-border,var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var(--xy-controls-button-background-color-hover-props,var(--xy-controls-button-background-color-hover,var(--xy-controls-button-background-color-hover-default)));color:var(--xy-controls-button-color-hover-props,var(--xy-controls-button-color-hover,var(--xy-controls-button-color-hover-default)))}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)))}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{background-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border:1px solid #fff;border-radius:1px;width:5px;height:5px;translate:-50% -50%}.react-flow__resize-control.handle.left{top:50%;left:0}.react-flow__resize-control.handle.right{top:50%;left:100%}.react-flow__resize-control.handle.top{top:0;left:50%}.react-flow__resize-control.handle.bottom{top:100%;left:50%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border-style:solid;border-width:0}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;height:100%;top:0;transform:translate(-50%)}.react-flow__resize-control.line.left{border-left-width:1px;left:0}.react-flow__resize-control.line.right{border-right-width:1px;left:100%}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{width:100%;height:1px;left:0;transform:translateY(-50%)}.react-flow__resize-control.line.top{border-top-width:1px;top:0}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color,var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color,var(--xy-edge-label-color-default))}
|