aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/index.html1
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts7
-rw-r--r--subprojects/frontend/src/editor/EditorTheme.ts4
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx86
-rw-r--r--subprojects/frontend/src/graph/GraphStore.ts51
-rw-r--r--subprojects/frontend/src/graph/GraphTheme.tsx76
-rw-r--r--subprojects/frontend/src/graph/ZoomCanvas.tsx5
-rw-r--r--subprojects/frontend/src/graph/dotSource.ts309
-rw-r--r--subprojects/frontend/src/graph/postProcessSVG.ts133
-rw-r--r--subprojects/frontend/src/utils/svgURL.ts9
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts30
-rw-r--r--subprojects/frontend/vite.config.ts2
12 files changed, 616 insertions, 97 deletions
diff --git a/subprojects/frontend/index.html b/subprojects/frontend/index.html
index f4b46da2..8992d538 100644
--- a/subprojects/frontend/index.html
+++ b/subprojects/frontend/index.html
@@ -19,6 +19,7 @@
19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b"> 19 <meta name="theme-color" media="(prefers-color-scheme:dark)" content="#21252b">
20 <style> 20 <style>
21 @import '@fontsource-variable/open-sans/wdth.css'; 21 @import '@fontsource-variable/open-sans/wdth.css';
22 @import '@fontsource-variable/open-sans/wdth-italic.css';
22 @import '@fontsource-variable/jetbrains-mono/wght.css'; 23 @import '@fontsource-variable/jetbrains-mono/wght.css';
23 @import '@fontsource-variable/jetbrains-mono/wght-italic.css'; 24 @import '@fontsource-variable/jetbrains-mono/wght-italic.css';
24 </style> 25 </style>
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 10f01099..b5989ad1 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -26,6 +26,7 @@ import { makeAutoObservable, observable, runInAction } from 'mobx';
26import { nanoid } from 'nanoid'; 26import { nanoid } from 'nanoid';
27 27
28import type PWAStore from '../PWAStore'; 28import type PWAStore from '../PWAStore';
29import GraphStore from '../graph/GraphStore';
29import getLogger from '../utils/getLogger'; 30import getLogger from '../utils/getLogger';
30import type XtextClient from '../xtext/XtextClient'; 31import type XtextClient from '../xtext/XtextClient';
31import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; 32import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
@@ -66,7 +67,7 @@ export default class EditorStore {
66 67
67 semanticsError: string | undefined; 68 semanticsError: string | undefined;
68 69
69 semantics: SemanticsSuccessResult | undefined; 70 graph: GraphStore;
70 71
71 constructor(initialValue: string, pwaStore: PWAStore) { 72 constructor(initialValue: string, pwaStore: PWAStore) {
72 this.id = nanoid(); 73 this.id = nanoid();
@@ -86,12 +87,12 @@ export default class EditorStore {
86 })().catch((error) => { 87 })().catch((error) => {
87 log.error('Failed to load XtextClient', error); 88 log.error('Failed to load XtextClient', error);
88 }); 89 });
90 this.graph = new GraphStore();
89 makeAutoObservable<EditorStore, 'client'>(this, { 91 makeAutoObservable<EditorStore, 'client'>(this, {
90 id: false, 92 id: false,
91 state: observable.ref, 93 state: observable.ref,
92 client: observable.ref, 94 client: observable.ref,
93 view: observable.ref, 95 view: observable.ref,
94 semantics: observable.ref,
95 searchPanel: false, 96 searchPanel: false,
96 lintPanel: false, 97 lintPanel: false,
97 contentAssist: false, 98 contentAssist: false,
@@ -298,7 +299,7 @@ export default class EditorStore {
298 299
299 setSemantics(semantics: SemanticsSuccessResult) { 300 setSemantics(semantics: SemanticsSuccessResult) {
300 this.semanticsError = undefined; 301 this.semanticsError = undefined;
301 this.semantics = semantics; 302 this.graph.setSemantics(semantics);
302 } 303 }
303 304
304 dispose(): void { 305 dispose(): void {
diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts
index 4508273b..308d5be0 100644
--- a/subprojects/frontend/src/editor/EditorTheme.ts
+++ b/subprojects/frontend/src/editor/EditorTheme.ts
@@ -10,9 +10,7 @@ import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw';
10import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; 10import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw';
11import { alpha, styled, type CSSObject } from '@mui/material/styles'; 11import { alpha, styled, type CSSObject } from '@mui/material/styles';
12 12
13function svgURL(svg: string): string { 13import svgURL from '../utils/svgURL';
14 return `url('data:image/svg+xml;utf8,${svg}')`;
15}
16 14
17export default styled('div', { 15export default styled('div', {
18 name: 'EditorTheme', 16 name: 'EditorTheme',
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
index 29e750f5..291314ec 100644
--- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
+++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
@@ -8,76 +8,24 @@ import * as d3 from 'd3';
8import { type Graphviz, graphviz } from 'd3-graphviz'; 8import { type Graphviz, graphviz } from 'd3-graphviz';
9import type { BaseType, Selection } from 'd3-selection'; 9import type { BaseType, Selection } from 'd3-selection';
10import { reaction, type IReactionDisposer } from 'mobx'; 10import { reaction, type IReactionDisposer } from 'mobx';
11import { observer } from 'mobx-react-lite';
11import { useCallback, useRef } from 'react'; 12import { useCallback, useRef } from 'react';
12 13
13import { useRootStore } from '../RootStoreProvider'; 14import { useRootStore } from '../RootStoreProvider';
14import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; 15import getLogger from '../utils/getLogger';
15 16
16import GraphTheme from './GraphTheme'; 17import GraphTheme from './GraphTheme';
17import { FitZoomCallback } from './ZoomCanvas'; 18import { FitZoomCallback } from './ZoomCanvas';
19import dotSource from './dotSource';
18import postProcessSvg from './postProcessSVG'; 20import postProcessSvg from './postProcessSVG';
19 21
20function toGraphviz( 22const LOG = getLogger('graph.DotGraphVisualizer');
21 semantics: SemanticsSuccessResult | undefined,
22): string | undefined {
23 if (semantics === undefined) {
24 return undefined;
25 }
26 const lines = [
27 'digraph {',
28 'graph [bgcolor=transparent];',
29 `node [fontsize=12, shape=plain, fontname="OpenSans"];`,
30 'edge [fontsize=10.5, color=black, fontname="OpenSans"];',
31 ];
32 const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`);
33 lines.push(
34 ...nodeIds.map(
35 (id, i) =>
36 `n${i} [id="${id}", label=<<table border="1" cellborder="0" cellspacing="0" cellpadding="4.5" style="rounded" bgcolor="green"><tr><td>${id}</td></tr><hr/><tr><td bgcolor="white">node</td></tr></table>>];`,
37 ),
38 );
39 Object.keys(semantics.partialInterpretation).forEach((relation) => {
40 if (relation === 'builtin::equals' || relation === 'builtin::contains') {
41 return;
42 }
43 const tuples = semantics.partialInterpretation[relation];
44 if (tuples === undefined) {
45 return;
46 }
47 const first = tuples[0];
48 if (first === undefined || first.length !== 3) {
49 return;
50 }
51 const nameFragments = relation.split('::');
52 const simpleName = nameFragments[nameFragments.length - 1] ?? relation;
53 lines.push(
54 ...tuples.map(([from, to, value]) => {
55 if (
56 typeof from !== 'number' ||
57 typeof to !== 'number' ||
58 typeof value !== 'string'
59 ) {
60 return '';
61 }
62 const isUnknown = value === 'UNKNOWN';
63 return `n${from} -> n${to} [
64 id="${nodeIds[from]},${nodeIds[to]},${relation}",
65 xlabel="${simpleName}",
66 style="${isUnknown ? 'dashed' : 'solid'}",
67 class="edge-${value}"
68 ];`;
69 }),
70 );
71 });
72 lines.push('}');
73 return lines.join('\n');
74}
75 23
76function ptToPx(pt: number): number { 24function ptToPx(pt: number): number {
77 return (pt * 4) / 3; 25 return (pt * 4) / 3;
78} 26}
79 27
80export default function DotGraphVisualizer({ 28function DotGraphVisualizer({
81 fitZoom, 29 fitZoom,
82 transitionTime, 30 transitionTime,
83}: { 31}: {
@@ -88,6 +36,7 @@ export default function DotGraphVisualizer({
88 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; 36 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime;
89 37
90 const { editorStore } = useRootStore(); 38 const { editorStore } = useRootStore();
39 const graph = editorStore?.graph;
91 const disposerRef = useRef<IReactionDisposer | undefined>(); 40 const disposerRef = useRef<IReactionDisposer | undefined>();
92 const graphvizRef = useRef< 41 const graphvizRef = useRef<
93 Graphviz<BaseType, unknown, null, undefined> | undefined 42 Graphviz<BaseType, unknown, null, undefined> | undefined
@@ -113,6 +62,9 @@ export default function DotGraphVisualizer({
113 undefined 62 undefined
114 >; 63 >;
115 renderer.keyMode('id'); 64 renderer.keyMode('id');
65 ['TRUE', 'UNKNOWN', 'ERROR'].forEach((icon) =>
66 renderer.addImage(`#${icon}`, 16, 16),
67 );
116 renderer.zoom(false); 68 renderer.zoom(false);
117 renderer.tweenPrecision('5%'); 69 renderer.tweenPrecision('5%');
118 renderer.tweenShapes(false); 70 renderer.tweenShapes(false);
@@ -125,6 +77,7 @@ export default function DotGraphVisualizer({
125 */ 77 */
126 renderer.transition(transition as any); 78 renderer.transition(transition as any);
127 let newViewBox = { width: 0, height: 0 }; 79 let newViewBox = { width: 0, height: 0 };
80 renderer.onerror(LOG.error.bind(LOG));
128 renderer.on( 81 renderer.on(
129 'postProcessSVG', 82 'postProcessSVG',
130 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. 83 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings.
@@ -139,19 +92,24 @@ export default function DotGraphVisualizer({
139 height: ptToPx(svg.viewBox.baseVal.height), 92 height: ptToPx(svg.viewBox.baseVal.height),
140 }; 93 };
141 } else { 94 } else {
95 // Do not trigger fit zoom.
142 newViewBox = { width: 0, height: 0 }; 96 newViewBox = { width: 0, height: 0 };
143 } 97 }
144 }, 98 },
145 ); 99 );
100 renderer.on('renderEnd', () => {
101 // `d3-graphviz` uses `<title>` elements for traceability,
102 // so we only remove them after the rendering is finished.
103 d3.select(element).selectAll('title').remove();
104 });
146 if (fitZoom !== undefined) { 105 if (fitZoom !== undefined) {
147 renderer.on('transitionStart', () => fitZoom(newViewBox)); 106 renderer.on('transitionStart', () => fitZoom(newViewBox));
148 } 107 }
149 disposerRef.current = reaction( 108 disposerRef.current = reaction(
150 () => editorStore?.semantics, 109 () => dotSource(graph),
151 (semantics) => { 110 (source) => {
152 const str = toGraphviz(semantics); 111 if (source !== undefined) {
153 if (str !== undefined) { 112 renderer.renderDot(source);
154 renderer.renderDot(str);
155 } 113 }
156 }, 114 },
157 { fireImmediately: true }, 115 { fireImmediately: true },
@@ -159,7 +117,7 @@ export default function DotGraphVisualizer({
159 graphvizRef.current = renderer; 117 graphvizRef.current = renderer;
160 } 118 }
161 }, 119 },
162 [editorStore, fitZoom, transitionTimeOrDefault], 120 [graph, fitZoom, transitionTimeOrDefault],
163 ); 121 );
164 122
165 return <GraphTheme ref={setElement} />; 123 return <GraphTheme ref={setElement} />;
@@ -169,3 +127,5 @@ DotGraphVisualizer.defaultProps = {
169 fitZoom: undefined, 127 fitZoom: undefined,
170 transitionTime: 250, 128 transitionTime: 250,
171}; 129};
130
131export default observer(DotGraphVisualizer);
diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts
new file mode 100644
index 00000000..b59bfb7d
--- /dev/null
+++ b/subprojects/frontend/src/graph/GraphStore.ts
@@ -0,0 +1,51 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { makeAutoObservable, observable } from 'mobx';
8
9import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
10
11export type Visibility = 'all' | 'must' | 'none';
12
13export default class GraphStore {
14 semantics: SemanticsSuccessResult = {
15 nodes: [],
16 relations: [],
17 partialInterpretation: {},
18 };
19
20 visibility = new Map<string, Visibility>();
21
22 constructor() {
23 makeAutoObservable(this, {
24 semantics: observable.ref,
25 });
26 }
27
28 getVisiblity(relation: string): Visibility {
29 return this.visibility.get(relation) ?? 'none';
30 }
31
32 setSemantics(semantics: SemanticsSuccessResult) {
33 this.semantics = semantics;
34 this.visibility.clear();
35 const names = new Set<string>();
36 this.semantics.relations.forEach(({ name, detail }) => {
37 names.add(name);
38 if (!this.visibility.has(name)) {
39 const newVisibility = detail.type === 'builtin' ? 'none' : 'all';
40 this.visibility.set(name, newVisibility);
41 }
42 });
43 const oldNames = new Set<string>();
44 this.visibility.forEach((_, key) => oldNames.add(key));
45 oldNames.forEach((key) => {
46 if (!names.has(key)) {
47 this.visibility.delete(key);
48 }
49 });
50 }
51}
diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx
index 41ba6ba5..989bd0c2 100644
--- a/subprojects/frontend/src/graph/GraphTheme.tsx
+++ b/subprojects/frontend/src/graph/GraphTheme.tsx
@@ -4,19 +4,28 @@
4 * SPDX-License-Identifier: EPL-2.0 4 * SPDX-License-Identifier: EPL-2.0
5 */ 5 */
6 6
7import { styled, type CSSObject } from '@mui/material/styles'; 7import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw';
8import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw';
9import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw';
10import { alpha, styled, type CSSObject } from '@mui/material/styles';
8 11
9function createEdgeColor(suffix: string, color: string): CSSObject { 12import svgURL from '../utils/svgURL';
13
14function createEdgeColor(
15 suffix: string,
16 stroke: string,
17 fill?: string,
18): CSSObject {
10 return { 19 return {
11 [`& .edge-${suffix}`]: { 20 [`.edge-${suffix}`]: {
12 '& text': { 21 '& text': {
13 fill: color, 22 fill: stroke,
14 }, 23 },
15 '& [stroke="black"]': { 24 '& [stroke="black"]': {
16 stroke: color, 25 stroke,
17 }, 26 },
18 '& [fill="black"]': { 27 '& [fill="black"]': {
19 fill: color, 28 fill: fill ?? stroke,
20 }, 29 },
21 }, 30 },
22 }; 31 };
@@ -27,7 +36,7 @@ export default styled('div', {
27})(({ theme }) => ({ 36})(({ theme }) => ({
28 '& svg': { 37 '& svg': {
29 userSelect: 'none', 38 userSelect: 'none',
30 '& .node': { 39 '.node': {
31 '& text': { 40 '& text': {
32 fontFamily: theme.typography.fontFamily, 41 fontFamily: theme.typography.fontFamily,
33 fill: theme.palette.text.primary, 42 fill: theme.palette.text.primary,
@@ -43,10 +52,32 @@ export default styled('div', {
43 }, 52 },
44 '& [fill="white"]': { 53 '& [fill="white"]': {
45 fill: theme.palette.background.default, 54 fill: theme.palette.background.default,
46 stroke: theme.palette.background.default,
47 }, 55 },
48 }, 56 },
49 '& .edge': { 57 '.node-INDIVIDUAL': {
58 '& [stroke="black"]': {
59 strokeWidth: 2,
60 },
61 },
62 '.node-shadow[fill="white"]': {
63 fill: alpha(
64 theme.palette.text.primary,
65 theme.palette.mode === 'dark' ? 0.32 : 0.24,
66 ),
67 },
68 '.node-exists-UNKNOWN [stroke="black"]': {
69 strokeDasharray: '5 2',
70 },
71 '.node-exists-FALSE': {
72 '& [fill="green"]': {
73 fill: theme.palette.background.default,
74 },
75 '& [stroke="black"]': {
76 strokeDasharray: '1 3',
77 stroke: theme.palette.text.secondary,
78 },
79 },
80 '.edge': {
50 '& text': { 81 '& text': {
51 fontFamily: theme.typography.fontFamily, 82 fontFamily: theme.typography.fontFamily,
52 fill: theme.palette.text.primary, 83 fill: theme.palette.text.primary,
@@ -58,7 +89,32 @@ export default styled('div', {
58 fill: theme.palette.text.primary, 89 fill: theme.palette.text.primary,
59 }, 90 },
60 }, 91 },
61 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), 92 ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'),
62 ...createEdgeColor('ERROR', theme.palette.error.main), 93 ...createEdgeColor('ERROR', theme.palette.error.main),
94 '.icon': {
95 maskSize: '12px 12px',
96 maskPosition: '50% 50%',
97 maskRepeat: 'no-repeat',
98 width: '100%',
99 height: '100%',
100 },
101 '.icon-TRUE': {
102 maskImage: svgURL(labelSVG),
103 background: theme.palette.text.primary,
104 },
105 '.icon-UNKNOWN': {
106 maskImage: svgURL(labelOutlinedSVG),
107 background: theme.palette.text.secondary,
108 },
109 '.icon-ERROR': {
110 maskImage: svgURL(cancelSVG),
111 background: theme.palette.error.main,
112 },
113 'text.label-UNKNOWN': {
114 fill: theme.palette.text.secondary,
115 },
116 'text.label-ERROR': {
117 fill: theme.palette.error.main,
118 },
63 }, 119 },
64})); 120}));
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx
index b8faae27..2bb7f139 100644
--- a/subprojects/frontend/src/graph/ZoomCanvas.tsx
+++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx
@@ -148,7 +148,8 @@ export default function ZoomCanvas({
148 const [x, y] = d3.pointer(event, canvas); 148 const [x, y] = d3.pointer(event, canvas);
149 return [x - width / 2, y - height / 2]; 149 return [x - width / 2, y - height / 2];
150 }) 150 })
151 .centroid([0, 0]); 151 .centroid([0, 0])
152 .scaleExtent([1 / 32, 8]);
152 zoomBehavior.on( 153 zoomBehavior.on(
153 'zoom', 154 'zoom',
154 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => { 155 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => {
@@ -214,6 +215,6 @@ export default function ZoomCanvas({
214 215
215ZoomCanvas.defaultProps = { 216ZoomCanvas.defaultProps = {
216 children: undefined, 217 children: undefined,
217 fitPadding: 16, 218 fitPadding: 8,
218 transitionTime: 250, 219 transitionTime: 250,
219}; 220};
diff --git a/subprojects/frontend/src/graph/dotSource.ts b/subprojects/frontend/src/graph/dotSource.ts
new file mode 100644
index 00000000..bf45d303
--- /dev/null
+++ b/subprojects/frontend/src/graph/dotSource.ts
@@ -0,0 +1,309 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import type {
8 NodeMetadata,
9 RelationMetadata,
10} from '../xtext/xtextServiceResults';
11
12import type GraphStore from './GraphStore';
13
14const EDGE_WEIGHT = 1;
15const CONTAINMENT_WEIGHT = 5;
16const UNKNOWN_WEIGHT_FACTOR = 0.5;
17
18function nodeName({ simpleName, kind }: NodeMetadata): string {
19 switch (kind) {
20 case 'INDIVIDUAL':
21 return `<b>${simpleName}</b>`;
22 case 'NEW':
23 return `<i>${simpleName}</i>`;
24 default:
25 return simpleName;
26 }
27}
28
29function relationName({ simpleName, detail }: RelationMetadata): string {
30 if (detail.type === 'class' && detail.abstractClass) {
31 return `<i>${simpleName}</i>`;
32 }
33 if (detail.type === 'reference' && detail.containment) {
34 return `<b>${simpleName}</b>`;
35 }
36 return simpleName;
37}
38
39interface NodeData {
40 exists: string;
41 equalsSelf: string;
42 unaryPredicates: Map<RelationMetadata, string>;
43}
44
45function computeNodeData(graph: GraphStore): NodeData[] {
46 const {
47 semantics: { nodes, relations, partialInterpretation },
48 } = graph;
49
50 const nodeData = Array.from(Array(nodes.length)).map(() => ({
51 exists: 'FALSE',
52 equalsSelf: 'FALSE',
53 unaryPredicates: new Map(),
54 }));
55
56 relations.forEach((relation) => {
57 if (relation.arity !== 1) {
58 return;
59 }
60 const visibility = graph.getVisiblity(relation.name);
61 if (visibility === 'none') {
62 return;
63 }
64 const interpretation = partialInterpretation[relation.name] ?? [];
65 interpretation.forEach(([index, value]) => {
66 if (
67 typeof index === 'number' &&
68 typeof value === 'string' &&
69 (visibility === 'all' || value !== 'UNKNOWN')
70 ) {
71 nodeData[index]?.unaryPredicates?.set(relation, value);
72 }
73 });
74 });
75
76 partialInterpretation['builtin::exists']?.forEach(([index, value]) => {
77 if (typeof index === 'number' && typeof value === 'string') {
78 const data = nodeData[index];
79 if (data !== undefined) {
80 data.exists = value;
81 }
82 }
83 });
84
85 partialInterpretation['builtin::equals']?.forEach(([index, other, value]) => {
86 if (
87 typeof index === 'number' &&
88 index === other &&
89 typeof value === 'string'
90 ) {
91 const data = nodeData[index];
92 if (data !== undefined) {
93 data.equalsSelf = value;
94 }
95 }
96 });
97
98 return nodeData;
99}
100
101function createNodes(graph: GraphStore, lines: string[]): void {
102 const nodeData = computeNodeData(graph);
103 const {
104 semantics: { nodes },
105 } = graph;
106
107 nodes.forEach((node, i) => {
108 const data = nodeData[i];
109 if (data === undefined) {
110 return;
111 }
112 const classes = [
113 `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`,
114 ].join(' ');
115 const name = nodeName(node);
116 const border = node.kind === 'INDIVIDUAL' ? 2 : 1;
117 lines.push(`n${i} [id="${node.name}", class="${classes}", label=<
118 <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white">
119 <tr><td cellpadding="4.5" width="32" bgcolor="green">${name}</td></tr>`);
120 if (data.unaryPredicates.size > 0) {
121 lines.push(
122 '<hr/><tr><td cellpadding="4.5"><table fixedsize="TRUE" align="left" border="0" cellborder="0" cellspacing="0" cellpadding="1.5">',
123 );
124 data.unaryPredicates.forEach((value, relation) => {
125 lines.push(
126 `<tr>
127 <td><img src="#${value}"/></td>
128 <td width="1.5"></td>
129 <td align="left" href="#${value}" id="${node.name},${
130 relation.name
131 },label">${relationName(relation)}</td>
132 </tr>`,
133 );
134 });
135 lines.push('</table></td></tr>');
136 }
137 lines.push('</table>>]');
138 });
139}
140
141function compare(
142 a: readonly (number | string)[],
143 b: readonly number[],
144): number {
145 if (a.length !== b.length + 1) {
146 throw new Error('Tuple length mismatch');
147 }
148 for (let i = 0; i < b.length; i += 1) {
149 const aItem = a[i];
150 const bItem = b[i];
151 if (typeof aItem !== 'number' || typeof bItem !== 'number') {
152 throw new Error('Invalid tuple');
153 }
154 if (aItem < bItem) {
155 return -1;
156 }
157 if (aItem > bItem) {
158 return 1;
159 }
160 }
161 return 0;
162}
163
164function binarySerach(
165 tuples: readonly (readonly (number | string)[])[],
166 key: readonly number[],
167): string | undefined {
168 let lower = 0;
169 let upper = tuples.length - 1;
170 while (lower <= upper) {
171 const middle = Math.floor((lower + upper) / 2);
172 const tuple = tuples[middle];
173 if (tuple === undefined) {
174 throw new Error('Range error');
175 }
176 const result = compare(tuple, key);
177 if (result === 0) {
178 const found = tuple[key.length];
179 if (typeof found !== 'string') {
180 throw new Error('Invalid tuple value');
181 }
182 return found;
183 }
184 if (result < 0) {
185 lower = middle + 1;
186 } else {
187 // result > 0
188 upper = middle - 1;
189 }
190 }
191 return undefined;
192}
193
194function createRelationEdges(
195 graph: GraphStore,
196 relation: RelationMetadata,
197 showUnknown: boolean,
198 lines: string[],
199): void {
200 const {
201 semantics: { nodes, partialInterpretation },
202 } = graph;
203 const { detail } = relation;
204
205 let constraint: 'true' | 'false' = 'true';
206 let weight = EDGE_WEIGHT;
207 let penwidth = 1;
208 let label = `"${relation.simpleName}"`;
209 if (detail.type === 'reference' && detail.containment) {
210 weight = CONTAINMENT_WEIGHT;
211 label = `<<b>${relation.simpleName}</b>>`;
212 penwidth = 2;
213 } else if (
214 detail.type === 'opposite' &&
215 graph.getVisiblity(detail.opposite) !== 'none'
216 ) {
217 constraint = 'false';
218 weight = 0;
219 }
220
221 const tuples = partialInterpretation[relation.name] ?? [];
222 tuples.forEach(([from, to, value]) => {
223 const isUnknown = value === 'UNKNOWN';
224 if (
225 (!showUnknown && isUnknown) ||
226 typeof from !== 'number' ||
227 typeof to !== 'number' ||
228 typeof value !== 'string'
229 ) {
230 return;
231 }
232
233 const fromNode = nodes[from];
234 const toNode = nodes[to];
235 if (fromNode === undefined || toNode === undefined) {
236 return;
237 }
238
239 let dir = 'forward';
240 let edgeConstraint = constraint;
241 let edgeWeight = weight;
242 const opposite = binarySerach(tuples, [to, from]);
243 const oppositeUnknown = opposite === 'UNKNOWN';
244 const oppositeSet = opposite !== undefined;
245 const oppositeVisible = oppositeSet && (showUnknown || !oppositeUnknown);
246 if (opposite === value) {
247 if (to < from) {
248 // We already added this edge in the reverse direction.
249 return;
250 }
251 if (to > from) {
252 dir = 'both';
253 }
254 } else if (oppositeVisible && to < from) {
255 // Let the opposite edge drive the graph layout.
256 edgeConstraint = 'false';
257 edgeWeight = 0;
258 } else if (isUnknown && (!oppositeSet || oppositeUnknown)) {
259 // Only apply the UNKNOWN value penalty if we aren't the opposite
260 // edge driving the graph layout from above, or the penalty would
261 // be applied anyway.
262 edgeWeight *= UNKNOWN_WEIGHT_FACTOR;
263 }
264
265 lines.push(`n${from} -> n${to} [
266 id="${fromNode.name},${toNode.name},${relation.name}",
267 dir="${dir}",
268 constraint=${edgeConstraint},
269 weight=${edgeWeight},
270 xlabel=${label},
271 penwidth=${penwidth},
272 style="${isUnknown ? 'dashed' : 'solid'}",
273 class="edge-${value}"
274 ]`);
275 });
276}
277
278function createEdges(graph: GraphStore, lines: string[]): void {
279 const {
280 semantics: { relations },
281 } = graph;
282 relations.forEach((relation) => {
283 if (relation.arity !== 2) {
284 return;
285 }
286 const visibility = graph.getVisiblity(relation.name);
287 if (visibility !== 'none') {
288 createRelationEdges(graph, relation, visibility === 'all', lines);
289 }
290 });
291}
292
293export default function dotSource(
294 graph: GraphStore | undefined,
295): string | undefined {
296 if (graph === undefined) {
297 return undefined;
298 }
299 const lines = [
300 'digraph {',
301 'graph [bgcolor=transparent];',
302 `node [fontsize=12, shape=plain, fontname="OpenSans"];`,
303 'edge [fontsize=10.5, color=black, fontname="OpenSans"];',
304 ];
305 createNodes(graph, lines);
306 createEdges(graph, lines);
307 lines.push('}');
308 return lines.join('\n');
309}
diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts
index 59cc15b9..13e4eb29 100644
--- a/subprojects/frontend/src/graph/postProcessSVG.ts
+++ b/subprojects/frontend/src/graph/postProcessSVG.ts
@@ -7,19 +7,48 @@
7import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; 7import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox';
8 8
9const SVG_NS = 'http://www.w3.org/2000/svg'; 9const SVG_NS = 'http://www.w3.org/2000/svg';
10const XLINK_NS = 'http://www.w3.org/1999/xlink';
11
12function modifyAttribute(element: Element, attribute: string, change: number) {
13 const valueString = element.getAttribute(attribute);
14 if (valueString === null) {
15 return;
16 }
17 const value = parseInt(valueString, 10);
18 element.setAttribute(attribute, String(value + change));
19}
20
21function addShadow(
22 node: SVGGElement,
23 container: SVGRectElement,
24 offset: number,
25): void {
26 const shadow = container.cloneNode() as SVGRectElement;
27 // Leave space for 1pt stroke around the original container.
28 const offsetWithStroke = offset - 0.5;
29 modifyAttribute(shadow, 'x', offsetWithStroke);
30 modifyAttribute(shadow, 'y', offsetWithStroke);
31 modifyAttribute(shadow, 'width', 1);
32 modifyAttribute(shadow, 'height', 1);
33 modifyAttribute(shadow, 'rx', 0.5);
34 modifyAttribute(shadow, 'ry', 0.5);
35 shadow.setAttribute('class', 'node-shadow');
36 shadow.id = `${node.id},shadow`;
37 node.insertBefore(shadow, node.firstChild);
38}
10 39
11function clipCompartmentBackground(node: SVGGElement) { 40function clipCompartmentBackground(node: SVGGElement) {
12 // Background rectangle of the node created by the `<table bgcolor="green">` 41 // Background rectangle of the node created by the `<table bgcolor="white">`
13 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. 42 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`.
14 const container = node.querySelector<SVGRectElement>('rect[fill="green"]'); 43 const container = node.querySelector<SVGRectElement>('rect[fill="white"]');
15 // Background rectangle of the lower compartment created by the `<td bgcolor="white">` 44 // Background rectangle of the lower compartment created by the `<td bgcolor="green">`
16 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. 45 // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`.
17 // Since dot doesn't round the coners of `<td>` background, 46 // Since dot doesn't round the coners of `<td>` background,
18 // we have to clip it ourselves. 47 // we have to clip it ourselves.
19 const compartment = node.querySelector<SVGPolygonElement>( 48 const compartment = node.querySelector<SVGRectElement>('rect[fill="green"]');
20 'polygon[fill="white"]', 49 // Make sure we provide traceability with IDs also for the border.
21 ); 50 const border = node.querySelector<SVGRectElement>('rect[stroke="black"]');
22 if (container === null || compartment === null) { 51 if (container === null || compartment === null || border === null) {
23 return; 52 return;
24 } 53 }
25 const copyOfContainer = container.cloneNode() as SVGRectElement; 54 const copyOfContainer = container.cloneNode() as SVGRectElement;
@@ -29,6 +58,17 @@ function clipCompartmentBackground(node: SVGGElement) {
29 clipPath.appendChild(copyOfContainer); 58 clipPath.appendChild(copyOfContainer);
30 node.appendChild(clipPath); 59 node.appendChild(clipPath);
31 compartment.setAttribute('clip-path', `url(#${clipId})`); 60 compartment.setAttribute('clip-path', `url(#${clipId})`);
61 // Enlarge the compartment to completely cover the background.
62 modifyAttribute(compartment, 'y', -5);
63 modifyAttribute(compartment, 'x', -5);
64 modifyAttribute(compartment, 'width', 10);
65 modifyAttribute(compartment, 'height', 5);
66 if (node.classList.contains('node-equalsSelf-UNKNOWN')) {
67 addShadow(node, container, 6);
68 }
69 container.id = `${node.id},container`;
70 compartment.id = `${node.id},compartment`;
71 border.id = `${node.id},border`;
32} 72}
33 73
34function createRect( 74function createRect(
@@ -51,7 +91,7 @@ function optimizeNodeShapes(node: SVGGElement) {
51 const rect = createRect(bbox, path); 91 const rect = createRect(bbox, path);
52 rect.setAttribute('rx', '12'); 92 rect.setAttribute('rx', '12');
53 rect.setAttribute('ry', '12'); 93 rect.setAttribute('ry', '12');
54 node.replaceChild(rect, path); 94 path.parentNode?.replaceChild(rect, path);
55 }); 95 });
56 node.querySelectorAll('polygon').forEach((polygon) => { 96 node.querySelectorAll('polygon').forEach((polygon) => {
57 const bbox = parsePolygonBBox(polygon); 97 const bbox = parsePolygonBBox(polygon);
@@ -62,18 +102,83 @@ function optimizeNodeShapes(node: SVGGElement) {
62 'points', 102 'points',
63 `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, 103 `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`,
64 ); 104 );
65 node.replaceChild(polyline, polygon); 105 polygon.parentNode?.replaceChild(polyline, polygon);
66 } else { 106 } else {
67 const rect = createRect(bbox, polygon); 107 const rect = createRect(bbox, polygon);
68 node.replaceChild(rect, polygon); 108 polygon.parentNode?.replaceChild(rect, polygon);
69 } 109 }
70 }); 110 });
71 clipCompartmentBackground(node); 111 clipCompartmentBackground(node);
72} 112}
73 113
114function hrefToClass(node: SVGGElement) {
115 node.querySelectorAll<SVGAElement>('a').forEach((a) => {
116 if (a.parentNode === null) {
117 return;
118 }
119 const href = a.getAttribute('href') ?? a.getAttributeNS(XLINK_NS, 'href');
120 if (href === 'undefined' || !href?.startsWith('#')) {
121 return;
122 }
123 while (a.lastChild !== null) {
124 const child = a.lastChild;
125 a.removeChild(child);
126 if (child.nodeType === Node.ELEMENT_NODE) {
127 const element = child as Element;
128 element.classList.add('label', `label-${href.replace('#', '')}`);
129 a.after(child);
130 }
131 }
132 a.parentNode.removeChild(a);
133 });
134}
135
136function replaceImages(node: SVGGElement) {
137 node.querySelectorAll<SVGImageElement>('image').forEach((image) => {
138 const href =
139 image.getAttribute('href') ?? image.getAttributeNS(XLINK_NS, 'href');
140 if (href === 'undefined' || !href?.startsWith('#')) {
141 return;
142 }
143 const width = image.getAttribute('width')?.replace('px', '') ?? '';
144 const height = image.getAttribute('height')?.replace('px', '') ?? '';
145 const foreign = document.createElementNS(SVG_NS, 'foreignObject');
146 foreign.setAttribute('x', image.getAttribute('x') ?? '');
147 foreign.setAttribute('y', image.getAttribute('y') ?? '');
148 foreign.setAttribute('width', width);
149 foreign.setAttribute('height', height);
150 const div = document.createElement('div');
151 div.classList.add('icon', `icon-${href.replace('#', '')}`);
152 foreign.appendChild(div);
153 const sibling = image.nextElementSibling;
154 // Since dot doesn't respect the `id` attribute on table cells with a single image,
155 // compute the ID based on the ID of the next element (the label).
156 if (
157 sibling !== null &&
158 sibling.tagName.toLowerCase() === 'g' &&
159 sibling.id !== ''
160 ) {
161 foreign.id = `${sibling.id},icon`;
162 }
163 image.parentNode?.replaceChild(foreign, image);
164 });
165}
166
74export default function postProcessSvg(svg: SVGSVGElement) { 167export default function postProcessSvg(svg: SVGSVGElement) {
75 svg 168 // svg
76 .querySelectorAll<SVGTitleElement>('title') 169 // .querySelectorAll<SVGTitleElement>('title')
77 .forEach((title) => title.parentNode?.removeChild(title)); 170 // .forEach((title) => title.parentElement?.removeChild(title));
78 svg.querySelectorAll<SVGGElement>('g.node').forEach(optimizeNodeShapes); 171 svg.querySelectorAll<SVGGElement>('g.node').forEach((node) => {
172 optimizeNodeShapes(node);
173 hrefToClass(node);
174 replaceImages(node);
175 });
176 // Increase padding to fit box shadows for multi-objects.
177 const viewBox = [
178 svg.viewBox.baseVal.x - 6,
179 svg.viewBox.baseVal.y - 6,
180 svg.viewBox.baseVal.width + 12,
181 svg.viewBox.baseVal.height + 12,
182 ];
183 svg.setAttribute('viewBox', viewBox.join(' '));
79} 184}
diff --git a/subprojects/frontend/src/utils/svgURL.ts b/subprojects/frontend/src/utils/svgURL.ts
new file mode 100644
index 00000000..9b8ecbd5
--- /dev/null
+++ b/subprojects/frontend/src/utils/svgURL.ts
@@ -0,0 +1,9 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7export default function svgURL(svg: string): string {
8 return `url('data:image/svg+xml;utf8,${svg}')`;
9}
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
index 12f87b26..caf2cf0b 100644
--- a/subprojects/frontend/src/xtext/xtextServiceResults.ts
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -126,8 +126,36 @@ export const FormattingResult = DocumentStateResult.extend({
126 126
127export type FormattingResult = z.infer<typeof FormattingResult>; 127export type FormattingResult = z.infer<typeof FormattingResult>;
128 128
129export const NodeMetadata = z.object({
130 name: z.string(),
131 simpleName: z.string(),
132 kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']),
133});
134
135export type NodeMetadata = z.infer<typeof NodeMetadata>;
136
137export const RelationMetadata = z.object({
138 name: z.string(),
139 simpleName: z.string(),
140 arity: z.number().nonnegative(),
141 detail: z.union([
142 z.object({ type: z.literal('class'), abstractClass: z.boolean() }),
143 z.object({ type: z.literal('reference'), containment: z.boolean() }),
144 z.object({
145 type: z.literal('opposite'),
146 container: z.boolean(),
147 opposite: z.string(),
148 }),
149 z.object({ type: z.literal('predicate'), error: z.boolean() }),
150 z.object({ type: z.literal('builtin') }),
151 ]),
152});
153
154export type RelationMetadata = z.infer<typeof RelationMetadata>;
155
129export const SemanticsSuccessResult = z.object({ 156export const SemanticsSuccessResult = z.object({
130 nodes: z.string().nullable().array(), 157 nodes: NodeMetadata.array(),
158 relations: RelationMetadata.array(),
131 partialInterpretation: z.record( 159 partialInterpretation: z.record(
132 z.string(), 160 z.string(),
133 z.union([z.number(), z.string()]).array().array(), 161 z.union([z.number(), z.string()]).array().array(),
diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts
index 82e432de..63d5245f 100644
--- a/subprojects/frontend/vite.config.ts
+++ b/subprojects/frontend/vite.config.ts
@@ -30,7 +30,7 @@ const { mode, isDevelopment, devModePlugins, serverOptions } =
30process.env['NODE_ENV'] ??= mode; 30process.env['NODE_ENV'] ??= mode;
31 31
32const fontsGlob = [ 32const fontsGlob = [
33 'open-sans-latin-wdth-normal-*.woff2', 33 'open-sans-latin-wdth-{normal,italic}-*.woff2',
34 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2', 34 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2',
35]; 35];
36 36