Use markers with rounded crows feet

This commit is contained in:
Adrian Hall 2020-03-10 13:48:53 +00:00
parent 29b6e00071
commit bab4649a1e
3 changed files with 69 additions and 388 deletions

View File

@ -1,21 +1,16 @@
//import * as d3 from 'd3';
const ERMarkers = {
ONLY_ONE_START: 'ONLY_ONE_START',
ONLY_ONE_END: 'ONLY_ONE_END',
ZERO_OR_ONE_START: 'ZERO_OR_ONE_START',
ZERO_OR_ONE_END: 'ZERO_OR_ONE_END',
ONE_OR_MORE_START: 'ONE_OR_MORE_START',
ONE_OR_MORE_END: 'ONE_OR_MORE_END',
ZERO_OR_MORE_START: 'ZERO_OR_MORE_START',
ZERO_OR_MORE_END: 'ZERO_OR_MORE_END'
};
/**
* Put the markers into the svg DOM for use in paths
* Put the markers into the svg DOM for later use with edge paths
*/
const insertMarkers = function(elem, conf) {
let marker;
@ -96,73 +91,73 @@ const insertMarkers = function(elem, conf) {
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONE_OR_MORE_START)
.attr('refX', 0)
.attr('refY', 9)
.attr('markerWidth', 18)
.attr('markerHeight', 18)
.attr('refX', 18)
.attr('refY', 18)
.attr('markerWidth', 45)
.attr('markerHeight', 36)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M0,0 L9,9 L0,18 M15,0 L15,18');
.attr('d', 'M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27');
elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONE_OR_MORE_END)
.attr('refX', 18)
.attr('refY', 9)
.attr('markerWidth', 21)
.attr('markerHeight', 18)
.attr('refX', 27)
.attr('refY', 18)
.attr('markerWidth', 45)
.attr('markerHeight', 36)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M3,0 L3,18 M18,0 L9,9 L18,18');
.attr('d', 'M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_MORE_START)
.attr('refX', 0)
.attr('refY', 9)
.attr('markerWidth', 30)
.attr('markerHeight', 18)
.attr('refX', 18)
.attr('refY', 18)
.attr('markerWidth', 57)
.attr('markerHeight', 36)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 21)
.attr('cy', 9)
.attr('cx', 48)
.attr('cy', 18)
.attr('r', 6);
marker
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M0,0 L9,9 L0,18');
.attr('d', 'M0,18 Q18,0 36,18 Q18,36 0,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_MORE_END)
.attr('refX', 30)
.attr('refY', 9)
.attr('markerWidth', 30)
.attr('markerHeight', 18)
.attr('refX', 39)
.attr('refY', 18)
.attr('markerWidth', 57)
.attr('markerHeight', 36)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 9)
.attr('cy', 9)
.attr('cy', 18)
.attr('r', 6);
marker
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M30,0 L21,9 L30,18');
.attr('d', 'M21,18 Q39,0 57,18 Q39,36 21,18');
return;
};

View File

@ -16,9 +16,10 @@ export const setConf = function(cnf) {
};
/**
* Function that adds the entities as vertices
* Function that adds the entities as vertices in the graph prior to laying out
* @param entities The entities to be added to the graph
* @param g The graph that is to be drawn
* @returns {Object} The object containing all the entities as properties
*/
const addEntities = function(entities, g) {
const keys = Object.keys(entities);
@ -36,6 +37,7 @@ const addEntities = function(entities, g) {
id: entity
});
});
return entities;
};
/**
@ -92,26 +94,38 @@ const drawEntities = function(diagram, entities, g, svgId) {
});
}; // drawEntities
/**
* Add each relationship to the graph
* @param relationships the relationships to be added
* @param g the graph
* @return {Array} The array of relationships
*/
const addRelationships = function(relationships, g) {
relationships.forEach(function(r) {
g.setEdge(r.entityA, r.entityB, { relationship: r });
});
return relationships;
}; // addRelationships
/**
*
*/
const drawRelationships = function(diagram, relationships, g) {
relationships.forEach(function(rel) {
//drawRelationship(diagram, rel, g);
drawRelationshipFromLayout(diagram, rel, g);
});
}; // drawRelationships
/**
* Draw a relationship using edge information from the graph
* @param diagram the svg node
* @param rel the relationship to draw in the svg
* @param g the graph containing the edge information
*/
const drawRelationshipFromLayout = function(diagram, rel, g) {
// Find the edge relating to this relationship
const edge = g.edge({ v: rel.entityA, w: rel.entityB });
// Using it's points, generate a line function
edge.points = edge.points.filter(p => !Number.isNaN(p.y)); // TODO: why is necessary?
// Get a function that will generate the line path
const lineFunction = d3
.line()
@ -130,7 +144,7 @@ const drawRelationshipFromLayout = function(diagram, rel, g) {
.attr('stroke', conf.stroke)
.attr('fill', 'none');
// TODO: Understand this
// TODO: Understand this better
let url = '';
if (conf.arrowMarkerAbsolute) {
url =
@ -143,8 +157,8 @@ const drawRelationshipFromLayout = function(diagram, rel, g) {
url = url.replace(/\)/g, '\\)');
}
// TODO: change the way enums are imported
// Decide which start and end markers it needs
// 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
switch (rel.cardinality) {
case erDb.Cardinality.ONLY_ONE_TO_ONE_OR_MORE:
svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')');
@ -249,285 +263,10 @@ const drawRelationshipFromLayout = function(diagram, rel, g) {
}
};
/*
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) {
const topToe = function(point) {
return {
x: point.x + toeXDelta,
y: point.y - toeYDelta
};
};
const bottomToe = function(point) {
return {
x: point.x - toeXDelta,
y: point.y + toeYDelta
};
};
const lower = {
top: fromPoint.x < toPoint.x ? topToe(toPoint) : topToe(fromPoint),
bottom: fromPoint.x < toPoint.x ? bottomToe(toPoint) : bottomToe(fromPoint)
};
const upper = {
top: fromPoint.x < toPoint.x ? topToe(fromPoint) : topToe(toPoint),
bottom: fromPoint.x < toPoint.x ? bottomToe(fromPoint) : bottomToe(toPoint)
};
return {
to: fromPoint.x < toPoint.x ? lower : upper,
from: fromPoint.x < toPoint.x ? upper : lower
};
}
*/
/*
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 }
};
};
*/
/*
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
* 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
*/
export const draw = function(text, id) {
logger.info('Drawing ER diagram');
@ -543,25 +282,26 @@ export const draw = function(text, id) {
}
// Get a reference to the diagram node
const diagram = d3.select(`[id='${id}']`);
const svg = d3.select(`[id='${id}']`);
// Add cardinality 'marker' definitions to the svg
erMarkers.insertMarkers(diagram, conf);
// Add cardinality marker definitions to the svg
erMarkers.insertMarkers(svg, conf);
// 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
// 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: 'TB',
rankdir: 'LR',
marginx: 20,
marginy: 20,
nodesep: 100,
@ -571,31 +311,18 @@ export const draw = function(text, id) {
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}"]`);
// Add the entities and relationships to the graph
const entities = addEntities(erDb.getEntities(), g);
const relationships = addRelationships(erDb.getRelationships(), g);
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);
// Draw the relationships first because their markers need to be
// clipped by the entity boxes
drawRelationships(svg, relationships, g);
drawEntities(svg, entities, g, id);
//drawFeet(diagram, relationships, g);
drawRelationships(diagram, relationships, g);
drawEntities(diagram, entities, g, id);
const padding = 8;
const padding = 8; // TODO: move this to config
const svgBounds = svg.node().getBBox();
const width = svgBounds.width + padding * 4;

View File

@ -369,7 +369,7 @@ const config = {
/**
* Stroke color of box edges and lines
*/
stroke: 'purple',
stroke: 'gray',
/**
* Fill color of entity boxes
@ -377,52 +377,11 @@ const config = {
fill: 'honeydew',
/**
* Distance of the 'ankle' from the intersection point
* Opacity of entity boxes - if you want to see how the crows feet
* retain their elegant joins to the boxes regardless of the angle of incidence
* then override this to something less than 100%
*/
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'
fillOpacity: '100%'
}
};