Build your own TARDIS with D3

If you're interested in learning more about D3.js and understanding how to build custom charts with it (or if you just like Doctor Who!), then this blog is for you! It's a step-by-step guide to creating an interactive TARDIS from scratch with D3.

June 2, 2025

If you’ve been following these blog posts for a little while, you’ll know that I’m a big fan of TidyTuesday, a weekly social data project where participants explore a new dataset, create something from it (often a visualisation), and share the result and code with the community. Back in 2023, one of the datasets was all about Doctor Who episodes, and the chart I created that week compared viewership across each seasons and featured an image of a TARDIS:

ZgotmplZ

What you might not immediately notice, is that the TARDIS section of the chart is not an image, it’s actually a data visualisation built with ggplot2 in R:

ZgotmplZ

Earlier this year, to gain more experience in working with D3.js, I decided to recreate the TARDIS chart. So in this blog post, we’ll do two things:

  • Recreate the static version using D3 instead of R
  • Add interactivity which shows users fun Doctor Who facts

New to D3? The D3 in Depth book is a good tutorial for getting started. I also recommend checking out the D3 Graph Gallery for some inspiration (with editable code)!

Building a static version

We’ll start by creating an HTML file which loads the D3 library, a JavaScript file called chart.js where we’ll put our plotting code, and a CSS file to apply styling. Importantly, the HTML file should also include a div with an id of chart, as this is where our chart will appear. You can use a different id if you want, just make sure to also change it in the corresponding chart code later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="style.css">
  <script src="https://d3js.org/d3.v7.min.js"></script>
  <script src="chart.js"></script>
</head>
<body>
<div id="chart"></div>
</body>

Now, in the chart.js file, we’re going to set up the basic for our chart, and define a function called tardis() that will draw the TARDIS. It has an argument called data which we’ll come back to in a little bit. We define a width and height for the chart. Here, I’ve used a 4 by 6 ratio because it’s quite a standard size and roughly matches the aspect ratio of a TARDIS. We also set up the x and y axis scales, to define the grid that we’ll be drawing on. Here, we set the x axis to go between 0 and 80, and the y-axis to go between 0 and 120. Note that these also use a 4 by 6 ratio, so that our chart doesn’t end up squashed in one direction. You could just use 0-400 and 0-600 as your grid, but I find it easier to work with slightly smaller numbers and this makes it simpler if you want to change the size later.

We also set up our chart container, with the id of chart to match the HTML file, and set up the SVG with the correct width and height.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function tardis(data) {
  const width = 400;
  const height = 600;

  const xScale = d3.scaleLinear()
      .domain([ 0, 80 ])
      .range([ 0, width ]);
  
  const yScale = d3.scaleLinear()
      .domain([0, 120])
      .range([ 0, height ]);
  
  const chartContainer = d3.select("#chart");
  
  const svg = chartContainer
    .append("svg")
    .attr("viewBox", `0 0 ${width} ${height}`)
    .attr("preserveAspectRatio", "xMidYMid meet")
    .style("width", "100%")
    .style("height", "auto")
    .append("g");
};

Drawing the base

To draw the main base of the TARDIS i.e. the blue shape in the background, we could create a complex polygon with lots of corners. However, it’s actually easier to create this shape by overlaying multiple rectangles. And we’re going to start by creating a CSV file that contains the data to draw these rectangles.

We have two columns x and y that define the starting corner of each rectangle, and the rWidth and rHeight defines how wide and tall each rectangle is. We also create a column, fillCol, with the hex code that defines what colour the rectangle should be filled with (the rectangle will also be outlined with the same colour), and two columns for the transparency. Although we don’t really need the fillCol, fillOpacity, or strokeOpacity columns for just the base, we’ll make use of them later when we draw more elements. The data initial data looks like this:

1
2
3
4
5
6
7
x,y,rWidth,rHeight,fillCol,fillOpacity,strokeOpacity
10,30,60,90,#003B6F,1,1
8,110,64,10,#003B6F,1,1
8,26,64,6.5,#003B6F,1,1
10,25,60,1,#003B6F,1,1
13,21,54,4,#003B6F,1,1
15,18,50,3,#003B6F,1,1

We then add the following code inside the tardis() function to draw the rectangles based on this data we’ve created. We use the attr() function to set the x, y, width and height values based on the columns in the data, using the xScale() and yScale() to make sure they scale correctly to the chart area. We then apply the style() function to set the fill and stroke colours, alongside the opacity of each, again based on the columns in the data. In this example, I’ve set the stroke to "red" just so that you can see how the rectangles fit together to create the shape. In the real version, the stroke is set to the fillCol column using the commented out line of code below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
svg.selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', d => xScale(d.x))
  .attr('y', d => yScale(d.y))
  .attr('width', d => xScale(d.rWidth))
  .attr('height', d => yScale(d.rHeight))
  .style('fill', d => d.fillCol)
  .style('fill-opacity', d => d.fillOpacity)
  .style('stroke', "red")
  // .style('stroke', d => d.fillCol)
  .style('stroke-opacity', d => d.strokeOpacity);

We also need to make sure the input data is connected to the tardis() function. After the function definition, we read in the data and call the function with that data. We need to make sure that D3 interprets the columns we read in from the CSV file in the correct way (either character or numeric), before passing it to our tardis() function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Promise.all([
    d3.csv('data.csv', d => ({
      x: +d.x,
      y: +d.y,
      rWidth: +d.rWidth,
      rHeight: +d.rHeight,
      fillCol: d.fillCol,
      fillOpacity: +d.fillOpacity,
      strokeOpacity: +d.strokeOpacity
    }))
  ]).then(([data]) => {
    tardis(data);
});

ZgotmplZ

Adding some more rectangles

We now need to add more rectangles to our chart to draw the windows, panels, and similar elements. We add these to the same data file for the base rectangles, again specifying the x, y, rHeight, and rWidth columns. However, for these new rectangles, we change the colours and the opacity.

1
2
3
4
5
15,33,50,82,white,0.05,0.3
17,95,18,18,white,0.05,0.3
17,75,18,18,white,0.05,0.3
17,55,18,18,white,0.05,0.3
17,35,18,18,white,0.75,0.3

The windows at the top also have some lines on them. Although we could follow a similar process of creating a data file to draw some lines as a path, we’re going to cheat slightly and consider lines as just very thin rectangles. This means we can just add a few more lines to this data file.

Show full CSV file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
x,y,rWidth,rHeight,fillCol,fillOpacity,strokeOpacity
10,30,60,90,#003B6F,1,1
8,110,64,10,#003B6F,1,1
8,26,64,6.5,#003B6F,1,1
10,25,60,1,#003B6F,1,1
13,21,54,4,#003B6F,1,1
15,18,50,3,#003B6F,1,1
15,33,50,82,white,0.05,0.3
17,95,18,18,white,0.05,0.3
17,75,18,18,white,0.05,0.3
17,55,18,18,white,0.05,0.3
17,35,18,18,white,0.75,0.3
17.2,43.75,17.6,0.5,#003B6F,1,1
22.75,35.2,0.5,17.6,#003B6F,1,1
28.75,35.2,0.5,17.6,#003B6F,1,1
45,95,18,18,white,0.05,0.3
45,75,18,18,white,0.05,0.3
45,55,18,18,white,0.05,0.3
45,35,18,18,white,0.75,0.3
45.2,43.75,17.6,0.5,#003B6F,1,1
50.75,35.2,0.5,17.6,#003B6F,1,1
56.75,35.2,0.5,17.6,#003B6F,1,1
35,8,10,10,grey,1,1
36,8,8,10,lightgrey,1,1
35,8,10,2,black,1,1
35,15.6,10,2.5,black,1,1
12,26,56,5.5,black,1,1
19,56,14,16,grey,1,1
43,61,1,7,grey,1,1

Since the columns in the CSV file specify everything about how the rectangles look, we don’t need to make any further adjustments to the JavaScript in the tardis() function, as our existing code will draw these additional elements.

ZgotmplZ

Adding text and circles

We now want to draw some text and circles to add the final details. We could take a similar approach as before in using a csv file, but we have only a few circles and text elements to draw, so we could just draw these individually:

For example, to add the POLICE text to the top of the base, we use:

1
2
3
4
5
6
7
svg.append("text")
  .attr("text-anchor", "middle")
  .attr("y", yScale(30))
  .attr("x", xScale(25))
  .text("POLICE")
  .style("font-size", "16px")
  .style("fill", "white");

We then repeat this process for the word BOX, PUBLIC CALL, and the notice on the door. You could treat POLICE BOX as one element, but I found it easier to position it the way I wanted by doing them separately.

We can also add the following code to draw a small circle, forming part of the door handle:

1
2
3
4
5
 svg.append("circle")
  .attr("cy", yScale(74))
  .attr("cx", xScale(44))
  .attr("r", 4)
  .style("fill", "white");

We can repeat a few times for the other circular elements.

ZgotmplZ

Adding interactivity

We’re going add some tooltips to the TARDIS chart which display interesting facts about Doctor Who.

Tooltips data

We’ll add 8 different tooltips that are activated by hovering over the squares on the TARDIS doors. One slight issue with this approach is that some of the squares are currently underneath other elements e.g. the bars on the windows. This means that if we just go straight ahead and attach the tooltips to the squares, the hover effects will be quite odd and will be switching on and off where there are other elements on top of the squares.

To get around this problem, we’re simply going to draw new transparent squares on top of the ones that already exist, and add the tooltips to these. We don’t necessarily need to do this for all 8 squares, but I found it easier to keep all the tooltips data together. We could add these transparent squares to the CSV file that draws the rectangles, but I’m going to create a new CSV file that has the x, y, rWidth, and rHeight columns as before. We don’t need any of the colour-related columns as everything will be fully transparent. However, we will add a new column, tooltipText, which as the name suggests contains the text to be included in the tooltip.

In that tooltipText, we write out the 8 facts we want to appear. The examples used here are adapted from some of the facts at www.saga.co.uk/magazine/entertainment/doctor-who-25-fascinating-facts. You might notice that we use HTML to apply styling to the text e.g. italics tags, <i></i>, for episode names. Our tooltips data looks like this:

1
2
3
4
5
6
7
8
9
x,y,rWidth,rHeight,tooltipText
17,95,18,18,"TARDIS stands for Time And Relative Dimension In Space. It resembles an old police box because its <i>chameleon circuit</i> broke during a visit to London."
17,75,18,18,"Tenth Doctor David Tennant’s childhood ambition was to play the Doctor. He had a Doctor doll and wrote school essays inspired by the show."
17,55,18,18,"Ninety-seven of the show’s episodes are not held in the BBC’s archives, as many were wiped between 1967 and 1978."
17,35,18,18,"The Daleks first appeared on 21 December 1963, in the show’s fifth episode."
45,95,18,18,"The 50th anniversary episode, <i>The Day of the Doctor</i>, was the world’s biggest TV drama simulcast, showing in 98 countries."
45,75,18,18,"The first episode of Doctor Who aired on Saturday, 23 November 1963, and initially ended in 1989, before being relaunched in 2005."
45,55,18,18,"In a 2000 industry poll, Doctor Who placed third in a list of the 100 Greatest British Television Programmes of the 20th century."
45,35,18,18,"Mission to the Unknown (1965) is the only episode in the series’ history not to feature the Doctor."

Implementing tooltips

We need to adapt the code that reads in the data and runs the tardis() function because we now have a second data set to read in. Again, we need to make sure the columns are read in with the correct type. Notice that the tardis() function now has two arguments: data and tooltipsData:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Promise.all([
  d3.csv('data.csv', d => ({
    x: +d.x,
    y: +d.y,
    rWidth: +d.rWidth,
    rHeight: +d.rHeight,
    fillCol: d.fillCol,
    fillOpacity: +d.fillOpacity,
    strokeOpacity: +d.strokeOpacity
  })),
  d3.csv('tooltipsData.csv', d => ({
    x: +d.x,
    y: +d.y,
    rWidth: +d.rWidth,
    rHeight: +d.rHeight,
    class: d.class,
    tooltipText: d.tooltipText
  }))
]).then(([data, tooltipsData]) => {
  tardis(data, tooltipsData);
});

We can now use D3 to draw these transparent rectangles, using code that is very similar to that used to draw the base rectangles. We again use .attr() to set the x, y, and width, and height based on the tooltipsData. However, we hard code the fill and transparency colours to fully transparent rather than using columns in the data. We also add a class to these new rectangles, to enable them to be linked to the tooltips. Note that we use rect2 to differentiate these rectangles from rect.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
svg.selectAll('rect2')
  .data(tooltipsData)
  .enter()
  .append('rect')
  .attr('x', d => xScale(d.x))
  .attr('y', d => yScale(d.y))
  .attr('width', d => xScale(d.rWidth))
  .attr('height', d => yScale(d.rHeight))
  .attr('class', 'tooltipText')
  .style('fill', "white")
  .style('fill-opacity', 0)
  .style('stroke', "white")
  .style('stroke-opacity', 0);

Finally, we add some JavaScript to define when the tooltips appear. We create elements with the class tooltip that are fully transparent by default. When a user hovers over any element with the class tooltipText (any of our transparent rectangles), the opacity changes to 0.9. Upon hover, the text in the tooltipText column of the data is also added. When a user stops hover that element, the opacity is returned to 0 and the tooltip becomes invisible.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const tip = d3.select("body")
  .append("div")
  .attr("class", "tooltip")
  .style("opacity", 0);

d3.selectAll('.tooltipText')
  .on('mouseover', function(event, d) {
    tip.style('opacity', 0.9)
      .html(d.tooltipText)
      .style('left', (event.pageX - 25) + 'px')
      .style('top', (event.pageY - 50) + 'px');
  })
  .on('mouseout', function(event, d) {
    tip.style('opacity', 0);
  });

We then add some CSS to style all object with the tooltip class i.e. all of the tooltips. This sets the background colour to the same shade of blue as the TARDIS, uses white for the text and tooltip border, and sets the maximum width to 300px to force the text to wrap for long facts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.tooltip {
    position: absolute;
    pointer-events: none;
    background: #003B6F;
    border-style: solid;
    border-color: white;
    border-width: 2px;
    border-radius: 3px;
    padding: 5px;
    color: #fff;
    max-width: 300px;
}

ZgotmplZ

Optionally, we can add a summary and details element in the HTML file which displays the facts shown on the tooltips as plain text, for people who dislike or can’t use interactive elements like tooltips. Here’s a screen recording of the final product:

ZgotmplZ

Now you’re ready to go ahead, and create your very own TARDIS!

ZgotmplZ

Source: giphy.com


For attribution, please cite this work as:

Build your own TARDIS with D3.
Nicola Rennie. June 2, 2025.
nrennie.rbind.io/blog/build-your-own-tardis-d3
BibLaTeX Citation
@online{rennie2025,
  author = {Nicola Rennie},
  title = {Build your own TARDIS with D3},
  date = {2025-06-02},
  url = {https://nrennie.rbind.io/blog/build-your-own-tardis-d3}
}

Licence: creativecommons.org/licenses/by/4.0