From 2e2ebbf75b12784ac664d864865f01729b3eb8c4 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 24 Aug 2023 00:06:37 +0200 Subject: refactor(web): clean up graphviz visualization --- .../frontend/src/graph/DotGraphVisualizer.tsx | 142 ++++++++ subprojects/frontend/src/graph/GraphArea.tsx | 380 +-------------------- subprojects/frontend/src/graph/GraphPane.tsx | 2 +- subprojects/frontend/src/graph/GraphTheme.tsx | 64 ++++ subprojects/frontend/src/graph/ZoomButtons.tsx | 43 +++ subprojects/frontend/src/graph/ZoomCanvas.tsx | 177 ++++++++++ subprojects/frontend/src/graph/parseBBox.ts | 68 ++++ subprojects/frontend/src/graph/postProcessSVG.ts | 79 +++++ 8 files changed, 580 insertions(+), 375 deletions(-) create mode 100644 subprojects/frontend/src/graph/DotGraphVisualizer.tsx create mode 100644 subprojects/frontend/src/graph/GraphTheme.tsx create mode 100644 subprojects/frontend/src/graph/ZoomButtons.tsx create mode 100644 subprojects/frontend/src/graph/ZoomCanvas.tsx create mode 100644 subprojects/frontend/src/graph/parseBBox.ts create mode 100644 subprojects/frontend/src/graph/postProcessSVG.ts (limited to 'subprojects/frontend/src/graph') diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx new file mode 100644 index 00000000..7c25488a --- /dev/null +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import * as d3 from 'd3'; +import { type Graphviz, graphviz } from 'd3-graphviz'; +import type { BaseType, Selection } from 'd3-selection'; +import { reaction, type IReactionDisposer } from 'mobx'; +import { useCallback, useRef } from 'react'; + +import { useRootStore } from '../RootStoreProvider'; +import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; + +import GraphTheme from './GraphTheme'; +import postProcessSvg from './postProcessSVG'; + +function toGraphviz( + semantics: SemanticsSuccessResult | undefined, +): string | undefined { + if (semantics === undefined) { + return undefined; + } + const lines = [ + 'digraph {', + 'graph [bgcolor=transparent];', + `node [fontsize=12, shape=plain, fontname="OpenSans"];`, + 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', + ]; + const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); + lines.push( + ...nodeIds.map( + (id, i) => + `n${i} [id="${id}", label=<
${id}
node
>];`, + ), + ); + Object.keys(semantics.partialInterpretation).forEach((relation) => { + if (relation === 'builtin::equals' || relation === 'builtin::contains') { + return; + } + const tuples = semantics.partialInterpretation[relation]; + if (tuples === undefined) { + return; + } + const first = tuples[0]; + if (first === undefined || first.length !== 3) { + return; + } + const nameFragments = relation.split('::'); + const simpleName = nameFragments[nameFragments.length - 1] ?? relation; + lines.push( + ...tuples.map(([from, to, value]) => { + if ( + typeof from !== 'number' || + typeof to !== 'number' || + typeof value !== 'string' + ) { + return ''; + } + const isUnknown = value === 'UNKNOWN'; + return `n${from} -> n${to} [ + id="${nodeIds[from]},${nodeIds[to]},${relation}", + xlabel="${simpleName}", + style="${isUnknown ? 'dashed' : 'solid'}", + class="edge-${value}" + ];`; + }), + ); + }); + lines.push('}'); + return lines.join('\n'); +} + +export default function DotGraphVisualizer(): JSX.Element { + const { editorStore } = useRootStore(); + const disposerRef = useRef(); + const graphvizRef = useRef< + Graphviz | undefined + >(); + + const setElement = useCallback( + (element: HTMLDivElement | null) => { + if (disposerRef.current !== undefined) { + disposerRef.current(); + disposerRef.current = undefined; + } + if (graphvizRef.current !== undefined) { + // `@types/d3-graphviz` does not contain the signature for the `destroy` method. + (graphvizRef.current as unknown as { destroy(): void }).destroy(); + graphvizRef.current = undefined; + } + if (element !== null) { + element.replaceChildren(); + const renderer = graphviz(element) as Graphviz< + BaseType, + unknown, + null, + undefined + >; + renderer.keyMode('id'); + renderer.zoom(false); + renderer.tweenPrecision('5%'); + renderer.tweenShapes(false); + renderer.convertEqualSidedPolygons(false); + const transition = () => + d3.transition().duration(300).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); + renderer.on( + 'postProcessSVG', + // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. + ( + svgSelection: Selection, + ) => { + const svg = svgSelection.node(); + if (svg !== null) { + postProcessSvg(svg); + } + }, + ); + disposerRef.current = reaction( + () => editorStore?.semantics, + (semantics) => { + const str = toGraphviz(semantics); + if (str !== undefined) { + renderer.renderDot(str); + } + }, + { fireImmediately: true }, + ); + graphvizRef.current = renderer; + } + }, + [editorStore], + ); + + return ; +} diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index 6ca3bc87..32147d92 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx @@ -1,384 +1,16 @@ /* - * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors + * SPDX-FileCopyrightText: 2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ -import AddIcon from '@mui/icons-material/Add'; -import CropFreeIcon from '@mui/icons-material/CropFree'; -import RemoveIcon from '@mui/icons-material/Remove'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import { useTheme } from '@mui/material/styles'; -import { CSSProperties } from '@mui/material/styles/createTypography'; -import * as d3 from 'd3'; -import { type Graphviz, graphviz } from 'd3-graphviz'; -import type { BaseType, Selection } from 'd3-selection'; -import { zoom as d3Zoom } from 'd3-zoom'; -import { reaction, type IReactionDisposer } from 'mobx'; -import { useCallback, useRef, useState } from 'react'; - -import { useRootStore } from '../RootStoreProvider'; -import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; - -function toGraphviz( - semantics: SemanticsSuccessResult | undefined, -): string | undefined { - if (semantics === undefined) { - return undefined; - } - const lines = [ - 'digraph {', - 'graph [bgcolor=transparent];', - 'node [fontsize=16, shape=plain];', - 'edge [fontsize=12, color=black];', - ]; - const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); - lines.push( - ...nodeIds.map( - (id, i) => - `n${i} [id="${id}", label=<
${id}
node
>];`, - ), - ); - Object.keys(semantics.partialInterpretation).forEach((relation) => { - if (relation === 'builtin::equals' || relation === 'builtin::contains') { - return; - } - const tuples = semantics.partialInterpretation[relation]; - if (tuples === undefined) { - return; - } - const first = tuples[0]; - if (first === undefined || first.length !== 3) { - return; - } - const nameFragments = relation.split('::'); - const simpleName = nameFragments[nameFragments.length - 1] ?? relation; - lines.push( - ...tuples.map(([from, to, value]) => { - if ( - typeof from !== 'number' || - typeof to !== 'number' || - typeof value !== 'string' - ) { - return ''; - } - const isUnknown = value === 'UNKNOWN'; - return `n${from} -> n${to} [ - id="${nodeIds[from]},${nodeIds[to]},${relation}", - xlabel="${simpleName}", - style="${isUnknown ? 'dashed' : 'solid'}", - class="edge-${value}" - ];`; - }), - ); - }); - lines.push('}'); - return lines.join('\n'); -} - -interface Transform { - x: number; - y: number; - k: number; -} +import DotGraphVisualizer from './DotGraphVisualizer'; +import ZoomCanvas from './ZoomCanvas'; export default function GraphArea(): JSX.Element { - const { editorStore } = useRootStore(); - const theme = useTheme(); - const disposerRef = useRef(); - const graphvizRef = useRef< - Graphviz | undefined - >(); - const canvasRef = useRef(); - const elementRef = useRef(); - const zoomRef = useRef< - d3.ZoomBehavior | undefined - >(); - const [zoom, setZoom] = useState({ x: 0, y: 0, k: 1 }); - - const setCanvas = useCallback((element: HTMLDivElement | null) => { - canvasRef.current = element ?? undefined; - if (element === null) { - return; - } - const zoomBehavior = d3Zoom(); - // `@types/d3-zoom` does not contain the `center` function, because it is - // only available as a pull request for `d3-zoom`. - ( - zoomBehavior as unknown as { - center(callback: (event: MouseEvent) => [number, number]): unknown; - } - ).center((event: MouseEvent | Touch) => { - const { width, height } = element.getBoundingClientRect(); - const [x, y] = d3.pointer(event, element); - return [x - width / 2, y - height / 2]; - }); - // Custom `centroid` method added via patch. - ( - zoomBehavior as unknown as { - centroid(centroid: [number, number]): unknown; - } - ).centroid([0, 0]); - zoomBehavior.on('zoom', (event: d3.D3ZoomEvent) => - setZoom(event.transform), - ); - d3.select(element).call(zoomBehavior); - zoomRef.current = zoomBehavior; - }, []); - - const setElement = useCallback( - (element: HTMLDivElement | null) => { - elementRef.current = element ?? undefined; - if (disposerRef.current !== undefined) { - disposerRef.current(); - disposerRef.current = undefined; - } - if (graphvizRef.current !== undefined) { - // `@types/d3-graphviz` does not contain the signature for the `destroy` method. - (graphvizRef.current as unknown as { destroy(): void }).destroy(); - graphvizRef.current = undefined; - } - if (element !== null) { - element.replaceChildren(); - const renderer = graphviz(element) as Graphviz< - BaseType, - unknown, - null, - undefined - >; - renderer.keyMode('id'); - renderer.zoom(false); - renderer.tweenPrecision('5%'); - renderer.tweenShapes(false); - renderer.convertEqualSidedPolygons(false); - const transition = () => - d3.transition().duration(300).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); - renderer.on( - 'postProcessSVG', - // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. - ( - svgSelection: Selection, - ) => { - svgSelection.selectAll('title').remove(); - const svg = svgSelection.node(); - if (svg === null) { - return; - } - svg.querySelectorAll('.node').forEach((node) => { - node.querySelectorAll('path').forEach((path) => { - const d = path.getAttribute('d') ?? ''; - const points = d.split(/[A-Z ]/); - points.shift(); - const x = points.map((p) => { - return Number(p.split(',')[0] ?? 0); - }); - const y = points.map((p) => { - return Number(p.split(',')[1] ?? 0); - }); - const xmin = Math.min.apply(null, x); - const xmax = Math.max.apply(null, x); - const ymin = Math.min.apply(null, y); - const ymax = Math.max.apply(null, y); - const rect = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'rect', - ); - rect.setAttribute('fill', path.getAttribute('fill') ?? ''); - rect.setAttribute('stroke', path.getAttribute('stroke') ?? ''); - rect.setAttribute('x', String(xmin)); - rect.setAttribute('y', String(ymin)); - rect.setAttribute('width', String(xmax - xmin)); - rect.setAttribute('height', String(ymax - ymin)); - rect.setAttribute('height', String(ymax - ymin)); - rect.setAttribute('rx', '8'); - rect.setAttribute('ry', '8'); - node.replaceChild(rect, path); - }); - }); - }, - ); - disposerRef.current = reaction( - () => editorStore?.semantics, - (semantics) => { - const str = toGraphviz(semantics); - if (str !== undefined) { - renderer.renderDot(str); - } - }, - { fireImmediately: true }, - ); - graphvizRef.current = renderer; - } - }, - [editorStore], - ); - - const changeZoom = useCallback((event: React.MouseEvent, factor: number) => { - if (canvasRef.current === undefined || zoomRef.current === undefined) { - return; - } - const selection = d3.select(canvasRef.current); - const zoomTransition = selection.transition().duration(250); - const center: [number, number] = [0, 0]; - zoomRef.current.scaleBy(zoomTransition, factor, center); - event.preventDefault(); - event.stopPropagation(); - }, []); - - const fitZoom = useCallback((event: React.MouseEvent) => { - if ( - canvasRef.current === undefined || - zoomRef.current === undefined || - elementRef.current === undefined - ) { - 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 - 64) / width, - (canvasHeight - 64) / height, - ); - const selection = d3.select(canvasRef.current); - const zoomTransition = selection.transition().duration(250); - zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); - } - event.preventDefault(); - event.stopPropagation(); - }, []); - return ( - - - ), - fill: theme.palette.text.primary, - }, - '& [stroke="black"]': { - stroke: theme.palette.text.primary, - }, - '& [fill="green"]': { - fill: - theme.palette.mode === 'dark' - ? theme.palette.primary.dark - : theme.palette.primary.light, - }, - '& [fill="white"]': { - fill: theme.palette.background.default, - stroke: theme.palette.background.default, - }, - }, - '& .edge': { - '& text': { - ...(theme.typography.caption as Omit< - CSSProperties, - '@font-face' - >), - fill: theme.palette.text.primary, - }, - '& [stroke="black"]': { - stroke: theme.palette.text.primary, - }, - '& [fill="black"]': { - fill: theme.palette.text.primary, - }, - }, - '& .edge-UNKNOWN': { - '& text': { - fill: theme.palette.text.secondary, - }, - '& [stroke="black"]': { - stroke: theme.palette.text.secondary, - }, - '& [fill="black"]': { - fill: theme.palette.text.secondary, - }, - }, - '& .edge-ERROR': { - '& text': { - fill: theme.palette.error.main, - }, - '& [stroke="black"]': { - stroke: theme.palette.error.main, - }, - '& [fill="black"]': { - fill: theme.palette.error.main, - }, - }, - }, - }} - ref={setElement} - /> - - - changeZoom(event, 2)} - > - - - changeZoom(event, 0.5)} - > - - - - - - - + + + ); } diff --git a/subprojects/frontend/src/graph/GraphPane.tsx b/subprojects/frontend/src/graph/GraphPane.tsx index f04b9931..c2ef8927 100644 --- a/subprojects/frontend/src/graph/GraphPane.tsx +++ b/subprojects/frontend/src/graph/GraphPane.tsx @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021-2023 The Refinery Authors + * SPDX-FileCopyrightText: 2023 The Refinery Authors * * SPDX-License-Identifier: EPL-2.0 */ diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx new file mode 100644 index 00000000..41ba6ba5 --- /dev/null +++ b/subprojects/frontend/src/graph/GraphTheme.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { styled, type CSSObject } from '@mui/material/styles'; + +function createEdgeColor(suffix: string, color: string): CSSObject { + return { + [`& .edge-${suffix}`]: { + '& text': { + fill: color, + }, + '& [stroke="black"]': { + stroke: color, + }, + '& [fill="black"]': { + fill: color, + }, + }, + }; +} + +export default styled('div', { + name: 'GraphTheme', +})(({ theme }) => ({ + '& svg': { + userSelect: 'none', + '& .node': { + '& text': { + fontFamily: theme.typography.fontFamily, + fill: theme.palette.text.primary, + }, + '& [stroke="black"]': { + stroke: theme.palette.text.primary, + }, + '& [fill="green"]': { + fill: + theme.palette.mode === 'dark' + ? theme.palette.primary.dark + : theme.palette.primary.light, + }, + '& [fill="white"]': { + fill: theme.palette.background.default, + stroke: theme.palette.background.default, + }, + }, + '& .edge': { + '& text': { + fontFamily: theme.typography.fontFamily, + fill: theme.palette.text.primary, + }, + '& [stroke="black"]': { + stroke: theme.palette.text.primary, + }, + '& [fill="black"]': { + fill: theme.palette.text.primary, + }, + }, + ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), + ...createEdgeColor('ERROR', theme.palette.error.main), + }, +})); diff --git a/subprojects/frontend/src/graph/ZoomButtons.tsx b/subprojects/frontend/src/graph/ZoomButtons.tsx new file mode 100644 index 00000000..72f54774 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomButtons.tsx @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import AddIcon from '@mui/icons-material/Add'; +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'; + +export default function ZoomButtons({ + changeZoom, + fitZoom, +}: { + changeZoom: (event: React.MouseEvent, factor: number) => void; + fitZoom: (event: React.MouseEvent) => void; +}): JSX.Element { + return ( + + changeZoom(event, 2)} + > + + + changeZoom(event, 0.5)} + > + + + + + + + ); +} diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx new file mode 100644 index 00000000..eb3e9285 --- /dev/null +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +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 ZoomButtons from './ZoomButtons'; + +declare module 'd3-zoom' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters. + interface ZoomBehavior { + // `@types/d3-zoom` does not contain the `center` function, because it is + // only available as a pull request for `d3-zoom`. + center(callback: (event: MouseEvent | Touch) => [number, number]): this; + + // Custom `centroid` method added via patch. + centroid(centroid: [number, number]): this; + } +} + +interface Transform { + x: number; + y: number; + k: number; +} + +export default function ZoomCanvas({ + children, + fitPadding, + transitionTime, +}: { + children?: React.ReactNode; + fitPadding?: number; + transitionTime?: number; +}): JSX.Element { + 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 setCanvas = useCallback( + (canvas: HTMLDivElement | null) => { + canvasRef.current = canvas ?? undefined; + if (canvas === null) { + 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), + ); + d3.select(canvas).call(zoomBehavior); + zoomRef.current = zoomBehavior; + }, + [transitionTimeOrDefault], + ); + + const makeTransition = useCallback( + (element: HTMLDivElement) => + d3.select(element).transition().duration(transitionTimeOrDefault), + [transitionTimeOrDefault], + ); + + const changeZoom = useCallback( + (event: React.MouseEvent, factor: number) => { + 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], + ); + + const fitZoom = useCallback( + (event: React.MouseEvent) => { + if ( + canvasRef.current === undefined || + zoomRef.current === undefined || + elementRef.current === undefined + ) { + 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(); + }, + [fitPaddingOrDefault, makeTransition], + ); + + return ( + + + + {children} + + + + + ); +} + +ZoomCanvas.defaultProps = { + children: undefined, + fitPadding: 64, + transitionTime: 250, +}; diff --git a/subprojects/frontend/src/graph/parseBBox.ts b/subprojects/frontend/src/graph/parseBBox.ts new file mode 100644 index 00000000..9806cbca --- /dev/null +++ b/subprojects/frontend/src/graph/parseBBox.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2017, Magnus Jacobsson + * Copyright 2023, The Refinery Authors + * + * SPDX-License-Identifier: BSD-3-Clause AND EPL-2.0 + * + * This file Incorporates patches from the Refinery authors. + * + * Redistribution and use is only permitted if neither + * the name of the copyright holder Magnus Jacobsson nor the names of other + * contributors to the d3-graphviz project are used to endorse or promote + * products derived from this software as per the 3rd clause of the + * 3-clause BSD license. + * + * See LICENSES/BSD-3-Clause.txt for more details. + */ + +export interface BBox { + x: number; + y: number; + width: number; + height: number; +} + +function parsePoints(points: string[]): BBox { + const x = points.map((p) => Number(p.split(',')[0] ?? 0)); + const y = points.map((p) => Number(p.split(',')[1] ?? 0)); + const xmin = Math.min.apply(null, x); + const xmax = Math.max.apply(null, x); + const ymin = Math.min.apply(null, y); + const ymax = Math.max.apply(null, y); + return { + x: xmin, + y: ymin, + width: xmax - xmin, + height: ymax - ymin, + }; +} + +/** + * Compute the bounding box of a polygon without adding it to the DOM. + * + * Copyed from + * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L36-L53 + * + * @param path The polygon to compute the bounding box of. + * @returns The computed bounding box. + */ +export function parsePolygonBBox(polygon: SVGPolygonElement): BBox { + const points = (polygon.getAttribute('points') ?? '').split(' '); + return parsePoints(points); +} + +/** + * Compute the bounding box of a path without adding it to the DOM. + * + * Copyed from + * https://github.com/magjac/d3-graphviz/blob/81ab523fe5189a90da2d9d9cc9015c7079eea780/src/element.js#L56-L75 + * + * @param path The path to compute the bounding box of. + * @returns The computed bounding box. + */ +export function parsePathBBox(path: SVGPathElement): BBox { + const d = path.getAttribute('d') ?? ''; + const points = d.split(/[A-Z ]/); + points.shift(); + return parsePoints(points); +} diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts new file mode 100644 index 00000000..59cc15b9 --- /dev/null +++ b/subprojects/frontend/src/graph/postProcessSVG.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function clipCompartmentBackground(node: SVGGElement) { + // Background rectangle of the node created by the `` + // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. + const container = node.querySelector('rect[fill="green"]'); + // Background rectangle of the lower compartment created by the `
` + // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. + // Since dot doesn't round the coners of `` background, + // we have to clip it ourselves. + const compartment = node.querySelector( + 'polygon[fill="white"]', + ); + if (container === null || compartment === null) { + return; + } + const copyOfContainer = container.cloneNode() as SVGRectElement; + const clipPath = document.createElementNS(SVG_NS, 'clipPath'); + const clipId = `${node.id},,clip`; + clipPath.setAttribute('id', clipId); + clipPath.appendChild(copyOfContainer); + node.appendChild(clipPath); + compartment.setAttribute('clip-path', `url(#${clipId})`); +} + +function createRect( + { x, y, width, height }: BBox, + original: SVGElement, +): SVGRectElement { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('fill', original.getAttribute('fill') ?? ''); + rect.setAttribute('stroke', original.getAttribute('stroke') ?? ''); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(width)); + rect.setAttribute('height', String(height)); + return rect; +} + +function optimizeNodeShapes(node: SVGGElement) { + node.querySelectorAll('path').forEach((path) => { + const bbox = parsePathBBox(path); + const rect = createRect(bbox, path); + rect.setAttribute('rx', '12'); + rect.setAttribute('ry', '12'); + node.replaceChild(rect, path); + }); + node.querySelectorAll('polygon').forEach((polygon) => { + const bbox = parsePolygonBBox(polygon); + if (bbox.height === 0) { + const polyline = document.createElementNS(SVG_NS, 'polyline'); + polyline.setAttribute('stroke', polygon.getAttribute('stroke') ?? ''); + polyline.setAttribute( + 'points', + `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, + ); + node.replaceChild(polyline, polygon); + } else { + const rect = createRect(bbox, polygon); + node.replaceChild(rect, polygon); + } + }); + clipCompartmentBackground(node); +} + +export default function postProcessSvg(svg: SVGSVGElement) { + svg + .querySelectorAll('title') + .forEach((title) => title.parentNode?.removeChild(title)); + svg.querySelectorAll('g.node').forEach(optimizeNodeShapes); +} -- cgit v1.2.3-54-g00ecf