burtenshaw
feat: publish slopfarmer article
3878dd8
<div class="d3-slopfarmer-banner"></div>
<style>
.d3-slopfarmer-banner {
position: relative;
width: 100%;
height: 100%;
min-height: 260px;
overflow: hidden;
cursor: crosshair;
}
.d3-slopfarmer-banner canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
<script>
(() => {
const bootstrap = () => {
const scriptEl = document.currentScript;
let container = scriptEl ? scriptEl.previousElementSibling : null;
if (!(container && container.classList && container.classList.contains('d3-slopfarmer-banner'))) {
const candidates = Array.from(document.querySelectorAll('.d3-slopfarmer-banner'))
.filter(el => !(el.dataset && el.dataset.mounted === 'true'));
container = candidates[candidates.length - 1] || null;
}
if (!container) return;
if (container.dataset.mounted === 'true') return;
container.dataset.mounted = 'true';
// --- Data: 772 PRs in 4 categories ---
const N = 772;
const COUNTS = [326, 299, 97, 50];
const COLORS_HEX = ['#2D5A27', '#8B4513', '#6B6B6B', '#B8860B'];
const LABELS = ['feature', 'defect fix', 'docs', 'other'];
const COLORS_RGB = COLORS_HEX.map(h => ({
r: parseInt(h.slice(1, 3), 16),
g: parseInt(h.slice(3, 5), 16),
b: parseInt(h.slice(5, 7), 16)
}));
// --- Tuning ---
const INFLUENCE_RADIUS = 200;
const CLUSTER_FORCE = 0.06;
const HOME_FORCE = 0.008;
const DAMPING = 0.88;
const DRIFT_MAGNITUDE = 0.08;
const CONNECTION_DIST = 32;
const FADE_SPEED = 0.04; // how fast clustering fades in/out
// --- Build particles ---
const particles = [];
let ci = 0, cc = 0;
for (let i = 0; i < N; i++) {
if (cc >= COUNTS[ci]) { ci++; cc = 0; }
cc++;
const cat = ci;
const size = 1.5 + Math.random() * 2.8;
particles.push({
x: 0, y: 0, vx: 0, vy: 0,
homeX: 0, homeY: 0,
cat, size,
drift: Math.random() * Math.PI * 2,
driftV: DRIFT_MAGNITUDE * (0.4 + Math.random() * 0.6),
phase: Math.random() * Math.PI * 2,
opacity: 0.35 + Math.random() * 0.5,
clustered: 0 // 0 = free, 1 = fully clustered (smooth transition)
});
}
// Shuffle for mixed draw order
for (let i = particles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[particles[i], particles[j]] = [particles[j], particles[i]];
}
// --- State ---
let mouseX = -9999, mouseY = -9999, mouseIn = false;
let width = 980, height = 392, time = 0;
let clusterFade = 0; // global 0..1 how "clustered" the scene is
// --- Canvas setup ---
const canvas = document.createElement('canvas');
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
// --- Events ---
container.addEventListener('mousemove', e => {
const r = container.getBoundingClientRect();
mouseX = e.clientX - r.left; mouseY = e.clientY - r.top;
mouseIn = true;
});
container.addEventListener('mouseleave', () => { mouseIn = false; });
container.addEventListener('touchmove', e => {
const r = container.getBoundingClientRect();
const t = e.touches[0];
mouseX = t.clientX - r.left; mouseY = t.clientY - r.top;
mouseIn = true;
}, { passive: true });
container.addEventListener('touchend', () => { mouseIn = false; });
// --- Cluster target positions (proportional, computed each frame) ---
// Arranged in a gentle arc: feature left, defect center-left, docs center-right, other right
const clusterAnchors = [
{ rx: 0.18, ry: 0.48 },
{ rx: 0.40, ry: 0.40 },
{ rx: 0.62, ry: 0.52 },
{ rx: 0.84, ry: 0.44 }
];
function setSize() {
width = container.clientWidth || 980;
height = container.clientHeight || Math.round(width / 2.5);
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function scatter() {
setSize();
for (const p of particles) {
p.x = Math.random() * width;
p.y = Math.random() * height;
p.homeX = p.x;
p.homeY = p.y;
p.vx = 0; p.vy = 0;
}
}
function tick() {
time += 0.016;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
// Background
ctx.fillStyle = isDark ? '#1a1a1a' : '#FAFAF8';
ctx.fillRect(0, 0, width, height);
// Global cluster fade (smooth transition)
if (mouseIn) {
clusterFade = Math.min(1, clusterFade + FADE_SPEED);
} else {
clusterFade = Math.max(0, clusterFade - FADE_SPEED * 0.6);
}
// Cluster centers in pixels
const centers = clusterAnchors.map(a => ({ x: a.rx * width, y: a.ry * height }));
// --- Physics ---
for (const p of particles) {
// Ambient wander
p.drift += (Math.random() - 0.5) * 0.06;
const wanderX = Math.cos(p.drift) * p.driftV;
const wanderY = Math.sin(p.drift) * p.driftV + Math.sin(time * 0.6 + p.phase) * 0.015;
if (clusterFade > 0.01) {
// Check proximity to mouse
const dx = mouseX - p.x;
const dy = mouseY - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const inRange = dist < INFLUENCE_RADIUS;
const localInfluence = inRange ? (1 - dist / INFLUENCE_RADIUS) : 0;
// Target: pull toward category cluster center
const target = centers[p.cat];
// Add some jitter to the target so particles don't all pile on one pixel
const jitterAngle = p.phase;
const jitterR = p.size * 6;
const tx = target.x + Math.cos(jitterAngle + time * 0.3) * jitterR;
const ty = target.y + Math.sin(jitterAngle + time * 0.3) * jitterR;
const strength = clusterFade * localInfluence * CLUSTER_FORCE;
p.vx += (tx - p.x) * strength;
p.vy += (ty - p.y) * strength;
// Also reduce wander when clustering
const wanderScale = 1 - clusterFade * localInfluence * 0.9;
p.vx += wanderX * wanderScale;
p.vy += wanderY * wanderScale;
// Update per-particle clustered state for rendering
p.clustered += (localInfluence * clusterFade - p.clustered) * 0.08;
} else {
p.vx += wanderX;
p.vy += wanderY;
p.clustered *= 0.95;
}
// Gentle home pull (prevents particles from drifting to infinity)
p.vx += (p.homeX - p.x) * HOME_FORCE;
p.vy += (p.homeY - p.y) * HOME_FORCE;
// Edge repulsion
const m = 15;
if (p.x < m) p.vx += (m - p.x) * 0.06;
if (p.x > width - m) p.vx += (width - m - p.x) * 0.06;
if (p.y < m) p.vy += (m - p.y) * 0.06;
if (p.y > height - m) p.vy += (height - m - p.y) * 0.06;
p.vx *= DAMPING;
p.vy *= DAMPING;
p.x += p.vx;
p.y += p.vy;
}
// --- Draw connections (only for clustered particles) ---
if (clusterFade > 0.05) {
ctx.lineWidth = 0.6;
// Spatial hash for performance
const cellSize = CONNECTION_DIST;
const grid = {};
for (let i = 0; i < N; i++) {
const p = particles[i];
if (p.clustered < 0.15) continue;
const cx = Math.floor(p.x / cellSize);
const cy = Math.floor(p.y / cellSize);
const key = cx + ',' + cy;
(grid[key] = grid[key] || []).push(i);
}
const lineAlpha = isDark ? 0.18 : 0.12;
for (const key in grid) {
const cell = grid[key];
const [gx, gy] = key.split(',').map(Number);
// Check this cell and neighbors
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const nKey = (gx + dx) + ',' + (gy + dy);
const neighbor = grid[nKey];
if (!neighbor) continue;
for (const i of cell) {
const a = particles[i];
for (const j of neighbor) {
if (j <= i) continue;
const b = particles[j];
if (a.cat !== b.cat) continue;
const ddx = a.x - b.x;
const ddy = a.y - b.y;
const d = Math.sqrt(ddx * ddx + ddy * ddy);
if (d < CONNECTION_DIST) {
const fade = (1 - d / CONNECTION_DIST) * Math.min(a.clustered, b.clustered);
const alpha = lineAlpha * fade;
if (alpha < 0.005) continue;
const c = COLORS_RGB[a.cat];
ctx.strokeStyle = isDark
? `rgba(${Math.min(255, c.r + 120)},${Math.min(255, c.g + 120)},${Math.min(255, c.b + 120)},${alpha})`
: `rgba(${c.r},${c.g},${c.b},${alpha * 1.5})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
}
}
}
}
// --- Draw particles ---
for (const p of particles) {
const c = COLORS_RGB[p.cat];
// When clustered, particles brighten slightly
const boost = p.clustered * 0.3;
const alpha = Math.min(1, p.opacity + boost);
ctx.globalAlpha = alpha;
ctx.fillStyle = isDark
? `rgb(${Math.min(255, c.r + Math.round(p.clustered * 80))},${Math.min(255, c.g + Math.round(p.clustered * 80))},${Math.min(255, c.b + Math.round(p.clustered * 80))})`
: COLORS_HEX[p.cat];
// Square particles (no rounded corners)
const s = p.size + p.clustered * 1.2;
ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
}
ctx.globalAlpha = 1;
// --- Cluster labels (fade in when clusters form) ---
if (clusterFade > 0.15) {
const labelAlpha = Math.min(1, (clusterFade - 0.15) / 0.4);
ctx.font = `700 12px system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
for (let c = 0; c < 4; c++) {
const center = centers[c];
// Count how many particles of this category are actually clustered near center
let n = 0;
for (const p of particles) {
if (p.cat === c && p.clustered > 0.3) n++;
}
if (n < 5) continue;
const lx = center.x;
const ly = center.y - 24;
if (lx < 40 || lx > width - 40 || ly < 16) continue;
// Label background
const text = `${LABELS[c]} (${COUNTS[c]})`;
const tw = ctx.measureText(text).width;
ctx.globalAlpha = labelAlpha * 0.7;
ctx.fillStyle = isDark ? 'rgba(26,26,26,0.85)' : 'rgba(250,250,248,0.85)';
ctx.fillRect(lx - tw / 2 - 6, ly - 14, tw + 12, 18);
ctx.globalAlpha = labelAlpha * 0.85;
ctx.fillStyle = isDark ? `rgba(${COLORS_RGB[c].r + 80},${COLORS_RGB[c].g + 80},${COLORS_RGB[c].b + 80},1)` : COLORS_HEX[c];
ctx.fillText(text, lx, ly);
}
ctx.globalAlpha = 1;
}
requestAnimationFrame(tick);
}
scatter();
tick();
if (window.ResizeObserver) {
new ResizeObserver(() => {
setSize();
// Redistribute home positions
for (const p of particles) {
p.homeX = Math.random() * width;
p.homeY = Math.random() * height;
}
}).observe(container);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else { bootstrap(); }
})();
</script>