ten-minute-physics/src/pendulum/mod.tsx
monoid bf44ea8ae7 refactor: pendulum simulation
Refactor pendulum simulation: implement lazy loading for Pendulum component, add drawing utilities, and enhance world generation logic
2025-08-24 21:22:24 +09:00

167 lines
No EOL
6.2 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { updateWorld } from "./physics";
import { drawWorld } from "./drawing";
import { getDefaultMyWorld, type WorldState } from "./worlds";
export default function Pendulum() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [subStep, setSubStep] = useState(10); // number of sub-steps per frame
const [constraintIterations, setConstraintIterations] = useState(1); // number of constraint iterations per sub-step
// refs to hold pendulum instances and animation state so we can reset from UI
const worldRef = useRef<WorldState | null>(null);
const rafRef = useRef<number | null>(null);
const prevTimeRef = useRef<number>(0);
// Drag state
const draggingRef = useRef<{ ballIdx: number | null, offset: [number, number] | null }>({ ballIdx: null, offset: null });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const resize = () => {
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 400;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
if (!worldRef.current) {
worldRef.current = getDefaultMyWorld();
}
prevTimeRef.current = performance.now();
const animate = (time: number) => {
const deltaTime = time - prevTimeRef.current;
prevTimeRef.current = time;
const dt = Math.min(0.1, deltaTime / 1000) / subStep;
for (let i = 0; i < subStep; i++) {
if (worldRef.current) {
updateWorld(worldRef.current, dt, constraintIterations);
}
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
if (worldRef.current) {
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
// --- Drag interaction handlers ---
function getPointerPos(evt: PointerEvent) {
if (!canvas) return [0, 0];
const rect = canvas.getBoundingClientRect();
return [evt.clientX - rect.left, evt.clientY - rect.top] as [number, number];
}
function onPointerDown(e: PointerEvent) {
if (!worldRef.current) return;
const pos = getPointerPos(e);
let minDist = Infinity;
let closestIdx: number | null = null;
worldRef.current.balls.forEach((ball, idx) => {
const dx = ball.pos[0] - pos[0];
const dy = ball.pos[1] - pos[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 25 && dist < minDist && !ball.isFixed) { // Only allow dragging non-fixed balls
minDist = dist;
closestIdx = idx;
}
});
if (closestIdx !== null) {
draggingRef.current.ballIdx = closestIdx;
draggingRef.current.offset = [
worldRef.current.balls[closestIdx].pos[0] - pos[0],
worldRef.current.balls[closestIdx].pos[1] - pos[1],
];
// Pause animation while dragging
if (rafRef.current) cancelAnimationFrame(rafRef.current);
}
}
function onPointerMove(e: PointerEvent) {
if (!worldRef.current) return;
if (draggingRef.current.ballIdx === null) return;
const pos = getPointerPos(e);
const idx = draggingRef.current.ballIdx;
const offset = draggingRef.current.offset || [0, 0];
// Update ball position
worldRef.current.balls[idx].pos = [pos[0] + offset[0], pos[1] + offset[1]];
// Optionally update prevPos for stability
worldRef.current.balls[idx].prevPos = [pos[0] + offset[0], pos[1] + offset[1]];
// Redraw
if (!ctx) return;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
}
function onPointerUp() {
if (draggingRef.current.ballIdx !== null) {
draggingRef.current.ballIdx = null;
draggingRef.current.offset = null;
// Resume animation
prevTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(animate);
}
}
canvas.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
window.removeEventListener("resize", resize);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
canvas.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, [constraintIterations, subStep]);
// reset handler to re-create pendulums and avoid large dt on next frame
const reset = () => {
worldRef.current = getDefaultMyWorld();
prevTimeRef.current = performance.now();
};
return (
<div className="w-full h-full flex flex-col">
<div className="p-2">
<button
onClick={reset}
className="px-3 py-1.5 rounded border border-gray-300 bg-white text-sm hover:bg-gray-50 focus:outline-none"
>
</button>
<label className="ml-4 text-sm">
:
<input
type="number"
value={subStep}
onChange={(e) => setSubStep(Math.max(1, Math.min(100, Number(e.target.value))))}
className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
/>
</label>
<label className="ml-4 text-sm">
:
<input
type="number"
value={constraintIterations}
onChange={(e) => setConstraintIterations(Math.max(1, Math.min(200, Number(e.target.value))))}
className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
/>
</label>
</div>
<div className="flex-1 min-h-0">
<canvas
style={{ touchAction: "none" }}
ref={canvasRef}
className="w-full h-full"
/>
</div>
</div>
);
}