import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; const G = 9.81; const DURATION = 3; type PhysicsState = { state: "Static" | "Moving"; normalForce: number; maxStaticFriction: number; friction: number; netForce: number; acceleration: number; velocity: number; displacement: number; }; function round(value: number, digits = 2) { const p = 10 ** digits; return Math.round(value * p) / p; } function format(value: number, unit = "") { const n = Math.abs(value) < 1e-9 ? 0 : round(value, 2); return `${Number.isInteger(n) ? n : n.toFixed(2)}${unit}`; } function drawArrow( ctx: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number, color: string, width: number ) { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.stroke(); const angle = Math.atan2(toY - fromY, toX - fromX); const head = 10; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(toX, toY); ctx.lineTo( toX - head * Math.cos(angle - Math.PI / 6), toY - head * Math.sin(angle - Math.PI / 6) ); ctx.lineTo( toX - head * Math.cos(angle + Math.PI / 6), toY - head * Math.sin(angle + Math.PI / 6) ); ctx.closePath(); ctx.fill(); } export default function ForceLab() { const [massKg, setMassKg] = createSignal(10); const [appliedForceN, setAppliedForceN] = createSignal(60); const [muStatic, setMuStatic] = createSignal(0.5); const [muKinetic, setMuKinetic] = createSignal(0.3); const [time, setTime] = createSignal(0); const [playing, setPlaying] = createSignal(false); let simCanvasRef: HTMLCanvasElement | undefined; let raf = 0; let lastTime = 0; let lastCanvasWidth = 0; let lastCanvasHeight = 0; const physicsAt = (t: number): PhysicsState => { const normalForce = massKg() * G; const maxStaticFriction = muStatic() * normalForce; const startsMoving = appliedForceN() > maxStaticFriction; if (!startsMoving) { return { state: "Static", normalForce, maxStaticFriction, friction: appliedForceN(), netForce: 0, acceleration: 0, velocity: 0, displacement: 0, }; } const friction = muKinetic() * normalForce; const netForce = appliedForceN() - friction; const acceleration = netForce / massKg(); return { state: "Moving", normalForce, maxStaticFriction, friction, netForce, acceleration, velocity: acceleration * t, displacement: 0.5 * acceleration * t * t, }; }; const current = createMemo(() => physicsAt(time())); const pause = () => { setPlaying(false); if (raf) cancelAnimationFrame(raf); raf = 0; }; const play = () => { if (time() >= DURATION) setTime(0); setPlaying(true); lastTime = 0; const step = (stamp: number) => { if (!playing()) return; if (!lastTime) lastTime = stamp; const dt = (stamp - lastTime) / 1000; lastTime = stamp; setTime((prev) => { const next = Math.min(DURATION, prev + dt); if (next >= DURATION) { setPlaying(false); } return next; }); if (playing()) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); }; const reset = () => { pause(); setTime(0); }; createEffect(() => { const uk = muKinetic(); const us = muStatic(); if (uk > us) { setMuStatic(uk); } }); createEffect(() => { const canvas = simCanvasRef; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const width = Math.max(420, Math.floor(rect.width || 640)); const height = 250; if (width !== lastCanvasWidth || height !== lastCanvasHeight) { lastCanvasWidth = width; lastCanvasHeight = height; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.height = `${height}px`; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const p = current(); const style = getComputedStyle(canvas); const bg = style.getPropertyValue("--force-canvas-bg").trim() || "#f8fafc"; const grid = style.getPropertyValue("--force-canvas-grid").trim() || "#d7dde6"; const ink = style.getPropertyValue("--force-canvas-ink").trim() || "#111827"; ctx.clearRect(0, 0, width, height); ctx.fillStyle = bg; ctx.fillRect(0, 0, width, height); const groundY = height - 58; const scalePxPerM = 26; const visualShift = p.displacement * scalePxPerM * 0.9; const gridSize = 28; const gridPhase = ((visualShift % gridSize) + gridSize) % gridSize; ctx.strokeStyle = grid; ctx.lineWidth = 1; for (let x = -gridPhase; x < width; x += gridSize) { const px = Math.round(x) + 0.5; ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, groundY); ctx.stroke(); } ctx.strokeStyle = ink; ctx.lineWidth = 2; ctx.lineJoin = "round"; const toothWidth = 12; const toothHeight = 8; const toothPeriod = toothWidth * 2; const toothPhase = ((visualShift % toothPeriod) + toothPeriod) % toothPeriod; ctx.beginPath(); let started = false; for (let i = -4; ; i++) { const x = i * toothWidth - toothPhase; if (x > width + toothWidth * 4) break; const y = i % 2 === 0 ? groundY : groundY + toothHeight; if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); } } ctx.stroke(); const blockSize = Math.max(44, Math.min(86, 52 * Math.sqrt(massKg() / 10))); const blockX = width * 0.33; const blockY = groundY - blockSize; const grad = ctx.createLinearGradient(blockX, blockY, blockX + blockSize, blockY + blockSize); grad.addColorStop(0, "#ef4444"); grad.addColorStop(1, "#b91c1c"); ctx.fillStyle = grad; ctx.fillRect(blockX, blockY, blockSize, blockSize); ctx.strokeStyle = ink; ctx.lineWidth = 3; ctx.strokeRect(blockX, blockY, blockSize, blockSize); const centerY = blockY + blockSize / 2; const maxForce = Math.max(30, appliedForceN(), p.friction); const pullLen = 40 + (appliedForceN() / maxForce) * 130; const fricLen = 40 + (p.friction / maxForce) * 130; drawArrow( ctx, blockX + blockSize, centerY - 12, blockX + blockSize + pullLen, centerY - 12, "#2563eb", 4 ); drawArrow(ctx, blockX, centerY + 12, blockX - fricLen, centerY + 12, "#ef4444", 4); const vertLen = Math.max(24, Math.min(72, (massKg() * G) / 2.8)); drawArrow( ctx, blockX + blockSize / 2, blockY, blockX + blockSize / 2, blockY - vertLen, "#06b6d4", 2.5 ); drawArrow( ctx, blockX + blockSize / 2, blockY + blockSize, blockX + blockSize / 2, blockY + blockSize + vertLen, "#f97316", 2.5 ); ctx.fillStyle = "#1e293b"; ctx.font = "600 14px Inter, sans-serif"; ctx.textAlign = "left"; ctx.fillText("F", blockX + blockSize + pullLen + 8, centerY - 14); ctx.fillStyle = "#7f1d1d"; ctx.fillText("f_fric", blockX - fricLen - 56, centerY + 8); ctx.fillStyle = "#164e63"; ctx.font = "500 11px Inter, sans-serif"; ctx.textAlign = "center"; ctx.fillText(`F_N ${p.normalForce.toFixed(0)} N`, blockX + blockSize / 2, blockY - vertLen - 8); ctx.fillStyle = "#7c2d12"; ctx.fillText( `W ${p.normalForce.toFixed(0)} N`, blockX + blockSize / 2, blockY + blockSize + vertLen + 14 ); ctx.fillStyle = "#0f172a"; ctx.font = "700 11px Inter, sans-serif"; ctx.textAlign = "center"; ctx.fillText(`${massKg().toFixed(1)} kg`, blockX + blockSize / 2, blockY + blockSize / 2 + 3); }); onCleanup(() => { if (raf) cancelAnimationFrame(raf); }); return (

Force lab: animated friction simulation

{ reset(); setMassKg(Number(e.currentTarget.value)); }} />
{ reset(); setAppliedForceN(Number(e.currentTarget.value)); }} />
{ reset(); setMuStatic(Number(e.currentTarget.value)); }} />
{ reset(); setMuKinetic(Number(e.currentTarget.value)); }} />
{current().state === "Moving" ? "Moving / Sliding" : "Static State"} {format(time(), "s")} / 3.00s
{ simCanvasRef = el; }} class="force-lab__canvas" />
{ pause(); setTime(Number(e.currentTarget.value)); }} />

Friction threshold: {format(current().maxStaticFriction, " N")}
Current friction: {format(current().friction, " N")}
Net force: {format(current().netForce, " N")}
Acceleration: {format(current().acceleration, " m s⁻²")}
Velocity: {format(current().velocity, " m s⁻¹")} · Displacement:{" "} {format(current().displacement, " m")}

); }