import { dasherize } from '@ember/string'; import { IGraphOptions } from '@datahub/shared/types/graph/graph-options'; type Node = Com.Linkedin.Metadata.Graph.Node; type Graph = Com.Linkedin.Metadata.Graph.Graph; type Edge = Com.Linkedin.Metadata.Graph.Edge; type Attribute = Com.Linkedin.Metadata.Graph.Attribute; /** * Bag of all attributes possible in GraphViz * * https://graphviz.gitlab.io/_pages/doc/info/shapes.html */ interface IAllAtributes { port?: string; colspan?: string; border?: string; align?: 'CENTER' | 'LEFT' | 'RIGHT' | 'TEXT'; sides?: string; // LTBR (Left Top Bottom Right) bgcolor?: string; color?: string; cellboarder?: string; cellpadding?: string; cellspacing?: string; face?: string; pointSize?: string; // Graphviz won't render id without having href, post process required id?: string; href?: string; } /** * A graphViz pseudo html node * * https://graphviz.gitlab.io/_pages/doc/info/shapes.html */ interface IVizHtmlNode { type: string; children?: Array; text?: string; attributes?: IAllAtributes; } /** * Options for font element */ type IFontOptions = Pick; /** * FontElement pseudo html node */ interface IFont extends IVizHtmlNode { type: 'FONT'; children: Array; attributes: IFontOptions; } /** * Options for cell element */ type ICellOptions = Pick< IAllAtributes, | 'port' | 'colspan' | 'border' | 'align' | 'sides' | 'bgcolor' | 'color' | 'cellpadding' | 'cellspacing' | 'id' | 'href' >; /** * CellElement pseudo html node */ interface ICell extends IVizHtmlNode { type: 'TD'; children: Array; attributes: ICellOptions; } /** * RowElement pseudo html node */ interface IRow extends IVizHtmlNode { type: 'TR'; children: Array; } /** * Options available for a table */ type ITableOptions = Pick< IAllAtributes, | 'port' | 'colspan' | 'border' | 'align' | 'sides' | 'bgcolor' | 'color' | 'cellboarder' | 'cellpadding' | 'cellspacing' | 'id' | 'href' >; /** * TableElement pseudo node */ interface ITable extends IVizHtmlNode { type: 'TABLE'; children: Array; attributes?: ITableOptions; } /** * Text special node that is a string */ interface IText extends IVizHtmlNode { type: 'text'; text: string; } /** * Will transform a bag of options into attributes of a pseudo html element * for example: CELLSPACING="0" CELLPADDING="1" * @param options */ const domAttributesToString = (options: IAllAtributes): string => Object.keys(options) .map((key: keyof IAllAtributes): string => { const value = options[key]; const keyUppercased = dasherize(key).toUpperCase(); return `${keyUppercased}="${value}"`; }) .join(' '); /** * Will render a node. It will also render the children. * @param nodes */ const render = (nodes: Array): string => (nodes.filter(Boolean) as Array) .map(({ type, children, text, attributes }): string | undefined => children ? `<${type} ${attributes ? domAttributesToString(attributes) : ''}>${render(children)}` : text ) .join(''); /** * Text element constructor * @param text */ const text = (text: string): IText => ({ type: 'text', text }); /** * Font element constructor * @param child * @param attributes */ const font = (child: IVizHtmlNode, attributes: IFontOptions): IFont => ({ type: 'FONT', attributes, children: [child] }); /** * Cell element constructor * @param child * @param attributes */ const cell = (child: IVizHtmlNode, attributes: ICellOptions): ICell => ({ type: 'TD', attributes, children: [child] }); /** * Row element constructor * @param children */ const row = (children: Array): IRow => ({ type: 'TR', children }); /** * Table element constructor * @param children * @param attributes */ const table = (children: Array, attributes?: ITableOptions): ITable => ({ type: 'TABLE', children, attributes }); /** * Will create a row for an attribute * @param attribute */ const attributeToRow = (attribute: Attribute & { value?: string }): IRow => row([ cell( table( [ row([ cell(text(attribute.name), { align: 'LEFT' }), cell(text(attribute.type || attribute.value || ''), { align: 'RIGHT' }) ]) ], { cellspacing: '0', border: '0', cellpadding: '10' } ), { port: attribute.name, id: `ENTITY-ATTRIBUTE::${attribute.name}${attribute.reference ? `::${attribute.reference}` : ''}`, href: '-', bgcolor: 'red' } ) ]); /** * Will create an array of rows for a list of attributes * @param attributes */ const attributesToRows = (attributes: Array, _options?: IGraphOptions): Array => attributes.map(attributeToRow); /** * Will generate a toolbar for the node * @param node node to create the actions for * @param _options possible options needed */ const renderNodeToolbar = (node: Node, _options?: IGraphOptions): IVizHtmlNode => table( [ row([ cell(text('View dataset detail $1'), { id: `ENTITY-ACTION-GO-TO-ENTITY::${node.id}`, href: '-', align: 'RIGHT' }) ]) ], { cellspacing: '0', border: '0', cellpadding: '0' } ); /** * Will render a node label using pseudo HTML elements * @param node */ const renderNodeLabel = (node: Node, options?: IGraphOptions): string => `<${render([ // Root table that will serve as a margin for arrow connection table( [ row([ cell( // Table that will contain title and attribtues table( [ row([ // Title of the entity cell(font(text(node.displayName || node.id), { pointSize: '20' }), { align: 'LEFT', id: `ENTITY-TITLE::${node.id}`, href: '-', cellpadding: '10', cellspacing: '0', bgcolor: 'red', port: 'root' }) ]), // Append the attributes of the entity ...(node.attributes ? attributesToRows(node.attributes, options) : []), ...(node.entityUrn ? [ row([ cell(renderNodeToolbar(node, options), { align: 'LEFT', cellpadding: '10', cellspacing: '0', bgcolor: 'red', id: `ENTITY-ACTIONS::${node.id}`, href: '-' }) ]) ] : []) ], { cellspacing: '0', border: '0', cellpadding: '0' } ), {} ) ]) ], { cellspacing: '10', border: '0' } // for arrow separation ) ])}>`; /** * Will return a dot notation node * @param node */ const renderNode = (node: Node, options?: IGraphOptions): string => `"${node.id}" [ id = "ENTITY::${node.id}" label = ${renderNodeLabel(node, options)} ]`; /** * Will return a dot notation edges * @param node */ const renderEdges = (edges: Array, _options?: IGraphOptions): Array => edges.map((edge): string => { const fromPort = edge.fromAttribute || 'root'; const from = `"${edge.fromNode}":${fromPort}`; const to = `"${edge.toNode}":root`; const label = edge.attributes?.map(attr => `${attr.name}:${attr.value}`).join(' '); return `${from} -> ${to} [ id = "EDGE::${edge.fromNode}::${fromPort}::${edge.toNode}" ${label ? `label = "${label}"` : ''} ]`; }); /** * Will render all nodes * @param nodes */ const renderNodes = (nodes: Array, options?: IGraphOptions): Array => nodes.map(node => renderNode(node, options)); /** * Will render a graph into a dot notation * * https://graphviz.gitlab.io/_pages/doc/info/lang.html * * @param graph */ export function graphToDot(graph: Graph, options?: IGraphOptions): string { const dot = ` digraph { graph [ rankdir = "LR" bgcolor = "none" ]; node [ fontsize = "16" fontname = "helvetica, open-sans" shape = "none" margin = "0" ]; edge []; ranksep = 2 ${renderNodes(graph.nodes, options).join('\n')} ${graph.edges ? renderEdges(graph.edges, options).join('\n') : ''} }`; return dot; }