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

Sports Leaderboard Generator

Observable
interactive
D3
sports
Dynamic Sports Leaderboard Generator. Stats and images taken from ESPN for leagues including NBA, MLB, NHL, WNBA, and F1.
mlb_stats = FileAttachment("data/mlb_stats.csv").csv({typed: true})
nba_stats = FileAttachment("data/nba_stats.csv").csv({typed: true})
nhl_stats = FileAttachment("data/nhl_stats.csv").csv({typed: true})
wnba_stats = FileAttachment("data/wnba_stats.csv").csv({typed: true})
f1_stats = FileAttachment("data/f1_stats.csv").csv({typed: true})

results = league==="wnba_stats" ? wnba_stats : league==="nba_stats" ? nba_stats : league==="mlb_stats" ? mlb_stats : league==="nhl_stats" ? nhl_stats : f1_stats
metrics = Object.keys(results[0]).slice(8).sort()
top_5 = results.sort((a, b) => b[metric] - a[metric]).slice(0,5);
pre_title = league==="nba_stats" ? "NBA" : league==="mlb_stats" ? "MLB" : league==="wnba_stats" ? "WNBA" : league==="nhl_stats" ? "NHL" : ""
default_stat = league==="nba_stats" ? "Points Per Game" : league==="mlb_stats" ? "Batting Average" : league==="wnba_stats" ? "Points Per Game" : league==="nhl_stats" ? "Shooting Percentage" : "Points"

Stats

viewof league =  Inputs.select(new Map([["NBA", "nba_stats"], ["MLB", "mlb_stats"],["NHL","nhl_stats"],["WNBA","wnba_stats"],["F1","f1_stats"]]), {label: "League", value:'nba_stats'})
viewof metric = Inputs.select(metrics, {label: "Metric", value:default_stat})

Aesthetics

viewof box_color_type = {
  const input = Inputs.radio(["Mapped","Uniform"], {label: "Box Color Type", value:"Mapped"});
  input.classList.add("radio");
  return input;
}

viewof box_color= Inputs.text({label: "Box Color", placeholder: "Box Color", value:'black', disabled: box_color_type==="Uniform"? false: true})

viewof box_stroke= Inputs.text({label: "Box Stroke", placeholder: "Box Color", value:'none'})

viewof background = Inputs.text({label: "Background", placeholder: "Background Color", value:'#1E252C'})

viewof font_family= Inputs.select(['Antonio','Archivo Narrow','Bebas Neue','BenchNine', "IBM Plex Sans Condensed", 'Jockey One','Oswald',"Roboto Condensed","Sofia Sans Condensed"], {label:"Font Family", value:"Oswald"})

viewof circle_color= Inputs.text({label: "Circle Color", placeholder: "Color", value:'#EEF0F2'})

viewof font_color= Inputs.text({label: "Font Color", placeholder: "Color", value:'white'})

viewof title_color= Inputs.text({label: "Title Color", placeholder: "Color", value:'white'})

viewof top_player_font_size= Inputs.range([0, 50], {value: 32, step: 1, label: "Top Font Size"})

viewof player_font_size= Inputs.range([0, 30], {value: 23, step: 1, label: "Player Font Size"})

viewof box_opacity= Inputs.range([0, 1], {value: 0.85, step: 0.01, label: "Box Opacity"})

viewof logo_opacity= Inputs.range([0, 1], {value: .35, step: 0.01, label: "Logo Opacity"})


viewof circle_opacity= Inputs.range([0, 1], {value: .9, step: 0.01, label: "Circle Opacity"})
{
  const data = top_5;

  const svg = d3.create("svg")
      .attr("id", "leaderboard")
      .attr("viewBox", [-5, -85, 620, 650])
      .attr("style", "max-width: 100%; height: auto; height: intrinsic;")
      .style("background-color", background);


    // Define the rectangles
    const rectWidth = 140;
    const rectHeight = 160;
    const rectHeight2 = 350;
    const rectPadding = 10;
    const rectPaddingY = 20;
    const imgWidth = 300;
    const imgHeight = 300;
    const circleR = 45
    const circleCx = rectWidth/2
    const rankX= rectWidth/2
  
    const rectData = [
      { x: 10, y: 10, width: rectWidth*4+rectPadding*3, height: rectHeight, img_x: (rectWidth*4+rectPadding*3)/2 , img_height: imgHeight *2,circle_r: circleR+15, circle_cx: 80},
      { x: 10, y: rectHeight + rectPaddingY, width: rectWidth, height: rectHeight2, img_height: imgHeight, circle_r: circleR, circle_cx: circleCx},
      { x: rectWidth + rectPadding * 2, y: rectHeight + rectPaddingY, width: rectWidth, height: rectHeight2, img_height: imgHeight,circle_r: circleR, circle_cx: circleCx},
      { x: rectWidth * 2 + rectPadding * 3, y: rectHeight + rectPaddingY, width: rectWidth, height: rectHeight2, img_height:imgHeight, circle_r: circleR, circle_cx: circleCx},
      { x: rectWidth * 3 + rectPadding * 4, y: rectHeight + rectPaddingY, width: rectWidth, height: rectHeight2, img_height:imgHeight,  circle_r: circleR, circle_cx: circleCx},
    ];

  //title
  svg.append("text")
  .text(pre_title + " LEADERBOARD")
  .attr("x", 305)
  .attr("y",-35)
  .attr("font-size",40)
  .attr('fill',title_color)
  .attr("font-weight", 'bold')
  .attr("font-family", font_family)
  .attr("text-anchor","middle")
  .attr('alignment-baseling','middle')
  


  function toSentenceCase(str) {
  return str.replace(/_/g, ' ').replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
}

//subtitle
    svg.append("text")
  .text(toSentenceCase(metric))
  .attr("x", 305)
  .attr("y",-2)
  .attr("font-size",30)
  .attr('fill',title_color)
  .attr("font-family", font_family)
  .attr("text-anchor","middle")
  .attr('alignment-baseling','middle')
  .style("text-transform","uppercase")


const defs = svg.append("defs");

  // Define a unique ID for each clipping path
const clipPathId = (d, i) => `clip-${i}`;

const clipPaths = defs.selectAll("clipPath")
  .data(rectData)
  .enter()
  .append("clipPath")
  .attr("id", clipPathId);  // reuse the same ID as the rectangles

clipPaths.append("rect")
  .attr("width", (d) => d.width)
  .attr("height", (d) => d.height);

const rectGroup = svg.selectAll("g")
  .data(rectData)
  .enter()
  .append("g")
  .attr("transform", (d) => `translate(${d.x},${d.y})`);

rectGroup.append("rect")
  .attr("width", (d) => d.width)
  .attr("height", (d) => d.height)
  .attr("stroke", box_stroke)
  .attr("opacity", box_opacity)
  .attr("fill", (d, i) => box_color_type==="Mapped" ? data[i].team_color : box_color);

// append team logos
rectGroup.append("image")
  .attr("xlink:href", (d,i) => data[i].team_logo)
  .attr("x", (d) => (d.width - d.img_height) / 2)
  .attr("y", (d) => (d.height - d.img_height) / 2)
  .attr("height", (d) => d.img_height)
  .attr("width", (d) => d.img_height)
  .attr("opacity", logo_opacity)
  .attr("clip-path", (d, i) => `url(#${clipPathId(d, i)})`);

rectGroup.append("circle")
  .attr("cx", (d) => d.circle_cx)
  .attr("cy", (d) => d.height / 2)
  .attr("r", d => d.circle_r)
  .attr("fill", circle_color)
  .attr("id", "clipCircle")
  .attr("opacity", circle_opacity);

// Add the image with the clip path applied
rectGroup.append("image")
  .attr("xlink:href", (d,i) => data[i].headshot_url)
  .attr("x", (d, i) => i === 0 ? 70- d.imgWidth: (d.width - d.circle_r*2.7) / 2)
  .attr("y", (d) => (d.height - d.circle_r*2.6)/2)
  .attr("height",(d) => d.circle_r*2.7)
  .attr("width", (d) => d.circle_r*2.7)
  .attr("clip-path", 'circle(36% at 50% 50%)');

//rank
  rectGroup.append("text")
  .text(function(d, i) { return i+1; })
  .attr("x", (d,i) => i === 0 ? 160 : rectWidth/2)
  .attr("y", (d,i) => i === 0 ? rectHeight/2 + 10 : 32)
  .attr("font-weight", 'bold')
  .attr("font-size", (d,i) => i === 0 ? 50 : player_font_size)
  .attr("font-family", font_family)
  .attr("text-anchor", (d,i) => i === 0 ? "start" : 'middle')
  .attr('alignment-baseline', 'middle')
  .attr('fill', font_color)
  .style("text-shadow", "2px 2px 4px rgba(0, 0, 0, 0.5)");

// player first name
rectGroup.append("text")
  .text(function(d, i) {   return i === 0 ? data[i].player : data[i].player.split(" ")[0]; })
  .attr("x", (d,i) => i === 0 ? 200 : rectWidth/2)
  .attr("y", (d,i) => i === 0 ? rectHeight/2 + 10 -14: 65)
  .attr("font-weight", 'bold')
  .attr("font-size", (d,i) => i === 0 ? top_player_font_size : player_font_size)
  .attr("font-family", font_family)
  .attr("text-anchor", (d,i) => i === 0 ? "start" : 'middle')
  .style("text-transform", (d,i) => i===0 ? "uppercase": 'none')
  .attr('alignment-baseline', 'middle')
  .attr('fill', font_color)
  .style("text-shadow", "2px 2px 4px rgba(0, 0, 0, 0.5)");

  // player last name
  rectGroup.append("text")
  .text(function(d, i) {  return i === 0 ? '': data[i].player.split(" ")[1]; })
  .attr("x", (d,i) => i === 0 ? 300 : rectWidth/2)
  .attr("y", (d,i) => i === 0 ? 10 : 65 + player_font_size*1.4)
  .attr("font-weight", 'bold')
  .attr('font-size', player_font_size)
  .attr("font-family", font_family)
  .attr("text-anchor", (d,i) => i === 0 ? "start" : 'middle')
  .attr('fill', font_color)
  .style("text-transform","uppercase")
  .style("text-shadow", "2px 2px 4px rgba(0, 0, 0, 0.5)");


// player team
  rectGroup.append("text")
  .text(function(d, i) { return i === 0 ? data[i].team_name : data[i].team_abbr})
  .attr("x", (d,i) => i === 0 ? 200 : rectWidth/2)
  .attr("y", (d,i) => i === 0 ? rectHeight/2 + 10 +14 : 255)
  .attr("font-color","white")
  .attr("font-weight", 'bold')
  .attr("font-size", (d,i) => i === 0 ? 24 : 20)
  .attr("font-family", font_family)
  .attr("text-anchor", (d,i) => i === 0 ? "start" : 'middle')
  .attr('alignment-baseline', 'middle')
  .attr('fill', font_color)
  .style("text-shadow", "2px 2px 4px rgba(0, 0, 0, 0.5)");

// metric
  rectGroup.append("text")
  .text(function(d, i) { return data[i][metric]; })
  .attr("x", (d,i) => i === 0 ? 560 : rectWidth/2)
  .attr("y", (d,i) => i === 0 ? rectHeight/2+10 : 315)
  .attr("font-weight", 'bold')
  .attr("font-size", (d,i) => i === 0 ? 30 : player_font_size)
  .attr("font-family", font_family)
  .attr("text-anchor", (d,i) => i === 0 ? "end" : 'middle')
  .attr('alignment-baseline', 'middle')
  .attr('fill', font_color)
  .style("text-shadow", "2px 2px 4px rgba(0, 0, 0, 0.5)");
  
  
  svg.append("text")
    .text("")
    .attr("x", 300)
    .attr("y", 300)
    .attr("font-size", 140)
    .attr("fill", "white")
    .attr("text-anchor", "middle")
    .attr("opacity", 0.22)
    .attr("transform", "rotate(45, " + 300 + ", " + 300 + ")");

  svg.append("text")
  .text("Graphic: Tanya Shapiro")
  .attr("x", 10)
  .attr("y", 552)
  .attr("font-size", 16)
  .attr("fill","white")
  .attr("font-family", font_family)
  
  
  svg.append("text")
  .text("Data: ESPN")
  .attr("x", 600)
  .attr("y", 552)
  .attr("text-anchor", "end")
  .attr("font-size", 16)
  .attr("fill","white")
  .attr("font-family", font_family)

    

  return svg.node();

}
 
    Created with Quarto
    Copyright © 2023 Tanya Shapiro. All rights reserved.
Cookie Preferences