← Relativity & Light Speed
Lab

Lab: Spacetime Diagram

20 min JavaScript

Lab: Spacetime Diagram

Goal

Build a Minkowski spacetime diagram in a single HTML file. You will draw light cones, worldlines for stationary and moving observers, and visualize time dilation by comparing tick marks between reference frames. The diagram makes the geometry of special relativity visible: time and space are not separate – they are axes of a single structure.

Setup

You need a modern web browser. No other dependencies.

Create a single file called spacetime.html in any directory.

Step 1: HTML and Canvas Setup

Create spacetime.html with the following skeleton. The canvas is 600x600 pixels, with the origin placed at the center.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Minkowski Spacetime Diagram</title>
  <style>
    body {
      margin: 0;
      background: #0a0a1a;
      display: flex;
      flex-direction: column;
      align-items: center;
      font-family: monospace;
      color: #ccc;
    }
    canvas {
      margin-top: 20px;
    }
    .controls {
      margin-top: 12px;
    }
    label { margin-right: 12px; }
    input[type="range"] { vertical-align: middle; }
    #velocity-display { color: #ff6; }
  </style>
</head>
<body>
  <h2>Minkowski Spacetime Diagram</h2>
  <canvas id="diagram" width="600" height="600"></canvas>
  <div class="controls">
    <label>Observer velocity:
      <input type="range" id="velocity" min="0" max="95" value="60">
      <span id="velocity-display">0.60c</span>
    </label>
  </div>
  <script>
    // All JavaScript goes here in the following steps
  </script>
</body>
</html>

Open the file in your browser. You should see a dark page with a canvas and a velocity slider.

Step 2: Coordinate System

Replace the // All JavaScript goes here comment with the code below. This establishes a coordinate system where the origin is at the canvas center, the x-axis is space (rightward positive), and the y-axis is time (upward positive).

const canvas = document.getElementById("diagram");
const ctx = canvas.getContext("2d");
const W = canvas.width;
const H = canvas.height;
const originX = W / 2;
const originY = H / 2;
const scale = 40;  // pixels per unit

function toScreen(x, t) {
  return [originX + x * scale, originY - t * scale];
}

function drawLine(x1, t1, x2, t2, color, width = 1, dash = []) {
  const [sx1, sy1] = toScreen(x1, t1);
  const [sx2, sy2] = toScreen(x2, t2);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.setLineDash(dash);
  ctx.beginPath();
  ctx.moveTo(sx1, sy1);
  ctx.lineTo(sx2, sy2);
  ctx.stroke();
  ctx.setLineDash([]);
}

function drawLabel(x, t, text, color, offsetX = 5, offsetY = -5) {
  const [sx, sy] = toScreen(x, t);
  ctx.fillStyle = color;
  ctx.font = "12px monospace";
  ctx.fillText(text, sx + offsetX, sy + offsetY);
}

Step 3: Draw Axes

Add the function that draws the space and time axes with gridlines:

function drawAxes() {
  const range = 7;

  // Gridlines
  for (let i = -range; i <= range; i++) {
    drawLine(i, -range, i, range, "#1a1a2e", 0.5);
    drawLine(-range, i, range, i, "#1a1a2e", 0.5);
  }

  // Space axis (horizontal)
  drawLine(-range, 0, range, 0, "#555", 1);
  drawLabel(range - 0.5, 0, "space (x)", "#888", 5, 18);

  // Time axis (vertical)
  drawLine(0, -range, 0, range, "#555", 1);
  drawLabel(0, range - 0.3, "time (ct)", "#888", 8, 0);

  // Origin label
  drawLabel(0, 0, "O", "#888", -16, 16);
}

Step 4: Draw the Light Cone

Light travels at 45 degrees on a Minkowski diagram (because we use ct for the time axis, making c = 1). The light cone defines the boundary between causally connected and disconnected regions.

function drawLightCone() {
  const range = 7;

  // Future light cone
  drawLine(0, 0, range, range, "#ff0", 1.5, [6, 4]);
  drawLine(0, 0, -range, range, "#ff0", 1.5, [6, 4]);

  // Past light cone
  drawLine(0, 0, range, -range, "#ff0", 1.5, [6, 4]);
  drawLine(0, 0, -range, -range, "#ff0", 1.5, [6, 4]);

  // Labels
  drawLabel(3, 6.5, "future light cone", "#ff0", -40, 0);
  drawLabel(3, -6.5, "past light cone", "#ff0", -30, 15);

  // Shade the causal future region lightly
  const [ox, oy] = toScreen(0, 0);
  const [rx, ry] = toScreen(range, range);
  const [lx, ly] = toScreen(-range, range);
  ctx.fillStyle = "rgba(255, 255, 0, 0.03)";
  ctx.beginPath();
  ctx.moveTo(ox, oy);
  ctx.lineTo(rx, ry);
  ctx.lineTo(lx, ly);
  ctx.closePath();
  ctx.fill();
}

Step 5: Stationary Observer Worldline

A stationary observer stays at x = 0 for all time. Their worldline is a vertical line along the time axis. Add tick marks at each unit of proper time.

function drawStationaryObserver() {
  const range = 6;

  // Worldline: vertical line at x = 0
  drawLine(0, -range, 0, range, "#4af", 2.5);

  // Proper time ticks
  for (let t = -range; t <= range; t++) {
    if (t === 0) continue;
    const [sx, sy] = toScreen(0, t);
    ctx.strokeStyle = "#4af";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(sx - 5, sy);
    ctx.lineTo(sx + 5, sy);
    ctx.stroke();
  }

  drawLabel(0, 6.5, "stationary", "#4af", -55, 0);
}

Step 6: Moving Observer Worldline with Time Dilation

A moving observer’s worldline is tilted. At velocity v (in units of c), the worldline has slope 1/v in spacetime. The key physics: the moving observer’s proper time runs slower by the Lorentz factor gamma = 1 / sqrt(1 - v^2).

function drawMovingObserver(v) {
  if (v < 0.01) return;  // skip if essentially stationary

  const gamma = 1 / Math.sqrt(1 - v * v);
  const range = 6;

  // Worldline: x = v * t, so at time t, position is v*t
  const tMax = range;
  const tMin = -range;
  drawLine(v * tMin, tMin, v * tMax, tMax, "#f64", 2.5);

  // Proper time ticks along the worldline
  // Proper time interval dtau = dt / gamma
  // So coordinate time between ticks = gamma * 1 (one unit of proper time)
  const dtCoord = gamma;  // coordinate time per proper time unit
  for (let n = -Math.floor(range / dtCoord); n <= Math.floor(range / dtCoord); n++) {
    if (n === 0) continue;
    const tCoord = n * dtCoord;
    const xCoord = v * tCoord;
    if (Math.abs(tCoord) > range) continue;

    const [sx, sy] = toScreen(xCoord, tCoord);

    // Draw tick perpendicular to worldline direction
    const angle = Math.atan2(1, v);
    const perpX = -Math.sin(angle) * 5;
    const perpY = -Math.cos(angle) * 5;
    ctx.strokeStyle = "#f64";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(sx - perpX, sy - perpY);
    ctx.lineTo(sx + perpX, sy + perpY);
    ctx.stroke();
  }

  drawLabel(v * tMax, range - 0.5, `moving (${v.toFixed(2)}c)`, "#f64", 8, 0);

  // Show gamma value
  drawLabel(-6, -6.5, `gamma = ${gamma.toFixed(3)}`, "#f64", 0, 0);
  drawLabel(-6, -7.2, `Moving clock runs ${(1/gamma).toFixed(3)}x slower`, "#f64", 0, 0);
}

The tick marks on the moving observer’s worldline are spaced farther apart in coordinate time. This is time dilation: one second of proper time for the moving observer corresponds to gamma seconds of coordinate time for the stationary observer.

Step 7: Assemble and Animate

Wire everything together with the velocity slider:

function draw() {
  ctx.clearRect(0, 0, W, H);

  const v = parseInt(document.getElementById("velocity").value) / 100;
  document.getElementById("velocity-display").textContent = v.toFixed(2) + "c";

  drawAxes();
  drawLightCone();
  drawStationaryObserver();
  drawMovingObserver(v);

  // Legend
  ctx.fillStyle = "#888";
  ctx.font = "11px monospace";
  ctx.fillText("Tick marks = 1 unit of proper time", 10, H - 40);
  ctx.fillText("Dashed yellow = light (45 degrees, v=c)", 10, H - 24);
  ctx.fillText("Blue = stationary | Orange = moving", 10, H - 8);
}

draw();

document.getElementById("velocity").addEventListener("input", draw);

Reload the page. You should see the complete diagram.

Expected Output

When you open the page, you will see:

  • A dark coordinate grid with “space (x)” on the horizontal axis and “time (ct)” on the vertical axis
  • Dashed yellow lines at 45 degrees forming the light cone – the boundary of causal connection
  • A blue vertical line (stationary observer worldline) with evenly spaced tick marks
  • An orange tilted line (moving observer worldline at 0.60c) with tick marks spaced farther apart

When you adjust the velocity slider:

  • At v = 0.00c: the orange line overlaps the blue line (both stationary)
  • At v = 0.60c: gamma = 1.250, the moving clock runs 0.800x slower, ticks are visibly wider apart
  • At v = 0.90c: gamma = 2.294, the worldline tilts steeply toward the light cone, ticks are very far apart
  • At v = 0.95c: gamma = 3.203, the orange line nearly reaches 45 degrees – approaching the speed of light

The moving observer can never reach or cross the light cone (45 degrees), because that would require v >= c.

What You Learned