Last active
July 11, 2026 13:04
-
-
Save gkucmierz/1c19de31193cb63daa591285099a4cd1 to your computer and use it in GitHub Desktop.
Run this code instantly in your browser: https://instacode.app/gist/1c19de31193cb63daa591285099a4cd1
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
| // Perrin Number Sequence Interactive Canvas Visualization | |
| // instacode-app compatible | |
| const { canvas, getContext, onResize, onPointerMove } = require('canvas'); | |
| const ctx = getContext('2d'); | |
| let width = canvas.width; | |
| let height = canvas.height; | |
| // Track canvas size | |
| onResize((w, h) => { | |
| width = w; | |
| height = h; | |
| }); | |
| // Calculate Perrin numbers | |
| const MAX_N = 100; | |
| const P = [3n, 0n, 2n]; | |
| for (let i = 3; i <= MAX_N; i++) { | |
| P.push(P[i - 2] + P[i - 3]); | |
| } | |
| // Prime test helper | |
| function isPrime(num) { | |
| if (num <= 1) return false; | |
| if (num <= 3) return true; | |
| if (num % 2 === 0 || num % 3 === 0) return false; | |
| for (let i = 5; i * i <= num; i += 6) { | |
| if (num % i === 0 || num % (i + 2) === 0) return false; | |
| } | |
| return true; | |
| } | |
| // Custom round rect helper for maximum browser compatibility | |
| function drawRoundRect(c, x, y, w, h, radius) { | |
| c.beginPath(); | |
| c.moveTo(x + radius, y); | |
| c.lineTo(x + w - radius, y); | |
| c.quadraticCurveTo(x + w, y, x + w, y + radius); | |
| c.lineTo(x + w, y + h - radius); | |
| c.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); | |
| c.lineTo(x + radius, y + h); | |
| c.quadraticCurveTo(x, y + h, x, y + h - radius); | |
| c.lineTo(x, y + radius); | |
| c.quadraticCurveTo(x, y, x + radius, y); | |
| c.closePath(); | |
| } | |
| // Pre-calculate metadata | |
| const nodes = []; | |
| for (let n = 0; n <= MAX_N; n++) { | |
| const Pn = P[n]; | |
| const nPrime = isPrime(n); | |
| let divides = false; | |
| if (n > 0) { | |
| divides = (Pn % BigInt(n)) === 0n; | |
| } | |
| nodes.push({ | |
| n, | |
| val: Pn, | |
| isPrime: nPrime, | |
| divides, | |
| x: 0, | |
| y: 0, | |
| r: 0 | |
| }); | |
| } | |
| // Mouse interaction tracking | |
| let mouseX = -1000; | |
| let mouseY = -1000; | |
| let hoveredNode = null; | |
| onPointerMove((e) => { | |
| mouseX = e.x; | |
| mouseY = e.y; | |
| }); | |
| // Animation state | |
| let frame = 0; | |
| let currentMaxN = 0; // for growth animation on load | |
| function draw() { | |
| frame++; | |
| // Slowly reveal nodes on startup | |
| if (frame % 2 === 0 && currentMaxN < MAX_N) { | |
| currentMaxN++; | |
| } | |
| // Draw background gradient | |
| const bgGrad = ctx.createRadialGradient(width / 2, height / 2, 10, width / 2, height / 2, Math.max(width, height)); | |
| bgGrad.addColorStop(0, '#11131c'); | |
| bgGrad.addColorStop(1, '#07080c'); | |
| ctx.fillStyle = bgGrad; | |
| ctx.fillRect(0, 0, width, height); | |
| // Layout calculations | |
| const centerX = width * 0.55; | |
| const centerY = height * 0.5; | |
| const size = Math.min(width, height); | |
| const spiralScale = (size * 0.35) / Math.pow(Number(P[currentMaxN]), 0.35); | |
| // Draw grid | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; | |
| ctx.lineWidth = 1; | |
| const gridSize = 40; | |
| for (let x = 0; x < width; x += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, height); | |
| ctx.stroke(); | |
| } | |
| for (let y = 0; y < height; y += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(width, y); | |
| ctx.stroke(); | |
| } | |
| // Compute spiral node coordinates (Golden Angle ~137.5 degrees / 2.39996 rad) | |
| const angleStep = 2.39996; | |
| for (let i = 0; i <= currentMaxN; i++) { | |
| const node = nodes[i]; | |
| const val = Number(node.val); | |
| // Scale radius using power law to keep spacing aesthetic | |
| const r = Math.pow(val, 0.35) * spiralScale; | |
| const theta = i * angleStep + (frame * 0.002); // subtle rotation | |
| node.x = centerX + r * Math.cos(theta); | |
| node.y = centerY + r * Math.sin(theta); | |
| node.r = r; | |
| } | |
| // 1. Draw connecting spiral lines | |
| ctx.beginPath(); | |
| ctx.strokeStyle = 'rgba(139, 92, 246, 0.25)'; // neon violet line | |
| ctx.lineWidth = 1.5; | |
| for (let i = 0; i <= currentMaxN; i++) { | |
| const node = nodes[i]; | |
| if (i === 0) { | |
| ctx.moveTo(node.x, node.y); | |
| } else { | |
| ctx.lineTo(node.x, node.y); | |
| } | |
| } | |
| ctx.stroke(); | |
| // 2. Draw nodes | |
| hoveredNode = null; | |
| let minDistance = 15; // hover threshold in pixels | |
| for (let i = 0; i <= currentMaxN; i++) { | |
| const node = nodes[i]; | |
| // Determine color based on math properties | |
| let color = '#8b5cf6'; // composite (purple) | |
| let radius = 4; | |
| if (node.n === 0 || node.n === 1 || node.n === 2) { | |
| color = '#6b7280'; // initial values (gray) | |
| radius = 5; | |
| } else if (node.isPrime) { | |
| color = '#10b981'; // prime (emerald green) | |
| radius = 6; | |
| } else if (node.divides) { | |
| // Perrin Pseudoprime (gold glow) | |
| color = '#fbbf24'; | |
| radius = 8; | |
| } | |
| // Check hover | |
| const dx = mouseX - node.x; | |
| const dy = mouseY - node.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist < minDistance) { | |
| minDistance = dist; | |
| hoveredNode = node; | |
| } | |
| // Draw node dot | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, radius, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| // Add glow to highlights | |
| if (node.divides || node.isPrime) { | |
| ctx.shadowColor = color; | |
| ctx.shadowBlur = 10; | |
| } else { | |
| ctx.shadowBlur = 0; | |
| } | |
| ctx.fill(); | |
| ctx.shadowBlur = 0; // reset | |
| } | |
| // 3. Draw Left Panel (Dashboard Table & Stats) | |
| const panelWidth = Math.max(300, width * 0.28); | |
| ctx.fillStyle = 'rgba(15, 23, 42, 0.85)'; | |
| ctx.fillRect(0, 0, panelWidth, height); | |
| // Right border for panel | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(panelWidth, 0); | |
| ctx.lineTo(panelWidth, height); | |
| ctx.stroke(); | |
| // Draw Header text | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 18px Inter, sans-serif'; | |
| ctx.fillText('Perrin Numbers Dashboard', 20, 40); | |
| ctx.fillStyle = '#94a3b8'; | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.fillText('Recurrence: P(n) = P(n-2) + P(n-3)', 20, 62); | |
| ctx.fillText('Initial: P(0)=3, P(1)=0, P(2)=2', 20, 78); | |
| // Draw Legend | |
| const legendY = 110; | |
| ctx.fillStyle = '#10b981'; | |
| ctx.beginPath(); ctx.arc(30, legendY, 5, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#94a3b8'; ctx.fillText('n is Prime (P(n) % n === 0)', 45, legendY + 4); | |
| ctx.fillStyle = '#8b5cf6'; | |
| ctx.beginPath(); ctx.arc(30, legendY + 22, 5, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#94a3b8'; ctx.fillText('n is Composite', 45, legendY + 26); | |
| ctx.fillStyle = '#fbbf24'; | |
| ctx.beginPath(); ctx.arc(30, legendY + 44, 6, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#fbbf24'; ctx.fillText('Perrin Pseudoprime (c divides P(c))', 45, legendY + 48); | |
| // Draw Table header | |
| const tableY = 200; | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'; | |
| ctx.fillRect(15, tableY, panelWidth - 30, 26); | |
| ctx.fillStyle = '#e2e8f0'; | |
| ctx.font = 'bold 11px Inter, sans-serif'; | |
| ctx.fillText('n', 25, tableY + 17); | |
| ctx.fillText('P(n)', 60, tableY + 17); | |
| ctx.fillText('Prime?', panelWidth - 95, tableY + 17); | |
| ctx.fillText('Divides?', panelWidth - 50, tableY + 17); | |
| // Draw Table rows (sliding window based on currentMaxN) | |
| const maxRows = Math.floor((height - tableY - 60) / 24); | |
| const startRow = Math.max(0, currentMaxN - maxRows + 1); | |
| for (let r = 0; r < maxRows; r++) { | |
| const nIdx = startRow + r; | |
| if (nIdx > currentMaxN) break; | |
| const node = nodes[nIdx]; | |
| const rowY = tableY + 36 + r * 24; | |
| // Alternating rows | |
| if (r % 2 === 0) { | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.015)'; | |
| ctx.fillRect(15, rowY - 16, panelWidth - 30, 22); | |
| } | |
| ctx.font = '12px Courier New, monospace'; | |
| // Highlight hovered row | |
| if (hoveredNode && hoveredNode.n === node.n) { | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 12px Courier New, monospace'; | |
| } else { | |
| ctx.fillStyle = '#cbd5e1'; | |
| } | |
| ctx.fillText(node.n.toString(), 25, rowY); | |
| // Format large numbers | |
| let valStr = node.val.toString(); | |
| if (valStr.length > 14) { | |
| valStr = valStr.substring(0, 11) + '...'; | |
| } | |
| ctx.fillText(valStr, 60, rowY); | |
| ctx.font = '11px Inter, sans-serif'; | |
| ctx.fillStyle = node.isPrime ? '#10b981' : '#64748b'; | |
| ctx.fillText(node.isPrime ? 'YES' : 'NO', panelWidth - 95, rowY); | |
| let divText = '-'; | |
| let divColor = '#64748b'; | |
| if (node.n > 0) { | |
| divText = node.divides ? 'YES' : 'NO'; | |
| divColor = node.divides ? (node.isPrime ? '#10b981' : '#fbbf24') : '#ef4444'; | |
| } | |
| ctx.fillStyle = divColor; | |
| ctx.fillText(divText, panelWidth - 50, rowY); | |
| } | |
| // Draw bottom information footer | |
| ctx.fillStyle = '#64748b'; | |
| ctx.font = '10px Inter, sans-serif'; | |
| ctx.fillText(`Tracking up to P(${currentMaxN}) | Plastic Ratio \u03c8 \u2248 1.3247`, 20, height - 20); | |
| // 4. Draw Hover Tooltip on spiral | |
| if (hoveredNode) { | |
| const pad = 12; | |
| const ttW = 280; | |
| const ttH = 110; | |
| // Position tooltip to avoid edges | |
| let ttX = hoveredNode.x + 15; | |
| let ttY = hoveredNode.y - ttH / 2; | |
| if (ttX + ttW > width) ttX = hoveredNode.x - ttW - 15; | |
| if (ttY < 10) ttY = 10; | |
| if (ttY + ttH > height) ttY = height - ttH - 10; | |
| // Tooltip box | |
| ctx.fillStyle = 'rgba(15, 23, 42, 0.95)'; | |
| ctx.strokeStyle = hoveredNode.divides ? '#fbbf24' : 'rgba(255, 255, 255, 0.15)'; | |
| ctx.lineWidth = 1.5; | |
| drawRoundRect(ctx, ttX, ttY, ttW, ttH, 8); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // Tooltip header | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.font = 'bold 14px Inter, sans-serif'; | |
| ctx.fillText(`Term n = ${hoveredNode.n}`, ttX + pad, ttY + 22); | |
| // Value | |
| let valStr = hoveredNode.val.toString(); | |
| if (valStr.length > 25) { | |
| valStr = valStr.substring(0, 22) + '...'; | |
| } | |
| ctx.fillStyle = '#cbd5e1'; | |
| ctx.font = '12px Courier New, monospace'; | |
| ctx.fillText(`P(n) = ${valStr}`, ttX + pad, ttY + 45); | |
| // Prime status | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.fillStyle = '#94a3b8'; | |
| ctx.fillText(`Type: `, ttX + pad, ttY + 68); | |
| ctx.fillStyle = hoveredNode.isPrime ? '#10b981' : '#a855f7'; | |
| ctx.fillText(hoveredNode.isPrime ? 'Prime Number' : 'Composite Number', ttX + pad + 38, ttY + 68); | |
| // Divisibility property | |
| ctx.fillStyle = '#94a3b8'; | |
| ctx.fillText(`P(n) % n: `, ttX + pad, ttY + 90); | |
| let remText = ''; | |
| let remColor = '#94a3b8'; | |
| if (hoveredNode.n === 0) { | |
| remText = 'Undefined (n=0)'; | |
| } else { | |
| const rem = hoveredNode.val % BigInt(hoveredNode.n); | |
| if (rem === 0n) { | |
| remText = '0 (Divisible'; | |
| remColor = '#10b981'; | |
| } else { | |
| remText = `${rem} (Not divisible`; | |
| remColor = '#ef4444'; | |
| } | |
| if (hoveredNode.divides && !hoveredNode.isPrime) { | |
| remText += ' - PSEUDOPRIME!)'; | |
| remColor = '#fbbf24'; | |
| } else { | |
| remText += ')'; | |
| } | |
| } | |
| ctx.fillStyle = remColor; | |
| ctx.fillText(remText, ttX + pad + 56, ttY + 90); | |
| // Highlight dot outline | |
| ctx.strokeStyle = '#ffffff'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.arc(hoveredNode.x, hoveredNode.y, 10, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| requestAnimationFrame(draw); | |
| } | |
| // Start animation loop | |
| draw(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment