Skip to content

Instantly share code, notes, and snippets.

@gkucmierz
Last active July 11, 2026 13:04
Show Gist options
  • Select an option

  • Save gkucmierz/1c19de31193cb63daa591285099a4cd1 to your computer and use it in GitHub Desktop.

Select an option

Save gkucmierz/1c19de31193cb63daa591285099a4cd1 to your computer and use it in GitHub Desktop.
Run this code instantly in your browser: https://instacode.app/gist/1c19de31193cb63daa591285099a4cd1
// 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