Public
Edited
Jun 6
Insert cell
import_data = FileAttachment("MC1_graph.json").json()
Insert cell
d3 = require("d3@5")
Insert cell
sailorSongsForTimeline = {
if (!processedData || !selectedYear) return [];

const songs = processedData.sailorSongs;
return songs
.filter(song => song.release_year <= selectedYear)
.map(song => ({
id: song.id,
title: song.name,
releaseDate: new Date(song.release_date),
genre: song.genre,
notable: song.notable
}));
}

Insert cell
// THIS CELL MUST BE NAMED 'data'
data = {
// Assuming 'import_data' is a cell that loads FileAttachment("MC1_graph.json").json()
// If 'import_data' isn't a cell, replace 'import_data' with
// 'await FileAttachment("MC1_graph.json").json()' directly here.
const rawGraph = import_data; // Or directly load the file here if import_data isn't a cell

// 1. Process nodes: Ensure IDs are numbers and 'nodeType' is consistently named
const nodes = rawGraph.nodes.map(node => ({
...node, // Keep all other properties from the raw node
id: Number(node.id), // Convert ID to a number
// Map 'nodeType' from either 'node.nodeType' (camelCase) OR 'node["Node Type"]' (bracket notation)
// Based on earlier debugging, the raw JSON likely uses "Node Type"
nodeType: node.nodeType || node["Node Type"]
}));

// 2. Process links: Ensure source/target are numbers and 'edgeType' is consistently named
const links = rawGraph.links.map(link => ({
...link, // Keep all other properties from the raw link
source: Number(link.source), // Convert source ID to a number
target: Number(link.target), // Convert target ID to a number
// Map 'edgeType' from either 'link.edgeType' (camelCase) OR 'link["Edge Type"]' (bracket notation)
// Based on earlier debugging, the raw JSON likely uses "Edge Type"
edgeType: link.edgeType || link["Edge Type"]
}));

// 3. Create the nodeMap for efficient node lookups using numeric IDs
const nodeMap = new Map(nodes.map(d => [d.id, d]));

// 4. Return the processed nodes, links, and the nodeMap
return { nodes, links, nodeMap };
}
Insert cell
//Find Sailor Shift's node
sailorShift = {
const ssNode = data.nodes.find(d => d.name === "Sailor Shift" && d["Node Type"] === "Person");
return ssNode;
}
Insert cell
// This cell is named 'sailorShiftInfluences'
// It depends on 'data' (your processed graph data) and 'sailorShift' (the Sailor Shift node object)
sailorShiftInfluences = {
// Destructure nodes, links, and nodeMap from the 'data' cell's output
const { nodes, links, nodeMap } = data;
const sailorShiftId = sailorShift.id; // Get Sailor Shift's ID

const sailorShiftWorkIds = new Set(); // To store IDs of works by Sailor Shift
// Define edge types that indicate influence (still need to confirm if you want to include contributions)
const influenceEdgeTypes = ['InStyleOf', 'InterpolatesFrom', 'CoverOf', 'LyricalReferenceTo', 'DirectlySamples'];
// IMPORTANT: If you want to include 'PerformerOf', 'ComposerOf', etc. as "influences" on the timeline,
// ADD THEM HERE. Based on our last discussion, it seems you want to.
// Example if adding them:
const influenceEdgeTypes = [
'InStyleOf', 'InterpolatesFrom', 'CoverOf', 'LyricalReferenceTo', 'DirectlySamples',
'PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf' // Add these for contributions
];

const allInfluences = []; // Array to store the final influence objects

// --- Phase 1: Find all songs/albums performed/composed/produced by Sailor Shift ---
// Define edge types that indicate Sailor Shift's involvement in a work's creation
const productionEdgeTypes = ['PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf'];

console.log("--- Phase 1: Finding Sailor Shift's Own Works ---");
for (const link of links) {
if (link.source === sailorShiftId && productionEdgeTypes.includes(link.edgeType)) {
const targetNode = nodeMap.get(link.target);
if (targetNode && ['Song', 'Album'].includes(targetNode.nodeType)) {
sailorShiftWorkIds.add(targetNode.id);
console.log(`DEBUG (P1-SUCCESS): Found Sailor Shift's work: ${targetNode.name} (ID: ${targetNode.id}), Type: ${targetNode.nodeType} via ${link.edgeType}`);
} else if (targetNode) {
// console.log(`DEBUG (P1-SKIP-NODE_TYPE): Link from SS to ${targetNode.name} (ID: ${targetNode.id}) is production type, but target is type '${targetNode.nodeType}' (not Song/Album).`);
} else {
// console.warn(`DEBUG (P1-WARN-MISSING_TARGET): Target node not found for ID: ${link.target} (from source ${link.source}, type ${link.edgeType}).`);
}
} else if (link.source === sailorShiftId) {
// console.log(`DEBUG (P1-SKIP-EDGE_TYPE): Link from SS (ID: ${link.source}) has type '${link.edgeType}' (not a production type: ${JSON.stringify(productionEdgeTypes)}).`);
}
}
console.log(`DEBUG: Phase 1 complete. Identified ${sailorShiftWorkIds.size} Sailor Shift's works.`);
console.log("DEBUG: Sailor Shift Work IDs found:", Array.from(sailorShiftWorkIds));
if (sailorShiftWorkIds.size === 0) {
console.warn("DEBUG: No songs/albums found for Sailor Shift in Phase 1. This means no production links meet criteria.");
return []; // Early exit if no works found
}

// --- Phase 2: Finding Influences ON Sailor Shift's Works ---
// Based on your definition: Source (influenced) --EdgeType--> Target (influencer)
// So, Sailor Shift's work is the SOURCE, and the influencer is the TARGET.
console.log("\nDEBUG: --- Phase 2: Finding Influences on Sailor Shift's Works ---");
for (const link of links) {
// CRUCIAL CHANGE HERE: We're looking for links where Sailor Shift's work is the SOURCE
if (sailorShiftWorkIds.has(link.source) && influenceEdgeTypes.includes(link.edgeType)) {
const influencedWork = nodeMap.get(link.source); // This is Sailor Shift's work
const influencer = nodeMap.get(link.target); // This is the entity that influenced it

if (influencedWork && influencer) {
allInfluences.push({
targetWorkId: influencedWork.id, // Now 'targetWorkId' is influencedWork.id
targetWorkName: influencedWork.name,
targetWorkType: influencedWork.nodeType,
targetReleaseDate: influencedWork.release_date ? new Date(influencedWork.release_date) : null,
influencerId: influencer.id,
influencerName: influencer.name || influencer.stage_name,
influencerType: influencer.nodeType,
influenceType: link.edgeType
});
console.log(`DEBUG (P2-SUCCESS): Found Influence: ${influencer.name} (ID: ${influencer.id}) influenced ${influencedWork.name} (ID: ${influencedWork.id}) via ${link.edgeType}`);
} else {
console.warn(`DEBUG (P2-WARN-MISSING_NODE): Missing influencedWork or influencer for link: Source ID ${link.source}, Target ID ${link.target}.`);
}
} else if (sailorShiftWorkIds.has(link.source)) {
// This is a link from SS's work, but not an influence type
// console.log(`DEBUG (P2-SKIP-EDGE_TYPE): Link from SS work, but edge type '${link.edgeType}' is not an influence type: ${JSON.stringify(influenceEdgeTypes)}.`);
} else {
// This link's source is not one of SS's works, or target is not SS's work (if it were an inverse influence)
// console.log(`DEBUG (P2-SKIP-SOURCE): Link source ${link.source} is not one of Sailor Shift's works.`);
}
}
console.log(`DEBUG: Phase 2 complete. Found ${allInfluences.length} influences.`);
console.log("--- DEBUG END: sailorShiftInfluences ---");

// --- Sort the influences by the release date of Sailor Shift's work ---
allInfluences.sort((a, b) => {
if (!a.targetReleaseDate && !b.targetReleaseDate) return 0;
if (!a.targetReleaseDate) return 1;
if (!b.targetReleaseDate) return -1;
return a.targetReleaseDate.getTime() - b.targetReleaseDate.getTime();
});

return allInfluences; // Return the final sorted array of influences
}
Insert cell
// Cell: sailorShiftInfluences
// This cell processes graph data to find Sailor Shift's own works and
// then identifies influence relationships *on* those works based on the clarified link direction.
sailorShiftInfluences = {
const { nodes, links, nodeMap } = data;
const sailorShiftId = sailorShift.id; // Get Sailor Shift's main node ID

console.log("DEBUG: Starting sailorShiftInfluences processing with CLARIFIED LINK DIRECTION.");
console.log("DEBUG: Sailor Shift ID:", sailorShiftId);
console.log("DEBUG: Total nodes in data:", nodes.length);
console.log("DEBUG: Total links in data:", links.length);

if (!nodeMap.has(sailorShiftId)) {
console.error("ERROR: Sailor Shift ID not found in nodeMap. Check 'sailorShift.id' and 'data.json'. This is a critical error.");
return { sailorShiftWorks: new Map(), allInfluences: [], workRelationships: new Map() };
}

const sailorShiftWorks = new Map(); // Map: workId -> { nodeObject, release_year }
// Define edge types that indicate Sailor Shift's involvement in a work's creation.
// These are links *from* Sailor Shift (Person) *to* her Songs/Albums.
const productionEdgeTypes = ['PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf'];
console.log("DEBUG: Production Edge Types (SS -> Own Work):", productionEdgeTypes);

// --- Phase 1: Find all songs/albums performed/composed/produced by Sailor Shift ---
console.log("DEBUG: --- Phase 1: Identifying Sailor Shift's Own Works (SS -> Work) ---");
let p1_productionLinksChecked = 0;
let p1_worksFound = 0;
for (const link of links) {
if (link.source === sailorShiftId && productionEdgeTypes.includes(link.edgeType)) {
p1_productionLinksChecked++;
const targetNode = nodeMap.get(link.target);

// Check if target node exists, is a Song/Album, and has a release_date
if (targetNode && ['Song', 'Album'].includes(targetNode.nodeType) && targetNode.release_date) {
sailorShiftWorks.set(targetNode.id, {
node: targetNode,
release_year: new Date(targetNode.release_date).getFullYear()
});
p1_worksFound++;
// console.log(`DEBUG (P1-SUCCESS): Found SS work: '${targetNode.title || targetNode.name}' (ID: ${targetNode.id}), Year: ${new Date(targetNode.release_date).getFullYear()}, via '${link.edgeType}'`);
} else {
// console.log(`DEBUG (P1-SKIP): Link from SS (${sailorShiftId}) via '${link.edgeType}' to target ID ${link.target}. Not a valid SS work (Node: ${targetNode ? (targetNode.title || targetNode.name) : 'MISSING'}, Type: ${targetNode?.nodeType}, Has Date: ${!!targetNode?.release_date}).`);
}
}
}
console.log(`DEBUG: Phase 1 complete. Checked ${p1_productionLinksChecked} production links. Identified ${sailorShiftWorks.size} Sailor Shift's works.`);
if (sailorShiftWorks.size === 0) {
console.warn("WARNING: No songs/albums found for Sailor Shift in Phase 1. This means no production links met criteria. This will result in an empty influences array.");
return { sailorShiftWorks: new Map(), allInfluences: [], workRelationships: new Map() };
}
console.log("DEBUG: Sailor Shift Work IDs found:", Array.from(sailorShiftWorks.keys()));


const allInfluences = []; // Array to store influence relationships (SS Work -> Influencer)
// Define influence edge types for the Y-axis.
// These are links *from* Sailor Shift's works *to* the influencing artists/works.
const influenceEdgeTypes = ['InStyleOf', 'InterpolatesFrom', 'CoverOf', 'LyricalReferenceTo', 'DirectlySamples'];
console.log("DEBUG: Influence Edge Types (SS Work -> Influencer):", influenceEdgeTypes);

// --- Phase 2: Finding Influences ON Sailor Shift's Works ---
// CLARIFIED LOGIC: link.source IS Sailor Shift's work, link.target IS the influencer.
console.log("DEBUG: --- Phase 2: Finding Influences (SS Work -> Influencer) ---");
let p2_influenceLinksChecked = 0;
let p2_influencesFound = 0;
for (const link of links) {
// Check if the link's source is one of Sailor Shift's identified works
// AND if the link's edgeType is one of the defined influence types.
if (sailorShiftWorks.has(link.source) && influenceEdgeTypes.includes(link.edgeType)) {
p2_influenceLinksChecked++;
const influencedWorkData = sailorShiftWorks.get(link.source); // This is Sailor Shift's work (source of the influence link)
const influencerNode = nodeMap.get(link.target); // This is the entity that influenced it (target of the influence link)

if (influencedWorkData && influencerNode) {
// Ensure the influencer is a Person or MusicalGroup, as these are the entities we want to represent as dots.
if (['Person', 'MusicalGroup'].includes(influencerNode.nodeType)) {
allInfluences.push({
influencedWorkId: influencedWorkData.node.id,
influencedWorkName: influencedWorkData.node.title || influencedWorkData.node.name,
influencedWorkType: influencedWorkData.node.nodeType,
influencedWorkYear: influencedWorkData.release_year,
influencerId: influencerNode.id,
influencerName: influencerNode.name || influencerNode.stage_name,
influencerType: influencerNode.nodeType,
influenceType: link.edgeType // This is the category for the Y-axis
});
p2_influencesFound++;
// console.log(`DEBUG (P2-SUCCESS): Found Influence: SS Work '${influencedWorkData.node.title || influencedWorkData.node.name}' (ID: ${influencedWorkData.node.id}) was influenced by '${influencerNode.name || influencerNode.stage_name}' (ID: ${influencerNode.id}) via '${link.edgeType}'.`);
} else {
// console.log(`DEBUG (P2-SKIP-TYPE): Link from SS work (${link.source}) via '${link.edgeType}' to target '${link.target}' (Type: ${influencerNode.nodeType}). Influencer is not a Person or MusicalGroup.`);
}
} else {
// console.log(`DEBUG (P2-SKIP-NODE): Missing either influencedWorkData for source '${link.source}' or influencerNode for target '${link.target}' for link type '${link.edgeType}'.`);
}
}
}
console.log(`DEBUG: Phase 2 complete. Checked ${p2_influenceLinksChecked} potential influence links. Found ${allInfluences.length} total influence relationships.`);


// --- Phase 3: Cross-referencing all relevant nodes for Sailor Shift's Works ---
// This helps in getting a comprehensive view of connections, potentially useful for genre determination.
const workRelationships = new Map(); // Map: workId -> [{ relatedNode, edgeType, isSource }, ...]
const relevantNodeTypesForRelationships = ['Person', 'MusicalGroup', 'RecordLabel'];

console.log("DEBUG: --- Phase 3: Building Work Relationships (for SS works) ---");
sailorShiftWorks.forEach((workData, workId) => {
workRelationships.set(workId, []); // Initialize an empty array for each SS work

// Find all links where this SS work is either the source or the target
links.forEach(link => {
let relatedNodeId = null;
let isWorkSource = false; // Is the SS work the source of *this* link?

if (link.source === workId) {
relatedNodeId = link.target;
isWorkSource = true;
} else if (link.target === workId) {
relatedNodeId = link.source;
}

if (relatedNodeId) {
const relatedNode = nodeMap.get(relatedNodeId);
if (relatedNode && relevantNodeTypesForRelationships.includes(relatedNode.nodeType)) {
workRelationships.get(workId).push({
node: relatedNode,
edgeType: link.edgeType,
isWorkSource: isWorkSource // True if SS work is source, false if SS work is target
});
// console.log(`DEBUG (P3-CONN): Work '${workData.node.title || workData.node.name}' connected to '${relatedNode.name || relatedNode.title}' (Type: ${relatedNode.nodeType}) via '${link.edgeType}'.`);
}
}
});
});
console.log(`DEBUG: Phase 3 complete. Built relationships for ${workRelationships.size} Sailor Shift's works.`);

console.log("DEBUG: --- END sailorShiftInfluences processing ---");

// Sort influence relationships by the influenced work's year for consistency
allInfluences.sort((a, b) => {
if (a.influencedWorkYear !== b.influencedWorkYear) {
return a.influencedWorkYear - b.influencedWorkYear;
}
return (a.influencedWorkName || '').localeCompare(b.influencedWorkName || ''); // Secondary sort by name
});

return {
sailorShiftWorks: sailorShiftWorks,
allInfluences: allInfluences,
workRelationships: workRelationships
};
}
Insert cell
// New cell: sailorShiftOutgoingInfluences
sailorShiftOutgoingInfluences = {
const { nodes, links, nodeMap } = data;
const sailorShiftId = sailorShift.id;

const outgoingInfluences = [];
const influenceEdgeTypes = ['InStyleOf', 'InterpolatesFrom', 'CoverOf', 'LyricalReferenceTo', 'DirectlySamples'];

// First, get all of Sailor Shift's works (songs/albums she composed, performed, etc.)
// We can reuse the logic from Phase 1 of sailorShiftInfluences if it's not exposed
const sailorShiftWorkIds = new Set();
const productionEdgeTypes = ['PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf'];
for (const link of links) {
if (link.source === sailorShiftId && productionEdgeTypes.includes(link.edgeType)) {
const targetNode = nodeMap.get(link.target);
if (targetNode && ['Song', 'Album'].includes(targetNode.nodeType)) {
sailorShiftWorkIds.add(targetNode.id);
}
}
}

// Now, find links where Sailor Shift (or her works) is the SOURCE of an influence link
for (const link of links) {
// Check if Sailor Shift herself is the source and influences someone
if (link.source === sailorShiftId && influenceEdgeTypes.includes(link.edgeType)) {
const targetNode = nodeMap.get(link.target);
if (targetNode) { // Ensure target node exists
outgoingInfluences.push({
sourceId: sailorShiftId,
sourceName: sailorShift.name,
sourceType: sailorShift.nodeType,
influencedWorkId: targetNode.id,
influencedWorkName: targetNode.name || targetNode.stage_name,
influencedWorkType: targetNode.nodeType,
influenceType: link.edgeType,
direction: 'outgoing_from_person' // SS herself influences
});
}
}
// Check if one of Sailor Shift's works is the source and influences someone
else if (sailorShiftWorkIds.has(link.source) && influenceEdgeTypes.includes(link.edgeType)) {
const sourceWork = nodeMap.get(link.source);
const targetNode = nodeMap.get(link.target);
if (sourceWork && targetNode) { // Ensure both nodes exist
outgoingInfluences.push({
sourceId: sourceWork.id,
sourceName: sourceWork.name,
sourceType: sourceWork.nodeType,
influencedWorkId: targetNode.id,
influencedWorkName: targetNode.name || targetNode.stage_name,
influencedWorkType: targetNode.nodeType,
influenceType: link.edgeType,
direction: 'outgoing_from_work' // SS's work influences
});
}
}
}

return outgoingInfluences;
}
Insert cell
// New cell: sailorShiftCollaborations
sailorShiftCollaborations = {
const { nodes, links, nodeMap } = data;
const sailorShiftId = sailorShift.id;

const collaborations = [];
// Define edge types that signify direct collaboration
const collaborationEdgeTypes = ['PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf', 'MemberOf']; // 'MemberOf' if she's part of a group, or vice-versa

for (const link of links) {
let collaboratorId = null;
let relationship = null; // To indicate SS's role in the collaboration

if (collaborationEdgeTypes.includes(link.edgeType)) {
if (link.source === sailorShiftId) {
collaboratorId = link.target;
relationship = `SS is ${link.edgeType.replace('Of','')}`; // e.g., 'SS is Performer'
} else if (link.target === sailorShiftId) {
collaboratorId = link.source;
relationship = `SS is ${link.edgeType.replace('Of','')}'s target`; // e.g., 'SS is PerformerOf target'
}

if (collaboratorId !== null) {
const collaboratorNode = nodeMap.get(collaboratorId);
// Only include Person or MusicalGroup nodes as collaborators for simplicity
if (collaboratorNode && (collaboratorNode.nodeType === 'Person' || collaboratorNode.nodeType === 'MusicalGroup')) {
collaborations.push({
sailorShiftId: sailorShiftId,
collaboratorId: collaboratorNode.id,
collaboratorName: collaboratorNode.name || collaboratorNode.stage_name,
collaboratorType: collaboratorNode.nodeType,
collaborationType: link.edgeType,
relationshipToSS: relationship
});
}
}
}
}

return collaborations;
}
Insert cell
// New cell: sailorShiftWorksData
sailorShiftWorksData = {
const { nodes, links, nodeMap } = data;
const sailorShiftId = sailorShift.id;

const sailorShiftWorks = new Map(); // Map to store unique works {id: node_data}

// Find all songs and albums Sailor Shift is primarily associated with
// (e.g., ComposerOf, PerformerOf, ProducerOf, LyricistOf)
const productionEdgeTypes = ['PerformerOf', 'ComposerOf', 'ProducerOf', 'LyricistOf'];

for (const link of links) {
if (link.source === sailorShiftId && productionEdgeTypes.includes(link.edgeType)) {
const targetNode = nodeMap.get(link.target);
if (targetNode && ['Song', 'Album'].includes(targetNode.nodeType)) {
if (!sailorShiftWorks.has(targetNode.id)) {
sailorShiftWorks.set(targetNode.id, targetNode);
}
}
}
}

// Convert map to array and sort by release_date for chronological plotting
const sortedWorks = Array.from(sailorShiftWorks.values())
.filter(d => d.release_date) // Only include works with a release date
.sort((a, b) => new Date(a.release_date) - new Date(b.release_date));

return sortedWorks;
}
Insert cell
// New cell: worksInfluenceImpactPlot
worksInfluenceImpactPlot = {
const incomingInfluences = sailorShiftInfluences;
const outgoingInfluences = sailorShiftOutgoingInfluences;
const sailorShiftNode = sailorShift;
const relevantWorks = sailorShiftWorksData; // Your sorted works data
const { nodes, nodeMap } = data; // Original full data for node lookup

const width = 1200; // Wider for horizontal spread
const height = Math.max(800, relevantWorks.length * 70 + 100); // Dynamic height based on number of works

const graphNodes = [];
const graphLinks = [];
const nodesToAddMap = new Map(); // For unique nodes in the graph

// Helper function to add a node to the map if not already present
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;
}

// Add Sailor Shift herself (as a central reference, perhaps at the very top)
if (addNodeToGraph(sailorShiftNode.id, 'person_center', sailorShiftNode)) {
nodesToAddMap.get(sailorShiftNode.id).fx = width / 2;
nodesToAddMap.get(sailorShiftNode.id).fy = 50; // Place at the top
}


// 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();
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more