viewof tvDirector = Inputs.select(["Mike Flanagan", "Ryan Murphy", "Joss Whedon", "Shonda Rhimes", "J.J. Abrams"], {label: "TV Creator"})
viewof actorRadius = Inputs.radio(["Default", "Episode Count"], {label: "Actor Radius", value: "Episode Count"})
viewof actorNodeStyle = Inputs.radio(["Name", "Image"], {label: "Actor Display", value: "Image"})
Cast Connections
network
interactive
D3
Explore cast connections across different TV shows in a creator’s universe. Data from tmdb.
This project is a love letter to my partner, Kristen - she is a walking encyclopedia of all things TV and film. In her honor, I’ve selected some of her favorite TV show creators to explore the interconnected cast within each creator’s universe.
Filters
viewof height = Inputs.range([10, 1200], {label: "Height",
value: tvDirector === 'Mike Flanagan' ? 500 : 675, step:1})
viewof collide = Inputs.toggle({ label: "Collide", value: true })
viewof useRadial = Inputs.toggle({ label: "Radial Force", value: true })
viewof radialForce = Inputs.range([0.01, 0.9], {
step: 0.01,
value: this?.value || 0.11,
label: "Force Strength",
disabled: !useRadial
})
flanagan_cast = FileAttachment("data/flanagan_cast.csv").csv({typed: true})
murphy_cast = FileAttachment("data/murphy_cast.csv").csv({typed: true})
whedon_cast = FileAttachment("data/whedon_cast.csv").csv({typed: true})
rhimes_cast = FileAttachment("data/rhimes_cast.csv").csv({typed: true})
abrams_cast = FileAttachment("data/abrams_cast.csv").csv({typed: true})
showColor = '#2a97ea';
flanagan = d3.json('https://raw.githubusercontent.com/tashapiro/cast-connections/main/data/flanagan.json');
murphy = d3.json('https://raw.githubusercontent.com/tashapiro/cast-connections/main/data/murphy.json');
whedon = d3.json('https://raw.githubusercontent.com/tashapiro/cast-connections/main/data/whedon.json');
rhimes = d3.json('https://raw.githubusercontent.com/tashapiro/cast-connections/main/data/rhimes.json');
abrams = d3.json('https://raw.githubusercontent.com/tashapiro/cast-connections/main/data/abrams.json');
//dynamic data
data = tvDirector === 'Mike Flanagan' ? flanagan : tvDirector === 'Ryan Murphy' ? murphy : tvDirector === 'Joss Whedon' ? whedon : tvDirector==='Shonda Rhimes' ? rhimes : abrams;
db = DuckDBClient.of({
show_cast: tvDirector === 'Mike Flanagan' ? flanagan_cast : tvDirector === 'Ryan Murphy' ? murphy_cast : tvDirector === 'Joss Whedon' ? whedon_cast: tvDirector === 'Shonda Rhimes' ? rhimes_cast : abrams_cast
})
actors = db.sql`select name, image_url, sum(episode_count) as total_episodes
from show_cast
group by 1,2`
network = {
const max_episodes = d3.max(actors.map(d=>d.total_episodes));
const strokeColor = "black";
const highlightStroke = "pink";
const imgLength = 24;
const imgWidth = 24;
const radiusMultiplier = 30 / max_episodes;
const lineWidth = 1;
const showNodeFill = showColor;
const showNodeStroke = "black";
const lineHeight = 16;
const fontColor = "white";
const fontFamily= "Oswald";
const minRadius = max_episodes * .3;
const showRadius = 30;
const textRadiusMultiplier = 0.7;
const width = 800;
const links = data.links; // Reuse the same nodes to have the animation work when changing the inputs
const nodes = data.nodes;
function getRadius(node) {
const getEpisodes = actors.find((i) => i.name === node.id);
const totalEpisodes = getEpisodes ? getEpisodes.total_episodes : 0;
let effectiveEpisodes;
if (actorRadius === "Episode Count" & totalEpisodes > minRadius) {
effectiveEpisodes = totalEpisodes * radiusMultiplier;
}
else if(actorRadius == "Episode Count" & totalEpisodes <= minRadius){
effectiveEpisodes = minRadius * radiusMultiplier
}
else if (actorRadius === "Default") {
effectiveEpisodes = showRadius/2;
} else {
// Handle other cases here if needed
effectiveEpisodes = 0; // Default value when actorRadius is not recognized
}
return effectiveEpisodes;
}
const simulation = d3.forceSimulation(nodes)
.alpha(1)
.force("link", d3.forceLink(links).id(d => d.id).distance(30).strength(0.25))
.force("charge", d3.forceManyBody())
.force("collision", d3.forceCollide().radius(d=> d.group === 'Show' ? showRadius *1.15 : getRadius(d)*1.1))
.force("collide", collide ? d3.forceCollide(11).iterations(4): null)
.force(
"position",
useRadial ?
d3
.forceRadial(
(d) => (d.id.includes("Drag Race") ? width*0.01 : width * 0.5),
width / 2,
height / 2
)
.strength(radialForce)
: null
)
.force("x", d3.forceX(width/2))
.force("y", d3.forceY(height/2).strength(0.1*width/height))
.force("center", d3.forceCenter(width / 2, height / 2));
const svg = d3.create("svg")
.style("overflow", "hidden")
.attr("viewBox", [0, 0, width, height]);
const nodeMouseOver = (d, links) => {
tooltip.style("visibility", "visible")
.html(createTooltipText(links, d.id, actors, d.group));
node
.transition(500)
.style('opacity', o => {
console.log("o", o, d);
const isConnectedValue = isConnected(o.id, d.id);
if (isConnectedValue) {
return 1.0;
}
return 0.1;
});
link
.transition(500)
.style('stroke-opacity', o => {
console.log(o.source.id === d.id)
return (o.source.id === d.id || o.target.id === d.id ? 1 : 0.1)
})
.transition(500)
.attr('marker-end', o => (o.source.id === d.id || o.target.id === d.id ? 'url(#arrowhead)' : 'url()'));
}
const nodeMouseOut = (e, d) => {
tooltip.style("visibility", "hidden");
node
.transition(500)
.style('opacity', 1);
link
.transition(500)
.style("stroke-opacity", o => {
console.log(o.value)
});
};
const tooltip = d3.select("body").append("div")
.attr("class", "toolTip")
.style("position", "absolute")
.style("visibility", "hidden")
.text("Placeholder");
// create link reference
let linkedByIndex = {};
data.links.forEach(d => {
linkedByIndex[`${d.source.id},${d.target.id}`] = true;
});
// nodes map
let nodesById = {};
data.nodes.forEach(d => {
nodesById[d.id] = {...d};
})
const isConnectedAsSource = (a, b) => linkedByIndex[`${a},${b}`];
const isConnectedAsTarget = (a, b) => linkedByIndex[`${b},${a}`];
const isConnected = (a, b) => isConnectedAsTarget(a, b) || isConnectedAsSource(a, b) || a === b;
const link = svg.append("g")
.attr("stroke", "#D0D0D0")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line")
.attr("stroke-width", d => lineWidth);
const node = svg.append("g")
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr('class', 'node')
.call(drag(simulation));
// Iterate through each node
node.each(function (d) {
const currentNode = d3.select(this);
const radius = d.group === 'Show' ? showRadius : getRadius(d);
const textRadius = d.group === 'Show' ? showRadius *textRadiusMultiplier : getRadius(d) * textRadiusMultiplier;
const labelText = d.id; // Use the 'id' property from the data
// Create a circle for each node
currentNode.append('circle')
.attr("r", radius)
.attr("fill", d.group === 'Show' ? showNodeFill : 'black')
.attr("stroke", "black");
// Create text label for each node based on 'id'
currentNode.append("text")
.style("text-anchor", "middle")
.attr("transform", `translate(0, 0) scale(${textRadius / getTextRadius(getLines(labelText, lineHeight), lineHeight)})`)
.selectAll("tspan")
.data(getLines(labelText, lineHeight))
.enter()
.append("tspan")
.attr("x", 0)
.attr("y", (d, i) => (i - getLines(labelText, lineHeight).length / 2 + 0.8) * lineHeight)
.style("font-family", "Oswald")
.style("fill", fontColor)
.text(d => d.text);
if (actorNodeStyle === "Image") {
currentNode.append("image")
.attr("class","headshot")
.attr("x", (d) => -getRadius(d))
.attr("y", (d) => -getRadius(d))
.attr("width", (d) => getRadius(d)*2)
.attr("href", (d) => {
const matchingRefImg = actors.find((i) => i.name === d.id);
return matchingRefImg ? matchingRefImg.image_url : "";
})
.style("clip-path", (d) => {
const getEpisodes = actors.find((i) => i.name === d.id);
return getEpisodes ? "circle(" + getRadius(d) + "px at " + getRadius(d) + "px " + getRadius(d) + "px)" : radius;//
})
}
currentNode.append('circle')
.attr('fill', 'transparent')
.attr("r", radius)
.attr("stroke", "black")
.on("mouseover", (e, d) => nodeMouseOver(d, data.links))
.on("mouseout", nodeMouseOut)
.on('mousemove', (event) => tooltip.style("top", (event.pageY - 10) + "px").style("left", (event.pageX + 10) + "px"));
});
const 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("transform", d => `translate(${d.x}, ${d.y})`);
};
simulation.on("tick", tick);
invalidation.then(() => simulation.stop());
tick();
return svg.node();
}
function getLines(text, lineHeight) {
const text_array = [text]
const words = text_array[0].split(/\s+/g);
function measureWidth() {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
return text => context.measureText(text).width;
}
const targetWidth = Math.sqrt(measureWidth()(text) * lineHeight);
const lines = (function() {
let line;
let lineWidth0 = Infinity;
const lines = [];
for (let i = 0, n = words.length; i < n; ++i) {
let lineText1 = (line ? line.text + " " : "") + words[i];
let lineWidth1 = measureWidth()(lineText1);
if ((lineWidth0 + lineWidth1) / 2 < targetWidth) {
line.width = lineWidth0 = lineWidth1;
line.text = lineText1;
} else {
lineWidth0 = measureWidth()(words[i]);
line = { width: lineWidth0, text: words[i] };
lines.push(line);
}
}
return lines;
})();
return lines;
}
function getTextRadius (lines, lineHeight) {
let radius = 0;
for (let i = 0, n = lines.length; i < n; ++i) {
const dy = (Math.abs(i - n / 2 + 0.5) + 0.5) * lineHeight;
const dx = lines[i].width / 2;
radius = Math.max(radius, Math.sqrt(dx ** 2 + dy ** 2));
}
return radius;
}
function createTooltipText (links, id, reference, group) {
const regex = /S\d+/;
const sourceLinks = links.filter(link => link.source.id === id);
const targetLinks = links.filter(link => link.target.id === id);
const ref = reference.filter(d => d.name === id);
if (group === 'Show') {
const count = sourceLinks.length;
return `<strong>${id}</strong> has a total of <strong>${count} actor${count === 1 ? '' : 's'}</strong>`;
}
else {
const count = targetLinks.length;
return `<strong>${id}</strong> appeared on <strong>${count} show${count === 1 ? '' : 's'}</strong> and a total of <strong>${ref[0].total_episodes} episodes</strong>`;
}
}
drag = simulation => {
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 d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}