From f20474c728e97af79a6d63783619c2515549b107 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 24 Aug 2023 17:12:16 +0200 Subject: feat(frontend): automatic fit zoom --- .../frontend/src/graph/DotGraphVisualizer.tsx | 35 ++++- subprojects/frontend/src/graph/GraphArea.tsx | 2 +- subprojects/frontend/src/graph/ZoomButtons.tsx | 30 ++-- subprojects/frontend/src/graph/ZoomCanvas.tsx | 172 +++++++++++++-------- .../language/semantics/metadata/Metadata.java | 12 ++ .../language/semantics/metadata/NodeKind.java | 12 ++ .../language/semantics/metadata/NodeMetadata.java | 9 ++ .../language/semantics/metadata/RelationKind.java | 18 +++ .../semantics/metadata/RelationMetadata.java | 10 ++ 9 files changed, 219 insertions(+), 81 deletions(-) create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java 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'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; import GraphTheme from './GraphTheme'; +import { FitZoomCallback } from './ZoomCanvas'; import postProcessSvg from './postProcessSVG'; function toGraphviz( @@ -72,7 +73,20 @@ function toGraphviz( return lines.join('\n'); } -export default function DotGraphVisualizer(): JSX.Element { +function ptToPx(pt: number): number { + return (pt * 4) / 3; +} + +export default function DotGraphVisualizer({ + fitZoom, + transitionTime, +}: { + fitZoom?: FitZoomCallback; + transitionTime?: number; +}): JSX.Element { + const transitionTimeOrDefault = + transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; + const { editorStore } = useRootStore(); const disposerRef = useRef(); const graphvizRef = useRef< @@ -104,12 +118,13 @@ export default function DotGraphVisualizer(): JSX.Element { renderer.tweenShapes(false); renderer.convertEqualSidedPolygons(false); const transition = () => - d3.transition().duration(300).ease(d3.easeCubic); + d3.transition().duration(transitionTimeOrDefault).ease(d3.easeCubic); /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any -- Workaround for error in `@types/d3-graphviz`. */ renderer.transition(transition as any); + let newViewBox = { width: 0, height: 0 }; renderer.on( 'postProcessSVG', // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. @@ -119,9 +134,18 @@ export default function DotGraphVisualizer(): JSX.Element { const svg = svgSelection.node(); if (svg !== null) { postProcessSvg(svg); + newViewBox = { + width: ptToPx(svg.viewBox.baseVal.width), + height: ptToPx(svg.viewBox.baseVal.height), + }; + } else { + newViewBox = { width: 0, height: 0 }; } }, ); + if (fitZoom !== undefined) { + renderer.on('transitionStart', () => fitZoom(newViewBox)); + } disposerRef.current = reaction( () => editorStore?.semantics, (semantics) => { @@ -135,8 +159,13 @@ export default function DotGraphVisualizer(): JSX.Element { graphvizRef.current = renderer; } }, - [editorStore], + [editorStore, fitZoom, transitionTimeOrDefault], ); return ; } + +DotGraphVisualizer.defaultProps = { + fitZoom: undefined, + transitionTime: 250, +}; 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'; export default function GraphArea(): JSX.Element { return ( - + {(fitZoom) => } ); } 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'; import RemoveIcon from '@mui/icons-material/Remove'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; + +import type { ChangeZoomCallback, SetFitZoomCallback } from './ZoomCanvas'; export default function ZoomButtons({ changeZoom, fitZoom, + setFitZoom, }: { - changeZoom: (event: React.MouseEvent, factor: number) => void; - fitZoom: (event: React.MouseEvent) => void; + changeZoom: ChangeZoomCallback; + fitZoom: boolean; + setFitZoom: SetFitZoomCallback; }): JSX.Element { return ( - changeZoom(event, 2)} - > + changeZoom(2)}> - changeZoom(event, 0.5)} - > + changeZoom(0.5)}> - + setFitZoom(!fitZoom)} + aria-label="Fit screen" + size="small" + className="iconOnly" + > - + ); } 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'; import * as d3 from 'd3'; import { zoom as d3Zoom } from 'd3-zoom'; import React, { useCallback, useRef, useState } from 'react'; +import { useResizeDetector } from 'react-resize-detector'; import ZoomButtons from './ZoomButtons'; @@ -29,103 +30,138 @@ interface Transform { k: number; } +export type ChangeZoomCallback = (factor: number) => void; + +export type SetFitZoomCallback = (fitZoom: boolean) => void; + +export type FitZoomCallback = (newSize?: { + width: number; + height: number; +}) => void; + export default function ZoomCanvas({ children, fitPadding, transitionTime, }: { - children?: React.ReactNode; + children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode); fitPadding?: number; transitionTime?: number; }): JSX.Element { + const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; + const transitionTimeOrDefault = + transitionTime ?? ZoomCanvas.defaultProps.transitionTime; + const canvasRef = useRef(); const elementRef = useRef(); const zoomRef = useRef< d3.ZoomBehavior | undefined >(); - const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; - const transitionTimeOrDefault = - transitionTime ?? ZoomCanvas.defaultProps.transitionTime; - const [zoom, setZoom] = useState({ x: 0, y: 0, k: 1 }); + const [fitZoom, setFitZoom] = useState(true); + const fitZoomRef = useRef(fitZoom); - const setCanvas = useCallback( - (canvas: HTMLDivElement | null) => { - canvasRef.current = canvas ?? undefined; - if (canvas === null) { + const makeTransition = useCallback( + (element: HTMLDivElement) => + d3.select(element).transition().duration(transitionTimeOrDefault), + [transitionTimeOrDefault], + ); + + const fitZoomCallback = useCallback( + (newSize) => { + if ( + !fitZoomRef.current || + canvasRef.current === undefined || + zoomRef.current === undefined || + elementRef.current === undefined + ) { return; } - const zoomBehavior = d3Zoom() - .duration(transitionTimeOrDefault) - .center((event) => { - const { width, height } = canvas.getBoundingClientRect(); - const [x, y] = d3.pointer(event, canvas); - return [x - width / 2, y - height / 2]; - }) - .centroid([0, 0]); - zoomBehavior.on( - 'zoom', - (event: d3.D3ZoomEvent) => - setZoom(event.transform), + let width = 0; + let height = 0; + if (newSize === undefined) { + const elementRect = elementRef.current.getBoundingClientRect(); + const currentFactor = d3.zoomTransform(canvasRef.current).k; + width = elementRect.width / currentFactor; + height = elementRect.height / currentFactor; + } else { + ({ width, height } = newSize); + } + if (width === 0 || height === 0) { + return; + } + const canvasRect = canvasRef.current.getBoundingClientRect(); + const factor = Math.min( + 1.0, + (canvasRect.width - 2 * fitPaddingOrDefault) / width, + (canvasRect.height - 2 * fitPaddingOrDefault) / height, ); - d3.select(canvas).call(zoomBehavior); - zoomRef.current = zoomBehavior; + const zoomTransition = makeTransition(canvasRef.current); + zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); }, - [transitionTimeOrDefault], + [fitPaddingOrDefault, makeTransition], ); - const makeTransition = useCallback( - (element: HTMLDivElement) => - d3.select(element).transition().duration(transitionTimeOrDefault), - [transitionTimeOrDefault], + const setFitZoomCallback = useCallback( + (newFitZoom) => { + setFitZoom(newFitZoom); + fitZoomRef.current = newFitZoom; + if (newFitZoom) { + fitZoomCallback(); + } + }, + [fitZoomCallback], ); - const changeZoom = useCallback( - (event: React.MouseEvent, factor: number) => { + const changeZoomCallback = useCallback( + (factor) => { + setFitZoomCallback(false); if (canvasRef.current === undefined || zoomRef.current === undefined) { return; } const zoomTransition = makeTransition(canvasRef.current); const center: [number, number] = [0, 0]; zoomRef.current.scaleBy(zoomTransition, factor, center); - event.preventDefault(); - event.stopPropagation(); }, - [makeTransition], + [makeTransition, setFitZoomCallback], ); - const fitZoom = useCallback( - (event: React.MouseEvent) => { - if ( - canvasRef.current === undefined || - zoomRef.current === undefined || - elementRef.current === undefined - ) { + const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]); + + const { ref: resizeRef } = useResizeDetector({ + onResize, + refreshMode: 'debounce', + refreshRate: transitionTimeOrDefault, + }); + + const setCanvas = useCallback( + (canvas: HTMLDivElement | null) => { + canvasRef.current = canvas ?? undefined; + resizeRef(canvas); + if (canvas === null) { return; } - const { width: canvasWidth, height: canvasHeight } = - canvasRef.current.getBoundingClientRect(); - const { width: scaledWidth, height: scaledHeight } = - elementRef.current.getBoundingClientRect(); - const currentFactor = d3.zoomTransform(canvasRef.current).k; - const width = scaledWidth / currentFactor; - const height = scaledHeight / currentFactor; - if (width > 0 && height > 0) { - const factor = Math.min( - 1.0, - (canvasWidth - fitPaddingOrDefault) / width, - (canvasHeight - fitPaddingOrDefault) / height, - ); - const zoomTransition = makeTransition(canvasRef.current); - zoomRef.current.transform( - zoomTransition, - d3.zoomIdentity.scale(factor), - ); - } - event.preventDefault(); - event.stopPropagation(); + const zoomBehavior = d3Zoom() + .duration(transitionTimeOrDefault) + .center((event) => { + const { width, height } = canvas.getBoundingClientRect(); + const [x, y] = d3.pointer(event, canvas); + return [x - width / 2, y - height / 2]; + }) + .centroid([0, 0]); + zoomBehavior.on( + 'zoom', + (event: d3.D3ZoomEvent) => { + setZoom(event.transform); + if (event.sourceEvent) { + setFitZoomCallback(false); + } + }, + ); + d3.select(canvas).call(zoomBehavior); + zoomRef.current = zoomBehavior; }, - [fitPaddingOrDefault, makeTransition], + [transitionTimeOrDefault, setFitZoomCallback, resizeRef], ); return ( @@ -162,16 +198,22 @@ export default function ZoomCanvas({ }} ref={elementRef} > - {children} + {typeof children === 'function' + ? children(fitZoomCallback) + : children} - + ); } ZoomCanvas.defaultProps = { children: undefined, - fitPadding: 64, + fitPadding: 16, transitionTime: 250, }; 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public sealed interface Metadata permits NodeMetadata, RelationMetadata { + String fullyQualifiedName(); + + String simpleName(); +} 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public enum NodeKind { + IMPLICIT, + INDIVIDUAL, + ENUM_LITERAL +} 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record NodeMetadata(String fullyQualifiedName, String simpleName, NodeKind kind) implements Metadata { +} 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public enum RelationKind { + BUILTIN, + CLASS, + ENUM, + REFERENCE, + OPPOSITE, + CONTAINMENT, + CONTAINER, + PREDICATE, + ERROR +} 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record RelationMetadata(String fullyQualifiedName, String simpleName, int arity, RelationKind kind, + String opposite) implements Metadata { +} -- cgit v1.2.3-54-g00ecf