special relativity

Spacetime Diagram

Interactive Minkowski diagram explorer. Visualize world lines, light cones, Lorentz transformations, and relativistic paradoxes in special relativity.

Click to add events · Drag to pan · Scroll to zoom
0 0.50 c
0.5× 1.00×
Velocity (v)
0.50c
Lorentz Factor (γ)
1.155
Angle (θ)
26.6°
Shows a single moving object's world line (red) against a stationary observer (white). Adjust velocity to see how the world line tilts in spacetime.
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

const canvas = document.getElementById('spacetimeCanvas');
    const ctx = canvas.getContext('2d');

    const CONSTANTS = {
      c: 1,
      scale: 80,
    };

    let state = {
      scenario: 'single',
      velocity: 0.5,
      zoom: 1,
      panX: 0,
      panY: 0,
      events: [],
      selectedEvent: null,
      isAnimating: false,
      animationTime: 0,
      showPrimed: true,
      showLightCone: true,
      showHyperbolas: false,
      showSimultaneity: false,
      showGrid: false,
    };

    // UI Elements
    const velocitySlider = document.getElementById('velocitySlider');
    const zoomSlider = document.getElementById('zoomSlider');
    const velocityDisplay = document.getElementById('velocityDisplay');
    const zoomDisplay = document.getElementById('zoomDisplay');
    const playBtn = document.getElementById('playBtn');
    const resetBtn = document.getElementById('resetBtn');
    const scenarioButtons = document.querySelectorAll('[data-scenario]');
    const showPrimedCb = document.getElementById('showPrimed');
    const showLightConeCb = document.getElementById('showLightCone');
    const showHyperbolicCb = document.getElementById('showHyperbolas');
    const showSimultaneityCb = document.getElementById('showSimultaneity');
    const showGridCb = document.getElementById('showGrid');
    const statVelocity = document.getElementById('statVelocity');
    const statGamma = document.getElementById('statGamma');
    const statAngle = document.getElementById('statAngle');
    const scenarioDesc = document.getElementById('scenarioDesc');
    const animationHint = document.getElementById('animationHint');
    const eventStats = document.getElementById('eventStats');
    const statInterval = document.getElementById('statInterval');
    const statIntervalType = document.getElementById('statIntervalType');

    function beta() {
      return state.velocity;
    }

    function gamma() {
      const b = beta();
      return 1 / Math.sqrt(1 - b * b);
    }

    function lorentzAngle() {
      return Math.atan(beta());
    }

    function updateStats() {
      const b = beta();
      const g = gamma();
      const angle = (lorentzAngle() * 180) / Math.PI;

      statVelocity.textContent = (b * 100).toFixed(0) + '%c';
      statGamma.textContent = g.toFixed(3);
      statAngle.textContent = angle.toFixed(1) + '°';
    }

    function drawGrid() {
      if (!state.showGrid) return;
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 0.5;
      const scale = CONSTANTS.scale * state.zoom;
      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;

      for (let i = -10; i <= 10; i++) {
        if (i === 0) continue;
        ctx.beginPath();
        ctx.moveTo(centerX + i * scale, centerY - canvas.height / 2);
        ctx.lineTo(centerX + i * scale, centerY + canvas.height / 2);
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(centerX - canvas.width / 2, centerY + i * scale);
        ctx.lineTo(centerX + canvas.width / 2, centerY + i * scale);
        ctx.stroke();
      }
    }

    function drawAxes() {
      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;

      ctx.strokeStyle = '#e8e0d5';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(centerX - 10 * scale, centerY);
      ctx.lineTo(centerX + 10 * scale, centerY);
      ctx.stroke();

      ctx.beginPath();
      ctx.moveTo(centerX, centerY - 10 * scale);
      ctx.lineTo(centerX, centerY + 10 * scale);
      ctx.stroke();

      ctx.fillStyle = '#e8e0d5';
      ctx.font = 'bold 12px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'top';
      ctx.fillText('x', centerX + 9 * scale + 15, centerY - 10);
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      ctx.fillText('ct', centerX - 10, centerY - 9 * scale - 10);

      ctx.fillStyle = '#555555';
      ctx.font = '10px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'top';
      for (let i = -9; i <= 9; i += 3) {
        if (i !== 0) {
          ctx.fillText(i.toString(), centerX + i * scale, centerY + 12);
        }
      }

      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      for (let i = -9; i <= 9; i += 3) {
        if (i !== 0) {
          ctx.fillText(i.toString(), centerX - 12, centerY - i * scale);
        }
      }

      ctx.fillStyle = '#1e1e1e';
      const radius = 3;
      ctx.beginPath();
      ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    function drawLightCone() {
      if (!state.showLightCone) return;
      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;
      const extent = 10 * scale;

      ctx.strokeStyle = '#f5c518';
      ctx.lineWidth = 1.5;
      ctx.setLineDash([5, 5]);

      ctx.beginPath();
      ctx.moveTo(centerX - extent, centerY + extent);
      ctx.lineTo(centerX + extent, centerY - extent);
      ctx.stroke();

      ctx.beginPath();
      ctx.moveTo(centerX - extent, centerY - extent);
      ctx.lineTo(centerX + extent, centerY + extent);
      ctx.stroke();

      ctx.setLineDash([]);
    }

    function drawPrimedAxes() {
      if (!state.showPrimed) return;

      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;
      const angle = lorentzAngle();

      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 1.5;
      ctx.globalAlpha = 0.6;

      const extent = 8 * scale;

      // ct' axis
      ctx.beginPath();
      ctx.moveTo(centerX - Math.cos(angle) * extent, centerY + Math.sin(angle) * extent);
      ctx.lineTo(centerX + Math.cos(angle) * extent, centerY - Math.sin(angle) * extent);
      ctx.stroke();

      // x' axis
      ctx.beginPath();
      ctx.moveTo(centerX - Math.sin(angle) * extent, centerY - Math.cos(angle) * extent);
      ctx.lineTo(centerX + Math.sin(angle) * extent, centerY + Math.cos(angle) * extent);
      ctx.stroke();

      ctx.globalAlpha = 1;

      ctx.fillStyle = '#ff2200';
      ctx.font = '10px "DM Mono"';
      ctx.globalAlpha = 0.6;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'bottom';
      ctx.fillText("ct'", centerX + Math.cos(angle) * extent + 15, centerY - Math.sin(angle) * extent - 5);
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      ctx.fillText("x'", centerX + Math.sin(angle) * extent - 10, centerY + Math.cos(angle) * extent + 10);
      ctx.globalAlpha = 1;
    }

    function drawHyperbolas() {
      if (!state.showHyperbolas) return;

      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;

      ctx.strokeStyle = '#00c896';
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.4;
      ctx.setLineDash([3, 3]);

      for (let s = 1; s <= 3; s++) {
        ctx.beginPath();
        let first = true;
        for (let ct = -8; ct <= 8; ct += 0.2) {
          const x2 = ct * ct - s * s;
          if (x2 >= 0) {
            const x = Math.sqrt(x2);
            const px = centerX + x * scale;
            const py = centerY - ct * scale;
            if (first) {
              ctx.moveTo(px, py);
              first = false;
            } else {
              ctx.lineTo(px, py);
            }
          }
        }
        ctx.stroke();

        ctx.beginPath();
        first = true;
        for (let ct = -8; ct <= 8; ct += 0.2) {
          const x2 = ct * ct - s * s;
          if (x2 >= 0) {
            const x = -Math.sqrt(x2);
            const px = centerX + x * scale;
            const py = centerY - ct * scale;
            if (first) {
              ctx.moveTo(px, py);
              first = false;
            } else {
              ctx.lineTo(px, py);
            }
          }
        }
        ctx.stroke();
      }

      ctx.setLineDash([]);
      ctx.globalAlpha = 1;
    }

    function drawSimultaneitySample() {
      if (!state.showSimultaneity) return;

      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;
      const angle = lorentzAngle();

      ctx.strokeStyle = '#ff5555';
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.3;
      ctx.setLineDash([4, 4]);

      const extent = 8 * scale;

      for (let t = -3; t <= 3; t++) {
        const ct = t * 2 * scale;
        const dx = extent;

        ctx.beginPath();
        ctx.moveTo(centerX - Math.sin(angle) * dx, centerY + Math.cos(angle) * dx - ct);
        ctx.lineTo(centerX + Math.sin(angle) * dx, centerY - Math.cos(angle) * dx - ct);
        ctx.stroke();
      }

      ctx.setLineDash([]);
      ctx.globalAlpha = 1;
    }

    function drawWorldLines() {
      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;

      if (state.scenario === 'single' || state.scenario === 'twin') {
        ctx.strokeStyle = '#e8e0d5';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(centerX, centerY - 9 * scale);
        ctx.lineTo(centerX, centerY + 9 * scale);
        ctx.stroke();

        if (state.scenario === 'twin') {
          ctx.strokeStyle = '#ff2200';
          ctx.lineWidth = 2;

          const b = beta();
          const angle = Math.atan(b);

          const outExtent = 5 * scale;
          const startCt = -3 * scale;
          const returnCt = 3 * scale;

          ctx.beginPath();
          ctx.moveTo(centerX, centerY + startCt);
          const outX = outExtent * Math.sin(angle);
          const outCt = outExtent * Math.cos(angle);
          ctx.lineTo(centerX + outX, centerY - outCt);

          ctx.lineTo(centerX - outX, centerY - returnCt);
          ctx.lineTo(centerX, centerY + returnCt);
          ctx.stroke();
        } else {
          const b = beta();
          const angle = Math.atan(b);

          ctx.strokeStyle = '#ff2200';
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.moveTo(centerX, centerY - 9 * scale);

          const slope = Math.tan(angle);
          ctx.moveTo(centerX - 4 * scale, centerY - (9 - 4 * slope) * scale);
          ctx.lineTo(centerX + 4 * scale, centerY - (9 + 4 * slope) * scale);
          ctx.stroke();
        }
      } else if (state.scenario === 'signals') {
        ctx.strokeStyle = '#e8e0d5';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(centerX - 4 * scale, centerY - 8 * scale);
        ctx.lineTo(centerX - 4 * scale, centerY + 8 * scale);
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(centerX + 4 * scale, centerY - 8 * scale);
        ctx.lineTo(centerX + 4 * scale, centerY + 8 * scale);
        ctx.stroke();

        ctx.strokeStyle = '#f5c518';
        ctx.lineWidth = 1;
        ctx.setLineDash([3, 3]);

        ctx.beginPath();
        ctx.moveTo(centerX - 4 * scale, centerY - 2 * scale);
        ctx.lineTo(centerX + 4 * scale, centerY + 2 * scale);
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(centerX + 4 * scale, centerY - 3 * scale);
        ctx.lineTo(centerX - 4 * scale, centerY + 3 * scale);
        ctx.stroke();

        ctx.setLineDash([]);
      } else if (state.scenario === 'simultaneity') {
        ctx.strokeStyle = '#e8e0d5';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(centerX, centerY - 9 * scale);
        ctx.lineTo(centerX, centerY + 9 * scale);
        ctx.stroke();

        const b = beta();
        const angle = Math.atan(b);

        ctx.strokeStyle = '#ff2200';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(centerX, centerY - 9 * scale);
        ctx.lineTo(centerX + 9 * Math.cos(angle) * scale, centerY - 9 * Math.sin(angle) * scale);
        ctx.stroke();

        ctx.strokeStyle = '#e8e0d5';
        ctx.setLineDash([3, 3]);
        const eventX = 3 * scale;
        ctx.beginPath();
        ctx.moveTo(centerX - 5 * scale, centerY - eventX);
        ctx.lineTo(centerX + 5 * scale, centerY - eventX);
        ctx.stroke();

        ctx.strokeStyle = '#ff2200';
        ctx.beginPath();
        ctx.moveTo(centerX - 5 * Math.sin(angle) * scale, centerY - eventX - 5 * Math.cos(angle) * scale);
        ctx.lineTo(centerX + 5 * Math.sin(angle) * scale, centerY - eventX + 5 * Math.cos(angle) * scale);
        ctx.stroke();

        ctx.setLineDash([]);
      }
    }

    function drawEvents() {
      const centerX = canvas.width / 2 + state.panX;
      const centerY = canvas.height / 2 + state.panY;
      const scale = CONSTANTS.scale * state.zoom;

      state.events.forEach((event, idx) => {
        const px = centerX + event.x * scale;
        const py = centerY - event.ct * scale;

        if (idx === state.selectedEvent) {
          ctx.fillStyle = '#ff5555';
          ctx.strokeStyle = '#ff5555';
          ctx.lineWidth = 2;
        } else {
          ctx.fillStyle = '#00c896';
          ctx.strokeStyle = '#00c896';
          ctx.lineWidth = 1;
        }

        ctx.beginPath();
        ctx.arc(px, py, 5, 0, Math.PI * 2);
        ctx.fill();
        ctx.stroke();
      });

      if (state.events.length === 2) {
        const e1 = state.events[0];
        const e2 = state.events[1];

        ctx.strokeStyle = '#555555';
        ctx.lineWidth = 1;
        ctx.setLineDash([3, 3]);
        ctx.beginPath();
        ctx.moveTo(centerX + e1.x * scale, centerY - e1.ct * scale);
        ctx.lineTo(centerX + e2.x * scale, centerY - e2.ct * scale);
        ctx.stroke();
        ctx.setLineDash([]);

        const deltaX = e2.x - e1.x;
        const deltaCt = e2.ct - e1.ct;
        const interval = deltaCt * deltaCt - deltaX * deltaX;

        statInterval.textContent = interval.toFixed(3);

        if (interval > 0.01) {
          statIntervalType.textContent = 'Timelike (separation in time)';
        } else if (interval < -0.01) {
          statIntervalType.textContent = 'Spacelike (separation in space)';
        } else {
          statIntervalType.textContent = 'Lightlike (on light cone)';
        }
        eventStats.style.display = 'grid';
      } else {
        eventStats.style.display = 'none';
      }
    }

    function draw() {
      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      drawGrid();
      drawLightCone();
      drawHyperbolas();
      drawSimultaneitySample();
      drawAxes();
      drawPrimedAxes();
      drawWorldLines();
      drawEvents();
    }

    function handleScenarioChange(scenario) {
      state.scenario = scenario;
      state.events = [];
      state.selectedEvent = null;
      state.animationTime = 0;
      state.isAnimating = false;
      playBtn.textContent = 'Play';

      scenarioButtons.forEach(btn => btn.classList.remove('active'));
      document.querySelector(`[data-scenario="${scenario}"]`).classList.add('active');

      const descriptions = {
        single: 'Shows a single moving object world line (red) against stationary observer (white). Adjust velocity to see world line tilt.',
        twin: 'Twin paradox: stationary twin (white) vs traveling twin (red). Click Play to animate. The bent world line has shorter proper time.',
        signals: 'Light signals between two observers. Light paths are 45° lines. Demonstrates light cone causality structure.',
        simultaneity: 'Relativity of simultaneity. Events simultaneous in frame S (white line) are not simultaneous in frame S\' (red line).',
      };
      scenarioDesc.textContent = descriptions[scenario];

      if (scenario === 'twin') {
        animationHint.textContent = 'Click Play to animate the twin paradox and see proper time accumulation.';
      } else {
        animationHint.textContent = '';
      }

      draw();
    }

    function handlePlayAnimation() {
      if (state.scenario === 'twin') {
        state.isAnimating = !state.isAnimating;
        playBtn.textContent = state.isAnimating ? 'Pause' : 'Play';
        if (state.isAnimating) {
          animateFrame();
        }
      }
    }

    function animateFrame() {
      if (!state.isAnimating) return;

      state.animationTime += 0.05;
      if (state.animationTime > 2) {
        state.animationTime = 0;
      }

      draw();
      requestAnimationFrame(animateFrame);
    }

    function handleCanvasClick(e) {
      const rect = canvas.getBoundingClientRect();
      const x = (e.clientX - rect.left - canvas.width / 2 - state.panX) / (CONSTANTS.scale * state.zoom);
      const ct = (canvas.height / 2 + state.panY - (e.clientY - rect.top)) / (CONSTANTS.scale * state.zoom);

      if (state.events.length < 2) {
        state.events.push({ x, ct });
        if (state.events.length === 1) {
          state.selectedEvent = 0;
        } else {
          state.selectedEvent = 1;
        }
      } else {
        state.events = [{ x, ct }];
        state.selectedEvent = 0;
      }

      draw();
    }

    canvas.addEventListener('click', handleCanvasClick);

    velocitySlider.addEventListener('input', (e) => {
      state.velocity = parseFloat(e.target.value);
      velocityDisplay.textContent = state.velocity.toFixed(2);
      updateStats();
      draw();
    });

    zoomSlider.addEventListener('input', (e) => {
      state.zoom = parseFloat(e.target.value);
      zoomDisplay.textContent = state.zoom.toFixed(2) + '×';
      draw();
    });

    playBtn.addEventListener('click', handlePlayAnimation);

    resetBtn.addEventListener('click', () => {
      state.panX = 0;
      state.panY = 0;
      state.zoom = 1;
      state.velocity = 0.5;
      state.events = [];
      state.selectedEvent = null;
      state.animationTime = 0;
      state.isAnimating = false;
      playBtn.textContent = 'Play';
      velocitySlider.value = 0.5;
      zoomSlider.value = 1;
      velocityDisplay.textContent = '0.50';
      zoomDisplay.textContent = '1.00×';
      updateStats();
      draw();
    });

    scenarioButtons.forEach(btn => {
      btn.addEventListener('click', () => {
        handleScenarioChange(btn.dataset.scenario);
      });
    });

    showPrimedCb.addEventListener('change', (e) => {
      state.showPrimed = e.target.checked;
      draw();
    });

    showLightConeCb.addEventListener('change', (e) => {
      state.showLightCone = e.target.checked;
      draw();
    });

    showHyperbolicCb.addEventListener('change', (e) => {
      state.showHyperbolas = e.target.checked;
      draw();
    });

    showSimultaneityCb.addEventListener('change', (e) => {
      state.showSimultaneity = e.target.checked;
      draw();
    });

    showGridCb.addEventListener('change', (e) => {
      state.showGrid = e.target.checked;
      draw();
    });

    canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      const delta = e.deltaY > 0 ? 0.9 : 1.1;
      state.zoom = Math.max(0.5, Math.min(3, state.zoom * delta));
      zoomSlider.value = state.zoom;
      zoomDisplay.textContent = state.zoom.toFixed(2) + '×';
      draw();
    });

    let isDragging = false;
    let dragStartX, dragStartY;

    canvas.addEventListener('mousedown', (e) => {
      isDragging = true;
      dragStartX = e.clientX;
      dragStartY = e.clientY;
    });

    canvas.addEventListener('mousemove', (e) => {
      if (isDragging && e.buttons === 2) {
        const dx = e.clientX - dragStartX;
        const dy = e.clientY - dragStartY;
        state.panX += dx;
        state.panY += dy;
        dragStartX = e.clientX;
        dragStartY = e.clientY;
        draw();
      }
    });

    canvas.addEventListener('mouseup', () => {
      isDragging = false;
    });

    canvas.addEventListener('contextmenu', (e) => {
      e.preventDefault();
    });

    updateStats();
    draw();