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/index.html | 1 + subprojects/frontend/src/editor/EditorStore.ts | 7 +- subprojects/frontend/src/editor/EditorTheme.ts | 4 +- .../frontend/src/graph/DotGraphVisualizer.tsx | 86 ++---- subprojects/frontend/src/graph/GraphStore.ts | 51 ++++ subprojects/frontend/src/graph/GraphTheme.tsx | 76 ++++- subprojects/frontend/src/graph/ZoomCanvas.tsx | 5 +- subprojects/frontend/src/graph/dotSource.ts | 309 +++++++++++++++++++++ subprojects/frontend/src/graph/postProcessSVG.ts | 133 ++++++++- subprojects/frontend/src/utils/svgURL.ts | 9 + .../frontend/src/xtext/xtextServiceResults.ts | 30 +- subprojects/frontend/vite.config.ts | 2 +- 12 files changed, 616 insertions(+), 97 deletions(-) create mode 100644 subprojects/frontend/src/graph/GraphStore.ts create mode 100644 subprojects/frontend/src/graph/dotSource.ts create mode 100644 subprojects/frontend/src/utils/svgURL.ts (limited to 'subprojects/frontend') diff --git a/subprojects/frontend/index.html b/subprojects/frontend/index.html index f4b46da2..8992d538 100644 --- a/subprojects/frontend/index.html +++ b/subprojects/frontend/index.html @@ -19,6 +19,7 @@ diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index 10f01099..b5989ad1 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -26,6 +26,7 @@ import { makeAutoObservable, observable, runInAction } from 'mobx'; import { nanoid } from 'nanoid'; import type PWAStore from '../PWAStore'; +import GraphStore from '../graph/GraphStore'; import getLogger from '../utils/getLogger'; import type XtextClient from '../xtext/XtextClient'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; @@ -66,7 +67,7 @@ export default class EditorStore { semanticsError: string | undefined; - semantics: SemanticsSuccessResult | undefined; + graph: GraphStore; constructor(initialValue: string, pwaStore: PWAStore) { this.id = nanoid(); @@ -86,12 +87,12 @@ export default class EditorStore { })().catch((error) => { log.error('Failed to load XtextClient', error); }); + this.graph = new GraphStore(); makeAutoObservable(this, { id: false, state: observable.ref, client: observable.ref, view: observable.ref, - semantics: observable.ref, searchPanel: false, lintPanel: false, contentAssist: false, @@ -298,7 +299,7 @@ export default class EditorStore { setSemantics(semantics: SemanticsSuccessResult) { this.semanticsError = undefined; - this.semantics = semantics; + this.graph.setSemantics(semantics); } dispose(): void { diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts index 4508273b..308d5be0 100644 --- a/subprojects/frontend/src/editor/EditorTheme.ts +++ b/subprojects/frontend/src/editor/EditorTheme.ts @@ -10,9 +10,7 @@ import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; import { alpha, styled, type CSSObject } from '@mui/material/styles'; -function svgURL(svg: string): string { - return `url('data:image/svg+xml;utf8,${svg}')`; -} +import svgURL from '../utils/svgURL'; export default styled('div', { name: 'EditorTheme', diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx index 29e750f5..291314ec 100644 --- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx @@ -8,76 +8,24 @@ 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 { observer } from 'mobx-react-lite'; import { useCallback, useRef } from 'react'; import { useRootStore } from '../RootStoreProvider'; -import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; +import getLogger from '../utils/getLogger'; import GraphTheme from './GraphTheme'; import { FitZoomCallback } from './ZoomCanvas'; +import dotSource from './dotSource'; 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'); -} +const LOG = getLogger('graph.DotGraphVisualizer'); function ptToPx(pt: number): number { return (pt * 4) / 3; } -export default function DotGraphVisualizer({ +function DotGraphVisualizer({ fitZoom, transitionTime, }: { @@ -88,6 +36,7 @@ export default function DotGraphVisualizer({ transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; const { editorStore } = useRootStore(); + const graph = editorStore?.graph; const disposerRef = useRef(); const graphvizRef = useRef< Graphviz | undefined @@ -113,6 +62,9 @@ export default function DotGraphVisualizer({ undefined >; renderer.keyMode('id'); + ['TRUE', 'UNKNOWN', 'ERROR'].forEach((icon) => + renderer.addImage(`#${icon}`, 16, 16), + ); renderer.zoom(false); renderer.tweenPrecision('5%'); renderer.tweenShapes(false); @@ -125,6 +77,7 @@ export default function DotGraphVisualizer({ */ renderer.transition(transition as any); let newViewBox = { width: 0, height: 0 }; + renderer.onerror(LOG.error.bind(LOG)); renderer.on( 'postProcessSVG', // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. @@ -139,19 +92,24 @@ export default function DotGraphVisualizer({ height: ptToPx(svg.viewBox.baseVal.height), }; } else { + // Do not trigger fit zoom. newViewBox = { width: 0, height: 0 }; } }, ); + renderer.on('renderEnd', () => { + // `d3-graphviz` uses `` elements for traceability, + // so we only remove them after the rendering is finished. + d3.select(element).selectAll('title').remove(); + }); if (fitZoom !== undefined) { renderer.on('transitionStart', () => fitZoom(newViewBox)); } disposerRef.current = reaction( - () => editorStore?.semantics, - (semantics) => { - const str = toGraphviz(semantics); - if (str !== undefined) { - renderer.renderDot(str); + () => dotSource(graph), + (source) => { + if (source !== undefined) { + renderer.renderDot(source); } }, { fireImmediately: true }, @@ -159,7 +117,7 @@ export default function DotGraphVisualizer({ graphvizRef.current = renderer; } }, - [editorStore, fitZoom, transitionTimeOrDefault], + [graph, fitZoom, transitionTimeOrDefault], ); return <GraphTheme ref={setElement} />; @@ -169,3 +127,5 @@ DotGraphVisualizer.defaultProps = { fitZoom: undefined, transitionTime: 250, }; + +export default observer(DotGraphVisualizer); diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts new file mode 100644 index 00000000..b59bfb7d --- /dev/null +++ b/subprojects/frontend/src/graph/GraphStore.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { makeAutoObservable, observable } from 'mobx'; + +import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; + +export type Visibility = 'all' | 'must' | 'none'; + +export default class GraphStore { + semantics: SemanticsSuccessResult = { + nodes: [], + relations: [], + partialInterpretation: {}, + }; + + visibility = new Map<string, Visibility>(); + + constructor() { + makeAutoObservable(this, { + semantics: observable.ref, + }); + } + + getVisiblity(relation: string): Visibility { + return this.visibility.get(relation) ?? 'none'; + } + + setSemantics(semantics: SemanticsSuccessResult) { + this.semantics = semantics; + this.visibility.clear(); + const names = new Set<string>(); + this.semantics.relations.forEach(({ name, detail }) => { + names.add(name); + if (!this.visibility.has(name)) { + const newVisibility = detail.type === 'builtin' ? 'none' : 'all'; + this.visibility.set(name, newVisibility); + } + }); + const oldNames = new Set<string>(); + this.visibility.forEach((_, key) => oldNames.add(key)); + oldNames.forEach((key) => { + if (!names.has(key)) { + this.visibility.delete(key); + } + }); + } +} diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx index 41ba6ba5..989bd0c2 100644 --- a/subprojects/frontend/src/graph/GraphTheme.tsx +++ b/subprojects/frontend/src/graph/GraphTheme.tsx @@ -4,19 +4,28 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { styled, type CSSObject } from '@mui/material/styles'; +import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; +import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; +import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; +import { alpha, styled, type CSSObject } from '@mui/material/styles'; -function createEdgeColor(suffix: string, color: string): CSSObject { +import svgURL from '../utils/svgURL'; + +function createEdgeColor( + suffix: string, + stroke: string, + fill?: string, +): CSSObject { return { - [`& .edge-${suffix}`]: { + [`.edge-${suffix}`]: { '& text': { - fill: color, + fill: stroke, }, '& [stroke="black"]': { - stroke: color, + stroke, }, '& [fill="black"]': { - fill: color, + fill: fill ?? stroke, }, }, }; @@ -27,7 +36,7 @@ export default styled('div', { })(({ theme }) => ({ '& svg': { userSelect: 'none', - '& .node': { + '.node': { '& text': { fontFamily: theme.typography.fontFamily, fill: theme.palette.text.primary, @@ -43,10 +52,32 @@ export default styled('div', { }, '& [fill="white"]': { fill: theme.palette.background.default, - stroke: theme.palette.background.default, }, }, - '& .edge': { + '.node-INDIVIDUAL': { + '& [stroke="black"]': { + strokeWidth: 2, + }, + }, + '.node-shadow[fill="white"]': { + fill: alpha( + theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.32 : 0.24, + ), + }, + '.node-exists-UNKNOWN [stroke="black"]': { + strokeDasharray: '5 2', + }, + '.node-exists-FALSE': { + '& [fill="green"]': { + fill: theme.palette.background.default, + }, + '& [stroke="black"]': { + strokeDasharray: '1 3', + stroke: theme.palette.text.secondary, + }, + }, + '.edge': { '& text': { fontFamily: theme.typography.fontFamily, fill: theme.palette.text.primary, @@ -58,7 +89,32 @@ export default styled('div', { fill: theme.palette.text.primary, }, }, - ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), + ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), ...createEdgeColor('ERROR', theme.palette.error.main), + '.icon': { + maskSize: '12px 12px', + maskPosition: '50% 50%', + maskRepeat: 'no-repeat', + width: '100%', + height: '100%', + }, + '.icon-TRUE': { + maskImage: svgURL(labelSVG), + background: theme.palette.text.primary, + }, + '.icon-UNKNOWN': { + maskImage: svgURL(labelOutlinedSVG), + background: theme.palette.text.secondary, + }, + '.icon-ERROR': { + maskImage: svgURL(cancelSVG), + background: theme.palette.error.main, + }, + 'text.label-UNKNOWN': { + fill: theme.palette.text.secondary, + }, + 'text.label-ERROR': { + fill: theme.palette.error.main, + }, }, })); diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx index b8faae27..2bb7f139 100644 --- a/subprojects/frontend/src/graph/ZoomCanvas.tsx +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx @@ -148,7 +148,8 @@ export default function ZoomCanvas({ const [x, y] = d3.pointer(event, canvas); return [x - width / 2, y - height / 2]; }) - .centroid([0, 0]); + .centroid([0, 0]) + .scaleExtent([1 / 32, 8]); zoomBehavior.on( 'zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => { @@ -214,6 +215,6 @@ export default function ZoomCanvas({ ZoomCanvas.defaultProps = { children: undefined, - fitPadding: 16, + fitPadding: 8, transitionTime: 250, }; diff --git a/subprojects/frontend/src/graph/dotSource.ts b/subprojects/frontend/src/graph/dotSource.ts new file mode 100644 index 00000000..bf45d303 --- /dev/null +++ b/subprojects/frontend/src/graph/dotSource.ts @@ -0,0 +1,309 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import type { + NodeMetadata, + RelationMetadata, +} from '../xtext/xtextServiceResults'; + +import type GraphStore from './GraphStore'; + +const EDGE_WEIGHT = 1; +const CONTAINMENT_WEIGHT = 5; +const UNKNOWN_WEIGHT_FACTOR = 0.5; + +function nodeName({ simpleName, kind }: NodeMetadata): string { + switch (kind) { + case 'INDIVIDUAL': + return `<b>${simpleName}</b>`; + case 'NEW': + return `<i>${simpleName}</i>`; + default: + return simpleName; + } +} + +function relationName({ simpleName, detail }: RelationMetadata): string { + if (detail.type === 'class' && detail.abstractClass) { + return `<i>${simpleName}</i>`; + } + if (detail.type === 'reference' && detail.containment) { + return `<b>${simpleName}</b>`; + } + return simpleName; +} + +interface NodeData { + exists: string; + equalsSelf: string; + unaryPredicates: Map<RelationMetadata, string>; +} + +function computeNodeData(graph: GraphStore): NodeData[] { + const { + semantics: { nodes, relations, partialInterpretation }, + } = graph; + + const nodeData = Array.from(Array(nodes.length)).map(() => ({ + exists: 'FALSE', + equalsSelf: 'FALSE', + unaryPredicates: new Map(), + })); + + relations.forEach((relation) => { + if (relation.arity !== 1) { + return; + } + const visibility = graph.getVisiblity(relation.name); + if (visibility === 'none') { + return; + } + const interpretation = partialInterpretation[relation.name] ?? []; + interpretation.forEach(([index, value]) => { + if ( + typeof index === 'number' && + typeof value === 'string' && + (visibility === 'all' || value !== 'UNKNOWN') + ) { + nodeData[index]?.unaryPredicates?.set(relation, value); + } + }); + }); + + partialInterpretation['builtin::exists']?.forEach(([index, value]) => { + if (typeof index === 'number' && typeof value === 'string') { + const data = nodeData[index]; + if (data !== undefined) { + data.exists = value; + } + } + }); + + partialInterpretation['builtin::equals']?.forEach(([index, other, value]) => { + if ( + typeof index === 'number' && + index === other && + typeof value === 'string' + ) { + const data = nodeData[index]; + if (data !== undefined) { + data.equalsSelf = value; + } + } + }); + + return nodeData; +} + +function createNodes(graph: GraphStore, lines: string[]): void { + const nodeData = computeNodeData(graph); + const { + semantics: { nodes }, + } = graph; + + nodes.forEach((node, i) => { + const data = nodeData[i]; + if (data === undefined) { + return; + } + const classes = [ + `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`, + ].join(' '); + const name = nodeName(node); + const border = node.kind === 'INDIVIDUAL' ? 2 : 1; + lines.push(`n${i} [id="${node.name}", class="${classes}", label=< + <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white"> + <tr><td cellpadding="4.5" width="32" bgcolor="green">${name}</td></tr>`); + if (data.unaryPredicates.size > 0) { + lines.push( + '<hr/><tr><td cellpadding="4.5"><table fixedsize="TRUE" align="left" border="0" cellborder="0" cellspacing="0" cellpadding="1.5">', + ); + data.unaryPredicates.forEach((value, relation) => { + lines.push( + `<tr> + <td><img src="#${value}"/></td> + <td width="1.5"></td> + <td align="left" href="#${value}" id="${node.name},${ + relation.name + },label">${relationName(relation)}</td> + </tr>`, + ); + }); + lines.push('</table></td></tr>'); + } + lines.push('</table>>]'); + }); +} + +function compare( + a: readonly (number | string)[], + b: readonly number[], +): number { + if (a.length !== b.length + 1) { + throw new Error('Tuple length mismatch'); + } + for (let i = 0; i < b.length; i += 1) { + const aItem = a[i]; + const bItem = b[i]; + if (typeof aItem !== 'number' || typeof bItem !== 'number') { + throw new Error('Invalid tuple'); + } + if (aItem < bItem) { + return -1; + } + if (aItem > bItem) { + return 1; + } + } + return 0; +} + +function binarySerach( + tuples: readonly (readonly (number | string)[])[], + key: readonly number[], +): string | undefined { + let lower = 0; + let upper = tuples.length - 1; + while (lower <= upper) { + const middle = Math.floor((lower + upper) / 2); + const tuple = tuples[middle]; + if (tuple === undefined) { + throw new Error('Range error'); + } + const result = compare(tuple, key); + if (result === 0) { + const found = tuple[key.length]; + if (typeof found !== 'string') { + throw new Error('Invalid tuple value'); + } + return found; + } + if (result < 0) { + lower = middle + 1; + } else { + // result > 0 + upper = middle - 1; + } + } + return undefined; +} + +function createRelationEdges( + graph: GraphStore, + relation: RelationMetadata, + showUnknown: boolean, + lines: string[], +): void { + const { + semantics: { nodes, partialInterpretation }, + } = graph; + const { detail } = relation; + + let constraint: 'true' | 'false' = 'true'; + let weight = EDGE_WEIGHT; + let penwidth = 1; + let label = `"${relation.simpleName}"`; + if (detail.type === 'reference' && detail.containment) { + weight = CONTAINMENT_WEIGHT; + label = `<<b>${relation.simpleName}</b>>`; + penwidth = 2; + } else if ( + detail.type === 'opposite' && + graph.getVisiblity(detail.opposite) !== 'none' + ) { + constraint = 'false'; + weight = 0; + } + + const tuples = partialInterpretation[relation.name] ?? []; + tuples.forEach(([from, to, value]) => { + const isUnknown = value === 'UNKNOWN'; + if ( + (!showUnknown && isUnknown) || + typeof from !== 'number' || + typeof to !== 'number' || + typeof value !== 'string' + ) { + return; + } + + const fromNode = nodes[from]; + const toNode = nodes[to]; + if (fromNode === undefined || toNode === undefined) { + return; + } + + let dir = 'forward'; + let edgeConstraint = constraint; + let edgeWeight = weight; + const opposite = binarySerach(tuples, [to, from]); + const oppositeUnknown = opposite === 'UNKNOWN'; + const oppositeSet = opposite !== undefined; + const oppositeVisible = oppositeSet && (showUnknown || !oppositeUnknown); + if (opposite === value) { + if (to < from) { + // We already added this edge in the reverse direction. + return; + } + if (to > from) { + dir = 'both'; + } + } else if (oppositeVisible && to < from) { + // Let the opposite edge drive the graph layout. + edgeConstraint = 'false'; + edgeWeight = 0; + } else if (isUnknown && (!oppositeSet || oppositeUnknown)) { + // Only apply the UNKNOWN value penalty if we aren't the opposite + // edge driving the graph layout from above, or the penalty would + // be applied anyway. + edgeWeight *= UNKNOWN_WEIGHT_FACTOR; + } + + lines.push(`n${from} -> n${to} [ + id="${fromNode.name},${toNode.name},${relation.name}", + dir="${dir}", + constraint=${edgeConstraint}, + weight=${edgeWeight}, + xlabel=${label}, + penwidth=${penwidth}, + style="${isUnknown ? 'dashed' : 'solid'}", + class="edge-${value}" + ]`); + }); +} + +function createEdges(graph: GraphStore, lines: string[]): void { + const { + semantics: { relations }, + } = graph; + relations.forEach((relation) => { + if (relation.arity !== 2) { + return; + } + const visibility = graph.getVisiblity(relation.name); + if (visibility !== 'none') { + createRelationEdges(graph, relation, visibility === 'all', lines); + } + }); +} + +export default function dotSource( + graph: GraphStore | undefined, +): string | undefined { + if (graph === undefined) { + return undefined; + } + const lines = [ + 'digraph {', + 'graph [bgcolor=transparent];', + `node [fontsize=12, shape=plain, fontname="OpenSans"];`, + 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', + ]; + createNodes(graph, lines); + createEdges(graph, lines); + lines.push('}'); + return lines.join('\n'); +} 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 `<table bgcolor="green">` + // Background rectangle of the node created by the `<table bgcolor="white">` // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. - const container = node.querySelector<SVGRectElement>('rect[fill="green"]'); - // Background rectangle of the lower compartment created by the `<td bgcolor="white">` + const container = node.querySelector<SVGRectElement>('rect[fill="white"]'); + // Background rectangle of the lower compartment created by the `<td bgcolor="green">` // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. // Since dot doesn't round the coners of `<td>` background, // we have to clip it ourselves. - const compartment = node.querySelector<SVGPolygonElement>( - 'polygon[fill="white"]', - ); - if (container === null || compartment === null) { + const compartment = node.querySelector<SVGRectElement>('rect[fill="green"]'); + // Make sure we provide traceability with IDs also for the border. + const border = node.querySelector<SVGRectElement>('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<SVGAElement>('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<SVGImageElement>('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<SVGTitleElement>('title') - .forEach((title) => title.parentNode?.removeChild(title)); - svg.querySelectorAll<SVGGElement>('g.node').forEach(optimizeNodeShapes); + // svg + // .querySelectorAll<SVGTitleElement>('title') + // .forEach((title) => title.parentElement?.removeChild(title)); + svg.querySelectorAll<SVGGElement>('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(' ')); } diff --git a/subprojects/frontend/src/utils/svgURL.ts b/subprojects/frontend/src/utils/svgURL.ts new file mode 100644 index 00000000..9b8ecbd5 --- /dev/null +++ b/subprojects/frontend/src/utils/svgURL.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +export default function svgURL(svg: string): string { + return `url('data:image/svg+xml;utf8,${svg}')`; +} diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts index 12f87b26..caf2cf0b 100644 --- a/subprojects/frontend/src/xtext/xtextServiceResults.ts +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts @@ -126,8 +126,36 @@ export const FormattingResult = DocumentStateResult.extend({ export type FormattingResult = z.infer<typeof FormattingResult>; +export const NodeMetadata = z.object({ + name: z.string(), + simpleName: z.string(), + kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']), +}); + +export type NodeMetadata = z.infer<typeof NodeMetadata>; + +export const RelationMetadata = z.object({ + name: z.string(), + simpleName: z.string(), + arity: z.number().nonnegative(), + detail: z.union([ + z.object({ type: z.literal('class'), abstractClass: z.boolean() }), + z.object({ type: z.literal('reference'), containment: z.boolean() }), + z.object({ + type: z.literal('opposite'), + container: z.boolean(), + opposite: z.string(), + }), + z.object({ type: z.literal('predicate'), error: z.boolean() }), + z.object({ type: z.literal('builtin') }), + ]), +}); + +export type RelationMetadata = z.infer<typeof RelationMetadata>; + export const SemanticsSuccessResult = z.object({ - nodes: z.string().nullable().array(), + nodes: NodeMetadata.array(), + relations: RelationMetadata.array(), partialInterpretation: z.record( z.string(), z.union([z.number(), z.string()]).array().array(), diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts index 82e432de..63d5245f 100644 --- a/subprojects/frontend/vite.config.ts +++ b/subprojects/frontend/vite.config.ts @@ -30,7 +30,7 @@ const { mode, isDevelopment, devModePlugins, serverOptions } = process.env['NODE_ENV'] ??= mode; const fontsGlob = [ - 'open-sans-latin-wdth-normal-*.woff2', + 'open-sans-latin-wdth-{normal,italic}-*.woff2', 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2', ]; -- cgit v1.2.3-54-g00ecf