@reventlessdev/reventless-graph 0.1.0-alpha.1
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 +79 -0
- package/package.json +47 -0
- package/rescript.json +19 -0
- package/src/GraphData.res +122 -0
- package/src/GraphData.res.mjs +130 -0
- package/src/GraphMode.res +56 -0
- package/src/GraphMode.res.mjs +72 -0
- package/src/GraphView.res +339 -0
- package/src/GraphView.res.mjs +429 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @reventlessdev/reventless-graph
|
|
2
|
+
|
|
3
|
+
The **graph view mode** for Reventless AutoUI, built on the framework-agnostic
|
|
4
|
+
[`@reventlessdev/rescript-cytoscape`](../../rescript/cytoscape) bindings:
|
|
5
|
+
|
|
6
|
+
- `ReventlessGraph.GraphMode.init()` — registers the `"graph"` view mode.
|
|
7
|
+
Offered automatically wherever a read model's (or nested collection's)
|
|
8
|
+
schema carries an entity-ref field — a `*Id` / `*Ids` foreign key, the same
|
|
9
|
+
heuristic the table cells link through.
|
|
10
|
+
- `ReventlessGraph.GraphView` — the underlying Cytoscape.js component the
|
|
11
|
+
`"graph"` mode renders (pan / zoom / fit, layered `dagre` layout by default).
|
|
12
|
+
- `ReventlessGraph.GraphData` — the pure adapter: `buildGraph(source)` →
|
|
13
|
+
`{nodes, edges}`, with no cytoscape or React in sight.
|
|
14
|
+
|
|
15
|
+
Each loaded row becomes a node and each entity reference a directed edge. When
|
|
16
|
+
a referenced id is itself a loaded row the edge resolves to that row's label —
|
|
17
|
+
so **self-referential fields (`managerId`, `dependsOnIds`, `parentId`) form
|
|
18
|
+
trees, org charts and dependency DAGs**. Ids pointing at unloaded entities
|
|
19
|
+
render as short stub nodes grouped by their stem.
|
|
20
|
+
|
|
21
|
+
This package is an **optional add-on**: reventless-ui never imports it, so
|
|
22
|
+
apps without relationship graphs pay no bundle cost.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add @reventlessdev/reventless-graph cytoscape cytoscape-dagre cytoscape-fcose
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`rescript.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
"dependencies": ["@reventlessdev/reventless-graph"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
App entry (once, before the first auto page renders):
|
|
37
|
+
|
|
38
|
+
```rescript
|
|
39
|
+
ReventlessGraph.GraphMode.init()
|
|
40
|
+
// or pick another layout for every graph view:
|
|
41
|
+
// ReventlessGraph.GraphMode.init(~layout="cose", ())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Layouts: `dagre` (layered, the default — bottom-to-top, so a referenced row
|
|
45
|
+
sits above the rows referencing it), `fcose` (force / cluster, and the only
|
|
46
|
+
one that lays out compound cluster boxes properly), `cose` (the built-in force
|
|
47
|
+
layout), `breadthfirst`, `concentric`, `grid`, `circle`.
|
|
48
|
+
|
|
49
|
+
Node colours come from the theme (`Tokens.Color.brand` for the home read
|
|
50
|
+
model, a literal palette for cross-entity stub groups). A canvas cannot read
|
|
51
|
+
`var(--rv-…)`, so GraphView resolves the tokens against its container element
|
|
52
|
+
and re-applies the stylesheet when the theme preset changes.
|
|
53
|
+
|
|
54
|
+
## Building in this repo
|
|
55
|
+
|
|
56
|
+
`pnpm run build` here uses `rescript-legacy`: the ReScript v12 build
|
|
57
|
+
orchestrator still panics (UTF-8 underline bug) when invoked from a downstream
|
|
58
|
+
package that walks into reventless-ui's bs-dependencies. Build in order —
|
|
59
|
+
`rescript/cytoscape`, then this package, then **reventless-ui last**: a build
|
|
60
|
+
here walks into ui and deletes its tracked dev-source `.res.mjs`
|
|
61
|
+
(`stories/`, `tests/`), which only `rescript-legacy clean && build` in ui
|
|
62
|
+
restores. Never use `-with-deps` from here, and `clean` this package after any
|
|
63
|
+
change to the bindings.
|
|
64
|
+
|
|
65
|
+
## Stories
|
|
66
|
+
|
|
67
|
+
Storybook lives in `reventless/ui`, but this package sits **downstream** of
|
|
68
|
+
reventless-ui, so its stories compile without the build cycle that a story
|
|
69
|
+
placed inside ui would create. Author graph stories under `stories/` here;
|
|
70
|
+
ui's Storybook picks them up via a glob that also scans `../../graph/stories`,
|
|
71
|
+
and ui carries cytoscape + this package as **dev**-only dependencies so the
|
|
72
|
+
bundler resolves them (the published ui surface stays cytoscape-free).
|
|
73
|
+
|
|
74
|
+
## Tests
|
|
75
|
+
|
|
76
|
+
`GraphData` is pure and covered by `tests/GraphDataTests.res` (`pnpm test`
|
|
77
|
+
here, or from the repo root — the root jest config lists this package as a
|
|
78
|
+
project). Layout is the library's, so there is nothing left to unit-test in
|
|
79
|
+
the renderer.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reventlessdev/reventless-graph",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Graph view mode (GraphView, GraphMode, GraphData) for Reventless AutoUI, built on @reventlessdev/rescript-cytoscape. Optional add-on: apps without relationship graphs never load it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/**/*.res",
|
|
8
|
+
"src/**/*.res.mjs",
|
|
9
|
+
"rescript.json"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rescript-legacy build",
|
|
13
|
+
"start": "rescript-legacy build -w",
|
|
14
|
+
"clean": "rescript-legacy clean",
|
|
15
|
+
"test": "jest"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@babel/preset-env": "^7.22.4",
|
|
19
|
+
"@glennsl/rescript-jest": "0.13.1",
|
|
20
|
+
"@rescript/react": "^0.13.1",
|
|
21
|
+
"@reventlessdev/rescript-cytoscape": "0.1.0-alpha.1",
|
|
22
|
+
"@reventlessdev/reventless-ui": "3.0.0-alpha.40",
|
|
23
|
+
"babel-jest": "^29.7.0",
|
|
24
|
+
"cytoscape": "^3.34.0",
|
|
25
|
+
"cytoscape-dagre": "^4.0.0",
|
|
26
|
+
"cytoscape-fcose": "^2.2.0",
|
|
27
|
+
"jest": "^29.7.0",
|
|
28
|
+
"react": "^18.3.1",
|
|
29
|
+
"react-dom": "^18.3.1",
|
|
30
|
+
"rescript": "^12.3.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@rescript/react": "^0.13.1",
|
|
34
|
+
"@reventlessdev/rescript-cytoscape": ">=0.1.0-alpha.0",
|
|
35
|
+
"@reventlessdev/reventless-ui": ">=3.0.0-alpha.39",
|
|
36
|
+
"cytoscape": ">=3.30.0",
|
|
37
|
+
"cytoscape-dagre": ">=2.5.0",
|
|
38
|
+
"cytoscape-fcose": ">=2.2.0",
|
|
39
|
+
"react": "^18.3.1",
|
|
40
|
+
"rescript": "^12.3.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"registry": "https://registry.npmjs.org",
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"gitHead": "ee9a72b1a1f76e7fdeab58922e4fb653a1b20cec"
|
|
47
|
+
}
|
package/rescript.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reventlessdev/reventless-graph",
|
|
3
|
+
"namespace": "ReventlessGraph",
|
|
4
|
+
"jsx": { "version": 4 },
|
|
5
|
+
"sources": [
|
|
6
|
+
{ "dir": "src", "subdirs": true },
|
|
7
|
+
{ "dir": "stories", "subdirs": true, "type": "dev" },
|
|
8
|
+
{ "dir": "tests", "subdirs": true, "type": "dev" }
|
|
9
|
+
],
|
|
10
|
+
"package-specs": { "module": "esmodule", "in-source": true },
|
|
11
|
+
"suffix": ".res.mjs",
|
|
12
|
+
"warnings": { "number": "-3" },
|
|
13
|
+
"dependencies": [
|
|
14
|
+
"@rescript/react",
|
|
15
|
+
"@glennsl/rescript-jest",
|
|
16
|
+
"@reventlessdev/reventless-ui",
|
|
17
|
+
"@reventlessdev/rescript-cytoscape"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Pure source → node/edge mapping for GraphView. Kept free of cytoscape and
|
|
2
|
+
// React (the MapData analog) so the shapes stay jest-testable and reusable.
|
|
3
|
+
//
|
|
4
|
+
// Each loaded row is a node; each entity-ref field (KEntityId /
|
|
5
|
+
// KArrayOfEntityIds — the same *Id/*Ids foreign-key heuristic the table cells
|
|
6
|
+
// link through) draws a directed edge to the referenced id. When a referenced
|
|
7
|
+
// id is itself a loaded row, the edge resolves to that row's label — so
|
|
8
|
+
// self-referential fields (managerId, dependsOnIds, parentId) form trees /
|
|
9
|
+
// org charts / dependency DAGs; ids pointing at unloaded entities render as
|
|
10
|
+
// short stub nodes grouped by their stem.
|
|
11
|
+
|
|
12
|
+
type node = {
|
|
13
|
+
id: string,
|
|
14
|
+
label: string,
|
|
15
|
+
group?: string, // colours the node by its home entity / ref stem
|
|
16
|
+
accent?: string, // explicit colour override
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type edge = {
|
|
20
|
+
from: string,
|
|
21
|
+
to_: string,
|
|
22
|
+
label?: string,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type graph = {
|
|
26
|
+
nodes: array<node>,
|
|
27
|
+
edges: array<edge>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Entity-ref fields of a schema: (fieldKey, stem, isMulti). Reuses
|
|
31
|
+
// AutoColumns.classify so the graph sees exactly the refs the table links.
|
|
32
|
+
let entityRefFields = (schema: Js.Json.t): array<(string, string, bool)> =>
|
|
33
|
+
ReventlessUi.AutoSemantics.properties(schema)->Array.filterMap(((key, propSchema)) => {
|
|
34
|
+
let ann = ReventlessUi.SchemaAnnotations.fromProp(propSchema)
|
|
35
|
+
switch ReventlessUi.AutoColumns.classify(~key, ~propSchema, ~ann) {
|
|
36
|
+
| ReventlessUi.AutoColumns.KEntityId(stem) => Some((key, stem, false))
|
|
37
|
+
| ReventlessUi.AutoColumns.KArrayOfEntityIds(stem) => Some((key, stem, true))
|
|
38
|
+
| _ => None
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
let graphCapable = (schema: Js.Json.t): bool => Array.length(entityRefFields(schema)) > 0
|
|
43
|
+
|
|
44
|
+
// Referenced ids for one ref field of one row (multi fans to many).
|
|
45
|
+
let refTargetIds = (
|
|
46
|
+
row: ReventlessUi.AutoModes.preparedRow,
|
|
47
|
+
field: string,
|
|
48
|
+
isMulti: bool,
|
|
49
|
+
): array<string> =>
|
|
50
|
+
if isMulti {
|
|
51
|
+
Js.Dict.get(row.dict, field)
|
|
52
|
+
->Option.flatMap(Js.Json.decodeArray)
|
|
53
|
+
->Option.getOr([])
|
|
54
|
+
->Array.filterMap(Js.Json.decodeString)
|
|
55
|
+
} else {
|
|
56
|
+
ReventlessUi.AutoModes.stringAt(row.dict, field)->Option.mapOr([], v => [v])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Pure graph derivation (exported for tests). Row nodes first, then target
|
|
60
|
+
// nodes; dedup by id keeps the row node so a referenced-and-loaded row keeps
|
|
61
|
+
// its real label.
|
|
62
|
+
let buildGraph = (source: ReventlessUi.AutoViewMode.source): graph => {
|
|
63
|
+
let refFields = entityRefFields(source.schema)
|
|
64
|
+
let rows = ReventlessUi.AutoModes.prepareRows(source)
|
|
65
|
+
let rowLabel = Js.Dict.empty()
|
|
66
|
+
rows->Array.forEach(r => Js.Dict.set(rowLabel, r.key, ReventlessUi.AutoModes.titleOf(source, r)))
|
|
67
|
+
let shortId = (id: string): string =>
|
|
68
|
+
String.length(id) > 8 ? String.slice(id, ~start=0, ~end=8) ++ `…` : id
|
|
69
|
+
|
|
70
|
+
let rowNodes: array<node> =
|
|
71
|
+
rows->Array.map(r => {
|
|
72
|
+
id: r.key,
|
|
73
|
+
label: ReventlessUi.AutoModes.titleOf(source, r),
|
|
74
|
+
group: source.readModelName,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
let targetNodes: array<node> =
|
|
78
|
+
rows->Array.flatMap(r =>
|
|
79
|
+
refFields->Array.flatMap(((field, stem, isMulti)) =>
|
|
80
|
+
refTargetIds(r, field, isMulti)->Array.map(targetId => {
|
|
81
|
+
switch Js.Dict.get(rowLabel, targetId) {
|
|
82
|
+
// A referenced row that is also loaded → real label, home group.
|
|
83
|
+
| Some(label) => {id: targetId, label, group: source.readModelName}
|
|
84
|
+
// Cross-entity stub → short id, grouped by the ref stem.
|
|
85
|
+
| None => {
|
|
86
|
+
id: targetId,
|
|
87
|
+
label: shortId(targetId),
|
|
88
|
+
group: ReventlessUi.Humanize.humanizeKey(stem),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
let nodes =
|
|
96
|
+
Array.concat(rowNodes, targetNodes)->Array.reduce([], (acc, node) =>
|
|
97
|
+
acc->Array.some(n => n.id == node.id) ? acc : Array.concat(acc, [node])
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
let edges: array<edge> =
|
|
101
|
+
rows->Array.flatMap(r =>
|
|
102
|
+
refFields->Array.flatMap(((field, _stem, isMulti)) =>
|
|
103
|
+
refTargetIds(r, field, isMulti)->Array.map(targetId => {
|
|
104
|
+
from: r.key,
|
|
105
|
+
to_: targetId,
|
|
106
|
+
label: ReventlessUi.Humanize.humanizeKey(field),
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
{nodes, edges}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Distinct group names in first-seen order — the stylesheet assigns one
|
|
115
|
+
// colour per group in exactly this order.
|
|
116
|
+
let groupsOf = (nodes: array<node>): array<string> =>
|
|
117
|
+
nodes->Array.reduce([], (acc, n) =>
|
|
118
|
+
switch n.group {
|
|
119
|
+
| Some(g) if !(acc->Array.includes(g)) => Array.concat(acc, [g])
|
|
120
|
+
| _ => acc
|
|
121
|
+
}
|
|
122
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js";
|
|
4
|
+
import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Humanize$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/Humanize.res.mjs";
|
|
8
|
+
import * as AutoModes$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoModes.res.mjs";
|
|
9
|
+
import * as AutoColumns$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoColumns.res.mjs";
|
|
10
|
+
import * as AutoSemantics$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoSemantics.res.mjs";
|
|
11
|
+
import * as SchemaAnnotations$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/SchemaAnnotations.res.mjs";
|
|
12
|
+
|
|
13
|
+
function entityRefFields(schema) {
|
|
14
|
+
return Stdlib_Array.filterMap(AutoSemantics$ReventlessUi.properties(schema), param => {
|
|
15
|
+
let propSchema = param[1];
|
|
16
|
+
let key = param[0];
|
|
17
|
+
let ann = SchemaAnnotations$ReventlessUi.fromProp(propSchema);
|
|
18
|
+
let stem = AutoColumns$ReventlessUi.classify(key, propSchema, ann);
|
|
19
|
+
if (typeof stem !== "object") {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
switch (stem.TAG) {
|
|
23
|
+
case "KEntityId" :
|
|
24
|
+
return [
|
|
25
|
+
key,
|
|
26
|
+
stem._0,
|
|
27
|
+
false
|
|
28
|
+
];
|
|
29
|
+
case "KArrayOfEntityIds" :
|
|
30
|
+
return [
|
|
31
|
+
key,
|
|
32
|
+
stem._0,
|
|
33
|
+
true
|
|
34
|
+
];
|
|
35
|
+
default:
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function graphCapable(schema) {
|
|
42
|
+
return entityRefFields(schema).length !== 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function refTargetIds(row, field, isMulti) {
|
|
46
|
+
if (isMulti) {
|
|
47
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Js_dict.get(row.dict, field), Js_json.decodeArray), []), Js_json.decodeString);
|
|
48
|
+
} else {
|
|
49
|
+
return Stdlib_Option.mapOr(AutoModes$ReventlessUi.stringAt(row.dict, field), [], v => [v]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildGraph(source) {
|
|
54
|
+
let refFields = entityRefFields(source.schema);
|
|
55
|
+
let rows = AutoModes$ReventlessUi.prepareRows(source);
|
|
56
|
+
let rowLabel = {};
|
|
57
|
+
rows.forEach(r => {
|
|
58
|
+
rowLabel[r.key] = AutoModes$ReventlessUi.titleOf(source, r);
|
|
59
|
+
});
|
|
60
|
+
let shortId = id => {
|
|
61
|
+
if (id.length > 8) {
|
|
62
|
+
return id.slice(0, 8) + `…`;
|
|
63
|
+
} else {
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
let rowNodes = rows.map(r => ({
|
|
68
|
+
id: r.key,
|
|
69
|
+
label: AutoModes$ReventlessUi.titleOf(source, r),
|
|
70
|
+
group: source.readModelName
|
|
71
|
+
}));
|
|
72
|
+
let targetNodes = rows.flatMap(r => refFields.flatMap(param => {
|
|
73
|
+
let stem = param[1];
|
|
74
|
+
return refTargetIds(r, param[0], param[2]).map(targetId => {
|
|
75
|
+
let label = Js_dict.get(rowLabel, targetId);
|
|
76
|
+
if (label !== undefined) {
|
|
77
|
+
return {
|
|
78
|
+
id: targetId,
|
|
79
|
+
label: label,
|
|
80
|
+
group: source.readModelName
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
return {
|
|
84
|
+
id: targetId,
|
|
85
|
+
label: shortId(targetId),
|
|
86
|
+
group: Humanize$ReventlessUi.humanizeKey(stem)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}));
|
|
91
|
+
let nodes = Stdlib_Array.reduce(rowNodes.concat(targetNodes), [], (acc, node) => {
|
|
92
|
+
if (acc.some(n => n.id === node.id)) {
|
|
93
|
+
return acc;
|
|
94
|
+
} else {
|
|
95
|
+
return acc.concat([node]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
let edges = rows.flatMap(r => refFields.flatMap(param => {
|
|
99
|
+
let field = param[0];
|
|
100
|
+
return refTargetIds(r, field, param[2]).map(targetId => ({
|
|
101
|
+
from: r.key,
|
|
102
|
+
to_: targetId,
|
|
103
|
+
label: Humanize$ReventlessUi.humanizeKey(field)
|
|
104
|
+
}));
|
|
105
|
+
}));
|
|
106
|
+
return {
|
|
107
|
+
nodes: nodes,
|
|
108
|
+
edges: edges
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function groupsOf(nodes) {
|
|
113
|
+
return Stdlib_Array.reduce(nodes, [], (acc, n) => {
|
|
114
|
+
let g = n.group;
|
|
115
|
+
if (g !== undefined && !acc.includes(g)) {
|
|
116
|
+
return acc.concat([g]);
|
|
117
|
+
} else {
|
|
118
|
+
return acc;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
entityRefFields,
|
|
125
|
+
graphCapable,
|
|
126
|
+
refTargetIds,
|
|
127
|
+
buildGraph,
|
|
128
|
+
groupsOf,
|
|
129
|
+
}
|
|
130
|
+
/* Humanize-ReventlessUi Not a pure module */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Registers the "graph" view mode with reventless-ui's registry.
|
|
2
|
+
// Registration is an explicit opt-in (GraphMode.init() from the host app),
|
|
3
|
+
// never a module side effect of reventless-ui: apps without relationship
|
|
4
|
+
// graphs never load this package or cytoscape.
|
|
5
|
+
|
|
6
|
+
let defaultLayout = "dagre"
|
|
7
|
+
|
|
8
|
+
let layoutRef = ref(defaultLayout)
|
|
9
|
+
|
|
10
|
+
let graphIcon =
|
|
11
|
+
<span ariaHidden=true style={ReactDOM.Style.make(~fontSize="0.9em", ~lineHeight="1", ())}>
|
|
12
|
+
{React.string(`◈`)}
|
|
13
|
+
</span>
|
|
14
|
+
|
|
15
|
+
module ModeView = {
|
|
16
|
+
@react.component
|
|
17
|
+
let make = (~source: ReventlessUi.AutoViewMode.source) => {
|
|
18
|
+
let rows = ReventlessUi.AutoModes.prepareRows(source)
|
|
19
|
+
let g = GraphData.buildGraph(source)
|
|
20
|
+
// Only loaded-row ids match openRow's find, so stub nodes are inert.
|
|
21
|
+
<GraphView
|
|
22
|
+
nodes=g.nodes
|
|
23
|
+
edges=g.edges
|
|
24
|
+
layout=layoutRef.contents
|
|
25
|
+
onOpenNode={id => ReventlessUi.AutoModes.openRow(source, rows, id)}
|
|
26
|
+
/>
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Call once at app startup, before the first auto page renders. `layout`
|
|
31
|
+
// picks the cytoscape layout the mode renders with: "dagre" (layered — the
|
|
32
|
+
// default, best for the hierarchical data entity refs usually describe),
|
|
33
|
+
// "fcose" / "cose" (force / cluster), "breadthfirst", "concentric", "grid",
|
|
34
|
+
// "circle".
|
|
35
|
+
let init = (~layout: option<string>=?, ()): unit => {
|
|
36
|
+
layout->Option.forEach(l => layoutRef := l)
|
|
37
|
+
ReventlessUi.AutoViewMode.register({
|
|
38
|
+
id: "graph",
|
|
39
|
+
label: "Graph",
|
|
40
|
+
icon: graphIcon,
|
|
41
|
+
// Single-source: a canvas spanning several read models (compound parents
|
|
42
|
+
// per source) is not supported yet.
|
|
43
|
+
arity: Single,
|
|
44
|
+
// Needs at least one entity-ref field (*Id/*Ids foreign key) to draw edges.
|
|
45
|
+
isCapable: GraphData.graphCapable,
|
|
46
|
+
render: sources =>
|
|
47
|
+
switch sources->Array.get(0) {
|
|
48
|
+
| Some(source) => <ModeView source />
|
|
49
|
+
| None => React.null
|
|
50
|
+
},
|
|
51
|
+
// Keep in sync with the `optionalModes` entry in ui's ComponentManifest —
|
|
52
|
+
// this registration only exists at app runtime, so the manifest can't
|
|
53
|
+
// read it from the registry.
|
|
54
|
+
capability: "entity-ref fields — one or more *Id/*Ids foreign keys drawn as a node-link diagram (self-referential fields form trees)",
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as JsxRuntime from "react/jsx-runtime";
|
|
5
|
+
import * as AutoModes$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoModes.res.mjs";
|
|
6
|
+
import * as AutoViewMode$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoViewMode.res.mjs";
|
|
7
|
+
import * as GraphData$ReventlessGraph from "./GraphData.res.mjs";
|
|
8
|
+
import * as GraphView$ReventlessGraph from "./GraphView.res.mjs";
|
|
9
|
+
|
|
10
|
+
let defaultLayout = "dagre";
|
|
11
|
+
|
|
12
|
+
let layoutRef = {
|
|
13
|
+
contents: defaultLayout
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let graphIcon = JsxRuntime.jsx("span", {
|
|
17
|
+
children: `◈`,
|
|
18
|
+
"aria-hidden": true,
|
|
19
|
+
style: {
|
|
20
|
+
fontSize: "0.9em",
|
|
21
|
+
lineHeight: "1"
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function GraphMode$ModeView(props) {
|
|
26
|
+
let source = props.source;
|
|
27
|
+
let rows = AutoModes$ReventlessUi.prepareRows(source);
|
|
28
|
+
let g = GraphData$ReventlessGraph.buildGraph(source);
|
|
29
|
+
return JsxRuntime.jsx(GraphView$ReventlessGraph.make, {
|
|
30
|
+
nodes: g.nodes,
|
|
31
|
+
edges: g.edges,
|
|
32
|
+
onOpenNode: id => AutoModes$ReventlessUi.openRow(source, rows, id),
|
|
33
|
+
layout: layoutRef.contents
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let ModeView = {
|
|
38
|
+
make: GraphMode$ModeView
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function init(layout, param) {
|
|
42
|
+
Stdlib_Option.forEach(layout, l => {
|
|
43
|
+
layoutRef.contents = l;
|
|
44
|
+
});
|
|
45
|
+
AutoViewMode$ReventlessUi.register({
|
|
46
|
+
id: "graph",
|
|
47
|
+
label: "Graph",
|
|
48
|
+
icon: graphIcon,
|
|
49
|
+
arity: "Single",
|
|
50
|
+
isCapable: GraphData$ReventlessGraph.graphCapable,
|
|
51
|
+
render: sources => {
|
|
52
|
+
let source = sources[0];
|
|
53
|
+
if (source !== undefined) {
|
|
54
|
+
return JsxRuntime.jsx(GraphMode$ModeView, {
|
|
55
|
+
source: source
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
capability: "entity-ref fields — one or more *Id/*Ids foreign keys drawn as a node-link diagram (self-referential fields form trees)"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
defaultLayout,
|
|
67
|
+
layoutRef,
|
|
68
|
+
graphIcon,
|
|
69
|
+
ModeView,
|
|
70
|
+
init,
|
|
71
|
+
}
|
|
72
|
+
/* graphIcon Not a pure module */
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// The graph view mode's renderer: a Cytoscape.js instance over GraphData's
|
|
2
|
+
// nodes/edges. Registered by GraphMode.init() (never by reventless-ui
|
|
3
|
+
// itself), so apps without relationship graphs pay no bundle cost.
|
|
4
|
+
//
|
|
5
|
+
// Cytoscape owns a canvas inside the container element and brings its own
|
|
6
|
+
// camera (pan / zoom / fit) and layouts — `dagre` (layered, the default: our
|
|
7
|
+
// data is mostly hierarchical) plus the built-in `cose` / `concentric` /
|
|
8
|
+
// `breadthfirst` / `grid` / `circle`.
|
|
9
|
+
//
|
|
10
|
+
// Theming: a canvas cannot read `var(--rv-…)`, and ThemeProvider scopes those
|
|
11
|
+
// custom properties to a wrapper class rather than :root — so the stylesheet
|
|
12
|
+
// resolves each token against the *container element* via getComputedStyle
|
|
13
|
+
// and is re-applied whenever the theme preset changes.
|
|
14
|
+
|
|
15
|
+
module Cy = Cytoscape.CytoscapeBindings
|
|
16
|
+
|
|
17
|
+
type cssStyleDeclaration
|
|
18
|
+
@val external getComputedStyle: Dom.element => cssStyleDeclaration = "getComputedStyle"
|
|
19
|
+
@send external getPropertyValue: (cssStyleDeclaration, string) => string = "getPropertyValue"
|
|
20
|
+
|
|
21
|
+
type resizeObserver
|
|
22
|
+
@new external makeResizeObserver: (unit => unit) => resizeObserver = "ResizeObserver"
|
|
23
|
+
@send external observe: (resizeObserver, Dom.element) => unit = "observe"
|
|
24
|
+
@send external disconnect: resizeObserver => unit = "disconnect"
|
|
25
|
+
|
|
26
|
+
// `dagre` (layered) and `fcose` (compound-aware force) are extensions,
|
|
27
|
+
// registered globally on the cytoscape module — do it once, lazily, from the
|
|
28
|
+
// only place that can need them (registering twice makes cytoscape warn).
|
|
29
|
+
let extensionsRegistered = ref(false)
|
|
30
|
+
let ensureExtensions = (): unit =>
|
|
31
|
+
if !extensionsRegistered.contents {
|
|
32
|
+
extensionsRegistered := true
|
|
33
|
+
Cy.use(Cy.dagre)
|
|
34
|
+
Cy.use(Cy.fcose)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let tokenValue = (el: Dom.element, token: string, ~fallback: string): string => {
|
|
38
|
+
let v = getComputedStyle(el)->getPropertyValue(token)->String.trim
|
|
39
|
+
v == "" ? fallback : v
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Literal colours for groups past the first (which takes the brand token) —
|
|
43
|
+
// custom properties never reach the canvas, so this mirrors MapData's
|
|
44
|
+
// literalPalette.
|
|
45
|
+
let literalPalette = ["#7c9c5e", "#b0743c", "#7466a3", "#4d8a8a", "#a35f74"]
|
|
46
|
+
|
|
47
|
+
// Group names become element classes, so strip anything not class-safe.
|
|
48
|
+
let groupClass = (group: string): string =>
|
|
49
|
+
"grp-" ++ group->String.replaceRegExp(%re("/[^a-zA-Z0-9_-]+/g"), "-")
|
|
50
|
+
|
|
51
|
+
let elementsOf = (~nodes: array<GraphData.node>, ~edges: array<GraphData.edge>): array<
|
|
52
|
+
Cy.element,
|
|
53
|
+
> => {
|
|
54
|
+
let ids = nodes->Array.map(n => n.id)
|
|
55
|
+
let nodeEls = nodes->Array.map(n =>
|
|
56
|
+
Cy.nodeElement({
|
|
57
|
+
data: {id: n.id, label: n.label},
|
|
58
|
+
classes: switch n.group {
|
|
59
|
+
| Some(g) => groupClass(g)
|
|
60
|
+
| None => ""
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
// Cytoscape throws on an edge with an unknown endpoint; buildGraph always
|
|
65
|
+
// emits stub nodes for its targets, but a hand-built graph may not.
|
|
66
|
+
let edgeEls =
|
|
67
|
+
edges
|
|
68
|
+
->Array.filter(e => ids->Array.includes(e.from) && ids->Array.includes(e.to_))
|
|
69
|
+
->Array.mapWithIndex((e, i) =>
|
|
70
|
+
Cy.edgeElement({
|
|
71
|
+
data: {
|
|
72
|
+
// Ids are unique across nodes AND edges in cytoscape, and node ids
|
|
73
|
+
// are row ids we don't control — hence a prefix a row id can't be.
|
|
74
|
+
id: "__edge__" ++ Int.toString(i),
|
|
75
|
+
source: e.from,
|
|
76
|
+
target: e.to_,
|
|
77
|
+
label: ?e.label,
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
Array.concat(nodeEls, edgeEls)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let stylesheet = (~el: Dom.element, ~groups: array<string>): array<Cy.styleRule> => {
|
|
85
|
+
let brand = tokenValue(el, ReventlessUi.Tokens.Color.brand, ~fallback="#3b6ea5")
|
|
86
|
+
let text = tokenValue(el, ReventlessUi.Tokens.Color.textPrimary, ~fallback="#1a1a1a")
|
|
87
|
+
let border = tokenValue(el, ReventlessUi.Tokens.Color.border, ~fallback="#d4d4d4")
|
|
88
|
+
let surface = tokenValue(el, ReventlessUi.Tokens.Color.surface, ~fallback="#ffffff")
|
|
89
|
+
let colorFor = (i: int): string =>
|
|
90
|
+
i == 0 ? brand : literalPalette->Array.getUnsafe(mod(i - 1, Array.length(literalPalette)))
|
|
91
|
+
|
|
92
|
+
let base = [
|
|
93
|
+
Cy.rule(
|
|
94
|
+
~selector="node",
|
|
95
|
+
[
|
|
96
|
+
("background-color", brand),
|
|
97
|
+
("border-width", "2"),
|
|
98
|
+
("border-color", surface),
|
|
99
|
+
("width", "18"),
|
|
100
|
+
("height", "18"),
|
|
101
|
+
("label", "data(label)"),
|
|
102
|
+
("color", text),
|
|
103
|
+
("font-size", "11px"),
|
|
104
|
+
("text-valign", "center"),
|
|
105
|
+
("text-halign", "right"),
|
|
106
|
+
("text-margin-x", "6"),
|
|
107
|
+
("text-wrap", "ellipsis"),
|
|
108
|
+
("text-max-width", "150"),
|
|
109
|
+
// Labels fade out rather than pile up when zoomed far out.
|
|
110
|
+
("min-zoomed-font-size", "7"),
|
|
111
|
+
],
|
|
112
|
+
),
|
|
113
|
+
// Compound parents (cluster boxes) — only present when a caller sets
|
|
114
|
+
// `parent` on the element data.
|
|
115
|
+
Cy.rule(
|
|
116
|
+
~selector="node:parent",
|
|
117
|
+
[
|
|
118
|
+
("background-color", border),
|
|
119
|
+
("background-opacity", "0.12"),
|
|
120
|
+
("border-width", "1"),
|
|
121
|
+
("border-color", border),
|
|
122
|
+
("text-valign", "top"),
|
|
123
|
+
("text-halign", "center"),
|
|
124
|
+
("font-size", "12px"),
|
|
125
|
+
("color", text),
|
|
126
|
+
("padding", "12"),
|
|
127
|
+
],
|
|
128
|
+
),
|
|
129
|
+
Cy.rule(
|
|
130
|
+
~selector="edge",
|
|
131
|
+
[
|
|
132
|
+
("width", "1.5"),
|
|
133
|
+
("line-color", border),
|
|
134
|
+
("target-arrow-color", border),
|
|
135
|
+
("target-arrow-shape", "triangle"),
|
|
136
|
+
("arrow-scale", "0.9"),
|
|
137
|
+
("curve-style", "bezier"),
|
|
138
|
+
],
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
let groupRules =
|
|
142
|
+
groups->Array.mapWithIndex((g, i) =>
|
|
143
|
+
Cy.rule(~selector="." ++ groupClass(g), [("background-color", colorFor(i))])
|
|
144
|
+
)
|
|
145
|
+
Array.concat(base, groupRules)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Per-layout tuning. Unknown names fall through with the shared options —
|
|
149
|
+
// cytoscape ignores what a layout doesn't understand.
|
|
150
|
+
let layoutFor = (name: string): Cy.layoutOptions =>
|
|
151
|
+
switch name {
|
|
152
|
+
| "dagre" => {
|
|
153
|
+
name: "dagre",
|
|
154
|
+
// Edges run row → referenced row (report → manager, task → its
|
|
155
|
+
// prerequisite), so bottom-to-top puts the referenced end on top: the
|
|
156
|
+
// CEO above their reports, prerequisites above what depends on them.
|
|
157
|
+
rankDir: "BT",
|
|
158
|
+
nodeSep: 28,
|
|
159
|
+
rankSep: 56,
|
|
160
|
+
padding: 24,
|
|
161
|
+
fit: true,
|
|
162
|
+
animate: false,
|
|
163
|
+
nodeDimensionsIncludeLabels: true,
|
|
164
|
+
}
|
|
165
|
+
// The force/cluster layout. fcose is the one that understands compound
|
|
166
|
+
// parents — the built-in `cose` piles cluster boxes on top of each other.
|
|
167
|
+
| "fcose" => {
|
|
168
|
+
name: "fcose",
|
|
169
|
+
quality: "proof",
|
|
170
|
+
padding: 24,
|
|
171
|
+
fit: true,
|
|
172
|
+
animate: false,
|
|
173
|
+
randomize: true,
|
|
174
|
+
idealEdgeLength: 80,
|
|
175
|
+
nodeSeparation: 90,
|
|
176
|
+
nodeRepulsion: 6000,
|
|
177
|
+
gravityCompound: 1.5,
|
|
178
|
+
nodeDimensionsIncludeLabels: true,
|
|
179
|
+
}
|
|
180
|
+
| "cose" => {
|
|
181
|
+
name: "cose",
|
|
182
|
+
padding: 24,
|
|
183
|
+
fit: true,
|
|
184
|
+
animate: false,
|
|
185
|
+
idealEdgeLength: 90,
|
|
186
|
+
nodeRepulsion: 9000,
|
|
187
|
+
componentSpacing: 80,
|
|
188
|
+
numIter: 800,
|
|
189
|
+
// cose relaxes from the *current* positions, which on a first render
|
|
190
|
+
// are undefined — it needs its own random seeding to spread out. The
|
|
191
|
+
// picture therefore varies run to run; that is force layout, and it is
|
|
192
|
+
// why `dagre` (stable and layered) is the mode's default.
|
|
193
|
+
randomize: true,
|
|
194
|
+
}
|
|
195
|
+
| "breadthfirst" => {
|
|
196
|
+
name: "breadthfirst",
|
|
197
|
+
directed: true,
|
|
198
|
+
padding: 24,
|
|
199
|
+
fit: true,
|
|
200
|
+
animate: false,
|
|
201
|
+
spacingFactor: 1.1,
|
|
202
|
+
nodeDimensionsIncludeLabels: true,
|
|
203
|
+
}
|
|
204
|
+
| "concentric" => {
|
|
205
|
+
name: "concentric",
|
|
206
|
+
padding: 24,
|
|
207
|
+
fit: true,
|
|
208
|
+
animate: false,
|
|
209
|
+
minNodeSpacing: 40,
|
|
210
|
+
nodeDimensionsIncludeLabels: true,
|
|
211
|
+
}
|
|
212
|
+
| other => {name: other, padding: 24, fit: true, animate: false}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module Style = {
|
|
216
|
+
let v = token => `var(${token})`
|
|
217
|
+
|
|
218
|
+
let wrap = (~height: string) =>
|
|
219
|
+
ReactDOM.Style.make(
|
|
220
|
+
~width="100%",
|
|
221
|
+
~height,
|
|
222
|
+
~background=v(ReventlessUi.Tokens.Color.surface),
|
|
223
|
+
~border=`1px solid ${v(ReventlessUi.Tokens.Color.border)}`,
|
|
224
|
+
~borderRadius=v(ReventlessUi.Tokens.Radius.md),
|
|
225
|
+
~overflow="hidden",
|
|
226
|
+
(),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
let empty = ReactDOM.Style.make(
|
|
230
|
+
~padding=v(ReventlessUi.Tokens.Spacing.lg),
|
|
231
|
+
~color=v(ReventlessUi.Tokens.Color.textSecondary),
|
|
232
|
+
~fontSize=v(ReventlessUi.Tokens.Typography.sizeSm),
|
|
233
|
+
(),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Content signature — producers rebuild the node/edge arrays on every render,
|
|
238
|
+
// so effects key off what the graph *is* rather than array identity (which
|
|
239
|
+
// would tear the instance down, and reset the camera, on every render).
|
|
240
|
+
let signatureOf = (nodes: array<GraphData.node>, edges: array<GraphData.edge>): string =>
|
|
241
|
+
nodes
|
|
242
|
+
->Array.map(n => `${n.id}|${n.label}|${n.group->Option.getOr("")}`)
|
|
243
|
+
->Array.join(",") ++
|
|
244
|
+
"//" ++
|
|
245
|
+
edges->Array.map(e => `${e.from}>${e.to_}`)->Array.join(",")
|
|
246
|
+
|
|
247
|
+
@react.component
|
|
248
|
+
let make = (
|
|
249
|
+
~nodes: array<GraphData.node>,
|
|
250
|
+
~edges: array<GraphData.edge>,
|
|
251
|
+
~onOpenNode: option<string => unit>=?,
|
|
252
|
+
~layout: string="dagre",
|
|
253
|
+
~height: string="520px",
|
|
254
|
+
) => {
|
|
255
|
+
let containerRef = React.useRef(Js.Nullable.null)
|
|
256
|
+
let cyRef: React.ref<option<Cy.t>> = React.useRef(None)
|
|
257
|
+
// The tap handler registers once per instance, and the resync effect reads
|
|
258
|
+
// the data, but both must see the CURRENT values (the producer re-renders
|
|
259
|
+
// with fresh rows).
|
|
260
|
+
let onOpenRef = React.useRef(onOpenNode)
|
|
261
|
+
onOpenRef.current = onOpenNode
|
|
262
|
+
let dataRef = React.useRef((nodes, edges))
|
|
263
|
+
dataRef.current = (nodes, edges)
|
|
264
|
+
let theme = ReventlessUi.ThemeContext.useTheme()
|
|
265
|
+
let signature = signatureOf(nodes, edges)
|
|
266
|
+
// Creation already used the current data; the resync effect skips its
|
|
267
|
+
// first run rather than immediately rebuilding what was just built.
|
|
268
|
+
let pristine = React.useRef(true)
|
|
269
|
+
|
|
270
|
+
React.useEffect0(() => {
|
|
271
|
+
switch containerRef.current->Js.Nullable.toOption {
|
|
272
|
+
| None => None
|
|
273
|
+
| Some(el) =>
|
|
274
|
+
ensureExtensions()
|
|
275
|
+
let cy = Cy.make({
|
|
276
|
+
container: el,
|
|
277
|
+
elements: elementsOf(~nodes, ~edges),
|
|
278
|
+
style: stylesheet(~el, ~groups=GraphData.groupsOf(nodes)),
|
|
279
|
+
layout: layoutFor(layout),
|
|
280
|
+
minZoom: 0.2,
|
|
281
|
+
maxZoom: 3.0,
|
|
282
|
+
})
|
|
283
|
+
Cy.onSelector(cy, "tap", "node", e =>
|
|
284
|
+
onOpenRef.current->Option.forEach(f => f(Cy.Event.targetId(e)))
|
|
285
|
+
)
|
|
286
|
+
cyRef.current = Some(cy)
|
|
287
|
+
// The canvas is sized from the container, so a container resize
|
|
288
|
+
// (drawer, split view, window) has to be pushed in.
|
|
289
|
+
let observer = makeResizeObserver(() => {
|
|
290
|
+
Cy.resize(cy)
|
|
291
|
+
Cy.fit(cy, 24)
|
|
292
|
+
})
|
|
293
|
+
observer->observe(el)
|
|
294
|
+
Some(
|
|
295
|
+
() => {
|
|
296
|
+
cyRef.current = None
|
|
297
|
+
observer->disconnect
|
|
298
|
+
Cy.destroy(cy)
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// New rows or a new layout → swap the elements and re-run the layout on the
|
|
305
|
+
// live instance (keeping the instance, and its extensions, alive).
|
|
306
|
+
React.useEffect2(() => {
|
|
307
|
+
if pristine.current {
|
|
308
|
+
pristine.current = false
|
|
309
|
+
} else {
|
|
310
|
+
switch (cyRef.current, containerRef.current->Js.Nullable.toOption) {
|
|
311
|
+
| (Some(cy), Some(el)) =>
|
|
312
|
+
let (nodes, edges) = dataRef.current
|
|
313
|
+
let _ = Cy.remove(cy, Cy.elements(cy))
|
|
314
|
+
let _ = Cy.add(cy, elementsOf(~nodes, ~edges))
|
|
315
|
+
Cy.style(cy, stylesheet(~el, ~groups=GraphData.groupsOf(nodes)))
|
|
316
|
+
Cy.layout(cy, layoutFor(layout))->Cy.run
|
|
317
|
+
| _ => ()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
None
|
|
321
|
+
}, (signature, layout))
|
|
322
|
+
|
|
323
|
+
// Theme switch → re-resolve the tokens and swap the whole stylesheet.
|
|
324
|
+
React.useEffect1(() => {
|
|
325
|
+
switch (cyRef.current, containerRef.current->Js.Nullable.toOption) {
|
|
326
|
+
| (Some(cy), Some(el)) =>
|
|
327
|
+
let (nodes, _) = dataRef.current
|
|
328
|
+
Cy.style(cy, stylesheet(~el, ~groups=GraphData.groupsOf(nodes)))
|
|
329
|
+
| _ => ()
|
|
330
|
+
}
|
|
331
|
+
None
|
|
332
|
+
}, [theme.preset])
|
|
333
|
+
|
|
334
|
+
if Array.length(nodes) == 0 {
|
|
335
|
+
<div style=Style.empty> {React.string("No relationships to graph.")} </div>
|
|
336
|
+
} else {
|
|
337
|
+
<div ref={ReactDOM.Ref.domRef(containerRef)} style={Style.wrap(~height)} />
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Cytoscape from "cytoscape";
|
|
5
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import CytoscapeDagre from "cytoscape-dagre";
|
|
8
|
+
import CytoscapeFcose from "cytoscape-fcose";
|
|
9
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
10
|
+
import * as JsxRuntime from "react/jsx-runtime";
|
|
11
|
+
import * as Tokens$ReventlessUi from "@reventlessdev/reventless-ui/src/tokens/Tokens.res.mjs";
|
|
12
|
+
import * as GraphData$ReventlessGraph from "./GraphData.res.mjs";
|
|
13
|
+
import * as ThemeContext$ReventlessUi from "@reventlessdev/reventless-ui/src/tokens/ThemeContext.res.mjs";
|
|
14
|
+
import * as CytoscapeBindings$Cytoscape from "@reventlessdev/rescript-cytoscape/src/CytoscapeBindings.res.mjs";
|
|
15
|
+
|
|
16
|
+
let extensionsRegistered = {
|
|
17
|
+
contents: false
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function ensureExtensions() {
|
|
21
|
+
if (!extensionsRegistered.contents) {
|
|
22
|
+
extensionsRegistered.contents = true;
|
|
23
|
+
CytoscapeBindings$Cytoscape.use(CytoscapeDagre);
|
|
24
|
+
return CytoscapeBindings$Cytoscape.use(CytoscapeFcose);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tokenValue(el, token, fallback) {
|
|
29
|
+
let v = getComputedStyle(el).getPropertyValue(token).trim();
|
|
30
|
+
if (v === "") {
|
|
31
|
+
return fallback;
|
|
32
|
+
} else {
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let literalPalette = [
|
|
38
|
+
"#7c9c5e",
|
|
39
|
+
"#b0743c",
|
|
40
|
+
"#7466a3",
|
|
41
|
+
"#4d8a8a",
|
|
42
|
+
"#a35f74"
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function groupClass(group) {
|
|
46
|
+
return "grp-" + group.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function elementsOf(nodes, edges) {
|
|
50
|
+
let ids = nodes.map(n => n.id);
|
|
51
|
+
let nodeEls = nodes.map(n => {
|
|
52
|
+
let g = n.group;
|
|
53
|
+
return {
|
|
54
|
+
data: {
|
|
55
|
+
id: n.id,
|
|
56
|
+
label: n.label
|
|
57
|
+
},
|
|
58
|
+
classes: g !== undefined ? groupClass(g) : ""
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
let edgeEls = edges.filter(e => {
|
|
62
|
+
if (ids.includes(e.from)) {
|
|
63
|
+
return ids.includes(e.to_);
|
|
64
|
+
} else {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}).map((e, i) => ({
|
|
68
|
+
data: {
|
|
69
|
+
id: "__edge__" + i.toString(),
|
|
70
|
+
source: e.from,
|
|
71
|
+
target: e.to_,
|
|
72
|
+
label: e.label
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
return nodeEls.concat(edgeEls);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stylesheet(el, groups) {
|
|
79
|
+
let brand = tokenValue(el, Tokens$ReventlessUi.Color.brand, "#3b6ea5");
|
|
80
|
+
let text = tokenValue(el, Tokens$ReventlessUi.Color.textPrimary, "#1a1a1a");
|
|
81
|
+
let border = tokenValue(el, Tokens$ReventlessUi.Color.border, "#d4d4d4");
|
|
82
|
+
let surface = tokenValue(el, Tokens$ReventlessUi.Color.surface, "#ffffff");
|
|
83
|
+
let colorFor = i => {
|
|
84
|
+
if (i === 0) {
|
|
85
|
+
return brand;
|
|
86
|
+
} else {
|
|
87
|
+
return literalPalette[Primitive_int.mod_(i - 1 | 0, literalPalette.length)];
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
let base = [
|
|
91
|
+
CytoscapeBindings$Cytoscape.rule("node", [
|
|
92
|
+
[
|
|
93
|
+
"background-color",
|
|
94
|
+
brand
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
"border-width",
|
|
98
|
+
"2"
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"border-color",
|
|
102
|
+
surface
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
"width",
|
|
106
|
+
"18"
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
"height",
|
|
110
|
+
"18"
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
"label",
|
|
114
|
+
"data(label)"
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
"color",
|
|
118
|
+
text
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
"font-size",
|
|
122
|
+
"11px"
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
"text-valign",
|
|
126
|
+
"center"
|
|
127
|
+
],
|
|
128
|
+
[
|
|
129
|
+
"text-halign",
|
|
130
|
+
"right"
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
"text-margin-x",
|
|
134
|
+
"6"
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
"text-wrap",
|
|
138
|
+
"ellipsis"
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
"text-max-width",
|
|
142
|
+
"150"
|
|
143
|
+
],
|
|
144
|
+
[
|
|
145
|
+
"min-zoomed-font-size",
|
|
146
|
+
"7"
|
|
147
|
+
]
|
|
148
|
+
]),
|
|
149
|
+
CytoscapeBindings$Cytoscape.rule("node:parent", [
|
|
150
|
+
[
|
|
151
|
+
"background-color",
|
|
152
|
+
border
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"background-opacity",
|
|
156
|
+
"0.12"
|
|
157
|
+
],
|
|
158
|
+
[
|
|
159
|
+
"border-width",
|
|
160
|
+
"1"
|
|
161
|
+
],
|
|
162
|
+
[
|
|
163
|
+
"border-color",
|
|
164
|
+
border
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
"text-valign",
|
|
168
|
+
"top"
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
"text-halign",
|
|
172
|
+
"center"
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
"font-size",
|
|
176
|
+
"12px"
|
|
177
|
+
],
|
|
178
|
+
[
|
|
179
|
+
"color",
|
|
180
|
+
text
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
"padding",
|
|
184
|
+
"12"
|
|
185
|
+
]
|
|
186
|
+
]),
|
|
187
|
+
CytoscapeBindings$Cytoscape.rule("edge", [
|
|
188
|
+
[
|
|
189
|
+
"width",
|
|
190
|
+
"1.5"
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"line-color",
|
|
194
|
+
border
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"target-arrow-color",
|
|
198
|
+
border
|
|
199
|
+
],
|
|
200
|
+
[
|
|
201
|
+
"target-arrow-shape",
|
|
202
|
+
"triangle"
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
"arrow-scale",
|
|
206
|
+
"0.9"
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
"curve-style",
|
|
210
|
+
"bezier"
|
|
211
|
+
]
|
|
212
|
+
])
|
|
213
|
+
];
|
|
214
|
+
let groupRules = groups.map((g, i) => CytoscapeBindings$Cytoscape.rule("." + groupClass(g), [[
|
|
215
|
+
"background-color",
|
|
216
|
+
colorFor(i)
|
|
217
|
+
]]));
|
|
218
|
+
return base.concat(groupRules);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function layoutFor(name) {
|
|
222
|
+
switch (name) {
|
|
223
|
+
case "breadthfirst" :
|
|
224
|
+
return {
|
|
225
|
+
name: "breadthfirst",
|
|
226
|
+
fit: true,
|
|
227
|
+
padding: 24,
|
|
228
|
+
animate: false,
|
|
229
|
+
spacingFactor: 1.1,
|
|
230
|
+
nodeDimensionsIncludeLabels: true,
|
|
231
|
+
directed: true
|
|
232
|
+
};
|
|
233
|
+
case "concentric" :
|
|
234
|
+
return {
|
|
235
|
+
name: "concentric",
|
|
236
|
+
fit: true,
|
|
237
|
+
padding: 24,
|
|
238
|
+
animate: false,
|
|
239
|
+
nodeDimensionsIncludeLabels: true,
|
|
240
|
+
minNodeSpacing: 40
|
|
241
|
+
};
|
|
242
|
+
case "cose" :
|
|
243
|
+
return {
|
|
244
|
+
name: "cose",
|
|
245
|
+
fit: true,
|
|
246
|
+
padding: 24,
|
|
247
|
+
animate: false,
|
|
248
|
+
idealEdgeLength: 90,
|
|
249
|
+
nodeRepulsion: 9000,
|
|
250
|
+
numIter: 800,
|
|
251
|
+
randomize: true,
|
|
252
|
+
componentSpacing: 80
|
|
253
|
+
};
|
|
254
|
+
case "dagre" :
|
|
255
|
+
return {
|
|
256
|
+
name: "dagre",
|
|
257
|
+
fit: true,
|
|
258
|
+
padding: 24,
|
|
259
|
+
animate: false,
|
|
260
|
+
nodeDimensionsIncludeLabels: true,
|
|
261
|
+
rankDir: "BT",
|
|
262
|
+
nodeSep: 28,
|
|
263
|
+
rankSep: 56
|
|
264
|
+
};
|
|
265
|
+
case "fcose" :
|
|
266
|
+
return {
|
|
267
|
+
name: "fcose",
|
|
268
|
+
fit: true,
|
|
269
|
+
padding: 24,
|
|
270
|
+
animate: false,
|
|
271
|
+
nodeDimensionsIncludeLabels: true,
|
|
272
|
+
idealEdgeLength: 80,
|
|
273
|
+
nodeRepulsion: 6000,
|
|
274
|
+
randomize: true,
|
|
275
|
+
quality: "proof",
|
|
276
|
+
nodeSeparation: 90,
|
|
277
|
+
gravityCompound: 1.5
|
|
278
|
+
};
|
|
279
|
+
default:
|
|
280
|
+
return {
|
|
281
|
+
name: name,
|
|
282
|
+
fit: true,
|
|
283
|
+
padding: 24,
|
|
284
|
+
animate: false
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function v(token) {
|
|
290
|
+
return `var(` + token + `)`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function wrap(height) {
|
|
294
|
+
return {
|
|
295
|
+
background: v(Tokens$ReventlessUi.Color.surface),
|
|
296
|
+
border: `1px solid ` + v(Tokens$ReventlessUi.Color.border),
|
|
297
|
+
height: height,
|
|
298
|
+
overflow: "hidden",
|
|
299
|
+
width: "100%",
|
|
300
|
+
borderRadius: v(Tokens$ReventlessUi.Radius.md)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let empty = {
|
|
305
|
+
color: v(Tokens$ReventlessUi.Color.textSecondary),
|
|
306
|
+
fontSize: v(Tokens$ReventlessUi.Typography.sizeSm),
|
|
307
|
+
padding: v(Tokens$ReventlessUi.Spacing.lg)
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
let Style = {
|
|
311
|
+
v: v,
|
|
312
|
+
wrap: wrap,
|
|
313
|
+
empty: empty
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
function signatureOf(nodes, edges) {
|
|
317
|
+
return nodes.map(n => n.id + `|` + n.label + `|` + Stdlib_Option.getOr(n.group, "")).join(",") + "//" + edges.map(e => e.from + `>` + e.to_).join(",");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function GraphView(props) {
|
|
321
|
+
let __height = props.height;
|
|
322
|
+
let __layout = props.layout;
|
|
323
|
+
let onOpenNode = props.onOpenNode;
|
|
324
|
+
let edges = props.edges;
|
|
325
|
+
let nodes = props.nodes;
|
|
326
|
+
let __layout_value = __layout !== undefined ? __layout : "dagre";
|
|
327
|
+
let __height_value = __height !== undefined ? __height : "520px";
|
|
328
|
+
let containerRef = React.useRef(null);
|
|
329
|
+
let cyRef = React.useRef(undefined);
|
|
330
|
+
let onOpenRef = React.useRef(onOpenNode);
|
|
331
|
+
onOpenRef.current = onOpenNode;
|
|
332
|
+
let dataRef = React.useRef([
|
|
333
|
+
nodes,
|
|
334
|
+
edges
|
|
335
|
+
]);
|
|
336
|
+
dataRef.current = [
|
|
337
|
+
nodes,
|
|
338
|
+
edges
|
|
339
|
+
];
|
|
340
|
+
let theme = ThemeContext$ReventlessUi.useTheme();
|
|
341
|
+
let signature = signatureOf(nodes, edges);
|
|
342
|
+
let pristine = React.useRef(true);
|
|
343
|
+
React.useEffect(() => {
|
|
344
|
+
let el = containerRef.current;
|
|
345
|
+
if (el == null) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
ensureExtensions();
|
|
349
|
+
let cy = Cytoscape({
|
|
350
|
+
container: el,
|
|
351
|
+
elements: elementsOf(nodes, edges),
|
|
352
|
+
style: stylesheet(el, GraphData$ReventlessGraph.groupsOf(nodes)),
|
|
353
|
+
layout: layoutFor(__layout_value),
|
|
354
|
+
minZoom: 0.2,
|
|
355
|
+
maxZoom: 3.0
|
|
356
|
+
});
|
|
357
|
+
cy.on("tap", "node", e => Stdlib_Option.forEach(onOpenRef.current, f => f(CytoscapeBindings$Cytoscape.Event.targetId(e))));
|
|
358
|
+
cyRef.current = Primitive_option.some(cy);
|
|
359
|
+
let observer = new ResizeObserver(() => {
|
|
360
|
+
cy.resize();
|
|
361
|
+
cy.fit(24);
|
|
362
|
+
});
|
|
363
|
+
observer.observe(el);
|
|
364
|
+
return () => {
|
|
365
|
+
cyRef.current = undefined;
|
|
366
|
+
observer.disconnect();
|
|
367
|
+
cy.destroy();
|
|
368
|
+
};
|
|
369
|
+
}, []);
|
|
370
|
+
React.useEffect(() => {
|
|
371
|
+
if (pristine.current) {
|
|
372
|
+
pristine.current = false;
|
|
373
|
+
} else {
|
|
374
|
+
let match = cyRef.current;
|
|
375
|
+
let match$1 = containerRef.current;
|
|
376
|
+
if (match !== undefined && !(match$1 == null)) {
|
|
377
|
+
let cy = Primitive_option.valFromOption(match);
|
|
378
|
+
let match$2 = dataRef.current;
|
|
379
|
+
let nodes = match$2[0];
|
|
380
|
+
cy.remove(cy.elements());
|
|
381
|
+
cy.add(elementsOf(nodes, match$2[1]));
|
|
382
|
+
cy.style(stylesheet(match$1, GraphData$ReventlessGraph.groupsOf(nodes)));
|
|
383
|
+
cy.layout(layoutFor(__layout_value)).run();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}, [
|
|
387
|
+
signature,
|
|
388
|
+
__layout_value
|
|
389
|
+
]);
|
|
390
|
+
React.useEffect(() => {
|
|
391
|
+
let match = cyRef.current;
|
|
392
|
+
let match$1 = containerRef.current;
|
|
393
|
+
if (match !== undefined && !(match$1 == null)) {
|
|
394
|
+
let match$2 = dataRef.current;
|
|
395
|
+
Primitive_option.valFromOption(match).style(stylesheet(match$1, GraphData$ReventlessGraph.groupsOf(match$2[0])));
|
|
396
|
+
}
|
|
397
|
+
}, [theme.preset]);
|
|
398
|
+
if (nodes.length === 0) {
|
|
399
|
+
return JsxRuntime.jsx("div", {
|
|
400
|
+
children: "No relationships to graph.",
|
|
401
|
+
style: empty
|
|
402
|
+
});
|
|
403
|
+
} else {
|
|
404
|
+
return JsxRuntime.jsx("div", {
|
|
405
|
+
ref: Primitive_option.some(containerRef),
|
|
406
|
+
style: wrap(__height_value)
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let Cy;
|
|
412
|
+
|
|
413
|
+
let make = GraphView;
|
|
414
|
+
|
|
415
|
+
export {
|
|
416
|
+
Cy,
|
|
417
|
+
extensionsRegistered,
|
|
418
|
+
ensureExtensions,
|
|
419
|
+
tokenValue,
|
|
420
|
+
literalPalette,
|
|
421
|
+
groupClass,
|
|
422
|
+
elementsOf,
|
|
423
|
+
stylesheet,
|
|
424
|
+
layoutFor,
|
|
425
|
+
Style,
|
|
426
|
+
signatureOf,
|
|
427
|
+
make,
|
|
428
|
+
}
|
|
429
|
+
/* empty Not a pure module */
|