We use cookies

We use cookies and other tracking technologies to improve your browsing experience on our website, to show you personalized content and targeted ads, to analyze our website traffic, and to understand where our visitors are coming from.

Tanya Shapiro
  • Home
  • About
  • Talks
  • Blog
  • Projects

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 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"})
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
})
showColor = "#2a97ea"
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);
}
getLines = ƒ(text, lineHeight)
getTextRadius = ƒ(lines, lineHeight)
createTooltipText = ƒ(links, id, reference, group)
 
    Created with Quarto
    Copyright © 2023 Tanya Shapiro. All rights reserved.
Cookie Preferences