chart = {
const sw = 0.4;
const so = 0.95;
const bundleBeta = 0.56;
const myColor = blackWhite
? (d) => (darkMode ? "white" : "black")
: colorFunc(colorNumber, colorRotation, colorSeed, darkenFactor);
const root = hierarchie;
root.leaves().forEach((leaf) => {
leaf.outgoing = [];
leaf.incoming = [];
const link = edges.find((node) => node.file === leaf.data.name);
if (link) {
link.links.forEach((targetLink) => {
const targetNode = root.find((d) => d.data.name === targetLink.file);
if (targetNode) {
leaf.outgoing.push([leaf, targetNode]);
console.log(targetNode);
}
});
}
edges.forEach((node) => {
node.links.forEach((link) => {
if (link.file === leaf.data.name) leaf.incoming.push([node, leaf]);
});
});
leaf.degree = leaf.incoming.length + leaf.outgoing.length;
});
console.log(root);
const width = 2000;
const height = width;
const links = root.links();
const nodes = root.descendants();
// Modify your force simulation to include a custom force for hierarchical distance constraints
const getDistance = (sourceDepth, targetDepth) => {
const depthDifference = Math.abs(sourceDepth - targetDepth);
return 30 * sourceDepth;
};
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
.id((d) => d.id)
.distance((d) => getDistance(d.source.height, d.target.height))
.strength(1)
)
.force("charge", d3.forceManyBody().strength(-50))
.force("x", d3.forceX())
.force("y", d3.forceY())
.force("radial", d3.forceRadial(0, 0, 0).strength(-0.08)); // Radial repulsion
nodes.forEach((node) => {
if (node.depth === 0) {
node.fx = 0; // Fix X position
node.fy = 0; // Fix Y position
}
});
// Create the container SVG.
const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Append links.
const link = svg
.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line");
// Append nodes.
const node = svg
.append("g")
.attr("fill", "#fff")
.attr("stroke", "#000")
.attr("stroke-width", 1.5)
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("stroke", (d) => (d.children ? null : "#fff"))
.attr("r", (d) => (d.children ? 0 : 3.5 + 0.1 * d.degree))
.attr("fill", (d) => {
const col = myColor(
d.ancestors().find((ancestor) => ancestor.depth == colorDepth)
);
return d.children ? col : darkMode ? darken(col, 0.8) : lighten(col, 0);
});
// Create a placeholder for the labels group - we'll add labels after simulation
let labelGroup;
// Update function for the simulation
simulation.on("tick", () => {
link
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
});
// Create a promise that resolves after 3 seconds
const simulationDuration = 3000; // 3 seconds
// Set up a promise to handle the timed simulation
const runTimedSimulation = new Promise((resolve) => {
// Start the simulation
console.log("Starting simulation...");
// Set a timeout to stop the simulation after the duration
setTimeout(() => {
simulation.stop();
console.log("Simulation stopped after 3 seconds");
resolve();
}, simulationDuration);
});
// Add the additional layer of edges and labels after the simulation has run
runTimedSimulation.then(() => {
link.attr("stroke-opacity", 0);
// This function creates a path that's relaxed between nodes according to the beta factor
function createBundledPath(source, target, beta = bundleBeta) {
// The beta factor controls how much bundling occurs
// We'll use it to adjust how much intermediate points influence the path
// Find the common ancestor path
let sourceAncestors = source.ancestors();
let targetAncestors = target.ancestors();
// Find the lowest common ancestor
let commonAncestor = null;
for (let i = 0; i < sourceAncestors.length; i++) {
for (let j = 0; j < targetAncestors.length; j++) {
if (sourceAncestors[i] === targetAncestors[j]) {
commonAncestor = sourceAncestors[i];
break;
}
}
if (commonAncestor) break;
}
// If no common ancestor is found, create a direct path
if (!commonAncestor) {
return `M${source.x},${source.y} L${target.x},${target.y}`;
}
// Create path points
let points = [];
// Add source point
points.push({ x: source.x, y: source.y });
// Get path to common ancestor
const sourceToCommon = [];
for (let i = 0; i < sourceAncestors.length; i++) {
const ancestor = sourceAncestors[i];
if (ancestor === source) continue; // Skip self
sourceToCommon.push({ x: ancestor.x, y: ancestor.y });
if (ancestor === commonAncestor) break;
}
// Get path from common ancestor to target
const commonToTarget = [];
for (let i = 0; i < targetAncestors.length; i++) {
const ancestor = targetAncestors[i];
if (ancestor === target) continue; // Skip self
if (ancestor === commonAncestor) {
// We've found the starting point
break;
}
commonToTarget.unshift({ x: ancestor.x, y: ancestor.y });
}
// Beta-influenced path generation
// When beta is close to 0, the path is more direct
// When beta is close to 1, the path follows the hierarchy more closely
if (beta <= 0) {
// Direct path - just source to target
return `M${source.x},${source.y} L${target.x},${target.y}`;
} else if (beta >= 1) {
// Full hierarchical path
points = points.concat(sourceToCommon, commonToTarget);
} else {
// Partial influence based on beta
// We'll use fewer intermediate points as beta decreases
// For source to common path
if (sourceToCommon.length > 0) {
// Always include the first point after source for minimal bundling
points.push(sourceToCommon[0]);
// If there are more points, add some based on beta
if (sourceToCommon.length > 1) {
// The number of points to include depends on beta
const pointsToInclude = Math.max(
1,
Math.floor(sourceToCommon.length * beta)
);
const step = sourceToCommon.length / (pointsToInclude + 1);
for (let i = 1; i < pointsToInclude; i++) {
const index = Math.min(
Math.floor(i * step),
sourceToCommon.length - 1
);
points.push(sourceToCommon[index]);
}
// Always include common ancestor
points.push(sourceToCommon[sourceToCommon.length - 1]);
}
}
// For common to target path
if (commonToTarget.length > 0) {
// Always include the point before target for minimal bundling
if (commonToTarget.length > 1) {
// The number of points to include depends on beta
const pointsToInclude = Math.max(
1,
Math.floor(commonToTarget.length * beta)
);
const step = commonToTarget.length / (pointsToInclude + 1);
for (let i = 1; i < pointsToInclude; i++) {
const index = Math.min(
Math.floor(i * step),
commonToTarget.length - 1
);
points.push(commonToTarget[index]);
}
}
// Include the last point before target
points.push(commonToTarget[commonToTarget.length - 1]);
}
}
// Add target point
points.push({ x: target.x, y: target.y });
// Create a line generator with a tension that varies based on beta
// curveBasis is good for high bundling, curveCatmullRom for lower bundling
const lineGenerator = d3
.line()
.x((d) => d.x)
.y((d) => d.y)
.curve(beta > 0.5 ? d3.curveBasis : d3.curveCatmullRom.alpha(beta));
return lineGenerator(points);
}
// Now add the extra layer of edges with adjustable bundling
svg
.append("g")
.attr("fill", "none")
.attr("stroke-width", sw)
.attr("stroke-opacity", so)
.selectAll("path")
.data(root.leaves().flatMap((leaf) => leaf.outgoing))
.join("path")
.style("mix-blend-mode", darkMode ? "normal" : "multiply")
.style("opacity", so)
.attr("d", ([i, o]) => {
try {
return createBundledPath(i, o, bundleBeta);
} catch (error) {
console.error("Error generating path:", error);
// Fallback to direct line if there's an error
return `M${i.x},${i.y} L${o.x},${o.y}`;
}
})
.each(function (d) {
d.path = this;
})
.attr("stroke", (d) =>
lighten(
myColor(
d[1].ancestors().find((ancestor) => ancestor.depth == colorDepth)
),
LighenFactor
)
);
console.log(
"Added extra layer of edges with adjustable bundling after simulation"
);
// Now add the labels after simulation is complete
labelGroup = svg
.append("g")
.attr("stroke-linejoin", "round")
.attr("stroke-width", 3);
// Add leaf nodes labels with proper orientation and anchoring
labelGroup
.selectAll("text")
.data(nodes)
.join("text")
.filter((d) => d.height == 0) // Only add labels for leaf nodes
// .attr("font-size", (d) => 10 + 0.05 * d.degree)
.attr("font-size", (d) => 8)
.attr("paint-order", "stroke")
.attr("stroke", "white")
.attr("fill", (d) => {
const col = myColor(
d.ancestors().find((ancestor) => ancestor.depth == colorDepth)
);
return col;
})
.text((d) => edges.find((n) => n.file == d.data.name)?.title)
.each(function (d) {
// Only position leaf nodes at the edge
const parent = d.parent;
if (!parent) return;
// Calculate angle between leaf and its parent (in radians)
const dx = d.x - parent.x;
const dy = d.y - parent.y;
const angle = Math.atan2(dy, dx);
// Calculate offset distance (adjust as needed)
// const offsetDistance = 15 + 0.1 * d.degree; // Base offset + adjustment for degree
const offsetDistance = 5; // Base offset + adjustment for degree
// Calculate offset position
const offsetX = d.x + Math.cos(angle) * offsetDistance;
const offsetY = d.y + Math.sin(angle) * offsetDistance;
// Calculate angle from vertical (0 to 2π)
// Convert from -π to π range to 0 to 2π range
const angleFromVertical = (angle + Math.PI * 2.5) % (Math.PI * 2);
// Set text-anchor based on angle from vertical
// Between 0 and π: start anchor (text sticks out right)
// Between π and 2π: end anchor (text sticks out left)
const textAnchor =
angleFromVertical >= 0 && angleFromVertical < Math.PI
? "start"
: "end";
// Adjust rotation so text is readable (not upside down)
let rotationAngle = angle * (180 / Math.PI); // Convert to degrees
// Keep text upright
if (rotationAngle > 90) rotationAngle -= 180;
if (rotationAngle < -90) rotationAngle += 180;
// Update text positioning and rotation
d3.select(this)
.attr("x", offsetX)
.attr("y", offsetY)
.attr("text-anchor", textAnchor)
.attr("transform", `rotate(${rotationAngle}, ${offsetX}, ${offsetY})`)
.attr("dy", ".35em"); // Vertical centering adjustment
});
// Add non-leaf node labels (just depth numbers) at their positions
// labelGroup
// .selectAll(".non-leaf-label")
// .data(nodes.filter((d) => d.height > 0)) // Only non-leaf nodes
// .join("text")
// .attr("class", "non-leaf-label")
// // .attr("font-size", (d) => d.children ? 0 : 10 + 0.05 * d.degree)
// .attr("font-size", (d) => (d.children ? 0 : 10))
// .attr("paint-order", "stroke")
// .attr("stroke", "white")
// .attr("text-anchor", "middle")
// .attr("x", (d) => d.x)
// .attr("y", (d) => d.y)
// .attr("dy", ".35em")
// .attr("fill", (d) => {
// const col = myColor(
// d.ancestors().find((ancestor) => ancestor.depth == colorDepth)
// );
// return col;
// })
// .text((d) => d.depth);
console.log("Added labels after simulation");
});
// Handle invalidation
invalidation.then(() => simulation.stop());
return svg.node();
}