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
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();