diff options
Diffstat (limited to 'subprojects/frontend')
-rw-r--r-- | subprojects/frontend/index.html | 1 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorTheme.ts | 4 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 86 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphStore.ts | 51 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphTheme.tsx | 76 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ZoomCanvas.tsx | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/dotSource.ts | 309 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/postProcessSVG.ts | 133 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/svgURL.ts | 9 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextServiceResults.ts | 30 | ||||
-rw-r--r-- | subprojects/frontend/vite.config.ts | 2 |
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'; | |||
26 | import { nanoid } from 'nanoid'; | 26 | import { nanoid } from 'nanoid'; |
27 | 27 | ||
28 | import type PWAStore from '../PWAStore'; | 28 | import type PWAStore from '../PWAStore'; |
29 | import GraphStore from '../graph/GraphStore'; | ||
29 | import getLogger from '../utils/getLogger'; | 30 | import getLogger from '../utils/getLogger'; |
30 | import type XtextClient from '../xtext/XtextClient'; | 31 | import type XtextClient from '../xtext/XtextClient'; |
31 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | 32 | import 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'; | |||
10 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; | 10 | import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; |
11 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; | 11 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; |
12 | 12 | ||
13 | function svgURL(svg: string): string { | 13 | import svgURL from '../utils/svgURL'; |
14 | return `url('data:image/svg+xml;utf8,${svg}')`; | ||
15 | } | ||
16 | 14 | ||
17 | export default styled('div', { | 15 | export 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'; | |||
8 | import { type Graphviz, graphviz } from 'd3-graphviz'; | 8 | import { type Graphviz, graphviz } from 'd3-graphviz'; |
9 | import type { BaseType, Selection } from 'd3-selection'; | 9 | import type { BaseType, Selection } from 'd3-selection'; |
10 | import { reaction, type IReactionDisposer } from 'mobx'; | 10 | import { reaction, type IReactionDisposer } from 'mobx'; |
11 | import { observer } from 'mobx-react-lite'; | ||
11 | import { useCallback, useRef } from 'react'; | 12 | import { useCallback, useRef } from 'react'; |
12 | 13 | ||
13 | import { useRootStore } from '../RootStoreProvider'; | 14 | import { useRootStore } from '../RootStoreProvider'; |
14 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | 15 | import getLogger from '../utils/getLogger'; |
15 | 16 | ||
16 | import GraphTheme from './GraphTheme'; | 17 | import GraphTheme from './GraphTheme'; |
17 | import { FitZoomCallback } from './ZoomCanvas'; | 18 | import { FitZoomCallback } from './ZoomCanvas'; |
19 | import dotSource from './dotSource'; | ||
18 | import postProcessSvg from './postProcessSVG'; | 20 | import postProcessSvg from './postProcessSVG'; |
19 | 21 | ||
20 | function toGraphviz( | 22 | const 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 | ||
76 | function ptToPx(pt: number): number { | 24 | function ptToPx(pt: number): number { |
77 | return (pt * 4) / 3; | 25 | return (pt * 4) / 3; |
78 | } | 26 | } |
79 | 27 | ||
80 | export default function DotGraphVisualizer({ | 28 | function 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 | |||
131 | export 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 | |||
7 | import { makeAutoObservable, observable } from 'mobx'; | ||
8 | |||
9 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | ||
10 | |||
11 | export type Visibility = 'all' | 'must' | 'none'; | ||
12 | |||
13 | export 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 | ||
7 | import { styled, type CSSObject } from '@mui/material/styles'; | 7 | import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; |
8 | import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; | ||
9 | import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; | ||
10 | import { alpha, styled, type CSSObject } from '@mui/material/styles'; | ||
8 | 11 | ||
9 | function createEdgeColor(suffix: string, color: string): CSSObject { | 12 | import svgURL from '../utils/svgURL'; |
13 | |||
14 | function 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 | ||
215 | ZoomCanvas.defaultProps = { | 216 | ZoomCanvas.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 | |||
7 | import type { | ||
8 | NodeMetadata, | ||
9 | RelationMetadata, | ||
10 | } from '../xtext/xtextServiceResults'; | ||
11 | |||
12 | import type GraphStore from './GraphStore'; | ||
13 | |||
14 | const EDGE_WEIGHT = 1; | ||
15 | const CONTAINMENT_WEIGHT = 5; | ||
16 | const UNKNOWN_WEIGHT_FACTOR = 0.5; | ||
17 | |||
18 | function 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 | |||
29 | function 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 | |||
39 | interface NodeData { | ||
40 | exists: string; | ||
41 | equalsSelf: string; | ||
42 | unaryPredicates: Map<RelationMetadata, string>; | ||
43 | } | ||
44 | |||
45 | function 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 | |||
101 | function 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 | |||
141 | function 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 | |||
164 | function 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 | |||
194 | function 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 | |||
278 | function 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 | |||
293 | export 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 @@ | |||
7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; | 7 | import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; |
8 | 8 | ||
9 | const SVG_NS = 'http://www.w3.org/2000/svg'; | 9 | const SVG_NS = 'http://www.w3.org/2000/svg'; |
10 | const XLINK_NS = 'http://www.w3.org/1999/xlink'; | ||
11 | |||
12 | function 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 | |||
21 | function 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 | ||
11 | function clipCompartmentBackground(node: SVGGElement) { | 40 | function 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 | ||
34 | function createRect( | 74 | function 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 | ||
114 | function 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 | |||
136 | function 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 | |||
74 | export default function postProcessSvg(svg: SVGSVGElement) { | 167 | export 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 | |||
7 | export 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 | ||
127 | export type FormattingResult = z.infer<typeof FormattingResult>; | 127 | export type FormattingResult = z.infer<typeof FormattingResult>; |
128 | 128 | ||
129 | export const NodeMetadata = z.object({ | ||
130 | name: z.string(), | ||
131 | simpleName: z.string(), | ||
132 | kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']), | ||
133 | }); | ||
134 | |||
135 | export type NodeMetadata = z.infer<typeof NodeMetadata>; | ||
136 | |||
137 | export 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 | |||
154 | export type RelationMetadata = z.infer<typeof RelationMetadata>; | ||
155 | |||
129 | export const SemanticsSuccessResult = z.object({ | 156 | export 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 } = | |||
30 | process.env['NODE_ENV'] ??= mode; | 30 | process.env['NODE_ENV'] ??= mode; |
31 | 31 | ||
32 | const fontsGlob = [ | 32 | const 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 | ||