| <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'; |
| |
| |
| 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) |
| })); |
| |
| |
| 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; |
| |
| |
| 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 |
| }); |
| } |
| |
| 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]]; |
| } |
| |
| |
| let mouseX = -9999, mouseY = -9999, mouseIn = false; |
| let width = 980, height = 392, time = 0; |
| let clusterFade = 0; |
| |
| |
| const canvas = document.createElement('canvas'); |
| container.appendChild(canvas); |
| const ctx = canvas.getContext('2d'); |
| |
| |
| 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; }); |
| |
| |
| |
| 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'; |
| |
| |
| ctx.fillStyle = isDark ? '#1a1a1a' : '#FAFAF8'; |
| ctx.fillRect(0, 0, width, height); |
| |
| |
| if (mouseIn) { |
| clusterFade = Math.min(1, clusterFade + FADE_SPEED); |
| } else { |
| clusterFade = Math.max(0, clusterFade - FADE_SPEED * 0.6); |
| } |
| |
| |
| const centers = clusterAnchors.map(a => ({ x: a.rx * width, y: a.ry * height })); |
| |
| |
| for (const p of particles) { |
| |
| 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) { |
| |
| 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; |
| |
| |
| const target = centers[p.cat]; |
| |
| 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; |
| |
| |
| const wanderScale = 1 - clusterFade * localInfluence * 0.9; |
| p.vx += wanderX * wanderScale; |
| p.vy += wanderY * wanderScale; |
| |
| |
| p.clustered += (localInfluence * clusterFade - p.clustered) * 0.08; |
| } else { |
| p.vx += wanderX; |
| p.vy += wanderY; |
| p.clustered *= 0.95; |
| } |
| |
| |
| p.vx += (p.homeX - p.x) * HOME_FORCE; |
| p.vy += (p.homeY - p.y) * HOME_FORCE; |
| |
| |
| 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; |
| } |
| |
| |
| if (clusterFade > 0.05) { |
| ctx.lineWidth = 0.6; |
| |
| 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); |
| |
| 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(); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| |
| for (const p of particles) { |
| const c = COLORS_RGB[p.cat]; |
| |
| 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]; |
| |
| const s = p.size + p.clustered * 1.2; |
| ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s); |
| } |
| ctx.globalAlpha = 1; |
| |
| |
| 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]; |
| |
| 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; |
| |
| |
| 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(); |
| |
| 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> |
|
|