Basic rendering for ER diagrams
This commit is contained in:
parent
6985391437
commit
1e2d014ac9
|
@ -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
|
||||
};
|
|
@ -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}"]`)
|
||||
|
|
|
@ -49,6 +49,10 @@ export const detectType = function(text) {
|
|||
return 'pie';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*erDiagram/)) {
|
||||
return 'er';
|
||||
}
|
||||
|
||||
return 'flowchart';
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue