Created
November 18, 2021 16:17
-
-
Save Nagelfar/e85183c4757581ce45dc980c4197f889 to your computer and use it in GitHub Desktop.
A dependency chord visualisation for Contexture (c) by Christoph Walcher
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Collaborations</title> | |
<meta charset="UTF-8"> | |
<script src="https://d3js.org/d3.v7.min.js"></script> | |
</head> | |
<body></body> | |
<script> | |
async function fetchDomains() { | |
const response = await fetch("http://localhost:5000/api/domains"); | |
return await response.json(); | |
} | |
async function fetchCollaborations() { | |
const response = await fetch("http://localhost:5000/api/collaborations"); | |
return await response.json(); | |
} | |
Promise.all([fetchDomains(), fetchCollaborations()]).then((responses) => { | |
const [domains, collaborations] = responses; | |
function niceName(name) { | |
return name.replaceAll(" ", "-"); | |
} | |
function resolveDomainNames(domainId) { | |
if (!domainId) { | |
return []; | |
} | |
const domain = domains.find((domain) => domain.id == domainId); | |
return [...resolveDomainNames(domain.parentDomainId), domain.key || domain.name] | |
.filter((domainName) => domainName) | |
.map(niceName); | |
} | |
function resolveCollaboratorName(collaborator) { | |
const boundedContexts = domains | |
.map((domain) => domain.boundedContexts) | |
.flat(); | |
if (collaborator.boundedContext) { | |
const boundedContext = boundedContexts.find( | |
(boundedContext) => boundedContext.id == collaborator.boundedContext | |
); | |
if (!boundedContext) { | |
throw new Error( | |
`Could not find a bounded context with id ${collaborator.boundedContext}` | |
); | |
} | |
const domainNames = resolveDomainNames( | |
boundedContext.parentDomainId | |
).join("."); | |
return `${domainNames}.${boundedContext.key || boundedContext.name}`; | |
} | |
if (collaborator.domain) { | |
const domain = domains.find( | |
(domain) => domain.id == collaborator.domain | |
); | |
if (!domain) { | |
throw new Error( | |
`Could not find a domain with id ${collaborator.domain}` | |
); | |
} | |
const domainNames = resolveDomainNames(domain.parentDomainId).join( | |
"." | |
); | |
return `${domainNames}.${domain.key || domain.name}`; | |
} | |
if (collaborator.externalSystem) { | |
return `externalSystem.${collaborator.externalSystem}`; | |
} | |
if (collaborator.frontend) { | |
return `frontend.${collaborator.frontend}`; | |
} | |
throw new Error( | |
`Could not resolve a name for collaborator ${JSON.stringify( | |
collaborator | |
)}` | |
); | |
} | |
function unique(names) { | |
const obj = {}; | |
names.forEach((name) => (obj[name] = true)); | |
return Object.keys(obj); | |
} | |
const collaborationData = collaborations.map((collaboration) => { | |
return { | |
source: resolveCollaboratorName(collaboration.initiator), | |
target: resolveCollaboratorName(collaboration.recipient), | |
value: 1, | |
}; | |
}); | |
// First step - ignore all domains/bounded contexts that do not define a collaboration | |
const collaborationNames = unique( | |
collaborations | |
.map((collaboration) => [ | |
collaboration.initiator, | |
collaboration.recipient, | |
]) | |
.flat() | |
.map(resolveCollaboratorName) | |
); | |
collaborationNames.sort(); | |
function buildMatrix(data, names) { | |
const index = new Map(names.map((name, i) => [name, i])); | |
const matrix = Array.from(index, () => new Array(names.length).fill(0)); | |
for (const { source, target, value } of data) { | |
matrix[index.get(source)][index.get(target)] += value; | |
} | |
return matrix; | |
} | |
renderCollaborations( | |
buildMatrix(collaborationData, collaborationNames), | |
collaborationNames | |
); | |
}); | |
function renderCollaborations(matrix, names) { | |
// Constants | |
const width = 1600; | |
const height = width; | |
const innerRadius = Math.min(width, height) * 0.5 - 190; | |
const outerRadius = innerRadius + 10; | |
// Helpers | |
const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius); | |
const chord = d3 | |
.chordDirected() | |
.padAngle(10 / innerRadius) | |
.sortSubgroups(d3.descending) | |
.sortChords(d3.descending); | |
const ribbon = d3 | |
.ribbonArrow() | |
.radius(innerRadius - 1) | |
.padAngle(1 / innerRadius); | |
const color = d3.scaleOrdinal( | |
names, | |
d3.quantize(d3.interpolateRainbow, names.length) | |
); | |
// Create SVG element | |
const svg = d3 | |
.select("body") | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.attr("viewBox", [-width / 2, -height / 2, width, height]); | |
const chords = chord(matrix); | |
const mouseOver = function(d) { | |
const index = this.attributes.index.value; | |
d3.selectAll(".chord") | |
.transition() | |
.duration(200) | |
.style("opacity", .1) | |
d3.selectAll(`.chord[sourceIndex="${index}"]`) | |
.transition() | |
.duration(200) | |
.style("opacity", 1) | |
} | |
const mouseLeave = function(d) { | |
d3.selectAll(".chord") | |
.transition() | |
.duration(200) | |
.style("opacity", 0.75) | |
} | |
const group = svg | |
.append("g") | |
.attr("font-size", 10) | |
.attr("font-family", "sans-serif") | |
.selectAll("g") | |
.data(chords.groups) | |
.join("g"); | |
group | |
.append("path") | |
.attr("fill", (d) => color(names[d.index])) | |
.attr("d", arc) | |
.attr("index", d => d.index ) | |
.on("mouseover", mouseOver ) | |
.on("mouseleave", mouseLeave); | |
group | |
.append("text") | |
.each((d) => (d.angle = (d.startAngle + d.endAngle) / 2)) | |
.attr("class", d => "group" ) | |
.attr("index", d => d.index ) | |
.attr("dy", "0.35em") | |
.attr( | |
"transform", | |
(d) => ` | |
rotate(${(d.angle * 180) / Math.PI - 90}) | |
translate(${outerRadius + 5}) | |
${d.angle > Math.PI ? "rotate(180)" : ""} | |
` | |
) | |
.attr("text-anchor", (d) => (d.angle > Math.PI ? "end" : null)) | |
.text((d) => names[d.index]) | |
.on("mouseover", mouseOver ) | |
.on("mouseleave", mouseLeave); | |
group.append("title").text( | |
(d) => `${names[d.index]} | |
${d3.sum( | |
chords, | |
(c) => (c.source.index === d.index) * c.source.value | |
)} outgoing | |
${d3.sum( | |
chords, | |
(c) => (c.target.index === d.index) * c.source.value | |
)} incoming ` | |
); | |
svg | |
.append("g") | |
.attr("fill-opacity", 0.75) | |
.selectAll("path") | |
.data(chords) | |
.join("path") | |
.style("mix-blend-mode", "multiply") | |
.attr("fill", (d) => color(names[d.target.index])) | |
.attr("d", ribbon) | |
.attr("class", d => "chord" ) | |
.attr("sourceIndex", d => d.source.index) | |
.attr("targetIndex", d => d.target.index) | |
.append("title") | |
.text( | |
(d) => | |
`${names[d.source.index]} → ${names[d.target.index]} ${ | |
d.source.value | |
}` | |
) | |
; | |
} | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment