Skip to content

Instantly share code, notes, and snippets.

@Nagelfar
Created November 18, 2021 16:17
Show Gist options
  • Save Nagelfar/e85183c4757581ce45dc980c4197f889 to your computer and use it in GitHub Desktop.
Save Nagelfar/e85183c4757581ce45dc980c4197f889 to your computer and use it in GitHub Desktop.
A dependency chord visualisation for Contexture (c) by Christoph Walcher
<!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