worksInfluenceImpactPlot = {
const incomingInfluences = sailorShiftInfluences;
const outgoingInfluences = sailorShiftOutgoingInfluences;
const sailorShiftNode = sailorShift;
const relevantWorks = sailorShiftWorksData;
const { nodes, nodeMap } = data;
const width = 1200;
const height = Math.max(800, relevantWorks.length * 70 + 100);
const graphNodes = [];
const graphLinks = [];
const nodesToAddMap = new Map();
function addNodeToGraph(nodeId, type, originalNode = null) {
if (!nodesToAddMap.has(nodeId)) {
const nodeData = originalNode || nodeMap.get(nodeId);
if (nodeData) {
nodesToAddMap.set(nodeId, { ...nodeData, type: type });
return true;
} else {
console.warn(`Node ID ${nodeId} referenced but not found in the main nodeMap. Skipping link.`);
return false;
}
}
return true;
}
if (addNodeToGraph(sailorShiftNode.id, 'person_center', sailorShiftNode)) {
nodesToAddMap.get(sailorShiftNode.id).fx = width / 2;
nodesToAddMap.get(sailorShiftNode.id).fy = 50;
}
// Add Sailor Shift's works and position them along the center vertical axis
const workXCenter = width / 2;
const workYScale = d3.scalePoint()
.domain(relevantWorks.map(d => d.id))
.range([100, height - 50]) // Start below SS person, end with some margin
.padding(0.5);
relevantWorks.forEach(work => {
if (addNodeToGraph(work.id, 'ss_work_center', work)) {
const node = nodesToAddMap.get(work.id);
node.fx = workXCenter;
node.fy = workYScale(work.id);
}
});
// Add incoming influences to graphNodes and graphLinks
incomingInfluences.forEach(d => {
const sourceId = d.influencerId;
const targetWorkId = d.targetWorkId; // This is SS's work
// Ensure both source (influencer) and target (SS's work) exist
const sourceExists = addNodeToGraph(sourceId, 'incoming_influencer');
const targetWorkExists = nodesToAddMap.has(targetWorkId) && nodesToAddMap.get(targetWorkId).type === 'ss_work_center';
if (sourceExists && targetWorkExists) {
graphLinks.push({
source: sourceId,
target: targetWorkId,
edgeType: d.influenceType,
linkType: 'incoming_influence',
originalData: d
});
}
});
// Add outgoing influences to graphNodes and graphLinks
outgoingInfluences.forEach(d => {
const sourceId = d.sourceId; // SS (person) or SS's work
const targetId = d.influencedWorkId; // The influenced entity/work
let sourceNodeCategory = null;
if (sourceId === sailorShiftNode.id) {
sourceNodeCategory = 'person_center'; // Link from SS person
} else if (nodesToAddMap.has(sourceId) && nodesToAddMap.get(sourceId).type === 'ss_work_center') {
sourceNodeCategory = 'ss_work_center'; // Link from SS's work
} else {
// If the source is neither SS person nor one of her identified works, skip this link
console.warn(`Outgoing influence source ${sourceId} is not a recognized SS work or SS herself. Skipping link.`);
return;
}
// Ensure both source (SS/SS's work) and target (influenced entity) exist
const sourceExists = nodesToAddMap.has(sourceId) && (nodesToAddMap.get(sourceId).type === 'person_center' || nodesToAddMap.get(sourceId).type === 'ss_work_center');
const targetExists = addNodeToGraph(targetId, 'outgoing_influenced');
if (sourceExists && targetExists) {
graphLinks.push({
source: sourceId,
target: targetId,
edgeType: d.influenceType,
linkType: 'outgoing_influence',
originalData: d
});
}
});
// Convert map to array for D3 simulation
graphNodes.push(...Array.from(nodesToAddMap.values()));
// --- D3.js Visualization Setup ---
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const linkColor = d3.scaleOrdinal()
.domain(['incoming_influence', 'outgoing_influence', 'person_to_work'])
.range(['#1f77b4', '#2ca02c', '#808080']); // Blue for incoming, Green for outgoing, Grey for person-to-work
const nodeColor = d3.scaleOrdinal()
.domain(['person_center', 'ss_work_center', 'incoming_influencer', 'outgoing_influenced', 'Person', 'MusicalGroup', 'Song', 'Album'])
.range(['red', 'purple', '#aec7e8', '#98df8a', 'lightgreen', 'lightblue', 'pink', 'lightgray']);
const simulation = d3.forceSimulation(graphNodes)
.force("link", d3.forceLink(graphLinks).id(d => d.id).distance(80))
.force("charge", d3.forceManyBody().strength(-250)) // Adjust for spreading
.force("x", d3.forceX(d => { // Custom X force to pull left/right
if (d.type === 'person_center' || d.type === 'ss_work_center') return workXCenter;
if (d.type === 'incoming_influencer') return workXCenter - 300; // Pull to left
if (d.type === 'outgoing_influenced') return workXCenter + 300; // Pull to right
return d.x; // No change for other nodes
}).strength(0.1))
.force("y", d3.forceY(d => { // Custom Y force to align with works
if (d.type === 'person_center') return d.fy;
if (d.type === 'ss_work_center') return d.fy;
// For influences/influenced, try to align with the Y of the work they connect to
const connectedLink = graphLinks.find(link =>
(link.source === d.id && nodesToAddMap.get(link.target)?.type === 'ss_work_center') ||
(link.target === d.id && nodesToAddMap.get(link.source)?.type === 'ss_work_center')
);
if (connectedLink) {
const ssWorkNode = nodesToAddMap.get(connectedLink.source === d.id ? connectedLink.target : connectedLink.source);
return ssWorkNode ? ssWorkNode.fy : d.y;
}
return d.y;
}).strength(0.08)) // Less strong Y force to allow some vertical spread within sections
.force("collide", d3.forceCollide().radius(15).strength(0.7)); // Add collision force to prevent node overlap
// --- Draw Links ---
const link = svg.append("g")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(graphLinks)
.join("line")
.attr("stroke-width", d => d.linkType === 'outgoing_influence' && d.edgeType === 'DirectlySamples' ? 3 : 1)
.attr("stroke", d => linkColor(d.linkType))
.attr("marker-end", d => (d.linkType === 'incoming_influence' || d.linkType === 'outgoing_influence') ? "url(#arrowhead)" : null);
svg.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 15)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 8)
.attr("markerHeight", 8)
.attr("xoverflow", "visible")
.append("path")
.attr("d", "M 0,-5 L 10,0 L 0,5")
.attr("fill", "#999");
// --- Draw Nodes ---
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll("circle")
.data(graphNodes)
.join("circle")
.attr("r", d => {
if (d.type === 'person_center') return 15;
if (d.type === 'ss_work_center') return 10;
return (d.nodeType === 'Person' || d.nodeType === 'MusicalGroup' ? 8 : 6);
})
.attr("fill", d => nodeColor(d.type || d.nodeType))
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Add labels to nodes
const labels = svg.append("g")
.selectAll("text")
.data(graphNodes)
.join("text")
.attr("x", d => {
if (d.type === 'incoming_influencer') return -12; // Labels to the left for incoming
return 12; // Labels to the right for others
})
.attr("y", "0.31em")
.text(d => d.name || d.stage_name || d.title || '')
.style("font-size", "10px")
.style("fill", "black")
.style("text-anchor", d => d.type === 'incoming_influencer' ? "end" : "start") // Align labels
.clone(true).lower()
.attr("fill", "none")
.attr("stroke", "white")
.attr("stroke-width", 3);
// --- Tooltips --- (reuse existing .tooltip CSS)
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0)
.style("position", "absolute")
.style("pointer-events", "none");
node.on("mouseover", function(event, d) {
tooltip.transition()
.duration(200)
.style("opacity", .9);
let content = `<strong>Name:</strong> ${d.name || d.stage_name || d.title || 'N/A'}<br><strong>Type:</strong> ${d.nodeType || d.type}`;
if (d.type === 'ss_work_center' && d.release_date) { content += `<br><strong>Release:</strong> ${d3.timeFormat("%Y-%m-%d")(new Date(d.release_date))}`; }
tooltip.html(content)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseout", function(event, d) {
tooltip.transition()
.duration(500)
.style("opacity", 0);
});
// --- Tick 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);
labels
.attr("x", d => d.x + (d.type === 'incoming_influencer' ? -12 : 12)) // Adjust label X again here
.attr("y", d => d.y + 4);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return svg.node();
}