physics
Lissajous Figures
Interactive oscillations simulator. Explore frequency ratios, phase differences, and the beautiful mathematics of harmonic motion in real-time.
Frequency Ratio
1:1
Period
1.00s
Figure Type
Circle
Phase Δ
0°
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// Preset configurations
const presets = [
{ a: 1, b: 1, delta: 0, name: "1:1\nCircle" },
{ a: 1, b: 2, delta: 0, name: "1:2\nFigure-8" },
{ a: 1, b: 3, delta: 0, name: "1:3\nTrefoil" },
{ a: 2, b: 3, delta: 0, name: "2:3\nPretzel" },
{ a: 3, b: 4, delta: 0, name: "3:4\nKnot" },
{ a: 3, b: 5, delta: 0, name: "3:5\nRose" },
{ a: 4, b: 5, delta: 0, name: "4:5\nComplex" },
{ a: 5, b: 6, delta: 0, name: "5:6\nStar" },
{ a: 1, b: 1, delta: 90, name: "1:1\nPhase 90°" }
];
const figureNames = {
"1:1": "Circle",
"1:2": "Figure-8",
"1:3": "Trefoil",
"2:3": "Pretzel",
"3:4": "Complex Knot",
"3:5": "Rose",
"4:5": "Complex",
"5:6": "Star"
};
// State
let state = {
a: 1,
b: 1,
amplA: 150,
amplB: 150,
phase: 0,
speed: 1,
lineThickness: 1.5,
trailLength: 200,
mode: "trace", // trace or animate
render: "2d", // 2d or 3d
t: 0,
isAnimating: true
};
// Canvas elements
const mainCanvas = document.getElementById("mainCanvas");
const mainCtx = mainCanvas.getContext("2d");
const projectionXCanvas = document.getElementById("projectionX");
const projectionXCtx = projectionXCanvas.getContext("2d");
const projectionYCanvas = document.getElementById("projectionY");
const projectionYCtx = projectionYCanvas.getContext("2d");
// Helper functions
function gcd(a, b) {
return b === 0 ? a : gcd(b, a % b);
}
function lcm(a, b) {
return (a * b) / gcd(a, b);
}
function getRatioString(a, b) {
if (a % 1 !== 0 || b % 1 !== 0) {
return `${a.toFixed(1)}:${b.toFixed(1)}`;
}
const g = gcd(a, b);
return `${a / g}:${b / g}`;
}
function getFigureType(a, b) {
const ratio = getRatioString(a, b);
return figureNames[ratio] || "Custom";
}
function getPeriod(a, b) {
if (a % 1 !== 0 || b % 1 !== 0) {
return (2 * Math.PI) / Math.min(a, b);
}
const l = lcm(Math.round(a), Math.round(b));
return (2 * Math.PI * l) / a;
}
// Color gradient
function getGradientColor(t, maxT) {
const ratio = t / maxT;
const colors = [
{ pos: 0, r: 255, g: 34, b: 0 }, // #ff2200
{ pos: 0.25, r: 255, g: 136, b: 0 }, // #ff8800
{ pos: 0.5, r: 255, g: 204, b: 0 }, // #ffcc00
{ pos: 0.75, r: 0, g: 200, b: 150 }, // #00c896
{ pos: 1, r: 68, g: 136, b: 255 } // #4488ff
];
let color1, color2;
for (let i = 0; i < colors.length - 1; i++) {
if (ratio >= colors[i].pos && ratio <= colors[i + 1].pos) {
color1 = colors[i];
color2 = colors[i + 1];
const localRatio = (ratio - color1.pos) / (color2.pos - color1.pos);
const r = Math.round(color1.r + (color2.r - color1.r) * localRatio);
const g = Math.round(color1.g + (color2.g - color1.g) * localRatio);
const b = Math.round(color1.b + (color2.b - color1.b) * localRatio);
return `rgb(${r},${g},${b})`;
}
}
return "rgb(68,136,255)";
}
// Draw Lissajous figure
function drawLissajous(canvas, ctx, a, b, delta, amplA, amplB, mode = "trace") {
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const pad = 40;
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, w, h);
// Grid
ctx.strokeStyle = "#1e1e1e";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
ctx.beginPath();
ctx.moveTo((i * w) / 4, 0);
ctx.lineTo((i * w) / 4, h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, (i * h) / 4);
ctx.lineTo(w, (i * h) / 4);
ctx.stroke();
}
// Calculate period
const period = getPeriod(a, b);
const steps = Math.max(500, Math.round(period * 100));
if (mode === "trace") {
// Draw complete figure
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * period;
const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
const y = cy + amplB * Math.sin(b * t);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
// Draw with gradient
const gradient = ctx.createLinearGradient(cx - amplA, cy, cx + amplA, cy);
gradient.addColorStop(0, "#ff2200");
gradient.addColorStop(0.25, "#ff8800");
gradient.addColorStop(0.5, "#ffcc00");
gradient.addColorStop(0.75, "#00c896");
gradient.addColorStop(1, "#4488ff");
ctx.strokeStyle = gradient;
ctx.lineWidth = state.lineThickness;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
} else {
// Animate mode
const currentStep = Math.floor((state.t % period) / period * steps);
const trailStart = Math.max(0, currentStep - state.trailLength);
ctx.strokeStyle = "rgba(255, 34, 0, 0.3)";
ctx.lineWidth = state.lineThickness * 0.7;
ctx.beginPath();
let firstPoint = true;
for (let i = trailStart; i <= currentStep; i++) {
const t = (i / steps) * period;
const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
const y = cy + amplB * Math.sin(b * t);
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// Current point
const t = (currentStep / steps) * period;
const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
const y = cy + amplB * Math.sin(b * t);
ctx.fillStyle = "#ff2200";
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255, 34, 0, 0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.stroke();
}
}
// Draw 3D isometric Lissajous
function drawLissajous3D(canvas, ctx, a, b, delta, amplA, amplB) {
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, w, h);
const period = getPeriod(a, b);
const steps = Math.max(500, Math.round(period * 100));
// Isometric projection factors
const isometricScale = 0.7;
const zMax = 100;
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * period;
const x = amplA * Math.sin(a * t + (delta * Math.PI) / 180);
const y = amplB * Math.sin(b * t);
const z = (t / period) * zMax - zMax / 2;
// Simple isometric projection
const screenX = cx + (x * isometricScale - z * isometricScale * 0.5);
const screenY = cy + (y * isometricScale + z * isometricScale * 0.25);
if (i === 0) {
ctx.moveTo(screenX, screenY);
} else {
ctx.lineTo(screenX, screenY);
}
}
const gradient = ctx.createLinearGradient(cx - amplA, cy, cx + amplA, cy);
gradient.addColorStop(0, "#ff2200");
gradient.addColorStop(0.25, "#ff8800");
gradient.addColorStop(0.5, "#ffcc00");
gradient.addColorStop(0.75, "#00c896");
gradient.addColorStop(1, "#4488ff");
ctx.strokeStyle = gradient;
ctx.lineWidth = state.lineThickness;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
}
// Draw projections
function drawProjections(a, b, delta, amplA, amplB) {
const period = getPeriod(a, b);
const steps = Math.max(300, Math.round(period * 100));
// X projection
const wX = projectionXCanvas.width;
const hX = projectionXCanvas.height;
const cxX = wX / 2;
const cyX = hX / 2;
projectionXCtx.fillStyle = "#0a0a0a";
projectionXCtx.fillRect(0, 0, wX, hX);
projectionXCtx.strokeStyle = "#2a2a2a";
projectionXCtx.lineWidth = 1;
projectionXCtx.beginPath();
projectionXCtx.moveTo(0, cyX);
projectionXCtx.lineTo(wX, cyX);
projectionXCtx.stroke();
projectionXCtx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * period;
const x = cxX + amplA * Math.sin(a * t + (delta * Math.PI) / 180) * 0.8;
const y = cyX + 20;
if (i === 0) {
projectionXCtx.moveTo(x, y);
} else {
projectionXCtx.lineTo(x, y);
}
}
projectionXCtx.strokeStyle = "#ff8800";
projectionXCtx.lineWidth = state.lineThickness;
projectionXCtx.stroke();
// Current point on X projection
const tX = (state.t % period) / period * steps;
const xX = cxX + amplA * Math.sin(a * (tX / steps * period) + (delta * Math.PI) / 180) * 0.8;
projectionXCtx.fillStyle = "#ff8800";
projectionXCtx.beginPath();
projectionXCtx.arc(xX, cyX + 20, 4, 0, Math.PI * 2);
projectionXCtx.fill();
// Y projection
const wY = projectionYCanvas.width;
const hY = projectionYCanvas.height;
const cxY = wY / 2;
const cyY = hY / 2;
projectionYCtx.fillStyle = "#0a0a0a";
projectionYCtx.fillRect(0, 0, wY, hY);
projectionYCtx.strokeStyle = "#2a2a2a";
projectionYCtx.lineWidth = 1;
projectionYCtx.beginPath();
projectionYCtx.moveTo(cxY, 0);
projectionYCtx.lineTo(cxY, hY);
projectionYCtx.stroke();
projectionYCtx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * period;
const x = cxY + 20;
const y = cyY + amplB * Math.sin(b * t) * 0.8;
if (i === 0) {
projectionYCtx.moveTo(x, y);
} else {
projectionYCtx.lineTo(x, y);
}
}
projectionYCtx.strokeStyle = "#4488ff";
projectionYCtx.lineWidth = state.lineThickness;
projectionYCtx.stroke();
// Current point on Y projection
const tY = (state.t % period) / period * steps;
const yY = cyY + amplB * Math.sin(b * (tY / steps * period)) * 0.8;
projectionYCtx.fillStyle = "#4488ff";
projectionYCtx.beginPath();
projectionYCtx.arc(cxY + 20, yY, 4, 0, Math.PI * 2);
projectionYCtx.fill();
}
// Draw preset thumbnail
function drawPresetThumbnail(canvas, a, b, delta) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const amplA = 30;
const amplB = 30;
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, w, h);
const period = getPeriod(a, b);
const steps = Math.max(100, Math.round(period * 30));
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * period;
const x = cx + amplA * Math.sin(a * t + (delta * Math.PI) / 180);
const y = cy + amplB * Math.sin(b * t);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = "#ff2200";
ctx.lineWidth = 1.5;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
}
// Initialize presets gallery
function initializePresets() {
const gallery = document.getElementById("presetGallery");
gallery.innerHTML = "";
presets.forEach((preset, index) => {
const container = document.createElement("div");
container.className = "preset-thumbnail active";
if (state.a === preset.a && state.b === preset.b && state.phase === preset.delta) {
container.classList.add("active");
}
const canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 100;
drawPresetThumbnail(canvas, preset.a, preset.b, preset.delta);
const label = document.createElement("div");
label.className = "preset-label";
label.textContent = preset.name.replace("\n", " ");
container.appendChild(canvas);
container.appendChild(label);
container.addEventListener("click", () => {
state.a = preset.a;
state.b = preset.b;
state.phase = preset.delta;
state.t = 0;
updateUI();
render();
updatePresetGallery();
});
gallery.appendChild(container);
});
}
function updatePresetGallery() {
const thumbnails = document.querySelectorAll(".preset-thumbnail");
thumbnails.forEach((thumb) => {
thumb.classList.remove("active");
});
thumbnails[0].classList.add("active");
}
// UI Updates
function updateUI() {
document.getElementById("aValue").textContent = state.a.toFixed(1);
document.getElementById("bValue").textContent = state.b.toFixed(1);
document.getElementById("amplAValue").textContent = Math.round(state.amplA);
document.getElementById("amplBValue").textContent = Math.round(state.amplB);
document.getElementById("phaseValue").textContent = Math.round(state.phase) + "°";
document.getElementById("speedValue").textContent = state.speed.toFixed(1) + "×";
document.getElementById("thickValue").textContent = state.lineThickness.toFixed(1);
document.getElementById("trailValue").textContent = Math.round(state.trailLength);
document.getElementById("aSlider").value = state.a;
document.getElementById("aInput").value = state.a;
document.getElementById("bSlider").value = state.b;
document.getElementById("bInput").value = state.b;
document.getElementById("amplASlider").value = state.amplA;
document.getElementById("amplAInput").value = state.amplA;
document.getElementById("amplBSlider").value = state.amplB;
document.getElementById("amplBInput").value = state.amplB;
document.getElementById("phaseSlider").value = state.phase;
document.getElementById("phaseInput").value = state.phase;
document.getElementById("speedSlider").value = state.speed;
document.getElementById("thickSlider").value = state.lineThickness;
document.getElementById("trailSlider").value = state.trailLength;
// Info panel
const ratio = getRatioString(state.a, state.b);
document.getElementById("ratioInfo").textContent = ratio;
document.getElementById("periodInfo").textContent = (getPeriod(state.a, state.b) / Math.PI).toFixed(2) + "π";
document.getElementById("figureTypeInfo").textContent = getFigureType(state.a, state.b);
document.getElementById("phaseInfo").textContent = Math.round(state.phase) + "°";
}
// Sync controls
function setupControlSync() {
// A slider <-> input
document.getElementById("aSlider").addEventListener("input", (e) => {
state.a = parseFloat(e.target.value);
state.t = 0;
updateUI();
render();
});
document.getElementById("aInput").addEventListener("input", (e) => {
state.a = parseFloat(e.target.value) || 1;
state.t = 0;
updateUI();
render();
});
// B slider <-> input
document.getElementById("bSlider").addEventListener("input", (e) => {
state.b = parseFloat(e.target.value);
state.t = 0;
updateUI();
render();
});
document.getElementById("bInput").addEventListener("input", (e) => {
state.b = parseFloat(e.target.value) || 1;
state.t = 0;
updateUI();
render();
});
// Amplitude A
document.getElementById("amplASlider").addEventListener("input", (e) => {
state.amplA = parseFloat(e.target.value);
updateUI();
render();
});
document.getElementById("amplAInput").addEventListener("input", (e) => {
state.amplA = parseFloat(e.target.value) || 50;
updateUI();
render();
});
// Amplitude B
document.getElementById("amplBSlider").addEventListener("input", (e) => {
state.amplB = parseFloat(e.target.value);
updateUI();
render();
});
document.getElementById("amplBInput").addEventListener("input", (e) => {
state.amplB = parseFloat(e.target.value) || 50;
updateUI();
render();
});
// Phase
document.getElementById("phaseSlider").addEventListener("input", (e) => {
state.phase = parseFloat(e.target.value);
updateUI();
render();
});
document.getElementById("phaseInput").addEventListener("input", (e) => {
state.phase = parseFloat(e.target.value) || 0;
updateUI();
render();
});
// Speed
document.getElementById("speedSlider").addEventListener("input", (e) => {
state.speed = parseFloat(e.target.value);
updateUI();
});
// Line thickness
document.getElementById("thickSlider").addEventListener("input", (e) => {
state.lineThickness = parseFloat(e.target.value);
updateUI();
render();
});
// Trail length
document.getElementById("trailSlider").addEventListener("input", (e) => {
state.trailLength = parseFloat(e.target.value);
updateUI();
});
// Mode buttons
document.getElementById("modeTrace").addEventListener("click", () => {
state.mode = "trace";
document.getElementById("modeTrace").classList.add("active");
document.getElementById("modeAnimate").classList.remove("active");
state.t = 0;
render();
});
document.getElementById("modeAnimate").addEventListener("click", () => {
state.mode = "animate";
document.getElementById("modeAnimate").classList.add("active");
document.getElementById("modeTrace").classList.remove("active");
state.t = 0;
});
// Render buttons
document.getElementById("render2D").addEventListener("click", () => {
state.render = "2d";
document.getElementById("render2D").classList.add("active");
document.getElementById("render3D").classList.remove("active");
render();
});
document.getElementById("render3D").addEventListener("click", () => {
state.render = "3d";
document.getElementById("render3D").classList.add("active");
document.getElementById("render2D").classList.remove("active");
render();
});
// Action buttons
document.getElementById("resetBtn").addEventListener("click", () => {
state = {
a: 1,
b: 1,
amplA: 150,
amplB: 150,
phase: 0,
speed: 1,
lineThickness: 1.5,
trailLength: 200,
mode: "trace",
render: "2d",
t: 0,
isAnimating: true
};
document.getElementById("modeTrace").classList.add("active");
document.getElementById("modeAnimate").classList.remove("active");
document.getElementById("render2D").classList.add("active");
document.getElementById("render3D").classList.remove("active");
updateUI();
render();
updatePresetGallery();
});
document.getElementById("clearBtn").addEventListener("click", () => {
state.t = 0;
render();
});
}
// Main render function
function render() {
if (state.render === "2d") {
drawLissajous(mainCanvas, mainCtx, state.a, state.b, state.phase, state.amplA, state.amplB, state.mode);
} else {
drawLissajous3D(mainCanvas, mainCtx, state.a, state.b, state.phase, state.amplA, state.amplB);
}
drawProjections(state.a, state.b, state.phase, state.amplA, state.amplB);
}
// Animation loop
function animate() {
if (state.mode === "animate") {
const period = getPeriod(state.a, state.b);
state.t += 0.016 * state.speed; // ~60fps
if (state.t > period) {
state.t = 0;
}
}
render();
requestAnimationFrame(animate);
}
// Initialize
initializePresets();
setupControlSync();
updateUI();
render();
animate();