diff options
author | Kristóf Marussy <kristof@marussy.com> | 2023-08-24 17:12:16 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2023-08-24 17:44:34 +0200 |
commit | f20474c728e97af79a6d63783619c2515549b107 (patch) | |
tree | 2650578c70ecd6bad730086fdfdcf3a6387106ec /subprojects/frontend/src | |
parent | chore: clarify permissing licensing (diff) | |
download | refinery-f20474c728e97af79a6d63783619c2515549b107.tar.gz refinery-f20474c728e97af79a6d63783619c2515549b107.tar.zst refinery-f20474c728e97af79a6d63783619c2515549b107.zip |
feat(frontend): automatic fit zoom
Diffstat (limited to 'subprojects/frontend/src')
-rw-r--r-- | subprojects/frontend/src/graph/DotGraphVisualizer.tsx | 35 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ZoomButtons.tsx | 30 | ||||
-rw-r--r-- | subprojects/frontend/src/graph/ZoomCanvas.tsx | 172 |
4 files changed, 158 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'; | |||
14 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; | 14 | import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; |
15 | 15 | ||
16 | import GraphTheme from './GraphTheme'; | 16 | import GraphTheme from './GraphTheme'; |
17 | import { FitZoomCallback } from './ZoomCanvas'; | ||
17 | import postProcessSvg from './postProcessSVG'; | 18 | import postProcessSvg from './postProcessSVG'; |
18 | 19 | ||
19 | function toGraphviz( | 20 | function toGraphviz( |
@@ -72,7 +73,20 @@ function toGraphviz( | |||
72 | return lines.join('\n'); | 73 | return lines.join('\n'); |
73 | } | 74 | } |
74 | 75 | ||
75 | export default function DotGraphVisualizer(): JSX.Element { | 76 | function ptToPx(pt: number): number { |
77 | return (pt * 4) / 3; | ||
78 | } | ||
79 | |||
80 | export 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 | |||
168 | DotGraphVisualizer.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'; | |||
10 | export default function GraphArea(): JSX.Element { | 10 | export 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'; | |||
9 | import RemoveIcon from '@mui/icons-material/Remove'; | 9 | import RemoveIcon from '@mui/icons-material/Remove'; |
10 | import IconButton from '@mui/material/IconButton'; | 10 | import IconButton from '@mui/material/IconButton'; |
11 | import Stack from '@mui/material/Stack'; | 11 | import Stack from '@mui/material/Stack'; |
12 | import ToggleButton from '@mui/material/ToggleButton'; | ||
13 | |||
14 | import type { ChangeZoomCallback, SetFitZoomCallback } from './ZoomCanvas'; | ||
12 | 15 | ||
13 | export default function ZoomButtons({ | 16 | export 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'; | |||
8 | import * as d3 from 'd3'; | 8 | import * as d3 from 'd3'; |
9 | import { zoom as d3Zoom } from 'd3-zoom'; | 9 | import { zoom as d3Zoom } from 'd3-zoom'; |
10 | import React, { useCallback, useRef, useState } from 'react'; | 10 | import React, { useCallback, useRef, useState } from 'react'; |
11 | import { useResizeDetector } from 'react-resize-detector'; | ||
11 | 12 | ||
12 | import ZoomButtons from './ZoomButtons'; | 13 | import ZoomButtons from './ZoomButtons'; |
13 | 14 | ||
@@ -29,103 +30,138 @@ interface Transform { | |||
29 | k: number; | 30 | k: number; |
30 | } | 31 | } |
31 | 32 | ||
33 | export type ChangeZoomCallback = (factor: number) => void; | ||
34 | |||
35 | export type SetFitZoomCallback = (fitZoom: boolean) => void; | ||
36 | |||
37 | export type FitZoomCallback = (newSize?: { | ||
38 | width: number; | ||
39 | height: number; | ||
40 | }) => void; | ||
41 | |||
32 | export default function ZoomCanvas({ | 42 | export 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 | ||
173 | ZoomCanvas.defaultProps = { | 215 | ZoomCanvas.defaultProps = { |
174 | children: undefined, | 216 | children: undefined, |
175 | fitPadding: 64, | 217 | fitPadding: 16, |
176 | transitionTime: 250, | 218 | transitionTime: 250, |
177 | }; | 219 | }; |