Skip to content

Commit 15a95a8

Browse files
Fix: #52. OAS Graph (#66)
* Implement graph visualization. * Squash and update graph. * Some experiments in visualization. * graph select and toggle * fix reload schema on typing * layouts array * updated select * fix lint * revert schema absolute uri * changeset * hidden semantic relations toggle --------- Co-authored-by: Roberto Polli <[email protected]>
1 parent 36c0140 commit 15a95a8

File tree

13 files changed

+952
-114
lines changed

13 files changed

+952
-114
lines changed

.changeset/tiny-oranges-cheat.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@teamdigitale/schema-editor': patch
3+
'example': patch
4+
---
5+
6+
New graph component that visualize semantic items

apps/example/public/schemas/test-schema-expanded.oas3.yaml

Lines changed: 109 additions & 109 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"turbo": "^2.0.3",
4040
"typescript": "^5.4.5",
4141
"typescript-eslint": "^7.13.0",
42-
"vitest": "^2.1.3"
42+
"vitest": "^3.2.4"
4343
},
4444
"lint-staged": {
4545
"**/src/**/*.{js,ts,jsx,tsx}": [

packages/schema-editor/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828
"@braintree/sanitize-url": "^7.0.2",
2929
"bootstrap": "^5.3.3",
3030
"bootstrap-italia": "^2.10.0",
31+
"cytoscape": "^3.33.1",
32+
"cytoscape-fcose": "^2.2.0",
3133
"design-react-kit": "^5.2.0",
3234
"immutable": "^4.3.6",
3335
"js-yaml": "^4.1.0",
3436
"jsonld": "^8.3.2",
3537
"lz-string": "^1.5.0",
38+
"react-cytoscapejs": "^2.0.0",
3639
"react-dropzone": "^14.2.3",
3740
"styled-components": "^6.1.11"
3841
},
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import cytoscape from 'cytoscape';
2+
import cytoscapeFcose from 'cytoscape-fcose';
3+
cytoscape.use(cytoscapeFcose);
4+
5+
export const LAYOUTS = ['fcose', 'breadthfirst'] as const;
6+
7+
export type LayoutTypes = (typeof LAYOUTS)[number];
8+
9+
export const LAYOUTS_MAP: Record<LayoutTypes, any> = {
10+
// FCOSE
11+
fcose: {
12+
name: 'fcose',
13+
fit: true,
14+
avoidOverlap: true,
15+
nodeDimensionsIncludeLabels: true,
16+
spacingFactor: 1.5,
17+
idealEdgeLength: 100,
18+
},
19+
// BREADTHFIRST
20+
breadthfirst: {
21+
name: 'breadthfirst',
22+
fit: true, // whether to fit the viewport to the graph
23+
// directed: false, // whether the tree is directed downwards (or edges can point in any direction if false)
24+
// direction: 'downward', // determines the direction in which the tree structure is drawn. The possible values are 'downward', 'upward', 'rightward', or 'leftward'.
25+
// padding: 30, // padding on fit
26+
// circle: false, // put depths in concentric circles if true, put depths top down if false
27+
// grid: false, // whether to create an even grid into which the DAG is placed (circle:false only)
28+
spacingFactor: 1.75, // positive spacing factor, larger => more space between nodes (N.B. n/a if causes overlap)
29+
// boundingBox: undefined, // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
30+
avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space
31+
nodeDimensionsIncludeLabels: true, // Excludes the label when calculating node bounding boxes for the layout algorithm
32+
// roots: undefined, // the roots of the trees
33+
// depthSort: undefined, // a sorting function to order nodes at equal depth. e.g. function(a, b){ return a.data('weight') - b.data('weight') }
34+
animate: true, // whether to transition the node positions
35+
// animationDuration: 500, // duration of animation in ms if enabled
36+
// animationEasing: undefined, // easing of animation if enabled,
37+
// animateFilter: function (node, i) {
38+
// return true;
39+
// }, // a function that determines whether the node should be animated. All nodes animated by default on animate enabled. Non-animated nodes are positioned immediately when the layout starts
40+
// ready: undefined, // callback on layoutready
41+
// stop: undefined, // callback on layoutstop
42+
// transform: function (node, position) {
43+
// return position;
44+
// }, // transform a given node position. Useful for changing flow direction in discrete layouts
45+
},
46+
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Core } from 'cytoscape';
2+
import { Col, FormGroup, Row, Toggle } from 'design-react-kit';
3+
import { List } from 'immutable';
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5+
import CytoscapeComponent from 'react-cytoscapejs';
6+
import { LAYOUTS, LAYOUTS_MAP, LayoutTypes } from './cytoscape-layouts';
7+
import { oasToGraph } from './oas-graph';
8+
9+
export const GraphSchema = ({ specSelectors, editorActions }) => {
10+
const specJson = specSelectors?.spec().toJSON();
11+
const { elements } = useMemo(() => oasToGraph(specJson).graph, [JSON.stringify(specJson)]);
12+
13+
const cyRef = useRef<Core | null>(null);
14+
const [showSemanticRelations, setShowSemanticRelations] = useState(false);
15+
const [layout, setLayout] = useState<LayoutTypes>('fcose');
16+
17+
// Update layout when layout state changes
18+
useEffect(() => {
19+
if (!cyRef.current || !LAYOUTS_MAP[layout]) {
20+
return;
21+
}
22+
cyRef.current.layout(LAYOUTS_MAP[layout]).run();
23+
}, [layout]);
24+
25+
// Centra il grafico al load
26+
useEffect(() => {
27+
setTimeout(() => {
28+
cyRef.current?.center();
29+
}, 100);
30+
}, [cyRef.current]);
31+
32+
// Click sui nodi
33+
const handleNodeClick = useCallback((e) => {
34+
e.stopPropagation();
35+
const path: string = e.target._private.data.id;
36+
if (!path.startsWith('#/')) {
37+
return;
38+
}
39+
const specPath = List(path.split('/').slice(1));
40+
const jumpPath = specSelectors.bestJumpPath({ specPath });
41+
editorActions.jumpToLine(specSelectors.getSpecLineFromPath(jumpPath));
42+
}, []);
43+
44+
useEffect(() => {
45+
cyRef.current?.on('click', 'node', handleNodeClick);
46+
return () => {
47+
cyRef.current?.off('click', 'node', handleNodeClick);
48+
};
49+
}, [cyRef.current]);
50+
51+
const radius = (node) => {
52+
const sizePerLink = 2;
53+
const degree = node.degree();
54+
const baseSize = node.data('label')?.length * 8;
55+
return baseSize + degree * sizePerLink;
56+
};
57+
58+
return (
59+
<div>
60+
<Row>
61+
<Col xs={12} md={6} lg={4} className="me-auto">
62+
{/* <FormGroup check>
63+
<Toggle
64+
label="Semantic Relations"
65+
checked={showSemanticRelations}
66+
onChange={(e) => setShowSemanticRelations(e.target.checked)}
67+
/>
68+
</FormGroup> */}
69+
</Col>
70+
71+
<Col xs={12} md={6}>
72+
<div className="select-wrapper">
73+
<select value={layout} onChange={(e) => setLayout(e.target.value as LayoutTypes)}>
74+
{LAYOUTS.map((x) => (
75+
<option key={x} value={x}>
76+
Layout {LAYOUTS_MAP[x].name}
77+
</option>
78+
))}
79+
</select>
80+
</div>
81+
</Col>
82+
</Row>
83+
84+
<CytoscapeComponent
85+
style={{ width: '100%', height: 'calc(100vh - 210px)' }}
86+
cy={(cy: Core) => (cyRef.current = cy)}
87+
elements={elements}
88+
layout={LAYOUTS_MAP[layout]}
89+
stylesheet={[
90+
{
91+
selector: 'node',
92+
style: {
93+
label: 'data(label)',
94+
width: radius,
95+
height: radius,
96+
padding: '16px',
97+
'text-valign': 'center',
98+
'text-halign': 'center',
99+
'background-color': '#11479e',
100+
color: '#ffffff',
101+
},
102+
},
103+
{
104+
selector: 'node[type="rdf"]',
105+
style: {
106+
'background-color': '#008055',
107+
},
108+
},
109+
{
110+
selector: 'node[type="blank"]',
111+
style: {
112+
'background-color': '#768593',
113+
width: 'label',
114+
height: 'label',
115+
},
116+
},
117+
{
118+
selector: 'node[type="@typed"]',
119+
style: {},
120+
},
121+
{
122+
selector: 'node[leaf=1]',
123+
style: {
124+
shape: 'rectangle',
125+
},
126+
},
127+
//
128+
// Edges
129+
{
130+
selector: 'edge',
131+
style: {
132+
width: 2,
133+
'curve-style': 'straight',
134+
'line-color': '#9dbaea',
135+
'target-arrow-color': '#9dbaea',
136+
'target-arrow-shape': 'triangle',
137+
},
138+
},
139+
{
140+
selector: 'edge[type="dashed"]',
141+
style: {
142+
'line-style': 'dashed',
143+
'target-arrow-shape': 'none',
144+
},
145+
},
146+
]}
147+
/>
148+
</div>
149+
);
150+
};
151+
152+
export default GraphSchema;
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mapToGraph, oasToMap } from './oas-graph';
3+
4+
const testcase_oas = {
5+
oas: {
6+
components: {
7+
schemas: {
8+
A: {
9+
properties: {
10+
a: {
11+
type: 'object',
12+
},
13+
c: {
14+
$ref: '#/components/schemas/C',
15+
},
16+
},
17+
},
18+
B: {
19+
$ref: '#/components/schemas/A',
20+
},
21+
C: {
22+
properties: {
23+
c: {
24+
$ref: '#/components/schemas/A',
25+
},
26+
b: {
27+
$ref: '#/components/schemas/B',
28+
},
29+
},
30+
},
31+
D: {
32+
properties: {
33+
d: {
34+
properties: {
35+
a: {
36+
$ref: '#/components/schemas/A',
37+
},
38+
},
39+
},
40+
},
41+
},
42+
},
43+
},
44+
},
45+
expected: {
46+
'#/components/schemas/A': ['#/components/schemas/C'],
47+
'#/components/schemas/B': ['#/components/schemas/A'],
48+
'#/components/schemas/C': ['#/components/schemas/A', '#/components/schemas/B'],
49+
'#/components/schemas/D': ['#/components/schemas/A'],
50+
},
51+
graph: {
52+
elements: [
53+
{ data: { id: '#/components/schemas/A', label: 'A' } },
54+
{ data: { id: '#/components/schemas/C', label: 'C' } },
55+
{ data: { id: '#/components/schemas/B', label: 'B' } },
56+
{ data: { id: '#/components/schemas/D', label: 'D' } },
57+
//
58+
{ data: { source: '#/components/schemas/A', target: '#/components/schemas/C' } },
59+
{ data: { source: '#/components/schemas/C', target: '#/components/schemas/A' } },
60+
{ data: { source: '#/components/schemas/C', target: '#/components/schemas/B' } },
61+
{ data: { source: '#/components/schemas/B', target: '#/components/schemas/A' } },
62+
{ data: { source: '#/components/schemas/D', target: '#/components/schemas/A' } },
63+
],
64+
},
65+
};
66+
const testcase_oas_2 = {
67+
oas: {
68+
components: {
69+
schemas: {
70+
S: {
71+
type: 'string',
72+
},
73+
A: {
74+
'x-jsonld-type': 'CPV:AType',
75+
properties: {
76+
a: {
77+
type: 'string',
78+
},
79+
c: {
80+
$ref: '#/components/schemas/C',
81+
},
82+
},
83+
},
84+
B: {
85+
$ref: '#/components/schemas/A',
86+
},
87+
C: {
88+
'x-jsonld-type': 'CPV:BType',
89+
properties: {
90+
c: {
91+
$ref: '#/components/schemas/A',
92+
},
93+
b: {
94+
$ref: '#/components/schemas/B',
95+
},
96+
},
97+
},
98+
D: {
99+
properties: {
100+
d: {
101+
properties: {
102+
a: {
103+
$ref: '#/components/schemas/A',
104+
},
105+
},
106+
},
107+
},
108+
},
109+
},
110+
},
111+
},
112+
expected: {
113+
'#/components/schemas/A': ['#/components/schemas/C'],
114+
'#/components/schemas/B': ['#/components/schemas/A'],
115+
'#/components/schemas/C': ['#/components/schemas/A', '#/components/schemas/B'],
116+
'#/components/schemas/D': ['#/components/schemas/A'],
117+
},
118+
graph: {
119+
elements: [
120+
{ data: { id: '#/components/schemas/S', label: 'S', type: 'blank' } },
121+
{ data: { id: '#/components/schemas/A', label: 'A', type: '@typed' } },
122+
{ data: { id: 'CPV:AType', label: 'CPV:AType', type: 'rdf' } },
123+
{ data: { id: '#/components/schemas/C', label: 'C', type: '@typed' } },
124+
{ data: { id: '#/components/schemas/B', label: 'B' } },
125+
{ data: { id: 'CPV:BType', label: 'CPV:BType', type: 'rdf' } },
126+
{ data: { id: '#/components/schemas/D', label: 'D' } },
127+
//
128+
{ data: { source: '#/components/schemas/A', target: 'CPV:AType', type: 'dashed' } },
129+
{ data: { source: '#/components/schemas/A', target: '#/components/schemas/C' } },
130+
{ data: { source: '#/components/schemas/C', target: 'CPV:BType', type: 'dashed' } },
131+
{ data: { source: '#/components/schemas/C', target: '#/components/schemas/A' } },
132+
{ data: { source: '#/components/schemas/C', target: '#/components/schemas/B' } },
133+
{ data: { source: '#/components/schemas/B', target: '#/components/schemas/A' } },
134+
{ data: { source: '#/components/schemas/D', target: '#/components/schemas/A' } },
135+
],
136+
},
137+
};
138+
139+
describe('createDepMap', () => {
140+
it('can extract refs', () => {
141+
const testcase = testcase_oas;
142+
const { result } = oasToMap(testcase.oas);
143+
// expect(result).toStrictEqual(testcase_oas.expected);
144+
145+
const { graph } = mapToGraph(result);
146+
expect(graph).toStrictEqual(testcase.graph);
147+
});
148+
149+
it('can annotate nodes', () => {
150+
const testcase = testcase_oas_2;
151+
const { result } = oasToMap(testcase.oas);
152+
// expect(result).toStrictEqual(testcase_oas.expected);
153+
154+
const { graph } = mapToGraph(result);
155+
expect(graph).toStrictEqual(testcase.graph);
156+
});
157+
});

0 commit comments

Comments
 (0)