@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 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 */