inheritance-diagram
Version:
Build an inheritance diagram for a class
481 lines (395 loc) • 13.2 kB
HTML
<html>
<head>
<style>html,body {height:100%; margin:0}</style>
</head>
<body>
<script>
/*
* Copyright (c) 2016-2020 Valerii Zinchenko
* Licensed under MIT (https://gitlab.com/valerii-zinchenko/inheritance-diagram/blob/master/LICENSE.txt)
* All source files are available at: https://gitlab.com/valerii-zinchenko/inheritance-diagram
*/
const tmp = document.createElement('div');
function escape(text) {
tmp.innerText = text;
return tmp.innerHTML;
}
function createSVGElement(nodeName, attributes) {
const el = document.createElementNS('http://www.w3.org/2000/svg', nodeName);
setAttributes(el, attributes);
return el;
}
function setAttributes(el, attributes) {
for (let key in attributes) {
el.setAttribute(key, attributes[key]);
}
}
function createNodeElement(g, node, renderingProperties, align = 'center') {
let domGroup = node.element;
if (domGroup) {
// reposition
g.append(domGroup);
return node.element;
}
const {padding} = renderingProperties.node;
let nodeClass = node.type;
// App link element if possible and make that element as the main container of rectangle and text
// --------------------------------------------------
if (node.type !== 'noi') {
if (node.data.link) {
domGroup = createSVGElement('a', {
'xlink:href': escape(node.data.link)
});
} else {
nodeClass += ' no-ref';
}
}
if (!domGroup) {
domGroup = createSVGElement('g');
}
g.append(domGroup);
setAttributes(domGroup, {
'class': nodeClass
});
// --------------------------------------------------
// Measure the text height
// --------------------------------------------------
const tmpText = createSVGElement('text');
// This character set is used just to calculate the height of a node to keep all rectangles with the same height
tmpText.innerHTML = '0123456789QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm-=!@#$%^&*(),./<>;\':"[]\\{}|`~';
domGroup.append(tmpText);
const textHeight = tmpText.getBoundingClientRect().height;
const boxHeight = textHeight + 2*padding[1];
tmpText.remove();
// --------------------------------------------------
// Add text (node name) into the main container
const text = createSVGElement('text', {
y: boxHeight / 2 + textHeight / 3,
'text-anchor': 'middle'
});
text.innerHTML = escape(node.name);
// adding text into the DOM to render and calculate the width
domGroup.append(text);
const textCR = text.getBoundingClientRect();
const width = 2*padding[0] + textCR.width;
setAttributes(text, {
x: width/2
});
// Create rectangle element in the main container
const domBorder = createSVGElement('rect', {
height: boxHeight,
width
});
domGroup.append(domBorder);
// move text on top of the rectangle
domGroup.append(text);
switch (align) {
case 'right':
domGroup.setAttribute('transform', `translate(${-width}, 0)`);
break;
case 'center':
domGroup.setAttribute('transform', `translate(${-width/2}, 0)`);
break;
// No processing for left aligned is added, because if will have "translate(0, 0)"
}
node.element = domGroup;
return domGroup;
}
function createNodeCluster(g, node, renderingProperties, extraYOffset = 0) {
const yMargin = renderingProperties.node.margin[0];
createNodeElement(g, node, renderingProperties);
const nodeHeight = node.element.getBoundingClientRect().height;
if (node.children.length > 0) {
const container = createSVGElement('g');
g.append(container);
createChildrenNodes(container, node.children, renderingProperties);
const firstRect = node.children[0].element.getBoundingClientRect();
const lastRect = node.children[node.children.length-1].element.getBoundingClientRect();
const start = firstRect.x + firstRect.width/2;
const end = (lastRect.x + lastRect.width/2 - start) / 2;
const yOffset = nodeHeight + yMargin + extraYOffset;
setAttributes(container, {
transform: `translate(${-(start + end)}, ${yOffset})`
});
g.prepend(createSVGElement('polyline', {
'class': 'inherit',
'marker-end': 'url(#inheritance)',
points: `0,${yOffset - yMargin / 3} 0,${node.element.getBoundingClientRect().height}`
}));
}
// reposition
createNodeElement(g, node, renderingProperties);
return g;
}
function createChildrenNodes(container, children, renderingProperties) {
children.forEach(child => {
const g = createSVGElement('g');
container.append(g);
createNodeCluster(g, child, renderingProperties);
});
const lineContainer = createSVGElement('g');
let child = container.firstChild;
const type = 'inherit';
const {margin} = renderingProperties.node;
let x = child.getBoundingClientRect().x;
const xPositions = [];
while (child) {
const cr = child.getBoundingClientRect();
const xPos = x - cr.x;
xPositions.push(xPos);
setAttributes(child, {
transform: `translate(${xPos}, 0)`
});
x += cr.width + margin[1];
child = child.nextSibling;
}
// Remove the first X position because it is always 0.
xPositions.shift();
const lineBusOffset = margin[0] / 3;
if (xPositions.length === 0) {
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,0 0,${-lineBusOffset}`
}));
} else {
// Remove the last X position to build a common connection line between the first and the last children and use the remaining children X positions to draw a simple connection to the common line
const endX = xPositions.pop();
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,0 0,${-lineBusOffset} ${endX},${-lineBusOffset} ${endX},0`
}));
xPositions.forEach(x => {
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,0 0,${-lineBusOffset}`,
transform: `translate(${x}, 0)`
}));
});
}
container.append(lineContainer);
}
function createParentCluster(container, noi, renderingProperties, xOffset) {
const {margin} = renderingProperties.node;
noi.parentStack.forEach((node, index) => {
const g = createSVGElement('g');
container.prepend(g);
createNodeElement(g, node, renderingProperties);
if (node.implements.length > 0) {
createInterfaces(g, node, renderingProperties, xOffset);
}
if (node.mixes.length > 0) {
createMixins(g, node, renderingProperties, xOffset);
}
const nodeHeight = g.getBoundingClientRect().height;
const offset = nodeHeight + margin[0];
g.prepend(createSVGElement('polyline', {
'class': 'inherit',
'marker-end': 'url(#inheritance)',
points: `0,${offset} 0,${node.element.getBoundingClientRect().height}`
}));
setAttributes(g, {
transform: `translate(0, ${-offset})`
});
container = g;
});
}
function createInterfaces(g, node, renderingProperties, xOffset) {
const type = 'interface';
const {margin} = renderingProperties.node;
const nodeHeight = node.element.getBoundingClientRect().height;
const nodeYSpacing = margin[0]/2;
const dy = nodeHeight + nodeYSpacing;
const container = createSVGElement('g');
g.prepend(container);
const ys = [];
node.implements.forEach((item, index) => {
const g = createSVGElement('g');
container.append(g);
createNodeElement(g, item, renderingProperties, 'left');
const y = index * dy;
ys.push(y);
setAttributes(g, {
transform: `translate(0, ${y})`
});
});
const lineContainer = createSVGElement('g');
const nodeRect = node.element.getBoundingClientRect();
const lineBusOffset = 2*margin[1];
if (ys.length > 1) {
ys.shift();
const endY = ys.pop() + nodeHeight/2;
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,${nodeHeight/2} -${lineBusOffset},${nodeHeight/2} -${lineBusOffset},${endY} 0,${endY}`,
'marker-end': 'url(#inheritance)',
'marker-start': 'url(#inheritance)'
}));
}
ys.forEach(y => {
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,0 -${lineBusOffset},0`,
transform: `translate(0, ${y + nodeHeight/2})`,
'marker-start': 'url(#inheritance)'
}));
});
const y = nodeRect.height/2;
container.prepend(createSVGElement('polyline', {
'class': 'implements',
points: `-${lineBusOffset},${y} -${xOffset - nodeRect.width/2},${y}`
}));
container.prepend(lineContainer);
setAttributes(container, {
transform: `translate(${xOffset}, 0)`
});
}
function createMixins(g, node, renderingProperties, xOffset) {
const type = 'mixes';
const {margin} = renderingProperties.node;
const nodeHeight = node.element.getBoundingClientRect().height;
const nodeYSpacing = margin[0]/2;
const dy = nodeHeight + nodeYSpacing;
const container = createSVGElement('g');
g.prepend(container);
const ys = [];
node.mixes.forEach((item, index) => {
const g = createSVGElement('g');
container.append(g);
createNodeElement(g, item, renderingProperties, 'right');
const y = index * dy;
ys.push(y);
setAttributes(g, {
transform: `translate(0, ${y})`
});
});
const lineContainer = createSVGElement('g');
const lineBusOffset = 2*margin[1];
if (ys.length > 1) {
ys.shift();
const endY = ys.pop() + nodeHeight/2;
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,${nodeHeight/2} ${lineBusOffset},${nodeHeight/2} ${lineBusOffset},${endY} 0,${endY}`,
'marker-end': 'url(#inheritance)',
'marker-start': 'url(#inheritance)'
}));
}
ys.forEach(y => {
lineContainer.append(createSVGElement('polyline', {
'class': type,
points: `0,0 ${lineBusOffset},0`,
transform: `translate(0, ${y + nodeHeight/2})`,
'marker-start': 'url(#inheritance)'
}));
});
const nodeRect = node.element.getBoundingClientRect();
const y = nodeRect.height/2;
container.prepend(createSVGElement('polyline', {
'class': 'mixes',
points: `${lineBusOffset},${y} ${xOffset - nodeRect.width/2},${y}`
}));
container.prepend(lineContainer);
setAttributes(container, {
transform: `translate(${-xOffset}, 0)`
});
}
function render(noi, renderingProperties) {
const endMarker = {
width: 6,
height: 5
};
const css = `
text {
font-size: initial;
}
rect {
stroke-width: 2;
stroke: black;
fill: white;
}
marker {
overflow: visible;
}
marker rect {
stroke: none;
stroke-width: 0
}
marker path {
fill: white;
stroke: black;
}
a {
cursor: pointer;
}
a text {
fill: blue;
text-decoration: underline;
}
polyline, line {
stroke-width: 2;
stroke: black;
fill: none;
}
.child rect,
.parent rect {
stroke: blue;
}
.interface rect {
stroke: blueviolet;
}
.mixin rect {
stroke: green;
}
.no-ref rect {
fill: lightgray;
}
${renderingProperties.css || ''}
`.replace(/\n/g, ' ');
document.body.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<defs>
<style type="text/css">${escape(css)}</style>
<marker id="inheritance" refX="${endMarker.height}" refY="${endMarker.width/2}" orient="auto-start-reverse">
<rect width="${endMarker.width}" height="${endMarker.height}"></rect>
<path d="M 0 0 L ${endMarker.height} ${endMarker.width/2} L 0 ${endMarker.width} z"></path>
</marker>
</defs>
<g></g>
</svg>`.replace(/\n/g, '').replace(/\s{2,}/g, ' ');
const svg = document.body.querySelector('svg');
const diagramContainer = svg.querySelector('g');
const {margin}= renderingProperties.node;
createNodeElement(diagramContainer, noi, renderingProperties);
let xOffset = noi.element.getBoundingClientRect().width / 2;
noi.parentStack.forEach(parent => {
createNodeElement(diagramContainer, parent, renderingProperties);
xOffset = Math.max(xOffset, parent.element.getBoundingClientRect().width / 2);
});
xOffset += 4*margin[1];
let diagramClientRect = diagramContainer.getBoundingClientRect();
let lastInterfaceY = 0
if (noi.implements.length > 0) {
createInterfaces(diagramContainer, noi, renderingProperties, xOffset);
const lastRect = noi.implements[noi.implements.length-1].element.getBoundingClientRect();
lastInterfaceY = lastRect.y;
}
let lastMixinY = 0;
if (noi.mixes.length > 0) {
createMixins(diagramContainer, noi, renderingProperties, xOffset);
const lastRect = noi.mixes[noi.mixes.length-1].element.getBoundingClientRect();
lastMixinY = lastRect.y;
}
createParentCluster(diagramContainer, noi, renderingProperties, xOffset);
createNodeCluster(diagramContainer, noi, renderingProperties, Math.max(lastInterfaceY, lastMixinY));
diagramClientRect = diagramContainer.getBoundingClientRect();
diagramContainer.setAttribute("transform", `translate(${-diagramClientRect.x + margin[1]/2}, ${-diagramClientRect.y + margin[0]/2})`);
setAttributes(svg, {
width: diagramClientRect.width + margin[1],
height: diagramClientRect.height + margin[0]
});
return document.body.innerHTML;
}
</script>
</body>
</html>