From a49083f31679c47e1685e0cedbc9a40cc8f48fd8 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 26 Aug 2023 21:44:58 +0200 Subject: refactor(frontent): improve graph drawing --- subprojects/frontend/src/graph/postProcessSVG.ts | 133 ++++++++++++++++++++--- 1 file changed, 119 insertions(+), 14 deletions(-) (limited to 'subprojects/frontend/src/graph/postProcessSVG.ts') diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts index 59cc15b9..13e4eb29 100644 --- a/subprojects/frontend/src/graph/postProcessSVG.ts +++ b/subprojects/frontend/src/graph/postProcessSVG.ts @@ -7,19 +7,48 @@ import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; const SVG_NS = 'http://www.w3.org/2000/svg'; +const XLINK_NS = 'http://www.w3.org/1999/xlink'; + +function modifyAttribute(element: Element, attribute: string, change: number) { + const valueString = element.getAttribute(attribute); + if (valueString === null) { + return; + } + const value = parseInt(valueString, 10); + element.setAttribute(attribute, String(value + change)); +} + +function addShadow( + node: SVGGElement, + container: SVGRectElement, + offset: number, +): void { + const shadow = container.cloneNode() as SVGRectElement; + // Leave space for 1pt stroke around the original container. + const offsetWithStroke = offset - 0.5; + modifyAttribute(shadow, 'x', offsetWithStroke); + modifyAttribute(shadow, 'y', offsetWithStroke); + modifyAttribute(shadow, 'width', 1); + modifyAttribute(shadow, 'height', 1); + modifyAttribute(shadow, 'rx', 0.5); + modifyAttribute(shadow, 'ry', 0.5); + shadow.setAttribute('class', 'node-shadow'); + shadow.id = `${node.id},shadow`; + node.insertBefore(shadow, node.firstChild); +} function clipCompartmentBackground(node: SVGGElement) { - // Background rectangle of the node created by the `` + // 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 `
` + const container = node.querySelector('rect[fill="white"]'); + // 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) { + const compartment = node.querySelector('rect[fill="green"]'); + // Make sure we provide traceability with IDs also for the border. + const border = node.querySelector('rect[stroke="black"]'); + if (container === null || compartment === null || border === null) { return; } const copyOfContainer = container.cloneNode() as SVGRectElement; @@ -29,6 +58,17 @@ function clipCompartmentBackground(node: SVGGElement) { clipPath.appendChild(copyOfContainer); node.appendChild(clipPath); compartment.setAttribute('clip-path', `url(#${clipId})`); + // Enlarge the compartment to completely cover the background. + modifyAttribute(compartment, 'y', -5); + modifyAttribute(compartment, 'x', -5); + modifyAttribute(compartment, 'width', 10); + modifyAttribute(compartment, 'height', 5); + if (node.classList.contains('node-equalsSelf-UNKNOWN')) { + addShadow(node, container, 6); + } + container.id = `${node.id},container`; + compartment.id = `${node.id},compartment`; + border.id = `${node.id},border`; } function createRect( @@ -51,7 +91,7 @@ function optimizeNodeShapes(node: SVGGElement) { const rect = createRect(bbox, path); rect.setAttribute('rx', '12'); rect.setAttribute('ry', '12'); - node.replaceChild(rect, path); + path.parentNode?.replaceChild(rect, path); }); node.querySelectorAll('polygon').forEach((polygon) => { const bbox = parsePolygonBBox(polygon); @@ -62,18 +102,83 @@ function optimizeNodeShapes(node: SVGGElement) { 'points', `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, ); - node.replaceChild(polyline, polygon); + polygon.parentNode?.replaceChild(polyline, polygon); } else { const rect = createRect(bbox, polygon); - node.replaceChild(rect, polygon); + polygon.parentNode?.replaceChild(rect, polygon); } }); clipCompartmentBackground(node); } +function hrefToClass(node: SVGGElement) { + node.querySelectorAll('a').forEach((a) => { + if (a.parentNode === null) { + return; + } + const href = a.getAttribute('href') ?? a.getAttributeNS(XLINK_NS, 'href'); + if (href === 'undefined' || !href?.startsWith('#')) { + return; + } + while (a.lastChild !== null) { + const child = a.lastChild; + a.removeChild(child); + if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as Element; + element.classList.add('label', `label-${href.replace('#', '')}`); + a.after(child); + } + } + a.parentNode.removeChild(a); + }); +} + +function replaceImages(node: SVGGElement) { + node.querySelectorAll('image').forEach((image) => { + const href = + image.getAttribute('href') ?? image.getAttributeNS(XLINK_NS, 'href'); + if (href === 'undefined' || !href?.startsWith('#')) { + return; + } + const width = image.getAttribute('width')?.replace('px', '') ?? ''; + const height = image.getAttribute('height')?.replace('px', '') ?? ''; + const foreign = document.createElementNS(SVG_NS, 'foreignObject'); + foreign.setAttribute('x', image.getAttribute('x') ?? ''); + foreign.setAttribute('y', image.getAttribute('y') ?? ''); + foreign.setAttribute('width', width); + foreign.setAttribute('height', height); + const div = document.createElement('div'); + div.classList.add('icon', `icon-${href.replace('#', '')}`); + foreign.appendChild(div); + const sibling = image.nextElementSibling; + // Since dot doesn't respect the `id` attribute on table cells with a single image, + // compute the ID based on the ID of the next element (the label). + if ( + sibling !== null && + sibling.tagName.toLowerCase() === 'g' && + sibling.id !== '' + ) { + foreign.id = `${sibling.id},icon`; + } + image.parentNode?.replaceChild(foreign, image); + }); +} + export default function postProcessSvg(svg: SVGSVGElement) { - svg - .querySelectorAll('title') - .forEach((title) => title.parentNode?.removeChild(title)); - svg.querySelectorAll('g.node').forEach(optimizeNodeShapes); + // svg + // .querySelectorAll('title') + // .forEach((title) => title.parentElement?.removeChild(title)); + svg.querySelectorAll('g.node').forEach((node) => { + optimizeNodeShapes(node); + hrefToClass(node); + replaceImages(node); + }); + // Increase padding to fit box shadows for multi-objects. + const viewBox = [ + svg.viewBox.baseVal.x - 6, + svg.viewBox.baseVal.y - 6, + svg.viewBox.baseVal.width + 12, + svg.viewBox.baseVal.height + 12, + ]; + svg.setAttribute('viewBox', viewBox.join(' ')); } -- cgit v1.2.3-54-g00ecf