diff --git a/console/client/.eslintrc.cjs b/console/client/.eslintrc.cjs index a00754cda9..7b8ae2a15a 100644 --- a/console/client/.eslintrc.cjs +++ b/console/client/.eslintrc.cjs @@ -27,6 +27,12 @@ module.exports = { 'func-style': ['error', 'expression'], '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/ban-ts-comment': [ + 2, + { + 'ts-ignore': 'allow-with-description', + }, + ], }, settings: { react: { diff --git a/console/client/package-lock.json b/console/client/package-lock.json index 8d7e50daad..7a43421515 100644 --- a/console/client/package-lock.json +++ b/console/client/package-lock.json @@ -14,6 +14,8 @@ "@headlessui/react": "1.7.16", "@heroicons/react": "2.0.18", "@monaco-editor/react": "4.5.2", + "@svgdotjs/svg.js": "3.2.0", + "@svgdotjs/svg.panzoom.js": "2.1.2", "@tailwindcss/forms": "^0.5.6", "@vitejs/plugin-react": "^4.0.4", "@viz-js/viz": "3.2.0", @@ -2060,6 +2062,23 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz", + "integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.panzoom.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.panzoom.js/-/svg.panzoom.js-2.1.2.tgz", + "integrity": "sha512-0Nzo2TRlTebW3pzfAPtHx8Ye7Y3kuMEkK7hwVJi0SgQUB/vstjg7fvCJxB++EqsuDEetP0/SC+4CpLMVm6Lh2g==", + "dependencies": { + "@svgdotjs/svg.js": "^3.0.16" + } + }, "node_modules/@swc/core": { "version": "1.3.77", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.77.tgz", diff --git a/console/client/package.json b/console/client/package.json index 35d3e534bd..ad5e5b9333 100644 --- a/console/client/package.json +++ b/console/client/package.json @@ -32,6 +32,8 @@ "@headlessui/react": "1.7.16", "@heroicons/react": "2.0.18", "@monaco-editor/react": "4.5.2", + "@svgdotjs/svg.js": "3.2.0", + "@svgdotjs/svg.panzoom.js": "2.1.2", "@tailwindcss/forms": "^0.5.6", "@vitejs/plugin-react": "^4.0.4", "@viz-js/viz": "3.2.0", diff --git a/console/client/src/features/modules/graph.css b/console/client/src/features/modules/Modules.css similarity index 95% rename from console/client/src/features/modules/graph.css rename to console/client/src/features/modules/Modules.css index 50ce545a2a..fc30ea3352 100644 --- a/console/client/src/features/modules/graph.css +++ b/console/client/src/features/modules/Modules.css @@ -18,11 +18,13 @@ html.dark:root { --edge-color: var(--white); } -g.graph > polygon { - fill: transparent; +#modules-flow-chart { + width: fit-content; + & > .graph > polygon { + fill: transparent; + } } - -#svg-pan-zoom-controls { +#pan-zoom-controls { & path { fill-opacity: 0.75; fill: var(--field-name-color); diff --git a/console/client/src/features/modules/ModulesPage.tsx b/console/client/src/features/modules/ModulesPage.tsx index 37e7084ffe..ac07762a61 100644 --- a/console/client/src/features/modules/ModulesPage.tsx +++ b/console/client/src/features/modules/ModulesPage.tsx @@ -5,28 +5,54 @@ import { modulesContext } from '../../providers/modules-provider' import { generateDot } from './generate-dot' import { dotToSVG } from './dot-to-svg' import { formatSVG } from './format-svg' -import './graph.css' +import { svgZoom } from './svg-zoom' +import { createControls } from './create-controls' +import './Modules.css' + export const ModulesPage = () => { const modules = React.useContext(modulesContext) - const dot = generateDot(modules) - const ref = React.useRef(null) + const viewportRef = React.useRef(null) + const controlRef = React.useRef(null) const [viewport, setViewPort] = React.useState() + const [controls, setControls] = React.useState() + const [svg, setSVG] = React.useState() + React.useEffect(() => { - const cur = ref.current - cur && setViewPort(cur) + const viewCur = viewportRef.current + viewCur && setViewPort(viewCur) + + const ctlCur = controlRef.current + ctlCur && setControls(ctlCur) }, []) + React.useEffect(() => { const renderSvg = async () => { - const svg = await dotToSVG(dot) - svg && viewport?.replaceChildren(formatSVG(svg)) + const dot = generateDot(modules) + const unformattedSVG = await dotToSVG(dot) + if (unformattedSVG) { + const formattedSVG = formatSVG(unformattedSVG) + viewport?.replaceChildren(formattedSVG) + setSVG(formattedSVG) + } } viewport && void renderSvg() - }, [dot, viewport]) - // console.log(generateDotFile(modules)) + }, [modules, viewport]) + + React.useEffect(() => { + if (controls && svg) { + const zoom = svgZoom() + const [buttons, removeListeners] = createControls(zoom) + controls.replaceChildren(...buttons.values()) + return () => { + removeListeners() + } + } + }, [controls, svg]) return (
} title='Modules' /> -
+
+
) } diff --git a/console/client/src/features/modules/constants.ts b/console/client/src/features/modules/constants.ts index f1a4905fb2..f362be5dac 100644 --- a/console/client/src/features/modules/constants.ts +++ b/console/client/src/features/modules/constants.ts @@ -1,11 +1,27 @@ export const callIconID = 'call-icon' export const callIcon = ` - - + + ` +export const controlIcons = { + in: ` + + + `, + out: ` + + `, + reset: ` + + + `, +} + export const moduleVerbCls = 'module-verb' export const moduleTitleCls = 'module-title' +export const vizID = 'modules-flow-chart' +export const controlsID = 'pan-zoom-controls' diff --git a/console/client/src/features/modules/create-controls.ts b/console/client/src/features/modules/create-controls.ts new file mode 100644 index 0000000000..37574ca495 --- /dev/null +++ b/console/client/src/features/modules/create-controls.ts @@ -0,0 +1,33 @@ +import { svgZoom } from './svg-zoom' +import { controlIcons } from './constants' + +export const createControls = ( + zoom: ReturnType, +): [Map<'in' | 'out' | 'reset', HTMLButtonElement>, () => void] => { + const actions = ['in', 'out', 'reset'] as const + const buttons: Map<(typeof actions)[number], HTMLButtonElement> = new Map() + for (const action of actions) { + const btn = document.createElement('button') + btn.classList.add( + ...'relative inline-flex items-center bg-white dark:hover:bg-indigo-700 dark:bg-gray-700/40 px-2 py-2 text-gray-500 dark:text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10'.split( + ' ', + ), + ) + const scr = document.createElement('span') + scr.classList.add('sr-only') + scr.innerText = action + const parser = new DOMParser() + const doc = parser.parseFromString(controlIcons[action], 'image/svg+xml') + const svg = doc.documentElement + btn.replaceChildren(scr, svg) + btn.addEventListener('click', zoom[action]) + buttons.set(action, btn) + } + + const removeEventListeners = () => { + for (const action of actions) { + buttons.get(action)?.removeEventListener('click', zoom[action]) + } + } + return [buttons, removeEventListeners] +} diff --git a/console/client/src/features/modules/format-svg.ts b/console/client/src/features/modules/format-svg.ts index d1d5f06ba9..aea91418df 100644 --- a/console/client/src/features/modules/format-svg.ts +++ b/console/client/src/features/modules/format-svg.ts @@ -1,7 +1,9 @@ -import { callIcon, moduleVerbCls, callIconID } from './constants' +import { callIcon, moduleVerbCls, callIconID, vizID } from './constants' export const formatSVG = (svg: SVGSVGElement): SVGSVGElement => { svg.insertAdjacentHTML('afterbegin', callIcon) - + svg.removeAttribute('width') + svg.removeAttribute('height') + svg.setAttribute('id', vizID) for (const $a of svg.querySelectorAll('a')) { const $g = $a.parentNode! as SVGSVGElement diff --git a/console/client/src/features/modules/svg-zoom.ts b/console/client/src/features/modules/svg-zoom.ts new file mode 100644 index 0000000000..f87c6a5aa2 --- /dev/null +++ b/console/client/src/features/modules/svg-zoom.ts @@ -0,0 +1,32 @@ +import { SVG } from '@svgdotjs/svg.js' +import '@svgdotjs/svg.panzoom.js/dist/svg.panzoom.esm.js' +import { vizID } from './constants' + +export const svgZoom = () => { + // enables panZoom + const canvas = SVG(`#${vizID}`) + //@ts-ignore: lib types bad + ?.panZoom() + const box = canvas.bbox() + return { + to(id: string) { + const module = canvas.findOne(`#${id}`) + //@ts-ignore: lib types bad + const bbox = module?.bbox() + if (bbox) { + canvas.zoom(2, { x: bbox.x, y: bbox.y }) + } + }, + in() { + const zoomLevel = canvas.zoom() + canvas.zoom(zoomLevel + 0.1) // Increase the zoom level by 0.1 + }, + out() { + const zoomLevel = canvas.zoom() + canvas.zoom(zoomLevel - 0.1) // Decrease the zoom level by 0.1 + }, + reset() { + canvas.viewbox(box).zoom(1, { x: 0, y: 0 }) // Reset zoom level to 1 and pan to origin + }, + } +}