aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-24 17:12:16 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-24 17:44:34 +0200
commitf20474c728e97af79a6d63783619c2515549b107 (patch)
tree2650578c70ecd6bad730086fdfdcf3a6387106ec
parentchore: clarify permissing licensing (diff)
downloadrefinery-f20474c728e97af79a6d63783619c2515549b107.tar.gz
refinery-f20474c728e97af79a6d63783619c2515549b107.tar.zst
refinery-f20474c728e97af79a6d63783619c2515549b107.zip
feat(frontend): automatic fit zoom
-rw-r--r--subprojects/frontend/src/graph/DotGraphVisualizer.tsx35
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx2
-rw-r--r--subprojects/frontend/src/graph/ZoomButtons.tsx30
-rw-r--r--subprojects/frontend/src/graph/ZoomCanvas.tsx172
-rw-r--r--subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java12
-rw-r--r--subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java12
-rw-r--r--subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java9
-rw-r--r--subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java18
-rw-r--r--subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java10
9 files changed, 219 insertions, 81 deletions
diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
index 7c25488a..29e750f5 100644
--- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
+++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx
@@ -14,6 +14,7 @@ import { useRootStore } from '../RootStoreProvider';
14import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; 14import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults';
15 15
16import GraphTheme from './GraphTheme'; 16import GraphTheme from './GraphTheme';
17import { FitZoomCallback } from './ZoomCanvas';
17import postProcessSvg from './postProcessSVG'; 18import postProcessSvg from './postProcessSVG';
18 19
19function toGraphviz( 20function toGraphviz(
@@ -72,7 +73,20 @@ function toGraphviz(
72 return lines.join('\n'); 73 return lines.join('\n');
73} 74}
74 75
75export default function DotGraphVisualizer(): JSX.Element { 76function ptToPx(pt: number): number {
77 return (pt * 4) / 3;
78}
79
80export default function DotGraphVisualizer({
81 fitZoom,
82 transitionTime,
83}: {
84 fitZoom?: FitZoomCallback;
85 transitionTime?: number;
86}): JSX.Element {
87 const transitionTimeOrDefault =
88 transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime;
89
76 const { editorStore } = useRootStore(); 90 const { editorStore } = useRootStore();
77 const disposerRef = useRef<IReactionDisposer | undefined>(); 91 const disposerRef = useRef<IReactionDisposer | undefined>();
78 const graphvizRef = useRef< 92 const graphvizRef = useRef<
@@ -104,12 +118,13 @@ export default function DotGraphVisualizer(): JSX.Element {
104 renderer.tweenShapes(false); 118 renderer.tweenShapes(false);
105 renderer.convertEqualSidedPolygons(false); 119 renderer.convertEqualSidedPolygons(false);
106 const transition = () => 120 const transition = () =>
107 d3.transition().duration(300).ease(d3.easeCubic); 121 d3.transition().duration(transitionTimeOrDefault).ease(d3.easeCubic);
108 /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, 122 /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument,
109 @typescript-eslint/no-explicit-any -- 123 @typescript-eslint/no-explicit-any --
110 Workaround for error in `@types/d3-graphviz`. 124 Workaround for error in `@types/d3-graphviz`.
111 */ 125 */
112 renderer.transition(transition as any); 126 renderer.transition(transition as any);
127 let newViewBox = { width: 0, height: 0 };
113 renderer.on( 128 renderer.on(
114 'postProcessSVG', 129 'postProcessSVG',
115 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. 130 // @ts-expect-error Custom `d3-graphviz` hook not covered by typings.
@@ -119,9 +134,18 @@ export default function DotGraphVisualizer(): JSX.Element {
119 const svg = svgSelection.node(); 134 const svg = svgSelection.node();
120 if (svg !== null) { 135 if (svg !== null) {
121 postProcessSvg(svg); 136 postProcessSvg(svg);
137 newViewBox = {
138 width: ptToPx(svg.viewBox.baseVal.width),
139 height: ptToPx(svg.viewBox.baseVal.height),
140 };
141 } else {
142 newViewBox = { width: 0, height: 0 };
122 } 143 }
123 }, 144 },
124 ); 145 );
146 if (fitZoom !== undefined) {
147 renderer.on('transitionStart', () => fitZoom(newViewBox));
148 }
125 disposerRef.current = reaction( 149 disposerRef.current = reaction(
126 () => editorStore?.semantics, 150 () => editorStore?.semantics,
127 (semantics) => { 151 (semantics) => {
@@ -135,8 +159,13 @@ export default function DotGraphVisualizer(): JSX.Element {
135 graphvizRef.current = renderer; 159 graphvizRef.current = renderer;
136 } 160 }
137 }, 161 },
138 [editorStore], 162 [editorStore, fitZoom, transitionTimeOrDefault],
139 ); 163 );
140 164
141 return <GraphTheme ref={setElement} />; 165 return <GraphTheme ref={setElement} />;
142} 166}
167
168DotGraphVisualizer.defaultProps = {
169 fitZoom: undefined,
170 transitionTime: 250,
171};
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx
index 32147d92..a1a741f3 100644
--- a/subprojects/frontend/src/graph/GraphArea.tsx
+++ b/subprojects/frontend/src/graph/GraphArea.tsx
@@ -10,7 +10,7 @@ import ZoomCanvas from './ZoomCanvas';
10export default function GraphArea(): JSX.Element { 10export default function GraphArea(): JSX.Element {
11 return ( 11 return (
12 <ZoomCanvas> 12 <ZoomCanvas>
13 <DotGraphVisualizer /> 13 {(fitZoom) => <DotGraphVisualizer fitZoom={fitZoom} />}
14 </ZoomCanvas> 14 </ZoomCanvas>
15 ); 15 );
16} 16}
diff --git a/subprojects/frontend/src/graph/ZoomButtons.tsx b/subprojects/frontend/src/graph/ZoomButtons.tsx
index 72f54774..83938cf4 100644
--- a/subprojects/frontend/src/graph/ZoomButtons.tsx
+++ b/subprojects/frontend/src/graph/ZoomButtons.tsx
@@ -9,13 +9,18 @@ import CropFreeIcon from '@mui/icons-material/CropFree';
9import RemoveIcon from '@mui/icons-material/Remove'; 9import RemoveIcon from '@mui/icons-material/Remove';
10import IconButton from '@mui/material/IconButton'; 10import IconButton from '@mui/material/IconButton';
11import Stack from '@mui/material/Stack'; 11import Stack from '@mui/material/Stack';
12import ToggleButton from '@mui/material/ToggleButton';
13
14import type { ChangeZoomCallback, SetFitZoomCallback } from './ZoomCanvas';
12 15
13export default function ZoomButtons({ 16export default function ZoomButtons({
14 changeZoom, 17 changeZoom,
15 fitZoom, 18 fitZoom,
19 setFitZoom,
16}: { 20}: {
17 changeZoom: (event: React.MouseEvent, factor: number) => void; 21 changeZoom: ChangeZoomCallback;
18 fitZoom: (event: React.MouseEvent) => void; 22 fitZoom: boolean;
23 setFitZoom: SetFitZoomCallback;
19}): JSX.Element { 24}): JSX.Element {
20 return ( 25 return (
21 <Stack 26 <Stack
@@ -23,21 +28,22 @@ export default function ZoomButtons({
23 p={1} 28 p={1}
24 sx={{ position: 'absolute', bottom: 0, right: 0 }} 29 sx={{ position: 'absolute', bottom: 0, right: 0 }}
25 > 30 >
26 <IconButton 31 <IconButton aria-label="Zoom in" onClick={() => changeZoom(2)}>
27 aria-label="Zoom in"
28 onClick={(event) => changeZoom(event, 2)}
29 >
30 <AddIcon fontSize="small" /> 32 <AddIcon fontSize="small" />
31 </IconButton> 33 </IconButton>
32 <IconButton 34 <IconButton aria-label="Zoom out" onClick={() => changeZoom(0.5)}>
33 aria-label="Zoom out"
34 onClick={(event) => changeZoom(event, 0.5)}
35 >
36 <RemoveIcon fontSize="small" /> 35 <RemoveIcon fontSize="small" />
37 </IconButton> 36 </IconButton>
38 <IconButton aria-label="Fit screen" onClick={fitZoom}> 37 <ToggleButton
38 value="show-replace"
39 selected={fitZoom}
40 onClick={() => setFitZoom(!fitZoom)}
41 aria-label="Fit screen"
42 size="small"
43 className="iconOnly"
44 >
39 <CropFreeIcon fontSize="small" /> 45 <CropFreeIcon fontSize="small" />
40 </IconButton> 46 </ToggleButton>
41 </Stack> 47 </Stack>
42 ); 48 );
43} 49}
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx
index eb3e9285..b8faae27 100644
--- a/subprojects/frontend/src/graph/ZoomCanvas.tsx
+++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx
@@ -8,6 +8,7 @@ import Box from '@mui/material/Box';
8import * as d3 from 'd3'; 8import * as d3 from 'd3';
9import { zoom as d3Zoom } from 'd3-zoom'; 9import { zoom as d3Zoom } from 'd3-zoom';
10import React, { useCallback, useRef, useState } from 'react'; 10import React, { useCallback, useRef, useState } from 'react';
11import { useResizeDetector } from 'react-resize-detector';
11 12
12import ZoomButtons from './ZoomButtons'; 13import ZoomButtons from './ZoomButtons';
13 14
@@ -29,103 +30,138 @@ interface Transform {
29 k: number; 30 k: number;
30} 31}
31 32
33export type ChangeZoomCallback = (factor: number) => void;
34
35export type SetFitZoomCallback = (fitZoom: boolean) => void;
36
37export type FitZoomCallback = (newSize?: {
38 width: number;
39 height: number;
40}) => void;
41
32export default function ZoomCanvas({ 42export default function ZoomCanvas({
33 children, 43 children,
34 fitPadding, 44 fitPadding,
35 transitionTime, 45 transitionTime,
36}: { 46}: {
37 children?: React.ReactNode; 47 children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode);
38 fitPadding?: number; 48 fitPadding?: number;
39 transitionTime?: number; 49 transitionTime?: number;
40}): JSX.Element { 50}): JSX.Element {
51 const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
52 const transitionTimeOrDefault =
53 transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
54
41 const canvasRef = useRef<HTMLDivElement | undefined>(); 55 const canvasRef = useRef<HTMLDivElement | undefined>();
42 const elementRef = useRef<HTMLDivElement | undefined>(); 56 const elementRef = useRef<HTMLDivElement | undefined>();
43 const zoomRef = useRef< 57 const zoomRef = useRef<
44 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined 58 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
45 >(); 59 >();
46 const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
47 const transitionTimeOrDefault =
48 transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
49
50 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 }); 60 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
61 const [fitZoom, setFitZoom] = useState(true);
62 const fitZoomRef = useRef(fitZoom);
51 63
52 const setCanvas = useCallback( 64 const makeTransition = useCallback(
53 (canvas: HTMLDivElement | null) => { 65 (element: HTMLDivElement) =>
54 canvasRef.current = canvas ?? undefined; 66 d3.select(element).transition().duration(transitionTimeOrDefault),
55 if (canvas === null) { 67 [transitionTimeOrDefault],
68 );
69
70 const fitZoomCallback = useCallback<FitZoomCallback>(
71 (newSize) => {
72 if (
73 !fitZoomRef.current ||
74 canvasRef.current === undefined ||
75 zoomRef.current === undefined ||
76 elementRef.current === undefined
77 ) {
56 return; 78 return;
57 } 79 }
58 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>() 80 let width = 0;
59 .duration(transitionTimeOrDefault) 81 let height = 0;
60 .center((event) => { 82 if (newSize === undefined) {
61 const { width, height } = canvas.getBoundingClientRect(); 83 const elementRect = elementRef.current.getBoundingClientRect();
62 const [x, y] = d3.pointer(event, canvas); 84 const currentFactor = d3.zoomTransform(canvasRef.current).k;
63 return [x - width / 2, y - height / 2]; 85 width = elementRect.width / currentFactor;
64 }) 86 height = elementRect.height / currentFactor;
65 .centroid([0, 0]); 87 } else {
66 zoomBehavior.on( 88 ({ width, height } = newSize);
67 'zoom', 89 }
68 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => 90 if (width === 0 || height === 0) {
69 setZoom(event.transform), 91 return;
92 }
93 const canvasRect = canvasRef.current.getBoundingClientRect();
94 const factor = Math.min(
95 1.0,
96 (canvasRect.width - 2 * fitPaddingOrDefault) / width,
97 (canvasRect.height - 2 * fitPaddingOrDefault) / height,
70 ); 98 );
71 d3.select(canvas).call(zoomBehavior); 99 const zoomTransition = makeTransition(canvasRef.current);
72 zoomRef.current = zoomBehavior; 100 zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor));
73 }, 101 },
74 [transitionTimeOrDefault], 102 [fitPaddingOrDefault, makeTransition],
75 ); 103 );
76 104
77 const makeTransition = useCallback( 105 const setFitZoomCallback = useCallback<SetFitZoomCallback>(
78 (element: HTMLDivElement) => 106 (newFitZoom) => {
79 d3.select(element).transition().duration(transitionTimeOrDefault), 107 setFitZoom(newFitZoom);
80 [transitionTimeOrDefault], 108 fitZoomRef.current = newFitZoom;
109 if (newFitZoom) {
110 fitZoomCallback();
111 }
112 },
113 [fitZoomCallback],
81 ); 114 );
82 115
83 const changeZoom = useCallback( 116 const changeZoomCallback = useCallback<ChangeZoomCallback>(
84 (event: React.MouseEvent, factor: number) => { 117 (factor) => {
118 setFitZoomCallback(false);
85 if (canvasRef.current === undefined || zoomRef.current === undefined) { 119 if (canvasRef.current === undefined || zoomRef.current === undefined) {
86 return; 120 return;
87 } 121 }
88 const zoomTransition = makeTransition(canvasRef.current); 122 const zoomTransition = makeTransition(canvasRef.current);
89 const center: [number, number] = [0, 0]; 123 const center: [number, number] = [0, 0];
90 zoomRef.current.scaleBy(zoomTransition, factor, center); 124 zoomRef.current.scaleBy(zoomTransition, factor, center);
91 event.preventDefault();
92 event.stopPropagation();
93 }, 125 },
94 [makeTransition], 126 [makeTransition, setFitZoomCallback],
95 ); 127 );
96 128
97 const fitZoom = useCallback( 129 const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]);
98 (event: React.MouseEvent) => { 130
99 if ( 131 const { ref: resizeRef } = useResizeDetector({
100 canvasRef.current === undefined || 132 onResize,
101 zoomRef.current === undefined || 133 refreshMode: 'debounce',
102 elementRef.current === undefined 134 refreshRate: transitionTimeOrDefault,
103 ) { 135 });
136
137 const setCanvas = useCallback(
138 (canvas: HTMLDivElement | null) => {
139 canvasRef.current = canvas ?? undefined;
140 resizeRef(canvas);
141 if (canvas === null) {
104 return; 142 return;
105 } 143 }
106 const { width: canvasWidth, height: canvasHeight } = 144 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>()
107 canvasRef.current.getBoundingClientRect(); 145 .duration(transitionTimeOrDefault)
108 const { width: scaledWidth, height: scaledHeight } = 146 .center((event) => {
109 elementRef.current.getBoundingClientRect(); 147 const { width, height } = canvas.getBoundingClientRect();
110 const currentFactor = d3.zoomTransform(canvasRef.current).k; 148 const [x, y] = d3.pointer(event, canvas);
111 const width = scaledWidth / currentFactor; 149 return [x - width / 2, y - height / 2];
112 const height = scaledHeight / currentFactor; 150 })
113 if (width > 0 && height > 0) { 151 .centroid([0, 0]);
114 const factor = Math.min( 152 zoomBehavior.on(
115 1.0, 153 'zoom',
116 (canvasWidth - fitPaddingOrDefault) / width, 154 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => {
117 (canvasHeight - fitPaddingOrDefault) / height, 155 setZoom(event.transform);
118 ); 156 if (event.sourceEvent) {
119 const zoomTransition = makeTransition(canvasRef.current); 157 setFitZoomCallback(false);
120 zoomRef.current.transform( 158 }
121 zoomTransition, 159 },
122 d3.zoomIdentity.scale(factor), 160 );
123 ); 161 d3.select(canvas).call(zoomBehavior);
124 } 162 zoomRef.current = zoomBehavior;
125 event.preventDefault();
126 event.stopPropagation();
127 }, 163 },
128 [fitPaddingOrDefault, makeTransition], 164 [transitionTimeOrDefault, setFitZoomCallback, resizeRef],
129 ); 165 );
130 166
131 return ( 167 return (
@@ -162,16 +198,22 @@ export default function ZoomCanvas({
162 }} 198 }}
163 ref={elementRef} 199 ref={elementRef}
164 > 200 >
165 {children} 201 {typeof children === 'function'
202 ? children(fitZoomCallback)
203 : children}
166 </Box> 204 </Box>
167 </Box> 205 </Box>
168 <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} /> 206 <ZoomButtons
207 changeZoom={changeZoomCallback}
208 fitZoom={fitZoom}
209 setFitZoom={setFitZoomCallback}
210 />
169 </Box> 211 </Box>
170 ); 212 );
171} 213}
172 214
173ZoomCanvas.defaultProps = { 215ZoomCanvas.defaultProps = {
174 children: undefined, 216 children: undefined,
175 fitPadding: 64, 217 fitPadding: 16,
176 transitionTime: 250, 218 transitionTime: 250,
177}; 219};
diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java
new file mode 100644
index 00000000..811ac2c0
--- /dev/null
+++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java
@@ -0,0 +1,12 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6package tools.refinery.language.semantics.metadata;
7
8public sealed interface Metadata permits NodeMetadata, RelationMetadata {
9 String fullyQualifiedName();
10
11 String simpleName();
12}
diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java
new file mode 100644
index 00000000..27a86cb3
--- /dev/null
+++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java
@@ -0,0 +1,12 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6package tools.refinery.language.semantics.metadata;
7
8public enum NodeKind {
9 IMPLICIT,
10 INDIVIDUAL,
11 ENUM_LITERAL
12}
diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java
new file mode 100644
index 00000000..8d91273c
--- /dev/null
+++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java
@@ -0,0 +1,9 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6package tools.refinery.language.semantics.metadata;
7
8public record NodeMetadata(String fullyQualifiedName, String simpleName, NodeKind kind) implements Metadata {
9}
diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java
new file mode 100644
index 00000000..28a3c565
--- /dev/null
+++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java
@@ -0,0 +1,18 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6package tools.refinery.language.semantics.metadata;
7
8public enum RelationKind {
9 BUILTIN,
10 CLASS,
11 ENUM,
12 REFERENCE,
13 OPPOSITE,
14 CONTAINMENT,
15 CONTAINER,
16 PREDICATE,
17 ERROR
18}
diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java
new file mode 100644
index 00000000..62de6031
--- /dev/null
+++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java
@@ -0,0 +1,10 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6package tools.refinery.language.semantics.metadata;
7
8public record RelationMetadata(String fullyQualifiedName, String simpleName, int arity, RelationKind kind,
9 String opposite) implements Metadata {
10}