trailbase/crates/assets/js/admin/src/components/ErdGraph.tsx
2025-08-07 10:02:32 +02:00

212 lines
4.9 KiB
TypeScript

import { onMount } from "solid-js";
import { Graph, Cell, Shape, Edge, Node } from "@antv/x6";
import { PortManager } from "@antv/x6/lib/model/port";
import { cn } from "@/lib/utils";
export const ER_NODE_NAME = "er-rect";
export const LINE_HEIGHT = 24;
export const NODE_WIDTH = 250;
const ACCENT_600 = "#0073aa";
const GRAY_100 = "#f3f7f9";
export const EDGE_COLOR = "#A2B1C3";
export type NodeMetadata = Node.Metadata;
export type EdgeMetadata = Edge.Metadata;
export type PortMetadata = PortManager.PortMetadata;
(function setupGraph() {
const ER_PORT_POSITION_NAME = "erPortPosition";
Graph.registerPortLayout(
ER_PORT_POSITION_NAME,
(portsPositionArgs) => {
return portsPositionArgs.map((_, index) => {
return {
position: {
x: 0,
y: (index + 1) * LINE_HEIGHT,
},
angle: 0,
};
});
},
true,
);
Graph.registerNode(
ER_NODE_NAME,
{
inherit: "rect",
markup: [
{
tagName: "rect",
selector: "body",
},
{
tagName: "text",
selector: "label",
},
],
attrs: {
rect: {
strokeWidth: 1,
stroke: ACCENT_600,
fill: ACCENT_600,
},
label: {
fontWeight: "bold",
fill: "white",
fontSize: 12,
},
},
ports: {
groups: {
list: {
markup: [
{
tagName: "rect",
selector: "portBody",
},
{
tagName: "text",
selector: "portNameLabel",
},
{
tagName: "text",
selector: "portTypeLabel",
},
],
attrs: {
portBody: {
width: NODE_WIDTH,
height: LINE_HEIGHT,
strokeWidth: 1,
stroke: ACCENT_600,
fill: GRAY_100,
magnet: true,
},
portNameLabel: {
ref: "portBody",
refX: 6,
refY: 6,
fontSize: 10,
},
portTypeLabel: {
ref: "portBody",
refX: 95,
refY: 6,
fontSize: 10,
},
},
position: ER_PORT_POSITION_NAME,
},
},
},
},
true,
);
})();
function createEdge(): Edge {
return new Shape.Edge({
attrs: {
line: {
stroke: EDGE_COLOR,
strokeWidth: 2,
},
},
});
}
export function ErdGraph(props: {
class?: string;
nodes: NodeMetadata[];
edges: EdgeMetadata[];
}) {
let ref: HTMLDivElement | undefined;
onMount(() => {
const graph = new Graph({
container: ref,
grid: {
visible: true,
},
autoResize: true,
interacting: {
edgeLabelMovable: false,
magnetConnectable: false,
},
connecting: {
connector: "rounded",
router: {
name: "er",
args: {
offset: 25,
direction: "H",
},
},
createEdge,
},
panning: {
enabled: true,
},
mousewheel: {
enabled: true,
// modifiers: ['ctrl', 'meta'],
minScale: 0.5,
maxScale: 2,
},
});
// Implement our own simple grid layout since @antv/layout seems to be out of sync:
//
// v0.3.25 results in "layout is not a function": https://github.com/antvis/X6/issues/4441
// v1.2 has completely in-compatible APIs. They'll probably need to overhaul x6 first.
const aspect = window.innerWidth / window.innerHeight;
const size = Math.ceil(Math.sqrt(props.nodes.length) * aspect);
const maxHeight = props.nodes.reduce((acc, node) => {
const ports = node.ports;
let numPorts = 0;
if (ports instanceof Array) {
numPorts = ports.length;
} else if (ports !== undefined) {
numPorts = 1;
}
return Math.max(acc, (numPorts + 1) * LINE_HEIGHT);
}, 0);
const cells: Cell[] = [
...props.nodes.map((n, index) => {
// Scatter nodes if no explicit position is already set.
if (n.position === undefined) {
const MARGIN = 80;
const col = index % size;
const row = Math.floor(index / size);
n.position = {
x: col * (NODE_WIDTH + MARGIN),
y: row * (maxHeight + MARGIN),
};
}
return graph.createNode(n);
}),
...props.edges.map((e) => graph.createEdge(e)),
];
graph.resetCells(cells);
graph.zoomToFit({ padding: 100, maxScale: 1 });
});
return (
<div
ref={ref}
class={cn(
"h-[calc(100dvh-66px)] w-[calc(100dvw-58px)] overflow-clip",
props.class,
)}
/>
);
}