@falkordb/canvas 0.0.3
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/LICENSE +21 -0
- package/README.md +189 -0
- package/package.json +52 -0
- package/src/falkordb-canvas-types.ts +90 -0
- package/src/falkordb-canvas-utils.ts +201 -0
- package/src/falkordb-canvas.ts +786 -0
- package/src/index.ts +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FalkorDB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# FalkorDB Canvas
|
|
2
|
+
|
|
3
|
+
A standalone web component for visualizing FalkorDB graphs using force-directed layouts.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🎨 **Force-directed graph layout** - Automatic positioning using D3 force simulation
|
|
8
|
+
- 🎯 **Interactive** - Click, hover, and right-click interactions
|
|
9
|
+
- 🌓 **Theme support** - Light and dark mode compatible
|
|
10
|
+
- âš¡ **Performance** - Optimized rendering with canvas
|
|
11
|
+
- 💀 **Loading states** - Built-in skeleton loading with pulse animation
|
|
12
|
+
- 🎨 **Customizable** - Colors, sizes, and behaviors
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install falkordb-canvas
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### HTML
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<!DOCTYPE html>
|
|
26
|
+
<html>
|
|
27
|
+
<head>
|
|
28
|
+
<title>FalkorDB Canvas Example</title>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<falkordb-canvas id="graph" style="width: 100%; height: 600px;"></falkordb-canvas>
|
|
32
|
+
|
|
33
|
+
<script type="module">
|
|
34
|
+
import 'falkordb-canvas';
|
|
35
|
+
|
|
36
|
+
const canvas = document.getElementById('graph');
|
|
37
|
+
|
|
38
|
+
// Set data
|
|
39
|
+
canvas.setData({
|
|
40
|
+
nodes: [
|
|
41
|
+
{ id: 1, labels: ['Person'], color: '#FF6B6B', visible: true, data: { name: 'Alice' } },
|
|
42
|
+
{ id: 2, labels: ['Person'], color: '#4ECDC4', visible: true, data: { name: 'Bob' } }
|
|
43
|
+
],
|
|
44
|
+
links: [
|
|
45
|
+
{ id: 1, relationship: 'KNOWS', color: '#999', source: 1, target: 2, visible: true, data: {} }
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Configure
|
|
50
|
+
canvas.setConfig({
|
|
51
|
+
width: 800,
|
|
52
|
+
height: 600,
|
|
53
|
+
backgroundColor: '#FFFFFF',
|
|
54
|
+
foregroundColor: '#1A1A1A',
|
|
55
|
+
onNodeClick: (node) => console.log('Clicked:', node)
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
### Methods
|
|
65
|
+
|
|
66
|
+
#### `setData(data: Data)`
|
|
67
|
+
Set the graph data (nodes and links).
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
canvas.setData({
|
|
71
|
+
nodes: [
|
|
72
|
+
{ id: 1, labels: ['Person'], color: '#FF6B6B', visible: true, data: { name: 'Alice' } }
|
|
73
|
+
],
|
|
74
|
+
links: [
|
|
75
|
+
{ id: 1, relationship: 'KNOWS', color: '#999', source: 1, target: 2, visible: true, data: {} }
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### `getData(): Data`
|
|
81
|
+
Get the current graph data.
|
|
82
|
+
|
|
83
|
+
#### `setConfig(config: ForceGraphConfig)`
|
|
84
|
+
Configure the graph visualization and behavior.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
canvas.setConfig({
|
|
88
|
+
width: 800,
|
|
89
|
+
height: 600,
|
|
90
|
+
backgroundColor: '#FFFFFF',
|
|
91
|
+
foregroundColor: '#1A1A1A',
|
|
92
|
+
displayTextPriority: [
|
|
93
|
+
{ name: 'name', ignore: false },
|
|
94
|
+
{ name: 'title', ignore: false }
|
|
95
|
+
],
|
|
96
|
+
cooldownTicks: 300,
|
|
97
|
+
isLoading: false,
|
|
98
|
+
onNodeClick: (node, event) => {},
|
|
99
|
+
onNodeRightClick: (node, event) => {},
|
|
100
|
+
onLinkRightClick: (link, event) => {},
|
|
101
|
+
onNodeHover: (node) => {},
|
|
102
|
+
onLinkHover: (link) => {},
|
|
103
|
+
onBackgroundClick: (event) => {},
|
|
104
|
+
onEngineStop: () => {},
|
|
105
|
+
isNodeSelected: (node) => false,
|
|
106
|
+
isLinkSelected: (link) => false
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### `getGraph(): ForceGraphInstance | undefined`
|
|
111
|
+
Get the underlying force-graph instance for advanced control.
|
|
112
|
+
|
|
113
|
+
### Configuration Options
|
|
114
|
+
|
|
115
|
+
| Option | Type | Description |
|
|
116
|
+
|--------|------|-------------|
|
|
117
|
+
| `width` | `number` | Canvas width in pixels |
|
|
118
|
+
| `height` | `number` | Canvas height in pixels |
|
|
119
|
+
| `backgroundColor` | `string` | Background color (hex or CSS color) |
|
|
120
|
+
| `foregroundColor` | `string` | Foreground color for borders and text |
|
|
121
|
+
| `displayTextPriority` | `TextPriority[]` | Priority order for displaying node text |
|
|
122
|
+
| `cooldownTicks` | `number \| undefined` | Number of simulation ticks before stopping |
|
|
123
|
+
| `isLoading` | `boolean` | Show/hide loading skeleton |
|
|
124
|
+
| `onNodeClick` | `function` | Callback when a node is clicked |
|
|
125
|
+
| `onNodeRightClick` | `function` | Callback when a node is right-clicked |
|
|
126
|
+
| `onLinkRightClick` | `function` | Callback when a link is right-clicked |
|
|
127
|
+
| `onNodeHover` | `function` | Callback when hovering over a node |
|
|
128
|
+
| `onLinkHover` | `function` | Callback when hovering over a link |
|
|
129
|
+
| `onBackgroundClick` | `function` | Callback when clicking the background |
|
|
130
|
+
| `onEngineStop` | `function` | Callback when the force simulation stops |
|
|
131
|
+
| `isNodeSelected` | `function` | Function to determine if a node is selected |
|
|
132
|
+
| `isLinkSelected` | `function` | Function to determine if a link is selected |
|
|
133
|
+
|
|
134
|
+
### Data Types
|
|
135
|
+
|
|
136
|
+
#### Node
|
|
137
|
+
```typescript
|
|
138
|
+
{
|
|
139
|
+
id: number;
|
|
140
|
+
labels: string[];
|
|
141
|
+
color: string;
|
|
142
|
+
visible: boolean;
|
|
143
|
+
data: Record<string, any>;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
#### Link
|
|
148
|
+
```typescript
|
|
149
|
+
{
|
|
150
|
+
id: number;
|
|
151
|
+
relationship: string;
|
|
152
|
+
color: string;
|
|
153
|
+
source: number; // Node ID
|
|
154
|
+
target: number; // Node ID
|
|
155
|
+
visible: boolean;
|
|
156
|
+
data: Record<string, any>;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Install dependencies
|
|
164
|
+
npm install
|
|
165
|
+
|
|
166
|
+
# Build
|
|
167
|
+
npm run build
|
|
168
|
+
|
|
169
|
+
# Watch mode
|
|
170
|
+
npm run dev
|
|
171
|
+
|
|
172
|
+
# Run example
|
|
173
|
+
npm run example
|
|
174
|
+
# Then open http://localhost:8080/examples/falkordb-canvas.example.html
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Browser Support
|
|
178
|
+
|
|
179
|
+
- Chrome/Edge (latest)
|
|
180
|
+
- Firefox (latest)
|
|
181
|
+
- Safari (latest)
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
|
186
|
+
|
|
187
|
+
## Contributing
|
|
188
|
+
|
|
189
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@falkordb/canvas",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "A standalone web component for visualizing FalkorDB graphs using force-directed layouts",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./dist/*": "./dist/*"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist",
|
|
23
|
+
"dev": "tsc --watch",
|
|
24
|
+
"example": "python3 -m http.server 8080",
|
|
25
|
+
"clean": "rm -rf dist",
|
|
26
|
+
"lint": "eslint ."
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"falkordb",
|
|
30
|
+
"graph",
|
|
31
|
+
"visualization",
|
|
32
|
+
"web-component",
|
|
33
|
+
"force-directed",
|
|
34
|
+
"canvas"
|
|
35
|
+
],
|
|
36
|
+
"author": "FalkorDB",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@types/react": "^19.2.7",
|
|
40
|
+
"d3": "^7.9.0",
|
|
41
|
+
"force-graph": "^1.44.4",
|
|
42
|
+
"react": "^19.2.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/d3": "^7.4.3",
|
|
46
|
+
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
|
47
|
+
"@typescript-eslint/parser": "^8.18.2",
|
|
48
|
+
"eslint": "^9.17.0",
|
|
49
|
+
"tsup": "^8.5.1",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { NodeObject } from "force-graph";
|
|
2
|
+
|
|
3
|
+
export interface ForceGraphConfig {
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
backgroundColor?: string;
|
|
7
|
+
foregroundColor?: string;
|
|
8
|
+
displayTextPriority?: TextPriority[];
|
|
9
|
+
onNodeClick?: (node: GraphNode, event: MouseEvent) => void;
|
|
10
|
+
onLinkClick?: (link: GraphLink, event: MouseEvent) => void;
|
|
11
|
+
onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void;
|
|
12
|
+
onLinkRightClick?: (link: GraphLink, event: MouseEvent) => void;
|
|
13
|
+
onNodeHover?: (node: GraphNode | null) => void;
|
|
14
|
+
onLinkHover?: (link: GraphLink | null) => void;
|
|
15
|
+
onBackgroundClick?: (event: MouseEvent) => void;
|
|
16
|
+
onEngineStop?: () => void;
|
|
17
|
+
onLoadingChange?: (loading: boolean) => void;
|
|
18
|
+
cooldownTicks?: number | undefined;
|
|
19
|
+
cooldownTime?: number;
|
|
20
|
+
isLinkSelected?: (link: GraphLink) => boolean;
|
|
21
|
+
isNodeSelected?: (node: GraphNode) => boolean;
|
|
22
|
+
isLoading?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type GraphNode = NodeObject & {
|
|
26
|
+
id: number;
|
|
27
|
+
labels: string[];
|
|
28
|
+
color: string;
|
|
29
|
+
visible: boolean;
|
|
30
|
+
displayName: [string, string];
|
|
31
|
+
data: {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
[key: string]: any;
|
|
34
|
+
};
|
|
35
|
+
x?: number;
|
|
36
|
+
y?: number;
|
|
37
|
+
vx?: number;
|
|
38
|
+
vy?: number;
|
|
39
|
+
fx?: number;
|
|
40
|
+
fy?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type GraphLink = {
|
|
44
|
+
id: number;
|
|
45
|
+
relationship: string;
|
|
46
|
+
color: string;
|
|
47
|
+
source: GraphNode;
|
|
48
|
+
target: GraphNode;
|
|
49
|
+
visible: boolean;
|
|
50
|
+
curve: number;
|
|
51
|
+
data: {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
[key: string]: any;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface GraphData {
|
|
58
|
+
nodes: GraphNode[];
|
|
59
|
+
links: GraphLink[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Node = Omit<
|
|
63
|
+
GraphNode,
|
|
64
|
+
"x" | "y" | "vx" | "vy" | "fx" | "fy" | "displayName"
|
|
65
|
+
>;
|
|
66
|
+
|
|
67
|
+
export type Link = Omit<GraphLink, "curve" | "source" | "target"> & {
|
|
68
|
+
source: number;
|
|
69
|
+
target: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface Data {
|
|
73
|
+
nodes: Node[];
|
|
74
|
+
links: Link[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TextPriority = {
|
|
78
|
+
name: string;
|
|
79
|
+
ignore: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ViewportState = {
|
|
83
|
+
zoom: number;
|
|
84
|
+
centerX: number;
|
|
85
|
+
centerY: number;
|
|
86
|
+
} | undefined;
|
|
87
|
+
|
|
88
|
+
// Force graph instance type from force-graph library
|
|
89
|
+
// The instance is created by calling ForceGraph as a function with a container element
|
|
90
|
+
export type ForceGraphInstance = import("force-graph").default<GraphNode, GraphLink> | undefined;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Data,
|
|
3
|
+
GraphData,
|
|
4
|
+
Node,
|
|
5
|
+
Link,
|
|
6
|
+
GraphNode,
|
|
7
|
+
GraphLink,
|
|
8
|
+
TextPriority,
|
|
9
|
+
} from "./falkordb-canvas-types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts Data format to GraphData format
|
|
13
|
+
* Adds runtime properties (x, y, vx, vy, fx, fy, displayName, curve)
|
|
14
|
+
*/
|
|
15
|
+
export function dataToGraphData(data: Data): GraphData {
|
|
16
|
+
const nodes: GraphNode[] = data.nodes.map((node) => ({
|
|
17
|
+
...node,
|
|
18
|
+
displayName: ["", ""] as [string, string],
|
|
19
|
+
x: undefined,
|
|
20
|
+
y: undefined,
|
|
21
|
+
vx: undefined,
|
|
22
|
+
vy: undefined,
|
|
23
|
+
fx: undefined,
|
|
24
|
+
fy: undefined,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Create a Map for O(1) node lookups by id
|
|
28
|
+
const nodeMap = new Map<number, GraphNode>();
|
|
29
|
+
nodes.forEach((node) => {
|
|
30
|
+
nodeMap.set(node.id, node);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const links: GraphLink[] = data.links.map((link) => {
|
|
34
|
+
const sourceNode = nodeMap.get(link.source);
|
|
35
|
+
const targetNode = nodeMap.get(link.target);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...link,
|
|
39
|
+
source: sourceNode!,
|
|
40
|
+
target: targetNode!,
|
|
41
|
+
curve: 0,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return { nodes, links };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Converts GraphData format to Data format
|
|
50
|
+
* Removes runtime properties (x, y, vx, vy, fx, fy, displayName, curve)
|
|
51
|
+
*/
|
|
52
|
+
export function graphDataToData(graphData: GraphData): Data {
|
|
53
|
+
const nodes: Node[] = graphData.nodes.map((node) => {
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
55
|
+
const { x, y, vx, vy, fx, fy, displayName, ...rest } = node;
|
|
56
|
+
return rest;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const links: Link[] = graphData.links.map((link) => {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
61
|
+
const { curve, source, target, ...rest } = link;
|
|
62
|
+
return {
|
|
63
|
+
...rest,
|
|
64
|
+
source: source.id,
|
|
65
|
+
target: target.id,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { nodes, links };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const getNodeDisplayText = (
|
|
73
|
+
node: Node,
|
|
74
|
+
displayTextPriority: TextPriority[]
|
|
75
|
+
) => {
|
|
76
|
+
const { data: nodeData } = node;
|
|
77
|
+
const displayText = displayTextPriority.find(({ name, ignore }) => {
|
|
78
|
+
const key = ignore
|
|
79
|
+
? Object.keys(nodeData).find(
|
|
80
|
+
(k) => k.toLowerCase() === name.toLowerCase()
|
|
81
|
+
)
|
|
82
|
+
: name;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
key &&
|
|
86
|
+
nodeData[key] &&
|
|
87
|
+
typeof nodeData[key] === "string" &&
|
|
88
|
+
nodeData[key].trim().length > 0
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (displayText) {
|
|
93
|
+
const key = displayText.ignore
|
|
94
|
+
? Object.keys(nodeData).find(
|
|
95
|
+
(k) => k.toLowerCase() === displayText.name.toLowerCase()
|
|
96
|
+
)
|
|
97
|
+
: displayText.name;
|
|
98
|
+
|
|
99
|
+
if (key) {
|
|
100
|
+
return String(nodeData[key]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return String(node.id);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const getNodeDisplayKey = (
|
|
108
|
+
node: Node,
|
|
109
|
+
displayTextPriority: TextPriority[]
|
|
110
|
+
) => {
|
|
111
|
+
const { data: nodeData } = node;
|
|
112
|
+
const displayText = displayTextPriority.find(({ name, ignore }) => {
|
|
113
|
+
const key = ignore
|
|
114
|
+
? Object.keys(nodeData).find(
|
|
115
|
+
(k) => k.toLowerCase() === name.toLowerCase()
|
|
116
|
+
)
|
|
117
|
+
: name;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
key &&
|
|
121
|
+
nodeData[key] &&
|
|
122
|
+
typeof nodeData[key] === "string" &&
|
|
123
|
+
nodeData[key].trim().length > 0
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (displayText) {
|
|
128
|
+
const key = displayText.ignore
|
|
129
|
+
? Object.keys(nodeData).find(
|
|
130
|
+
(k) => k.toLowerCase() === displayText.name.toLowerCase()
|
|
131
|
+
)
|
|
132
|
+
: displayText.name;
|
|
133
|
+
|
|
134
|
+
if (key) {
|
|
135
|
+
return key;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return String(node.id);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Wraps text into two lines with ellipsis handling for circular nodes
|
|
144
|
+
*/
|
|
145
|
+
export const wrapTextForCircularNode = (
|
|
146
|
+
ctx: CanvasRenderingContext2D,
|
|
147
|
+
text: string,
|
|
148
|
+
maxRadius: number
|
|
149
|
+
): [string, string] => {
|
|
150
|
+
const ellipsis = "...";
|
|
151
|
+
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
|
152
|
+
const halfTextHeight = 1.125;
|
|
153
|
+
const availableRadius = Math.sqrt(
|
|
154
|
+
Math.max(0, maxRadius * maxRadius - halfTextHeight * halfTextHeight)
|
|
155
|
+
);
|
|
156
|
+
const lineWidth = availableRadius * 2;
|
|
157
|
+
|
|
158
|
+
const words = text.split(/\s+/);
|
|
159
|
+
let line1 = "";
|
|
160
|
+
let line2 = "";
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
163
|
+
const word = words[i];
|
|
164
|
+
const testLine = line1 ? `${line1} ${word}` : word;
|
|
165
|
+
const testWidth = ctx.measureText(testLine).width;
|
|
166
|
+
|
|
167
|
+
if (testWidth <= lineWidth) {
|
|
168
|
+
line1 = testLine;
|
|
169
|
+
} else if (!line1) {
|
|
170
|
+
let partialWord = word;
|
|
171
|
+
while (
|
|
172
|
+
partialWord.length > 0 &&
|
|
173
|
+
ctx.measureText(partialWord).width > lineWidth
|
|
174
|
+
) {
|
|
175
|
+
partialWord = partialWord.slice(0, -1);
|
|
176
|
+
}
|
|
177
|
+
line1 = partialWord;
|
|
178
|
+
const remainingWords = [
|
|
179
|
+
word.slice(partialWord.length),
|
|
180
|
+
...words.slice(i + 1),
|
|
181
|
+
];
|
|
182
|
+
line2 = remainingWords.join(" ");
|
|
183
|
+
break;
|
|
184
|
+
} else {
|
|
185
|
+
line2 = words.slice(i).join(" ");
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (line2 && ctx.measureText(line2).width > lineWidth) {
|
|
191
|
+
while (
|
|
192
|
+
line2.length > 0 &&
|
|
193
|
+
ctx.measureText(line2).width + ellipsisWidth > lineWidth
|
|
194
|
+
) {
|
|
195
|
+
line2 = line2.slice(0, -1);
|
|
196
|
+
}
|
|
197
|
+
line2 += ellipsis;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [line1, line2 || ""];
|
|
201
|
+
};
|
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/* eslint-disable no-param-reassign */
|
|
2
|
+
|
|
3
|
+
import ForceGraph from "force-graph";
|
|
4
|
+
import * as d3 from "d3";
|
|
5
|
+
import {
|
|
6
|
+
Data,
|
|
7
|
+
ForceGraphInstance,
|
|
8
|
+
GraphData,
|
|
9
|
+
GraphLink,
|
|
10
|
+
GraphNode,
|
|
11
|
+
ForceGraphConfig,
|
|
12
|
+
ViewportState,
|
|
13
|
+
} from "./falkordb-canvas-types.js";
|
|
14
|
+
import {
|
|
15
|
+
dataToGraphData,
|
|
16
|
+
getNodeDisplayText,
|
|
17
|
+
graphDataToData,
|
|
18
|
+
wrapTextForCircularNode,
|
|
19
|
+
} from "./falkordb-canvas-utils.js";
|
|
20
|
+
|
|
21
|
+
const NODE_SIZE = 6;
|
|
22
|
+
const PADDING = 2;
|
|
23
|
+
|
|
24
|
+
// Force constants
|
|
25
|
+
const LINK_DISTANCE = 50;
|
|
26
|
+
const MAX_LINK_DISTANCE = 80;
|
|
27
|
+
const LINK_STRENGTH = 0.5;
|
|
28
|
+
const MIN_LINK_STRENGTH = 0.3;
|
|
29
|
+
const COLLISION_STRENGTH = 1.35;
|
|
30
|
+
const CHARGE_STRENGTH = -5;
|
|
31
|
+
const CENTER_STRENGTH = 0.4;
|
|
32
|
+
const COLLISION_BASE_RADIUS = NODE_SIZE * 2;
|
|
33
|
+
const HIGH_DEGREE_PADDING = 1.25;
|
|
34
|
+
const DEGREE_STRENGTH_DECAY = 15;
|
|
35
|
+
const CROWDING_THRESHOLD = 20;
|
|
36
|
+
|
|
37
|
+
// Create styles for the web component
|
|
38
|
+
function createStyles(): HTMLStyleElement {
|
|
39
|
+
const style = document.createElement("style");
|
|
40
|
+
style.textContent = `
|
|
41
|
+
:host {
|
|
42
|
+
display: block;
|
|
43
|
+
width: 100%;
|
|
44
|
+
height: 100%;
|
|
45
|
+
}
|
|
46
|
+
@keyframes pulse {
|
|
47
|
+
0%, 100% {
|
|
48
|
+
opacity: 1;
|
|
49
|
+
}
|
|
50
|
+
50% {
|
|
51
|
+
opacity: 0.5;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
return style;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class FalkorDBCanvas extends HTMLElement {
|
|
59
|
+
private graph: ForceGraphInstance;
|
|
60
|
+
|
|
61
|
+
private container: HTMLDivElement | null = null;
|
|
62
|
+
|
|
63
|
+
private loadingOverlay: HTMLDivElement | null = null;
|
|
64
|
+
|
|
65
|
+
private data: GraphData = { nodes: [], links: [] };
|
|
66
|
+
|
|
67
|
+
private config: ForceGraphConfig = {};
|
|
68
|
+
|
|
69
|
+
private nodeDegreeMap: Map<number, number> = new Map();
|
|
70
|
+
|
|
71
|
+
private relationshipsTextCache: Map<
|
|
72
|
+
string,
|
|
73
|
+
{
|
|
74
|
+
textWidth: number;
|
|
75
|
+
textHeight: number;
|
|
76
|
+
textAscent: number;
|
|
77
|
+
textDescent: number;
|
|
78
|
+
}
|
|
79
|
+
> = new Map();
|
|
80
|
+
|
|
81
|
+
private viewport: ViewportState;
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
super();
|
|
85
|
+
this.attachShadow({ mode: "open" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
connectedCallback() {
|
|
89
|
+
this.render();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
disconnectedCallback() {
|
|
93
|
+
if (this.graph) {
|
|
94
|
+
// eslint-disable-next-line no-underscore-dangle
|
|
95
|
+
this.graph._destructor();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setConfig(config: Partial<ForceGraphConfig>) {
|
|
100
|
+
Object.assign(this.config, config);
|
|
101
|
+
|
|
102
|
+
// Update event handlers if they were provided
|
|
103
|
+
if (config.onNodeClick || config.onNodeRightClick || config.onLinkRightClick ||
|
|
104
|
+
config.onNodeHover || config.onLinkHover || config.onBackgroundClick ||
|
|
105
|
+
config.onEngineStop || config.isNodeSelected || config.isLinkSelected) {
|
|
106
|
+
this.updateEventHandlers();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setWidth(width: number) {
|
|
111
|
+
if (this.config.width === width) return;
|
|
112
|
+
this.config.width = width;
|
|
113
|
+
if (this.graph) {
|
|
114
|
+
this.graph.width(width);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setHeight(height: number) {
|
|
119
|
+
if (this.config.height === height) return;
|
|
120
|
+
this.config.height = height;
|
|
121
|
+
if (this.graph) {
|
|
122
|
+
this.graph.height(height);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setBackgroundColor(color: string) {
|
|
127
|
+
if (this.config.backgroundColor === color) return;
|
|
128
|
+
this.config.backgroundColor = color;
|
|
129
|
+
if (this.graph) {
|
|
130
|
+
this.graph.backgroundColor(color);
|
|
131
|
+
}
|
|
132
|
+
if (this.loadingOverlay) {
|
|
133
|
+
this.loadingOverlay.style.background = color;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setForegroundColor(color: string) {
|
|
138
|
+
if (this.config.foregroundColor === color) return;
|
|
139
|
+
this.config.foregroundColor = color;
|
|
140
|
+
this.triggerRender();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setIsLoading(isLoading: boolean) {
|
|
144
|
+
if (this.config.isLoading === isLoading) return;
|
|
145
|
+
this.config.isLoading = isLoading;
|
|
146
|
+
this.updateLoadingState();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setCooldownTicks(ticks: number | undefined) {
|
|
150
|
+
if (this.config.cooldownTicks === ticks) return;
|
|
151
|
+
this.config.cooldownTicks = ticks;
|
|
152
|
+
if (this.graph) {
|
|
153
|
+
this.graph.cooldownTicks(ticks ?? Infinity);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setDisplayTextPriority(priority: ForceGraphConfig['displayTextPriority']) {
|
|
158
|
+
this.config.displayTextPriority = priority;
|
|
159
|
+
this.triggerRender();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getData(): Data {
|
|
163
|
+
return graphDataToData(this.data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
setData(data: Data) {
|
|
168
|
+
this.data = dataToGraphData(data);
|
|
169
|
+
this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
|
|
170
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
171
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
172
|
+
|
|
173
|
+
// Initialize graph if it hasn't been initialized yet
|
|
174
|
+
if (!this.graph && this.container) {
|
|
175
|
+
this.initGraph();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!this.graph) return;
|
|
179
|
+
|
|
180
|
+
this.calculateNodeDegree();
|
|
181
|
+
|
|
182
|
+
// Update graph data and properties
|
|
183
|
+
this.graph
|
|
184
|
+
.graphData(this.data)
|
|
185
|
+
.cooldownTicks(this.config.cooldownTicks ?? Infinity);
|
|
186
|
+
|
|
187
|
+
this.updateLoadingState();
|
|
188
|
+
this.setupForces();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getViewport(): ViewportState {
|
|
192
|
+
if (!this.graph) return undefined;
|
|
193
|
+
|
|
194
|
+
const { x: centerX, y: centerY } = this.graph.centerAt();
|
|
195
|
+
const zoom = this.graph.zoom();
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
zoom,
|
|
199
|
+
centerX,
|
|
200
|
+
centerY,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setViewport(viewport: ViewportState) {
|
|
205
|
+
this.viewport = viewport;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
getGraphData(): GraphData {
|
|
209
|
+
return this.data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setGraphData(data: GraphData) {
|
|
213
|
+
this.data = data;
|
|
214
|
+
|
|
215
|
+
this.config.cooldownTicks = 0;
|
|
216
|
+
this.config.isLoading = false;
|
|
217
|
+
|
|
218
|
+
if (!this.graph) return;
|
|
219
|
+
|
|
220
|
+
this.calculateNodeDegree();
|
|
221
|
+
this.graph
|
|
222
|
+
.graphData(this.data)
|
|
223
|
+
.cooldownTicks(this.config.cooldownTicks ?? Infinity);
|
|
224
|
+
|
|
225
|
+
if (this.viewport) {
|
|
226
|
+
this.graph.zoom(this.viewport.zoom, 0);
|
|
227
|
+
this.graph.centerAt(this.viewport.centerX, this.viewport.centerY, 0);
|
|
228
|
+
this.viewport = undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.updateLoadingState();
|
|
232
|
+
this.setupForces();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getGraph(): ForceGraphInstance | undefined {
|
|
236
|
+
return this.graph;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public getZoom(): number {
|
|
240
|
+
return this.graph?.zoom() || 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public zoom(zoomLevel: number): ForceGraphInstance | undefined {
|
|
244
|
+
if (!this.graph) return;
|
|
245
|
+
|
|
246
|
+
return this.graph.zoom(zoomLevel);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public zoomToFit(paddingMultiplier = 1, filter?: (node: GraphNode) => boolean) {
|
|
250
|
+
if (!this.graph || !this.shadowRoot) return;
|
|
251
|
+
|
|
252
|
+
// Get canvas from shadow DOM
|
|
253
|
+
const canvas = this.shadowRoot.querySelector("canvas") as HTMLCanvasElement;
|
|
254
|
+
if (!canvas) return;
|
|
255
|
+
|
|
256
|
+
const rect = canvas.getBoundingClientRect();
|
|
257
|
+
|
|
258
|
+
// Calculate padding as 10% of the smallest canvas dimension
|
|
259
|
+
const minDimension = Math.min(rect.width, rect.height);
|
|
260
|
+
const padding = minDimension * 0.1;
|
|
261
|
+
|
|
262
|
+
// Use the force-graph's built-in zoomToFit method
|
|
263
|
+
this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private triggerRender() {
|
|
267
|
+
if (!this.graph || this.graph.cooldownTicks() !== 0) return;
|
|
268
|
+
|
|
269
|
+
// If simulation is stopped (0), trigger one tick to re-render
|
|
270
|
+
this.graph.cooldownTicks(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private calculateNodeDegree() {
|
|
274
|
+
this.nodeDegreeMap.clear();
|
|
275
|
+
const { nodes, links } = this.data;
|
|
276
|
+
|
|
277
|
+
nodes.forEach((node) => this.nodeDegreeMap.set(node.id, 0));
|
|
278
|
+
|
|
279
|
+
links.forEach((link) => {
|
|
280
|
+
const sourceId = link.source.id;
|
|
281
|
+
const targetId = link.target.id;
|
|
282
|
+
|
|
283
|
+
this.nodeDegreeMap.set(
|
|
284
|
+
sourceId,
|
|
285
|
+
(this.nodeDegreeMap.get(sourceId) || 0) + 1
|
|
286
|
+
);
|
|
287
|
+
this.nodeDegreeMap.set(
|
|
288
|
+
targetId,
|
|
289
|
+
(this.nodeDegreeMap.get(targetId) || 0) + 1
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private createLoadingOverlay(): HTMLDivElement {
|
|
295
|
+
const overlay = document.createElement("div");
|
|
296
|
+
overlay.style.cssText = `
|
|
297
|
+
position: absolute;
|
|
298
|
+
inset: 0;
|
|
299
|
+
display: none;
|
|
300
|
+
align-items: center;
|
|
301
|
+
justify-content: center;
|
|
302
|
+
background: ${this.config.backgroundColor || "#FFFFFF"};
|
|
303
|
+
z-index: 10;
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
// Create skeleton loading structure (matching Spinning component pattern)
|
|
307
|
+
const skeletonContainer = document.createElement("div");
|
|
308
|
+
skeletonContainer.style.cssText = `
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
gap: 1rem;
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
// Create circular skeleton (matching h-12 w-12 rounded-full)
|
|
315
|
+
const circle = document.createElement("div");
|
|
316
|
+
circle.style.cssText = `
|
|
317
|
+
height: 3rem;
|
|
318
|
+
width: 3rem;
|
|
319
|
+
border-radius: 9999px;
|
|
320
|
+
background-color: #CCCCCC;
|
|
321
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
// Create lines container (matching space-y-2)
|
|
325
|
+
const linesContainer = document.createElement("div");
|
|
326
|
+
linesContainer.style.cssText = `
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
gap: 0.5rem;
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
// Create first line (matching h-4 w-[250px])
|
|
333
|
+
const line1 = document.createElement("div");
|
|
334
|
+
line1.style.cssText = `
|
|
335
|
+
height: 1rem;
|
|
336
|
+
width: 250px;
|
|
337
|
+
border-radius: 0.375rem;
|
|
338
|
+
background-color: #CCCCCC;
|
|
339
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
340
|
+
`;
|
|
341
|
+
|
|
342
|
+
// Create second line (matching h-4 w-[200px])
|
|
343
|
+
const line2 = document.createElement("div");
|
|
344
|
+
line2.style.cssText = `
|
|
345
|
+
height: 1rem;
|
|
346
|
+
width: 200px;
|
|
347
|
+
border-radius: 0.375rem;
|
|
348
|
+
background-color: #CCCCCC;
|
|
349
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
350
|
+
`;
|
|
351
|
+
|
|
352
|
+
linesContainer.appendChild(line1);
|
|
353
|
+
linesContainer.appendChild(line2);
|
|
354
|
+
skeletonContainer.appendChild(circle);
|
|
355
|
+
skeletonContainer.appendChild(linesContainer);
|
|
356
|
+
overlay.appendChild(skeletonContainer);
|
|
357
|
+
|
|
358
|
+
return overlay;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private render() {
|
|
362
|
+
if (!this.shadowRoot) return;
|
|
363
|
+
|
|
364
|
+
// Create container
|
|
365
|
+
this.container = document.createElement("div");
|
|
366
|
+
this.container.style.width = "100%";
|
|
367
|
+
this.container.style.height = "100%";
|
|
368
|
+
this.container.style.position = "relative";
|
|
369
|
+
|
|
370
|
+
// Create loading overlay
|
|
371
|
+
this.loadingOverlay = this.createLoadingOverlay();
|
|
372
|
+
|
|
373
|
+
// Add styles using standalone function
|
|
374
|
+
const style = createStyles();
|
|
375
|
+
|
|
376
|
+
this.shadowRoot.appendChild(style);
|
|
377
|
+
this.shadowRoot.appendChild(this.container);
|
|
378
|
+
this.initGraph();
|
|
379
|
+
this.container.appendChild(this.loadingOverlay);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private initGraph() {
|
|
383
|
+
if (!this.container) return;
|
|
384
|
+
|
|
385
|
+
this.calculateNodeDegree();
|
|
386
|
+
|
|
387
|
+
// Initialize force-graph
|
|
388
|
+
// Cast to any for the factory call pattern, result is properly typed as ForceGraphInstance
|
|
389
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
390
|
+
this.graph = (ForceGraph as any)()(this.container)
|
|
391
|
+
.width(this.config.width || 800)
|
|
392
|
+
.height(this.config.height || 600)
|
|
393
|
+
.backgroundColor(this.config.backgroundColor || "#FFFFFF")
|
|
394
|
+
.graphData(this.data)
|
|
395
|
+
.nodeRelSize(NODE_SIZE)
|
|
396
|
+
.nodeCanvasObjectMode(() => "after")
|
|
397
|
+
.linkCanvasObjectMode(() => "after")
|
|
398
|
+
.nodeLabel((node: GraphNode) =>
|
|
399
|
+
getNodeDisplayText(node, this.config.displayTextPriority || [])
|
|
400
|
+
)
|
|
401
|
+
.linkLabel((link: GraphLink) => link.relationship)
|
|
402
|
+
.linkDirectionalArrowRelPos(1)
|
|
403
|
+
.linkDirectionalArrowLength((link: GraphLink) => {
|
|
404
|
+
if (link.source === link.target) return 0;
|
|
405
|
+
return this.config.isLinkSelected?.(link) ? 4 : 2;
|
|
406
|
+
})
|
|
407
|
+
.linkDirectionalArrowColor((link: GraphLink) => link.color)
|
|
408
|
+
.linkWidth((link: GraphLink) =>
|
|
409
|
+
this.config.isLinkSelected?.(link) ? 2 : 1
|
|
410
|
+
)
|
|
411
|
+
.linkCurvature("curve")
|
|
412
|
+
.linkVisibility("visible")
|
|
413
|
+
.nodeVisibility("visible")
|
|
414
|
+
.cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
|
|
415
|
+
.cooldownTime(this.config.cooldownTime ?? 1000)
|
|
416
|
+
.enableNodeDrag(true)
|
|
417
|
+
.enableZoomInteraction(true)
|
|
418
|
+
.enablePanInteraction(true)
|
|
419
|
+
.onNodeClick((node: GraphNode, event: MouseEvent) => {
|
|
420
|
+
if (this.config.onNodeClick) {
|
|
421
|
+
this.config.onNodeClick(node, event);
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.onLinkClick((link: GraphLink, event: MouseEvent) => {
|
|
425
|
+
if (this.config.onLinkClick) {
|
|
426
|
+
this.config.onLinkClick(link, event);
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
.onNodeRightClick((node: GraphNode, event: MouseEvent) => {
|
|
430
|
+
if (this.config.onNodeRightClick) {
|
|
431
|
+
this.config.onNodeRightClick(node, event);
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
.onLinkRightClick((link: GraphLink, event: MouseEvent) => {
|
|
435
|
+
if (this.config.onLinkRightClick) {
|
|
436
|
+
this.config.onLinkRightClick(link, event);
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
.onNodeHover((node: GraphNode | null) => {
|
|
440
|
+
if (this.config.onNodeHover) {
|
|
441
|
+
this.config.onNodeHover(node);
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
.onLinkHover((link: GraphLink | null) => {
|
|
445
|
+
if (this.config.onLinkHover) {
|
|
446
|
+
this.config.onLinkHover(link);
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
.onBackgroundClick((event: MouseEvent) => {
|
|
450
|
+
if (this.config.onBackgroundClick) {
|
|
451
|
+
this.config.onBackgroundClick(event);
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
.onEngineStop(() => {
|
|
455
|
+
this.handleEngineStop();
|
|
456
|
+
if (this.config.onEngineStop) {
|
|
457
|
+
this.config.onEngineStop();
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
.nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D) => {
|
|
461
|
+
this.drawNode(node, ctx);
|
|
462
|
+
})
|
|
463
|
+
.linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D) => {
|
|
464
|
+
this.drawLink(link, ctx);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Setup forces
|
|
468
|
+
this.setupForces();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private setupForces() {
|
|
472
|
+
const linkForce = this.graph?.d3Force("link");
|
|
473
|
+
|
|
474
|
+
if (!linkForce) return;
|
|
475
|
+
if (!this.graph) return;
|
|
476
|
+
|
|
477
|
+
// Link force with dynamic distance and strength
|
|
478
|
+
linkForce
|
|
479
|
+
.distance((link: GraphLink) => {
|
|
480
|
+
const sourceId = link.source.id;
|
|
481
|
+
const targetId = link.target.id;
|
|
482
|
+
const sourceDegree = this.nodeDegreeMap.get(sourceId) || 0;
|
|
483
|
+
const targetDegree = this.nodeDegreeMap.get(targetId) || 0;
|
|
484
|
+
const maxDegree = Math.max(sourceDegree, targetDegree);
|
|
485
|
+
|
|
486
|
+
if (maxDegree >= CROWDING_THRESHOLD) {
|
|
487
|
+
const extraDistance = Math.min(
|
|
488
|
+
MAX_LINK_DISTANCE - LINK_DISTANCE,
|
|
489
|
+
(maxDegree - CROWDING_THRESHOLD) * 1.5
|
|
490
|
+
);
|
|
491
|
+
return LINK_DISTANCE + extraDistance;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return LINK_DISTANCE;
|
|
495
|
+
})
|
|
496
|
+
.strength((link: GraphLink) => {
|
|
497
|
+
const sourceId = link.source.id;
|
|
498
|
+
const targetId = link.target.id;
|
|
499
|
+
const sourceDegree = this.nodeDegreeMap.get(sourceId) || 0;
|
|
500
|
+
const targetDegree = this.nodeDegreeMap.get(targetId) || 0;
|
|
501
|
+
const maxDegree = Math.max(sourceDegree, targetDegree);
|
|
502
|
+
|
|
503
|
+
if (maxDegree <= DEGREE_STRENGTH_DECAY) {
|
|
504
|
+
return LINK_STRENGTH;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const strengthReduction = Math.max(
|
|
508
|
+
0,
|
|
509
|
+
(maxDegree - DEGREE_STRENGTH_DECAY) / DEGREE_STRENGTH_DECAY
|
|
510
|
+
);
|
|
511
|
+
const scaledStrength =
|
|
512
|
+
MIN_LINK_STRENGTH +
|
|
513
|
+
(LINK_STRENGTH - MIN_LINK_STRENGTH) * Math.exp(-strengthReduction);
|
|
514
|
+
|
|
515
|
+
return Math.max(MIN_LINK_STRENGTH, scaledStrength);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Collision force
|
|
519
|
+
this.graph.d3Force(
|
|
520
|
+
"collision",
|
|
521
|
+
d3
|
|
522
|
+
.forceCollide((node: GraphNode) => {
|
|
523
|
+
const degree = this.nodeDegreeMap.get(node.id) || 0;
|
|
524
|
+
return (
|
|
525
|
+
COLLISION_BASE_RADIUS + Math.sqrt(degree) * HIGH_DEGREE_PADDING
|
|
526
|
+
);
|
|
527
|
+
})
|
|
528
|
+
.strength(COLLISION_STRENGTH)
|
|
529
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
530
|
+
.iterations(2) as any
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// Center force
|
|
534
|
+
const centerForce = this.graph.d3Force("center");
|
|
535
|
+
if (centerForce) {
|
|
536
|
+
centerForce.strength(CENTER_STRENGTH);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Charge force
|
|
540
|
+
const chargeForce = this.graph.d3Force("charge");
|
|
541
|
+
if (chargeForce) {
|
|
542
|
+
chargeForce.strength(CHARGE_STRENGTH).distanceMax(300);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
|
|
547
|
+
if (!node.x || !node.y) {
|
|
548
|
+
node.x = 0;
|
|
549
|
+
node.y = 0;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1.5 : 0.5;
|
|
553
|
+
ctx.strokeStyle = this.config.foregroundColor || "#1A1A1A";
|
|
554
|
+
|
|
555
|
+
ctx.beginPath();
|
|
556
|
+
ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false);
|
|
557
|
+
ctx.fillStyle = node.color;
|
|
558
|
+
ctx.fill();
|
|
559
|
+
ctx.stroke();
|
|
560
|
+
|
|
561
|
+
// Draw text
|
|
562
|
+
ctx.fillStyle = "black";
|
|
563
|
+
ctx.textAlign = "center";
|
|
564
|
+
ctx.textBaseline = "middle";
|
|
565
|
+
ctx.font = "400 2px SofiaSans";
|
|
566
|
+
|
|
567
|
+
let [line1, line2] = node.displayName || ["", ""];
|
|
568
|
+
|
|
569
|
+
if (!line1 && !line2) {
|
|
570
|
+
const text = getNodeDisplayText(
|
|
571
|
+
node,
|
|
572
|
+
this.config.displayTextPriority || []
|
|
573
|
+
);
|
|
574
|
+
const textRadius = NODE_SIZE - PADDING / 2;
|
|
575
|
+
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
576
|
+
node.displayName = [line1, line2];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const textMetrics = ctx.measureText(line1);
|
|
580
|
+
const textHeight =
|
|
581
|
+
textMetrics.actualBoundingBoxAscent +
|
|
582
|
+
textMetrics.actualBoundingBoxDescent;
|
|
583
|
+
const halfTextHeight = (textHeight / 2) * 1.5;
|
|
584
|
+
|
|
585
|
+
if (line1) {
|
|
586
|
+
ctx.fillText(line1, node.x, line2 ? node.y - halfTextHeight : node.y);
|
|
587
|
+
}
|
|
588
|
+
if (line2) {
|
|
589
|
+
ctx.fillText(line2, node.x, node.y + halfTextHeight);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private drawLink(link: GraphLink, ctx: CanvasRenderingContext2D) {
|
|
594
|
+
const start = link.source;
|
|
595
|
+
const end = link.target;
|
|
596
|
+
|
|
597
|
+
if (!start.x || !start.y || !end.x || !end.y) {
|
|
598
|
+
start.x = 0;
|
|
599
|
+
start.y = 0;
|
|
600
|
+
end.x = 0;
|
|
601
|
+
end.y = 0;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let textX;
|
|
605
|
+
let textY;
|
|
606
|
+
let angle;
|
|
607
|
+
|
|
608
|
+
if (start.id === end.id) {
|
|
609
|
+
const radius = NODE_SIZE * (link.curve || 0) * 6.2;
|
|
610
|
+
const angleOffset = -Math.PI / 4;
|
|
611
|
+
textX = start.x + radius * Math.cos(angleOffset);
|
|
612
|
+
textY = start.y + radius * Math.sin(angleOffset);
|
|
613
|
+
angle = -angleOffset;
|
|
614
|
+
} else {
|
|
615
|
+
const dx = end.x - start.x;
|
|
616
|
+
const dy = end.y - start.y;
|
|
617
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
618
|
+
|
|
619
|
+
const perpX = dy / distance;
|
|
620
|
+
const perpY = -dx / distance;
|
|
621
|
+
|
|
622
|
+
const curvature = link.curve || 0;
|
|
623
|
+
const controlX =
|
|
624
|
+
(start.x + end.x) / 2 + perpX * curvature * distance * 1.0;
|
|
625
|
+
const controlY =
|
|
626
|
+
(start.y + end.y) / 2 + perpY * curvature * distance * 1.0;
|
|
627
|
+
|
|
628
|
+
const t = 0.5;
|
|
629
|
+
const oneMinusT = 1 - t;
|
|
630
|
+
textX =
|
|
631
|
+
oneMinusT * oneMinusT * start.x +
|
|
632
|
+
2 * oneMinusT * t * controlX +
|
|
633
|
+
t * t * end.x;
|
|
634
|
+
textY =
|
|
635
|
+
oneMinusT * oneMinusT * start.y +
|
|
636
|
+
2 * oneMinusT * t * controlY +
|
|
637
|
+
t * t * end.y;
|
|
638
|
+
|
|
639
|
+
const tangentX =
|
|
640
|
+
2 * oneMinusT * (controlX - start.x) + 2 * t * (end.x - controlX);
|
|
641
|
+
const tangentY =
|
|
642
|
+
2 * oneMinusT * (controlY - start.y) + 2 * t * (end.y - controlY);
|
|
643
|
+
angle = Math.atan2(tangentY, tangentX);
|
|
644
|
+
|
|
645
|
+
if (angle > Math.PI / 2) angle = -(Math.PI - angle);
|
|
646
|
+
if (angle < -Math.PI / 2) angle = -(-Math.PI - angle);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
ctx.font = "400 2px SofiaSans";
|
|
650
|
+
ctx.textAlign = "center";
|
|
651
|
+
ctx.textBaseline = "middle";
|
|
652
|
+
|
|
653
|
+
let cached = this.relationshipsTextCache.get(link.relationship);
|
|
654
|
+
|
|
655
|
+
if (!cached) {
|
|
656
|
+
const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } =
|
|
657
|
+
ctx.measureText(link.relationship);
|
|
658
|
+
cached = {
|
|
659
|
+
textWidth: width,
|
|
660
|
+
textHeight: actualBoundingBoxAscent + actualBoundingBoxDescent,
|
|
661
|
+
textAscent: actualBoundingBoxAscent,
|
|
662
|
+
textDescent: actualBoundingBoxDescent,
|
|
663
|
+
};
|
|
664
|
+
this.relationshipsTextCache.set(link.relationship, cached);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const { textWidth, textHeight, textAscent, textDescent } = cached;
|
|
668
|
+
|
|
669
|
+
ctx.save();
|
|
670
|
+
ctx.translate(textX, textY);
|
|
671
|
+
ctx.rotate(angle);
|
|
672
|
+
|
|
673
|
+
// Draw background
|
|
674
|
+
ctx.fillStyle = this.config.backgroundColor || "#FFFFFF";
|
|
675
|
+
const backgroundHeight = textHeight * 0.7;
|
|
676
|
+
|
|
677
|
+
// Move background up to align with text that appears at top of bg
|
|
678
|
+
// Use the actual text metrics to calculate proper vertical offset
|
|
679
|
+
const bgOffsetY = -(textAscent - textDescent) - 0.18;
|
|
680
|
+
ctx.fillRect(
|
|
681
|
+
-textWidth / 2,
|
|
682
|
+
-backgroundHeight / 2 + bgOffsetY,
|
|
683
|
+
textWidth,
|
|
684
|
+
backgroundHeight
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Draw text
|
|
688
|
+
ctx.fillStyle = this.config.foregroundColor || "#1A1A1A";
|
|
689
|
+
ctx.textBaseline = "middle";
|
|
690
|
+
ctx.fillText(link.relationship, 0, 0);
|
|
691
|
+
ctx.restore();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private updateLoadingState() {
|
|
695
|
+
if (!this.loadingOverlay) return;
|
|
696
|
+
|
|
697
|
+
if (this.config.isLoading) {
|
|
698
|
+
this.loadingOverlay.style.display = "flex";
|
|
699
|
+
} else {
|
|
700
|
+
this.loadingOverlay.style.display = "none";
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private handleEngineStop() {
|
|
705
|
+
if (!this.graph) return;
|
|
706
|
+
|
|
707
|
+
// If already stopped, don't do anything
|
|
708
|
+
if (this.config.cooldownTicks === 0) return;
|
|
709
|
+
|
|
710
|
+
// Zoom to fit using the same logic as handleZoomToFit from utils
|
|
711
|
+
const nodeCount = this.data.nodes.length;
|
|
712
|
+
const paddingMultiplier = nodeCount < 2 ? 4 : 1;
|
|
713
|
+
this.zoomToFit(paddingMultiplier);
|
|
714
|
+
|
|
715
|
+
// Stop the force simulation after centering (like it was in ForceGraph.tsx)
|
|
716
|
+
setTimeout(() => {
|
|
717
|
+
if (!this.graph) return;
|
|
718
|
+
// Stop loading
|
|
719
|
+
this.config.isLoading = false;
|
|
720
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
721
|
+
this.updateLoadingState();
|
|
722
|
+
this.config.cooldownTicks = 0;
|
|
723
|
+
this.graph.cooldownTicks(0);
|
|
724
|
+
}, 1000);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private updateEventHandlers() {
|
|
728
|
+
if (!this.graph) return;
|
|
729
|
+
|
|
730
|
+
this.graph
|
|
731
|
+
.onNodeClick((node: GraphNode, event: MouseEvent) => {
|
|
732
|
+
if (this.config.onNodeClick) {
|
|
733
|
+
this.config.onNodeClick(node, event);
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
.onLinkClick((link: GraphLink, event: MouseEvent) => {
|
|
737
|
+
if (this.config.onLinkClick) {
|
|
738
|
+
this.config.onLinkClick(link, event);
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
.onNodeRightClick((node: GraphNode, event: MouseEvent) => {
|
|
742
|
+
if (this.config.onNodeRightClick) {
|
|
743
|
+
this.config.onNodeRightClick(node, event);
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
.onLinkRightClick((link: GraphLink, event: MouseEvent) => {
|
|
747
|
+
if (this.config.onLinkRightClick) {
|
|
748
|
+
this.config.onLinkRightClick(link, event);
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
.onNodeHover((node: GraphNode | null) => {
|
|
752
|
+
if (this.config.onNodeHover) {
|
|
753
|
+
this.config.onNodeHover(node);
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
.onLinkHover((link: GraphLink | null) => {
|
|
757
|
+
if (this.config.onLinkHover) {
|
|
758
|
+
this.config.onLinkHover(link);
|
|
759
|
+
}
|
|
760
|
+
})
|
|
761
|
+
.onBackgroundClick((event: MouseEvent) => {
|
|
762
|
+
if (this.config.onBackgroundClick) {
|
|
763
|
+
this.config.onBackgroundClick(event);
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
.onEngineStop(() => {
|
|
767
|
+
this.handleEngineStop();
|
|
768
|
+
if (this.config.onEngineStop) {
|
|
769
|
+
this.config.onEngineStop();
|
|
770
|
+
}
|
|
771
|
+
})
|
|
772
|
+
.nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D) => {
|
|
773
|
+
this.drawNode(node, ctx);
|
|
774
|
+
})
|
|
775
|
+
.linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D) => {
|
|
776
|
+
this.drawLink(link, ctx);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Define the custom element
|
|
782
|
+
if (typeof window !== "undefined" && !customElements.get("falkordb-canvas")) {
|
|
783
|
+
customElements.define("falkordb-canvas", FalkorDBCanvas);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export default FalkorDBCanvas;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import FalkorDBCanvas from "./falkordb-canvas.js";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface HTMLElementTagNameMap {
|
|
6
|
+
"falkordb-canvas": FalkorDBCanvas;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
namespace JSX {
|
|
10
|
+
interface IntrinsicElements {
|
|
11
|
+
"falkordb-canvas": React.DetailedHTMLProps<
|
|
12
|
+
React.HTMLAttributes<FalkorDBCanvas>,
|
|
13
|
+
FalkorDBCanvas
|
|
14
|
+
>;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Main canvas class
|
|
20
|
+
export { FalkorDBCanvas as default, FalkorDBCanvas };
|
|
21
|
+
|
|
22
|
+
// Types
|
|
23
|
+
export type {
|
|
24
|
+
ForceGraphConfig,
|
|
25
|
+
GraphNode,
|
|
26
|
+
GraphLink,
|
|
27
|
+
GraphData,
|
|
28
|
+
Node,
|
|
29
|
+
Link,
|
|
30
|
+
Data,
|
|
31
|
+
TextPriority,
|
|
32
|
+
ViewportState,
|
|
33
|
+
ForceGraphInstance,
|
|
34
|
+
} from "./falkordb-canvas-types.js";
|
|
35
|
+
|
|
36
|
+
// Utils
|
|
37
|
+
export {
|
|
38
|
+
dataToGraphData,
|
|
39
|
+
graphDataToData,
|
|
40
|
+
getNodeDisplayText,
|
|
41
|
+
getNodeDisplayKey,
|
|
42
|
+
wrapTextForCircularNode,
|
|
43
|
+
} from "./falkordb-canvas-utils.js";
|