diff --git a/src/diagrams/er/erRenderer.js b/src/diagrams/er/erRenderer.js new file mode 100644 index 000000000..1a6368e1d --- /dev/null +++ b/src/diagrams/er/erRenderer.js @@ -0,0 +1,478 @@ +import graphlib from 'graphlib'; +import * as d3 from 'd3'; +import erDb from './erDb'; +import erParser from './parser/erDiagram'; +import dagre from 'dagre'; +import { getConfig } from '../../config'; +import { logger } from '../../logger'; + +const conf = {}; +export const setConf = function(cnf) { + const keys = Object.keys(cnf); + for (let i = 0; i < keys.length; i++) { + conf[keys[i]] = cnf[keys[i]]; + } +}; + +/** + * Function that adds the entities as vertices + * @param entities The entities to be added to the graph + * @param g The graph that is to be drawn + */ +const addEntities = function(entities, g) { + const keys = Object.keys(entities); + //const fontFamily = getConfig().fontFamily; + + keys.forEach(function(id) { + const entity = entities[id]; + + const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + + // Add the text content (the entity id) + /* + const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); + tspan.setAttribute('dy', '1em'); + tspan.setAttribute('x', '1'); + tspan.textContent = id; + tspan.setAttribute('style', 'font-family: monospace'); + svgLabel.appendChild(tspan); + */ + g.setNode(entity, { + labelType: 'svg', + width: 100, + height: 75, + //labelStyle: labelStyle, + shape: 'rect', + label: svgLabel, + id: entity + }); + }); +}; + +/** + * Use D3 to construct the svg elements for the entities + * @param diagram the svg node that contains the diagram + * @param entities the entities to be drawn + * @param g the dagre graph that contains the vertex and edge definitions post-layout + */ +const drawEntities = function(diagram, entities, g) { + // For each vertex in the graph: + // - append the text label centred in the right place + // - get it's bounding box and calculate the size of the enclosing rectangle + // - insert the enclosing rectangle + + g.nodes().forEach(function(v) { + console.debug('Handling node ', v); + + // Get the centre co-ordinate of the node so that we can centre the entity name + let centre = { x: g.node(v).x, y: g.node(v).y }; + + // Label the entity - this is done first so that we can get the bounding box + // which then determines the size of the rectangle + let textNode = diagram + .append('text') + .attr('x', centre.x) + .attr('y', centre.y) + .attr('dominant-baseline', 'middle') + .attr('text-anchor', 'middle') + .attr('style', 'font-family: ' + getConfig().fontFamily) + .text(v); + + let textBBox = textNode.node().getBBox(); + let entityWidth = Math.max(conf.minEntityWidth, textBBox.width + conf.entityPadding * 2); + let entityHeight = Math.max(conf.minEntityHeight, textBBox.height + conf.entityPadding * 2); + + // Add info to the node so that we can retrieve it later when drawing relationships + g.node(v).width = entityWidth; + g.node(v).height = entityHeight; + + // Draw the rectangle + let rectX = centre.x - entityWidth / 2; + let rectY = centre.y - entityHeight / 2; + diagram + .insert('rect') + .attr('fill', conf.fill) + .attr('fill-opacity', conf.fillOpacity) + .attr('stroke', conf.stroke) + .attr('x', rectX) + .attr('y', rectY) + .attr('width', entityWidth) + .attr('height', entityHeight); + + // TODO: Revisit + // Bit of a hack - we're adding the text AGAIN because + // the rectangle is filled to obscure the lines that go to the centre! + diagram + .append('text') + .attr('x', centre.x) + .attr('y', centre.y) + .attr('dominant-baseline', 'middle') + .attr('text-anchor', 'middle') + .attr('style', 'font-family: ' + getConfig().fontFamily) + .text(v); + }); +}; // drawEntities + +const addRelationships = function(relationships, g) { + relationships.forEach(function(r) { + g.setEdge(r.entityA, r.entityB, { relationship: r }); + }); +}; + +const drawRelationships = function(diagram, relationships, g) { + relationships.forEach(function(rel) { + drawRelationship(diagram, rel, g); + }); +}; // drawRelationships + +const drawRelationship = function(diagram, relationship, g) { + // Set the from and to co-ordinates using the graph vertices + + let from = { + x: g.node(relationship.entityA).x, + y: g.node(relationship.entityA).y + }; + + let to = { + x: g.node(relationship.entityB).x, + y: g.node(relationship.entityB).y + }; + + diagram + .append('line') + .attr('x1', from.x) + .attr('y1', from.y) + .attr('x2', to.x) + .attr('y2', to.y) + .attr('stroke', conf.stroke); +}; // drawRelationship + +const drawFeet = function(diagram, relationships, g) { + relationships.forEach(function(rel) { + // Get the points of intersection with the entities + const nodeA = g.node(rel.entityA); + const nodeB = g.node(rel.entityB); + + const fromIntersect = getIntersection( + nodeB.x - nodeA.x, + nodeB.y - nodeA.y, + nodeA.x, + nodeA.y, + nodeA.width / 2, + nodeA.height / 2 + ); + + dot(diagram, fromIntersect, conf.intersectColor); + + const toIntersect = getIntersection( + nodeA.x - nodeB.x, + nodeA.y - nodeB.y, + nodeB.x, + nodeB.y, + nodeB.width / 1, + nodeB.height / 2 + ); + + dot(diagram, toIntersect, conf.intersectColor); + + // Get the ankle and heel points + const anklePoints = getJoints(rel, fromIntersect, toIntersect, conf.ankleDistance); + + dot(diagram, { x: anklePoints.from.x, y: anklePoints.from.y }, conf.ankleColor); + dot(diagram, { x: anklePoints.to.x, y: anklePoints.to.y }, conf.ankleColor); + + const heelPoints = getJoints(rel, fromIntersect, toIntersect, conf.heelDistance); + + dot(diagram, { x: heelPoints.from.x, y: heelPoints.from.y }, conf.heelColor); + dot(diagram, { x: heelPoints.to.x, y: heelPoints.to.y }, conf.heelColor); + + // Get the toe points + const toePoints = getToes(rel, fromIntersect, toIntersect, conf.toeDistance); + + if (toePoints) { + dot(diagram, { x: toePoints.from.top.x, y: toePoints.from.top.y }, conf.toeColor); + dot(diagram, { x: toePoints.from.bottom.x, y: toePoints.from.bottom.y }, conf.toeColor); + dot(diagram, { x: toePoints.to.top.x, y: toePoints.to.top.y }, conf.toeColor); + dot(diagram, { x: toePoints.to.bottom.x, y: toePoints.to.bottom.y }, conf.toeColor); + + let paths = []; + paths.push(getToePath(heelPoints.from, toePoints.from.top, nodeA)); + paths.push(getToePath(heelPoints.from, toePoints.from.bottom, nodeA)); + paths.push(getToePath(heelPoints.to, toePoints.to.top, nodeB)); + paths.push(getToePath(heelPoints.to, toePoints.to.bottom, nodeB)); + + for (const path of paths) { + diagram + .append('path') + .attr('d', path) + .attr('stroke', conf.stroke) + .attr('fill', 'none'); + } + } + }); +}; // drawFeet + +const getToePath = function(heel, toe, tip) { + if (conf.toeStyle === 'straight') { + return `M ${heel.x} ${heel.y} L ${toe.x} ${toe.y} L ${tip.x} ${tip.y}`; + } else { + return `M ${heel.x} ${heel.y} Q ${toe.x} ${toe.y} ${tip.x} ${tip.y}`; + } +}; + +const getToes = function(relationship, fromPoint, toPoint, distance) { + if (conf.toeStyle === 'curved') { + distance *= 2; + } + + const gradient = (fromPoint.y - toPoint.y) / (fromPoint.x - toPoint.x); + const toeYDelta = getXDelta(distance, gradient); + const toeXDelta = toeYDelta * Math.abs(gradient); + + if (gradient > 0) { + if (fromPoint.x < toPoint.x) { + // Scenario A + + return { + to: { + top: { + x: toPoint.x + toeXDelta, + y: toPoint.y - toeYDelta + }, + bottom: { + x: toPoint.x - toeXDelta, + y: toPoint.y + toeYDelta + } + }, + from: { + top: { + x: fromPoint.x + toeXDelta, + y: fromPoint.y - toeYDelta + }, + bottom: { + x: fromPoint.x - toeXDelta, + y: fromPoint.y + toeYDelta + } + } + }; + } else { + // Scenario E + } + } +}; // getToes + +const getJoints = function(relationship, fromPoint, toPoint, distance) { + const gradient = (fromPoint.y - toPoint.y) / (fromPoint.x - toPoint.x); + let jointXDelta = getXDelta(distance, gradient); + let jointYDelta = jointXDelta * Math.abs(gradient); + + let toX, toY; + let fromX, fromY; + + if (gradient > 0) { + if (fromPoint.x < toPoint.x) { + // Scenario A + } else { + // Scenario E + jointXDelta *= -1; + jointYDelta *= -1; + } + + toX = toPoint.x - jointXDelta; + toY = toPoint.y - jointYDelta; + fromX = fromPoint.x + jointXDelta; + fromY = fromPoint.y + jointYDelta; + } + + if (gradient < 0) { + if (fromPoint.x < toPoint.x) { + // Scenario C + jointXDelta *= -1; + jointYDelta *= -1; + } else { + // Scenario G + } + + toX = toPoint.x + jointXDelta; + toY = toPoint.y - jointYDelta; + fromX = fromPoint.x - jointXDelta; + fromY = fromPoint.y + jointYDelta; + } + + if (!isFinite(gradient)) { + if (fromPoint.y < toPoint.y) { + // Scenario B + } else { + // Scenario F + jointXDelta *= -1; + jointYDelta *= -1; + } + + toX = toPoint.x; + toY = toPoint.y - distance; + fromX = fromPoint.x; + fromY = fromPoint.y + distance; + } + + if (gradient === 0) { + if (fromPoint.x < toPoint.x) { + // Scenario D + } else { + // Scenario H + jointXDelta *= -1; + jointYDelta *= -1; + } + + toX = toPoint.x - distance; + toY = toPoint.y; + fromX = fromPoint.x + distance; + fromY = fromPoint.y; + } + + return { + from: { x: fromX, y: fromY }, + to: { x: toX, y: toY } + }; +}; + +// Calculate point pXDelta w.r.t. an intersect point + +// Calcualate point pYDelta w.r.t. an intersect point + +// Calculate point qXDelta w.r.t. an intersect point + +// Calculate point qYDelta w.r.t. an intersect point + +// Now draw from the heel to point P then to the centre of the target entity + +// Now do the same again using point Q instead of P + +// Now draw the ankle + +const getXDelta = function(hypotenuse, gradient) { + return Math.sqrt((hypotenuse * hypotenuse) / (Math.abs(gradient) + 1)); +}; + +const getIntersection = function(dx, dy, cx, cy, w, h) { + if (Math.abs(dy / dx) < h / w) { + // Hit vertical edge of box + return { x: cx + (dx > 0 ? w : -w), y: cy + (dy * w) / Math.abs(dx) }; + } else { + // Hit horizontal edge of box + return { x: cx + (dx * h) / Math.abs(dy), y: cy + (dy > 0 ? h : -h) }; + } +}; // getIntersection + +const dot = function(diagram, p, color) { + // stick a small circle at point p + if (conf.dots) { + diagram + .append('circle') + .attr('cx', p.x) + .attr('cy', p.y) + .attr('r', conf.dotRadius) + .attr('fill', color); + } +}; // dot + +/** + * Draw en E-R diagram in the tag with id: id based on the text definition of the graph + * @param text + * @param id + */ +export const draw = function(text, id) { + logger.info('Drawing ER diagram'); + erDb.clear(); + const parser = erParser.parser; + parser.yy = erDb; + + // Parse the text to populate erDb + try { + parser.parse(text); + } catch (err) { + logger.debug('Parsing failed'); + } + + // Get a reference to the diagram node + const diagram = d3.select(`[id='${id}']`); + + // Add cardinality 'marker' definitions to the svg + //insertMarkers(diagram); + + // 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 (1 to many) as this influences graphlib to + // put the parent above the child, which is intuitive + g = new graphlib.Graph({ + multigraph: true, + directed: true, + compound: false + }) + .setGraph({ + rankdir: 'TB', + marginx: 20, + marginy: 20, + nodesep: 100, + ranksep: 100 + }) + .setDefaultEdgeLabel(function() { + return {}; + }); + + // Fetch the entities (which will become vertices) + const entities = erDb.getEntities(); + + // Add all the entities to the graph + addEntities(entities, g); + + const relationships = erDb.getRelationships(); + // Add all the relationships as edges on the graph + addRelationships(relationships, g); + + // Set up an SVG group so that we can translate the final graph. + // TODO: This is redundant -just use diagram from above + const svg = d3.select(`[id="${id}"]`); + + dagre.layout(g); // Node and edge positions will be updated + + // Run the renderer. This is what draws the final graph. + //const element = d3.select('#' + id + ' g'); + //render(element, g); + + drawRelationships(diagram, relationships, g); + drawFeet(diagram, relationships, g); + drawEntities(diagram, entities, g); + + const padding = 8; + + const svgBounds = svg.node().getBBox(); + const width = svgBounds.width + padding * 4; + const height = svgBounds.height + padding * 4; + logger.debug( + `new ViewBox 0 0 ${width} ${height}`, + `translate(${padding - g._label.marginx}, ${padding - g._label.marginy})` + ); + + if (conf.useMaxWidth) { + svg.attr('width', '100%'); + svg.attr('style', `max-width: ${width}px;`); + } else { + svg.attr('height', height); + svg.attr('width', width); + } + + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg + .select('g') + .attr('transform', `translate(${padding - g._label.marginx}, ${padding - svgBounds.y})`); +}; // draw + +export default { + setConf, + //addEntities, + draw +}; diff --git a/src/mermaidAPI.js b/src/mermaidAPI.js index 205254540..95dc56e70 100644 --- a/src/mermaidAPI.js +++ b/src/mermaidAPI.js @@ -40,6 +40,9 @@ import infoDb from './diagrams/info/infoDb'; import pieRenderer from './diagrams/pie/pieRenderer'; import pieParser from './diagrams/pie/parser/pie'; import pieDb from './diagrams/pie/pieDb'; +import erDb from './diagrams/er/erDb'; +import erParser from './diagrams/er/parser/erDiagram'; +import erRenderer from './diagrams/er/erRenderer'; const themes = {}; for (const themeName of ['default', 'forest', 'dark', 'neutral']) { @@ -342,6 +345,84 @@ const config = { edgeLengthFactor: '20', compositTitleSize: 35, radius: 5 + }, + + /** + * The object containing configurations specific for entity relationship diagrams + */ + er: { + /** + * The mimimum width of an entity box + */ + minEntityWidth: 100, + + /** + * The minimum height of an entity box + */ + minEntityHeight: 75, + + /** + * The minimum internal padding between the text in an entity box and the enclosing box borders + */ + entityPadding: 15, + + /** + * Stroke color of box edges and lines + */ + stroke: 'purple', + + /** + * Fill color of entity boxes + */ + fill: 'honeydew', + + /** + * Distance of the 'ankle' from the intersection point + */ + ankleDistance: 35, + + /** + * Distance of the 'heel' from the intersection point + */ + heelDistance: 20, + + /** + * Distance of the side 'toes' perpendicular to the intersection point + */ + toeDistance: 12, + + /** + * The style of the toes on the crow's foot: either 'curved' or 'straight' + */ + toeStyle: 'curved', + + /** + * THE REMAINING CONFIG OPTIONS FOR 'er' DIAGRAMS ARE EXPERIMENTAL AND ARE USEFUL + * DURING DEVELOPMENT BUT WILL PROBABLY BE REMOVED BEFORE E-R DIAGRAMS ARE PRODUCTIONIZED. + * THEY ARE HELPFUL IN DIAGNOSING POSITIONAL AND LAYOUT-RELATED ISSUES; THEY WOULDN'T + * LOOK GOOD ON REAL DIAGRAMS + */ + + // Opacity of entity boxes - helpful when < 100% to see lines 'behind' the box + fillOpacity: '100%', + + // Whether to show dots at important points in the diagram geometry + dots: false, + + // Radius of dots + dotRadius: 1.5, + + // Color of intersection point dots + intersectColor: 'green', + + // Color of 'ankle' dots + ankleColor: 'red', + + // Color of 'heel' dots + heelColor: 'blue', + + // Color of 'toe' dots + toeColor: 'darkorchid' } }; @@ -389,6 +470,11 @@ function parse(text) { parser = pieParser; parser.parser.yy = pieDb; break; + case 'er': + logger.debug('er'); + parser = erParser; + parser.parser.yy = erDb; + break; } parser.parser.yy.parseError = (str, hash) => { @@ -606,6 +692,10 @@ const render = function(id, _txt, cb, container) { pieRenderer.setConf(config.class); pieRenderer.draw(txt, id, pkg.version); break; + case 'er': + erRenderer.setConf(config.er); + erRenderer.draw(txt, id, pkg.version); + break; } d3.select(`[id="${id}"]`) diff --git a/src/utils.js b/src/utils.js index 1aec62d4f..055bb867a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -49,6 +49,10 @@ export const detectType = function(text) { return 'pie'; } + if (text.match(/^\s*erDiagram/)) { + return 'er'; + } + return 'flowchart'; };