Created
March 15, 2026 12:24
-
-
Save alexcatdad/ff43536bd62d847222356c45f84431ce to your computer and use it in GitHub Desktop.
Elastic membrane physics simulation — hover to deform container borders with spring-damped button + 60-point membrane collision
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Elastic Membrane Physics</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Mono:wght@300;400;500&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;1,9..144,300&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0c0c0f; | |
| --surface: #faf6f0; | |
| --grid-line: #e8e0d4; | |
| --grid-major: #d4c9b8; | |
| --border: #2a2520; | |
| --coral: #ff6b6b; | |
| --coral-dark: #e55a5a; | |
| --text-primary: #faf6f0; | |
| --text-muted: #7a7268; | |
| --text-dim: #4a4540; | |
| --accent-teal: #5ee4c0; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: var(--bg); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"); | |
| background-size: 256px 256px; | |
| pointer-events: none; | |
| z-index: 100; | |
| } | |
| body::after { | |
| content: ''; | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| width: 900px; | |
| height: 700px; | |
| transform: translate(-50%, -50%); | |
| background: radial-gradient(ellipse, rgba(255,107,107,0.04) 0%, transparent 70%); | |
| pointer-events: none; | |
| } | |
| .page { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 36px; } | |
| .header { text-align: center; max-width: 600px; } | |
| .header h1 { font-family: 'Fraunces', serif; font-weight: 300; font-size: 42px; color: var(--text-primary); letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 12px; } | |
| .header h1 em { font-style: italic; color: var(--coral); font-weight: 600; } | |
| .header p { font-family: 'DM Mono', monospace; font-weight: 300; font-size: 13px; color: var(--text-muted); letter-spacing: 0.04em; } | |
| .header .formula { display: inline-block; margin-top: 10px; padding: 6px 14px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 12px; color: var(--text-dim); letter-spacing: 0.08em; } | |
| .formula span { color: var(--accent-teal); } | |
| .canvas-wrap { position: relative; } | |
| .canvas-wrap canvas { display: block; border-radius: 4px; } | |
| .params { display: flex; gap: 32px; align-items: center; } | |
| .param { display: flex; align-items: baseline; gap: 8px; font-family: 'DM Mono', monospace; font-size: 11px; } | |
| .param-label { color: var(--text-dim); letter-spacing: 0.08em; text-transform: uppercase; font-size: 9px; } | |
| .param-value { color: var(--text-muted); font-weight: 500; } | |
| .param-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--text-dim); align-self: center; } | |
| .page { animation: fadeUp .8s ease-out both; } | |
| .header { animation: fadeUp .8s ease-out .1s both; } | |
| .canvas-wrap { animation: fadeUp .8s ease-out .25s both; } | |
| .params { animation: fadeUp .8s ease-out .4s both; } | |
| @keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page"> | |
| <header class="header"> | |
| <h1>Hooke's Law,<br>but make it <em>elastic</em></h1> | |
| <p>hover each shape to deform the membrane</p> | |
| <div class="formula"><span>F</span> = −k·x − d·v × 60 membrane points</div> | |
| </header> | |
| <div class="canvas-wrap"> | |
| <canvas id="canvas"></canvas> | |
| </div> | |
| <div class="params"> | |
| <div class="param"><span class="param-label">spring</span><span class="param-value">k=0.18 d=0.08</span></div> | |
| <div class="param-dot"></div> | |
| <div class="param"><span class="param-label">membrane</span><span class="param-value">60 points · tension · damped</span></div> | |
| <div class="param-dot"></div> | |
| <div class="param"><span class="param-label">collision</span><span class="param-value">shape-conforming</span></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================================ | |
| // BUTTON SPRING | |
| // ============================================================ | |
| const SPRING_K = 0.06; | |
| const SPRING_D = 0.52; | |
| // ============================================================ | |
| // MEMBRANE SIMULATION | |
| // ============================================================ | |
| const MEM_N = 60; | |
| const MEM_K = 0.18; | |
| const MEM_D = 0.55; | |
| const MEM_TENSION = 0.45; | |
| const MEM_SUBSTEPS = 4; | |
| const MEM_GAP = 3; | |
| // ============================================================ | |
| // LAYOUT | |
| // ============================================================ | |
| const CANVAS_W = 660; | |
| const CANVAS_H = 470; | |
| const C_W = 290; | |
| const C_H = 290; | |
| const C_R = 16; | |
| const C_GAP = 28; | |
| const C_TOP = 24; | |
| const C_BOT = C_TOP + C_H; | |
| const PILL_W = 130; | |
| const PILL_H = 42; | |
| const PILL_R = 21; | |
| const CIRC_R = 28; | |
| const REST_Y = 140; | |
| const HOVER_Y = 345; | |
| const LABEL_Y = 455; | |
| const GRID_SP = 18; | |
| // ============================================================ | |
| // COLORS | |
| // ============================================================ | |
| const COL_SURFACE = '#faf6f0'; | |
| const COL_GRID = '#e8e0d4'; | |
| const COL_GRID_MAJ = '#d4c9b8'; | |
| const COL_BORDER = '#2a2520'; | |
| const COL_BTN = '#ff6b6b'; | |
| const COL_SHADOW = '#e55a5a'; | |
| const COL_LABEL = '#7a7268'; | |
| const BORDER_W = 2.5; | |
| // ============================================================ | |
| // HELPERS | |
| // ============================================================ | |
| function lerp(a, b, t) { return a + (b - a) * t; } | |
| function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); } | |
| // ============================================================ | |
| // CANVAS | |
| // ============================================================ | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = CANVAS_W * dpr; | |
| canvas.height = CANVAS_H * dpr; | |
| canvas.style.width = CANVAS_W + 'px'; | |
| canvas.style.height = CANVAS_H + 'px'; | |
| ctx.scale(dpr, dpr); | |
| // ============================================================ | |
| // INSTANCES | |
| // ============================================================ | |
| const LX = (CANVAS_W - C_W * 2 - C_GAP) / 2; | |
| const RX = LX + C_W + C_GAP; | |
| function createInst(ox, shape, label) { | |
| const membrane = []; | |
| for (let i = 0; i < MEM_N; i++) membrane.push({ y: C_BOT, vy: 0 }); | |
| return { ox, shape, label, y: REST_Y, vy: 0, target: REST_Y, hovering: false, membrane }; | |
| } | |
| const insts = [ | |
| createInst(LX, 'pill', 'PILL'), | |
| createInst(RX, 'circle', 'CIRCLE') | |
| ]; | |
| // ============================================================ | |
| // BUTTON COLLISION — exact surface + soft falloff zone | |
| // ============================================================ | |
| const FALLOFF = 25; // px of soft influence outside button edge | |
| function btnSurfaceY(inst, px) { | |
| const cx = inst.ox + C_W / 2; | |
| if (inst.shape === 'pill') { | |
| const halfW = PILL_W / 2; | |
| const bl = cx - halfW, br = cx + halfW; | |
| if (px < bl || px > br) return null; | |
| const botY = inst.y + PILL_H / 2; | |
| if (px >= bl + PILL_R && px <= br - PILL_R) return botY; | |
| if (px < bl + PILL_R) { | |
| const ccx = bl + PILL_R, ccy = botY - PILL_R; | |
| const dx = px - ccx; | |
| return ccy + Math.sqrt(Math.max(0, PILL_R * PILL_R - dx * dx)); | |
| } | |
| const ccx = br - PILL_R, ccy = botY - PILL_R; | |
| const dx = px - ccx; | |
| return ccy + Math.sqrt(Math.max(0, PILL_R * PILL_R - dx * dx)); | |
| } else { | |
| const dx = px - cx; | |
| if (Math.abs(dx) > CIRC_R) return null; | |
| return inst.y + Math.sqrt(Math.max(0, CIRC_R * CIRC_R - dx * dx)); | |
| } | |
| } | |
| // Returns { t: 0-1 influence, surfY } with smooth falloff outside button edges | |
| function btnInfluence(inst, px) { | |
| const cx = inst.ox + C_W / 2; | |
| const sy = btnSurfaceY(inst, px); | |
| if (sy !== null) return { t: 1, surfY: sy }; | |
| // Point is outside button — check distance to nearest edge | |
| if (inst.shape === 'pill') { | |
| const halfW = PILL_W / 2; | |
| const bl = cx - halfW, br = cx + halfW; | |
| const dist = px < bl ? bl - px : px - br; | |
| if (dist > FALLOFF) return { t: 0, surfY: null }; | |
| const edgePx = clamp(px, bl, br); | |
| const edgeSy = btnSurfaceY(inst, edgePx); | |
| const t = 1 - (dist / FALLOFF); | |
| return { t: t * t, surfY: edgeSy }; // quadratic falloff | |
| } else { | |
| const dx = Math.abs(px - cx); | |
| const dist = dx - CIRC_R; | |
| if (dist > FALLOFF) return { t: 0, surfY: null }; | |
| const edgePx = px < cx ? cx - CIRC_R : cx + CIRC_R; | |
| const edgeSy = btnSurfaceY(inst, edgePx); | |
| const t = 1 - (dist / FALLOFF); | |
| return { t: t * t, surfY: edgeSy }; | |
| } | |
| } | |
| // ============================================================ | |
| // PHYSICS: Button spring | |
| // ============================================================ | |
| function updateButton(inst) { | |
| const a = -SPRING_K * (inst.y - inst.target) - SPRING_D * inst.vy; | |
| inst.vy += a; | |
| inst.y += inst.vy; | |
| } | |
| // ============================================================ | |
| // PHYSICS: Membrane simulation | |
| // ============================================================ | |
| function updateMembrane(inst) { | |
| const pts = inst.membrane; | |
| const n = pts.length; | |
| const sp = C_W / (n - 1); | |
| for (let sub = 0; sub < MEM_SUBSTEPS; sub++) { | |
| for (let i = 1; i < n - 1; i++) { | |
| const p = pts[i]; | |
| // Spring back to rest position | |
| const restF = -MEM_K * (p.y - C_BOT); | |
| // Damping | |
| const dampF = -MEM_D * p.vy; | |
| // Tension from neighbors (discrete Laplacian) | |
| const tensF = MEM_TENSION * (pts[i - 1].y + pts[i + 1].y - 2 * p.y); | |
| p.vy += restF + dampF + tensF; | |
| p.y += p.vy; | |
| // Collision with soft falloff around button edges | |
| const px = inst.ox + i * sp; | |
| const inf = btnInfluence(inst, px); | |
| if (inf.t > 0 && inf.surfY !== null) { | |
| // Blend the collision surface from C_BOT (at t=0) to actual surface (at t=1) | |
| const constraintY = lerp(C_BOT, inf.surfY + MEM_GAP, inf.t); | |
| if (p.y < constraintY) { | |
| p.y = constraintY; | |
| if (p.vy < 0) p.vy = 0; | |
| } | |
| } | |
| // Membrane cannot go above container bottom | |
| if (p.y < C_BOT) { | |
| p.y = C_BOT; | |
| p.vy = 0; | |
| } | |
| // Dead zone: kill micro-oscillations | |
| if (Math.abs(p.vy) < 0.05 && Math.abs(p.y - C_BOT) < 0.5) { | |
| p.y = C_BOT; | |
| p.vy = 0; | |
| } | |
| } | |
| // Pin endpoints | |
| pts[0].y = C_BOT; pts[0].vy = 0; | |
| pts[n - 1].y = C_BOT; pts[n - 1].vy = 0; | |
| } | |
| } | |
| // ============================================================ | |
| // DRAWING: Grid | |
| // ============================================================ | |
| function drawGrid(inst) { | |
| const l = inst.ox, t = C_TOP; | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.roundRect(l, t, C_W, C_H, C_R); | |
| ctx.clip(); | |
| ctx.strokeStyle = COL_GRID; | |
| ctx.lineWidth = 0.5; | |
| for (let x = l + GRID_SP; x < l + C_W; x += GRID_SP) { | |
| ctx.beginPath(); ctx.moveTo(x, t); ctx.lineTo(x, t + C_H); ctx.stroke(); | |
| } | |
| for (let y = t + GRID_SP; y < t + C_H; y += GRID_SP) { | |
| ctx.beginPath(); ctx.moveTo(l, y); ctx.lineTo(l + C_W, y); ctx.stroke(); | |
| } | |
| ctx.strokeStyle = COL_GRID_MAJ; | |
| ctx.lineWidth = 0.8; | |
| for (let x = l + GRID_SP * 4; x < l + C_W; x += GRID_SP * 4) { | |
| ctx.beginPath(); ctx.moveTo(x, t); ctx.lineTo(x, t + C_H); ctx.stroke(); | |
| } | |
| for (let y = t + GRID_SP * 4; y < t + C_H; y += GRID_SP * 4) { | |
| ctx.beginPath(); ctx.moveTo(l, y); ctx.lineTo(l + C_W, y); ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| // ============================================================ | |
| // DRAWING: Container (open bottom) | |
| // ============================================================ | |
| function drawContainer(inst) { | |
| const l = inst.ox, r = inst.ox + C_W, t = C_TOP, b = C_BOT, rd = C_R; | |
| // Shadow | |
| ctx.save(); | |
| ctx.shadowColor = 'rgba(0,0,0,0.25)'; | |
| ctx.shadowBlur = 30; | |
| ctx.shadowOffsetY = 8; | |
| ctx.beginPath(); | |
| ctx.roundRect(l, t, C_W, C_H, rd); | |
| ctx.fillStyle = COL_SURFACE; | |
| ctx.fill(); | |
| ctx.restore(); | |
| // Fill | |
| ctx.beginPath(); | |
| ctx.moveTo(l, b); ctx.lineTo(l, t + rd); | |
| ctx.arcTo(l, t, l + rd, t, rd); | |
| ctx.lineTo(r - rd, t); | |
| ctx.arcTo(r, t, r, t + rd, rd); | |
| ctx.lineTo(r, b); ctx.lineTo(l, b); | |
| ctx.closePath(); | |
| ctx.fillStyle = COL_SURFACE; | |
| ctx.fill(); | |
| drawGrid(inst); | |
| // Stroke (open bottom) | |
| ctx.beginPath(); | |
| ctx.moveTo(l, b); ctx.lineTo(l, t + rd); | |
| ctx.arcTo(l, t, l + rd, t, rd); | |
| ctx.lineTo(r - rd, t); | |
| ctx.arcTo(r, t, r, t + rd, rd); | |
| ctx.lineTo(r, b); | |
| ctx.strokeStyle = COL_BORDER; | |
| ctx.lineWidth = BORDER_W; | |
| ctx.stroke(); | |
| } | |
| // ============================================================ | |
| // DRAWING: Membrane (smooth curve through simulated points) | |
| // ============================================================ | |
| function drawMembrane(inst) { | |
| const pts = inst.membrane; | |
| const n = pts.length; | |
| const sp = C_W / (n - 1); | |
| const l = inst.ox, r = inst.ox + C_W; | |
| // Build screen-space points | |
| const screen = []; | |
| for (let i = 0; i < n; i++) { | |
| screen.push({ x: l + i * sp, y: pts[i].y }); | |
| } | |
| // Smooth curve helper using quadratic bezier through midpoints | |
| function traceCurve() { | |
| ctx.moveTo(screen[0].x, screen[0].y); | |
| for (let i = 0; i < n - 1; i++) { | |
| const mx = (screen[i].x + screen[i + 1].x) / 2; | |
| const my = (screen[i].y + screen[i + 1].y) / 2; | |
| ctx.quadraticCurveTo(screen[i].x, screen[i].y, mx, my); | |
| } | |
| ctx.lineTo(screen[n - 1].x, screen[n - 1].y); | |
| } | |
| // Fill (white area below container extending into bulge) | |
| ctx.beginPath(); | |
| traceCurve(); | |
| ctx.lineTo(r, C_BOT); | |
| ctx.lineTo(l, C_BOT); | |
| ctx.closePath(); | |
| ctx.fillStyle = COL_SURFACE; | |
| ctx.fill(); | |
| // Stroke (the membrane border) | |
| ctx.beginPath(); | |
| traceCurve(); | |
| ctx.strokeStyle = COL_BORDER; | |
| ctx.lineWidth = BORDER_W; | |
| ctx.stroke(); | |
| } | |
| // ============================================================ | |
| // DRAWING: Button with squash/stretch | |
| // ============================================================ | |
| function drawButton(inst) { | |
| const cx = inst.ox + C_W / 2; | |
| const y = inst.y; | |
| const speed = Math.abs(inst.vy); | |
| const sq = clamp(speed * 0.012, 0, 0.25); | |
| const scY = 1 - sq; | |
| const scX = 1 + sq * 0.6; | |
| ctx.save(); | |
| ctx.translate(cx, y); | |
| ctx.scale(scX, scY); | |
| ctx.translate(-cx, -y); | |
| if (inst.shape === 'pill') { | |
| const bx = cx - PILL_W / 2, by = y - PILL_H / 2; | |
| // Shadow | |
| ctx.beginPath(); | |
| ctx.roundRect(bx, by + 4, PILL_W, PILL_H, PILL_R); | |
| ctx.fillStyle = COL_SHADOW; | |
| ctx.fill(); | |
| // Fill | |
| ctx.beginPath(); | |
| ctx.roundRect(bx, by, PILL_W, PILL_H, PILL_R); | |
| ctx.fillStyle = COL_BTN; | |
| ctx.fill(); | |
| // Highlight | |
| ctx.beginPath(); | |
| ctx.roundRect(bx + 1, by + 1, PILL_W - 2, PILL_H / 2, [PILL_R, PILL_R, 0, 0]); | |
| ctx.fillStyle = 'rgba(255,255,255,0.15)'; | |
| ctx.fill(); | |
| // Text | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '500 13px "DM Mono", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('HOVER ME', cx, y + 1); | |
| } else { | |
| // Shadow | |
| ctx.beginPath(); | |
| ctx.arc(cx, y + 4, CIRC_R, 0, Math.PI * 2); | |
| ctx.fillStyle = COL_SHADOW; | |
| ctx.fill(); | |
| // Fill | |
| ctx.beginPath(); | |
| ctx.arc(cx, y, CIRC_R, 0, Math.PI * 2); | |
| ctx.fillStyle = COL_BTN; | |
| ctx.fill(); | |
| // Highlight | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.arc(cx, y, CIRC_R, 0, Math.PI * 2); | |
| ctx.clip(); | |
| ctx.beginPath(); | |
| ctx.ellipse(cx, y - CIRC_R * 0.35, CIRC_R * 0.7, CIRC_R * 0.45, 0, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(255,255,255,0.12)'; | |
| ctx.fill(); | |
| ctx.restore(); | |
| // Text | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '500 11px "DM Mono", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText('HOVER', cx, y + 1); | |
| } | |
| ctx.restore(); | |
| } | |
| // ============================================================ | |
| // DRAWING: Penetration annotation | |
| // ============================================================ | |
| function drawAnnotation(inst) { | |
| const halfH = inst.shape === 'pill' ? PILL_H / 2 : CIRC_R; | |
| const pen = Math.max(0, (inst.y + halfH) - C_BOT); | |
| if (pen < 2) return; | |
| const ax = inst.ox + C_W - 18; | |
| const topY = C_BOT; | |
| const botY = inst.y + halfH; | |
| ctx.save(); | |
| ctx.strokeStyle = 'rgba(94,228,192,0.5)'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([3, 3]); | |
| ctx.beginPath(); | |
| ctx.moveTo(ax, topY); ctx.lineTo(ax, botY); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.beginPath(); | |
| ctx.moveTo(ax - 4, topY); ctx.lineTo(ax + 4, topY); | |
| ctx.moveTo(ax - 4, botY); ctx.lineTo(ax + 4, botY); | |
| ctx.stroke(); | |
| ctx.fillStyle = 'rgba(94,228,192,0.7)'; | |
| ctx.font = '10px "DM Mono", monospace'; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(pen.toFixed(1) + 'px', ax + 8, (topY + botY) / 2); | |
| ctx.restore(); | |
| } | |
| // ============================================================ | |
| // DRAWING: Label | |
| // ============================================================ | |
| function drawLabel(inst) { | |
| const cx = inst.ox + C_W / 2; | |
| ctx.fillStyle = COL_LABEL; | |
| ctx.font = '300 11px "DM Mono", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(inst.label, cx, LABEL_Y); | |
| } | |
| // ============================================================ | |
| // MOUSE INPUT | |
| // ============================================================ | |
| let mx = -1, my = -1; | |
| function mousePos(e) { | |
| const r = canvas.getBoundingClientRect(); | |
| return { x: (e.clientX - r.left) * (CANVAS_W / r.width), y: (e.clientY - r.top) * (CANVAS_H / r.height) }; | |
| } | |
| function hitBtn(inst, px, py) { | |
| const cx = inst.ox + C_W / 2; | |
| if (inst.shape === 'pill') { | |
| const bx = cx - PILL_W / 2, by = inst.y - PILL_H / 2; | |
| return px >= bx && px <= bx + PILL_W && py >= by && py <= by + PILL_H; | |
| } | |
| const dx = px - cx, dy = py - inst.y; | |
| return dx * dx + dy * dy <= CIRC_R * CIRC_R; | |
| } | |
| function hitContainer(inst, px, py) { | |
| return px >= inst.ox && px <= inst.ox + C_W && py >= C_TOP && py <= C_BOT + 100; | |
| } | |
| canvas.addEventListener('mousemove', (e) => { | |
| const p = mousePos(e); | |
| mx = p.x; my = p.y; | |
| let cur = false; | |
| insts.forEach(inst => { | |
| if (inst.hovering) { | |
| if (!hitContainer(inst, mx, my)) { inst.hovering = false; inst.target = REST_Y; } | |
| } else { | |
| if (hitBtn(inst, mx, my)) { inst.hovering = true; inst.target = HOVER_Y; } | |
| } | |
| if (hitBtn(inst, mx, my)) cur = true; | |
| }); | |
| canvas.style.cursor = cur ? 'pointer' : 'default'; | |
| }); | |
| canvas.addEventListener('mouseleave', () => { | |
| mx = my = -1; | |
| insts.forEach(i => { i.hovering = false; i.target = REST_Y; }); | |
| canvas.style.cursor = 'default'; | |
| }); | |
| // ============================================================ | |
| // MAIN LOOP | |
| // ============================================================ | |
| function frame() { | |
| insts.forEach(updateButton); | |
| insts.forEach(updateMembrane); | |
| ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); | |
| insts.forEach(inst => { | |
| drawContainer(inst); | |
| drawMembrane(inst); | |
| drawButton(inst); | |
| drawAnnotation(inst); | |
| drawLabel(inst); | |
| }); | |
| requestAnimationFrame(frame); | |
| } | |
| requestAnimationFrame(frame); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment