import graphlib from 'graphlib'; import { line, curveBasis, select } from 'd3'; // import erDb from './erDb'; // import erParser from './parser/erDiagram'; import dagre from 'dagre'; import { getConfig } from '../../config'; import { log } from '../../logger'; import erMarkers from './erMarkers'; import { configureSvgSize } from '../../utils'; import addSVGAccessibilityFields from '../../accessibility'; import { parseGenericTypes } from '../common/common'; let conf = {}; /** * Allows the top-level API module to inject config specific to this renderer, storing it in the * local conf object. Note that generic config still needs to be retrieved using getConfig() * imported from the config module * * @param cnf */ export const setConf = function (cnf) { const keys = Object.keys(cnf); for (let i = 0; i < keys.length; i++) { conf[keys[i]] = cnf[keys[i]]; } }; /** * Draw attributes for an entity * * @param groupNode The svg group node for the entity * @param entityTextNode The svg node for the entity label text * @param attributes An array of attributes defined for the entity (each attribute has a type and a name) * @returns The bounding box of the entity, after attributes have been added */ const drawAttributes = (groupNode, entityTextNode, attributes) => { const heightPadding = conf.entityPadding / 3; // Padding internal to attribute boxes const widthPadding = conf.entityPadding / 3; // Ditto const attrFontSize = conf.fontSize * 0.85; const labelBBox = entityTextNode.node().getBBox(); const attributeNodes = []; // Intermediate storage for attribute nodes created so that we can do a second pass let hasKeyType = false; let hasComment = false; let maxTypeWidth = 0; let maxNameWidth = 0; let maxKeyWidth = 0; let maxCommentWidth = 0; let cumulativeHeight = labelBBox.height + heightPadding * 2; let attrNum = 1; // Check to see if any of the attributes has a key or a comment attributes.forEach((item) => { if (item.attributeKeyType !== undefined) { hasKeyType = true; } if (item.attributeComment !== undefined) { hasComment = true; } }); attributes.forEach((item) => { const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`; let nodeHeight = 0; const attributeType = parseGenericTypes(item.attributeType); // Add a text node for the attribute type const typeNode = groupNode .append('text') .attr('class', 'er entityLabel') .attr('id', `${attrPrefix}-type`) .attr('x', 0) .attr('y', 0) .attr('dominant-baseline', 'middle') .attr('text-anchor', 'left') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px' ) .text(attributeType); // Add a text node for the attribute name const nameNode = groupNode .append('text') .attr('class', 'er entityLabel') .attr('id', `${attrPrefix}-name`) .attr('x', 0) .attr('y', 0) .attr('dominant-baseline', 'middle') .attr('text-anchor', 'left') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px' ) .text(item.attributeName); const attributeNode = {}; attributeNode.tn = typeNode; attributeNode.nn = nameNode; const typeBBox = typeNode.node().getBBox(); const nameBBox = nameNode.node().getBBox(); maxTypeWidth = Math.max(maxTypeWidth, typeBBox.width); maxNameWidth = Math.max(maxNameWidth, nameBBox.width); nodeHeight = Math.max(typeBBox.height, nameBBox.height); if (hasKeyType) { const keyTypeNode = groupNode .append('text') .attr('class', 'er entityLabel') .attr('id', `${attrPrefix}-key`) .attr('x', 0) .attr('y', 0) .attr('dominant-baseline', 'middle') .attr('text-anchor', 'left') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px' ) .text(item.attributeKeyType || ''); attributeNode.kn = keyTypeNode; const keyTypeBBox = keyTypeNode.node().getBBox(); maxKeyWidth = Math.max(maxKeyWidth, keyTypeBBox.width); nodeHeight = Math.max(nodeHeight, keyTypeBBox.height); } if (hasComment) { const commentNode = groupNode .append('text') .attr('class', 'er entityLabel') .attr('id', `${attrPrefix}-comment`) .attr('x', 0) .attr('y', 0) .attr('dominant-baseline', 'middle') .attr('text-anchor', 'left') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px' ) .text(item.attributeComment || ''); attributeNode.cn = commentNode; const commentNodeBBox = commentNode.node().getBBox(); maxCommentWidth = Math.max(maxCommentWidth, commentNodeBBox.width); nodeHeight = Math.max(nodeHeight, commentNodeBBox.height); } attributeNode.height = nodeHeight; // Keep a reference to the nodes so that we can iterate through them later attributeNodes.push(attributeNode); cumulativeHeight += nodeHeight + heightPadding * 2; attrNum += 1; }); let widthPaddingFactor = 4; if (hasKeyType) { widthPaddingFactor += 2; } if (hasComment) { widthPaddingFactor += 2; } const maxWidth = maxTypeWidth + maxNameWidth + maxKeyWidth + maxCommentWidth; // Calculate the new bounding box of the overall entity, now that attributes have been added const bBox = { width: Math.max( conf.minEntityWidth, Math.max( labelBBox.width + conf.entityPadding * 2, maxWidth + widthPadding * widthPaddingFactor ) ), height: attributes.length > 0 ? cumulativeHeight : Math.max(conf.minEntityHeight, labelBBox.height + conf.entityPadding * 2), }; if (attributes.length > 0) { // There might be some spare width for padding out attributes if the entity name is very long const spareColumnWidth = Math.max( 0, (bBox.width - maxWidth - widthPadding * widthPaddingFactor) / (widthPaddingFactor / 2) ); // Position the entity label near the top of the entity bounding box entityTextNode.attr( 'transform', 'translate(' + bBox.width / 2 + ',' + (heightPadding + labelBBox.height / 2) + ')' ); // Add rectangular boxes for the attribute types/names let heightOffset = labelBBox.height + heightPadding * 2; // Start at the bottom of the entity label let attribStyle = 'attributeBoxOdd'; // We will flip the style on alternate rows to achieve a banded effect attributeNodes.forEach((attributeNode) => { // Calculate the alignment y co-ordinate for the type/name of the attribute const alignY = heightOffset + heightPadding + attributeNode.height / 2; // Position the type attribute attributeNode.tn.attr('transform', 'translate(' + widthPadding + ',' + alignY + ')'); // TODO Handle spareWidth in attr('width') // Insert a rectangle for the type const typeRect = groupNode .insert('rect', '#' + attributeNode.tn.node().id) .attr('class', `er ${attribStyle}`) .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', 0) .attr('y', heightOffset) .attr('width', maxTypeWidth + widthPadding * 2 + spareColumnWidth) .attr('height', attributeNode.height + heightPadding * 2); const nameXOffset = parseFloat(typeRect.attr('x')) + parseFloat(typeRect.attr('width')); // Position the name attribute attributeNode.nn.attr( 'transform', 'translate(' + (nameXOffset + widthPadding) + ',' + alignY + ')' ); // Insert a rectangle for the name const nameRect = groupNode .insert('rect', '#' + attributeNode.nn.node().id) .attr('class', `er ${attribStyle}`) .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', nameXOffset) .attr('y', heightOffset) .attr('width', maxNameWidth + widthPadding * 2 + spareColumnWidth) .attr('height', attributeNode.height + heightPadding * 2); let keyTypeAndCommentXOffset = parseFloat(nameRect.attr('x')) + parseFloat(nameRect.attr('width')); if (hasKeyType) { // Position the key type attribute attributeNode.kn.attr( 'transform', 'translate(' + (keyTypeAndCommentXOffset + widthPadding) + ',' + alignY + ')' ); // Insert a rectangle for the key type const keyTypeRect = groupNode .insert('rect', '#' + attributeNode.kn.node().id) .attr('class', `er ${attribStyle}`) .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', keyTypeAndCommentXOffset) .attr('y', heightOffset) .attr('width', maxKeyWidth + widthPadding * 2 + spareColumnWidth) .attr('height', attributeNode.height + heightPadding * 2); keyTypeAndCommentXOffset = parseFloat(keyTypeRect.attr('x')) + parseFloat(keyTypeRect.attr('width')); } if (hasComment) { // Position the comment attribute attributeNode.cn.attr( 'transform', 'translate(' + (keyTypeAndCommentXOffset + widthPadding) + ',' + alignY + ')' ); // Insert a rectangle for the comment groupNode .insert('rect', '#' + attributeNode.cn.node().id) .attr('class', `er ${attribStyle}`) .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', keyTypeAndCommentXOffset) .attr('y', heightOffset) .attr('width', maxCommentWidth + widthPadding * 2 + spareColumnWidth) .attr('height', attributeNode.height + heightPadding * 2); } // Increment the height offset to move to the next row heightOffset += attributeNode.height + heightPadding * 2; // Flip the attribute style for row banding attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd'; }); } else { // Ensure the entity box is a decent size without any attributes bBox.height = Math.max(conf.minEntityHeight, cumulativeHeight); // Position the entity label in the middle of the box entityTextNode.attr('transform', 'translate(' + bBox.width / 2 + ',' + bBox.height / 2 + ')'); } return bBox; }; /** * Use D3 to construct the svg elements for the entities * * @param svgNode The svg node that contains the diagram * @param entities The entities to be drawn * @param graph The graph that contains the vertex and edge definitions post-layout * @returns The first entity that was inserted */ const drawEntities = function (svgNode, entities, graph) { const keys = Object.keys(entities); let firstOne; keys.forEach(function (id) { // Create a group for each entity const groupNode = svgNode.append('g').attr('id', id); firstOne = firstOne === undefined ? id : firstOne; // Label the entity - this is done first so that we can get the bounding box // which then determines the size of the rectangle const textId = 'entity-' + id; const textNode = groupNode .append('text') .attr('class', 'er entityLabel') .attr('id', textId) .attr('x', 0) .attr('y', 0) .attr('dominant-baseline', 'middle') .attr('text-anchor', 'middle') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize + 'px' ) .text(id); const { width: entityWidth, height: entityHeight } = drawAttributes( groupNode, textNode, entities[id].attributes ); // Draw the rectangle - insert it before the text so that the text is not obscured const rectNode = groupNode .insert('rect', '#' + textId) .attr('class', 'er entityBox') .attr('fill', conf.fill) .attr('fill-opacity', '100%') .attr('stroke', conf.stroke) .attr('x', 0) .attr('y', 0) .attr('width', entityWidth) .attr('height', entityHeight); const rectBBox = rectNode.node().getBBox(); // Add the entity to the graph graph.setNode(id, { width: rectBBox.width, height: rectBBox.height, shape: 'rect', id: id, }); }); return firstOne; }; // drawEntities const adjustEntities = function (svgNode, graph) { graph.nodes().forEach(function (v) { if (typeof v !== 'undefined' && typeof graph.node(v) !== 'undefined') { svgNode .select('#' + v) .attr( 'transform', 'translate(' + (graph.node(v).x - graph.node(v).width / 2) + ',' + (graph.node(v).y - graph.node(v).height / 2) + ' )' ); } }); return; }; const getEdgeName = function (rel) { return (rel.entityA + rel.roleA + rel.entityB).replace(/\s/g, ''); }; /** * Add each relationship to the graph * * @param relationships The relationships to be added * @param g The graph * @returns {Array} The array of relationships */ const addRelationships = function (relationships, g) { relationships.forEach(function (r) { g.setEdge(r.entityA, r.entityB, { relationship: r }, getEdgeName(r)); }); return relationships; }; // addRelationships let relCnt = 0; /** * Draw a relationship using edge information from the graph * * @param svg The svg node * @param rel The relationship to draw in the svg * @param g The graph containing the edge information * @param insert The insertion point in the svg DOM (because relationships have markers that need to * sit 'behind' opaque entity boxes) * @param diagObj */ const drawRelationshipFromLayout = function (svg, rel, g, insert, diagObj) { relCnt++; // Find the edge relating to this relationship const edge = g.edge(rel.entityA, rel.entityB, getEdgeName(rel)); // Get a function that will generate the line path const lineFunction = line() .x(function (d) { return d.x; }) .y(function (d) { return d.y; }) .curve(curveBasis); // Insert the line at the right place const svgPath = svg .insert('path', '#' + insert) .attr('class', 'er relationshipLine') .attr('d', lineFunction(edge.points)) .attr('stroke', conf.stroke) .attr('fill', 'none'); // ...and with dashes if necessary if (rel.relSpec.relType === diagObj.db.Identification.NON_IDENTIFYING) { svgPath.attr('stroke-dasharray', '8,8'); } // TODO: Understand this better let url = ''; if (conf.arrowMarkerAbsolute) { url = window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search; url = url.replace(/\(/g, '\\('); url = url.replace(/\)/g, '\\)'); } // Decide which start and end markers it needs. It may be possible to be more concise here // by reversing a start marker to make an end marker...but this will do for now // Note that the 'A' entity's marker is at the end of the relationship and the 'B' entity's marker is at the start switch (rel.relSpec.cardA) { case diagObj.db.Cardinality.ZERO_OR_ONE: svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')'); break; case diagObj.db.Cardinality.ZERO_OR_MORE: svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')'); break; case diagObj.db.Cardinality.ONE_OR_MORE: svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')'); break; case diagObj.db.Cardinality.ONLY_ONE: svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')'); break; } switch (rel.relSpec.cardB) { case diagObj.db.Cardinality.ZERO_OR_ONE: svgPath.attr( 'marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')' ); break; case diagObj.db.Cardinality.ZERO_OR_MORE: svgPath.attr( 'marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')' ); break; case diagObj.db.Cardinality.ONE_OR_MORE: svgPath.attr( 'marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')' ); break; case diagObj.db.Cardinality.ONLY_ONE: svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')'); break; } // Now label the relationship // Find the half-way point const len = svgPath.node().getTotalLength(); const labelPoint = svgPath.node().getPointAtLength(len * 0.5); // Append a text node containing the label const labelId = 'rel' + relCnt; const labelNode = svg .append('text') .attr('class', 'er relationshipLabel') .attr('id', labelId) .attr('x', labelPoint.x) .attr('y', labelPoint.y) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') .attr( 'style', 'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize + 'px' ) .text(rel.roleA); // Figure out how big the opaque 'container' rectangle needs to be const labelBBox = labelNode.node().getBBox(); // Insert the opaque rectangle before the text label svg .insert('rect', '#' + labelId) .attr('class', 'er relationshipLabelBox') .attr('x', labelPoint.x - labelBBox.width / 2) .attr('y', labelPoint.y - labelBBox.height / 2) .attr('width', labelBBox.width) .attr('height', labelBBox.height) .attr('fill', 'white') .attr('fill-opacity', '85%'); return; }; /** * Draw en E-R diagram in the tag with id: id based on the text definition of the diagram * * @param text The text of the diagram * @param id The unique id of the DOM node that contains the diagram * @param _version * @param diag * @param diagObj */ export const draw = function (text, id, _version, diagObj) { conf = getConfig().er; log.info('Drawing ER diagram'); // diag.db.clear(); const securityLevel = getConfig().securityLevel; // Handle root and Document for when rendering in sanbox mode let sandboxElement; if (securityLevel === 'sandbox') { sandboxElement = select('#i' + id); } const root = securityLevel === 'sandbox' ? select(sandboxElement.nodes()[0].contentDocument.body) : select('body'); // const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document; // Parse the text to populate erDb // try { // parser.parse(text); // } catch (err) { // log.debug('Parsing failed'); // } // Get a reference to the svg node that contains the text const svg = root.select(`[id='${id}']`); // Add cardinality marker definitions to the svg erMarkers.insertMarkers(svg, conf); // Now we have to construct the diagram in a specific way: // --- // 1. Create all the entities in the svg node at 0,0, but with the correct dimensions (allowing for text content) // 2. Make sure they are all added to the graph // 3. Add all the edges (relationships) to the graph as well // 4. Let dagre do its magic to layout the graph. This assigns: // - the centre co-ordinates for each node, bearing in mind the dimensions and edge relationships // - the path co-ordinates for each edge // But it has no impact on the svg child nodes - the diagram remains with every entity rooted at 0,0 // 5. Now assign a transform to each entity in the svg node so that it gets drawn in the correct place, as determined by // its centre point, which is obtained from the graph, and it's width and height // 6. And finally, create all the edges in the svg node using information from the graph // --- // Create the graph let g; // TODO: Explore directed vs undirected graphs, and how the layout is affected // An E-R diagram could be said to be undirected, but there is merit in setting // the direction from parent to child in a one-to-many as this influences graphlib to // put the parent above the child (does it?), which is intuitive. Most relationships // in ER diagrams are one-to-many. g = new graphlib.Graph({ multigraph: true, directed: true, compound: false, }) .setGraph({ rankdir: conf.layoutDirection, marginx: 20, marginy: 20, nodesep: 100, edgesep: 100, ranksep: 100, }) .setDefaultEdgeLabel(function () { return {}; }); // Draw the entities (at 0,0), returning the first svg node that got // inserted - this represents the insertion point for relationship paths const firstEntity = drawEntities(svg, diagObj.db.getEntities(), g); // TODO: externalise the addition of entities to the graph - it's a bit 'buried' in the above // Add all the relationships to the graph const relationships = addRelationships(diagObj.db.getRelationships(), g); dagre.layout(g); // Node and edge positions will be updated // Adjust the positions of the entities so that they adhere to the layout adjustEntities(svg, g); // Draw the relationships relationships.forEach(function (rel) { drawRelationshipFromLayout(svg, rel, g, firstEntity, diagObj); }); const padding = conf.diagramPadding; const svgBounds = svg.node().getBBox(); const width = svgBounds.width + padding * 2; const height = svgBounds.height + padding * 2; configureSvgSize(svg, height, width, conf.useMaxWidth); svg.attr('viewBox', `${svgBounds.x - padding} ${svgBounds.y - padding} ${width} ${height}`); addSVGAccessibilityFields(diagObj.db, svg, id); }; // draw export default { setConf, draw, };