|
|
<div class="neural-flow" |
|
|
style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;position:relative;overflow:hidden;"></div> |
|
|
<script> |
|
|
(() => { |
|
|
const ensureAnime = (cb) => { |
|
|
if (window.anime && typeof window.anime === 'function') return cb(); |
|
|
let s = document.getElementById('anime-cdn-script'); |
|
|
if (!s) { |
|
|
s = document.createElement('script'); |
|
|
s.id = 'anime-cdn-script'; |
|
|
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/lib/anime.min.js'; |
|
|
document.head.appendChild(s); |
|
|
} |
|
|
const onReady = () => { if (window.anime && typeof window.anime === 'function') cb(); }; |
|
|
s.addEventListener('load', onReady, { once: true }); |
|
|
if (window.anime) onReady(); |
|
|
}; |
|
|
|
|
|
const bootstrap = () => { |
|
|
const mount = document.currentScript ? document.currentScript.previousElementSibling : null; |
|
|
const container = (mount && mount.querySelector && mount.querySelector('.neural-flow')) || document.querySelector('.neural-flow'); |
|
|
if (!container) return; |
|
|
if (container.dataset) { |
|
|
if (container.dataset.mounted === 'true') return; |
|
|
container.dataset.mounted = 'true'; |
|
|
} |
|
|
|
|
|
|
|
|
const canvas = document.createElement('canvas'); |
|
|
canvas.style.display = 'block'; |
|
|
canvas.style.width = '100%'; |
|
|
canvas.style.height = '100%'; |
|
|
container.appendChild(canvas); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
|
|
|
const getColors = () => { |
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; |
|
|
return { |
|
|
node: isDark ? 'rgba(206, 192, 250, 0.85)' : 'rgba(138, 100, 220, 0.8)', |
|
|
nodeActive: isDark ? 'rgba(232, 137, 171, 1)' : 'rgba(220, 80, 130, 1)', |
|
|
nodeGlow: isDark ? 'rgba(206, 192, 250, 0.4)' : 'rgba(138, 100, 220, 0.3)', |
|
|
connection: isDark ? 'rgba(78, 165, 183, 0.08)' : 'rgba(78, 165, 183, 0.15)', |
|
|
connectionActive: isDark ? 'rgba(232, 137, 171, 0.6)' : 'rgba(220, 80, 130, 0.5)', |
|
|
accent: isDark ? 'rgba(78, 165, 183, 0.9)' : 'rgba(50, 130, 160, 0.85)', |
|
|
particle: isDark ? 'rgba(232, 137, 171, 1)' : 'rgba(220, 80, 130, 1)', |
|
|
}; |
|
|
}; |
|
|
|
|
|
let colors = getColors(); |
|
|
|
|
|
|
|
|
const observer = new MutationObserver(() => { |
|
|
colors = getColors(); |
|
|
}); |
|
|
observer.observe(document.documentElement, { |
|
|
attributes: true, |
|
|
attributeFilter: ['data-theme'] |
|
|
}); |
|
|
|
|
|
|
|
|
const layers = [ |
|
|
{ nodes: 6, name: 'input' }, |
|
|
{ nodes: 10, name: 'hidden1' }, |
|
|
{ nodes: 8, name: 'hidden2' }, |
|
|
{ nodes: 4, name: 'output' } |
|
|
]; |
|
|
|
|
|
let nodes = []; |
|
|
let connections = []; |
|
|
let particles = []; |
|
|
let width, height; |
|
|
|
|
|
const resize = () => { |
|
|
width = container.clientWidth || 800; |
|
|
height = Math.max(260, Math.round(width / 3)); |
|
|
canvas.width = width; |
|
|
canvas.height = height; |
|
|
initNetwork(); |
|
|
}; |
|
|
|
|
|
const initNetwork = () => { |
|
|
nodes = []; |
|
|
connections = []; |
|
|
particles = []; |
|
|
|
|
|
const layerSpacing = width / (layers.length + 1); |
|
|
const margin = height * 0.15; |
|
|
|
|
|
|
|
|
let nodeIndex = 0; |
|
|
const layerStartIndices = []; |
|
|
|
|
|
layers.forEach((layer, layerIdx) => { |
|
|
layerStartIndices.push(nodeIndex); |
|
|
const x = layerSpacing * (layerIdx + 1); |
|
|
const availableHeight = height - 2 * margin; |
|
|
const nodeSpacing = availableHeight / (layer.nodes + 1); |
|
|
|
|
|
for (let i = 0; i < layer.nodes; i++) { |
|
|
const y = margin + nodeSpacing * (i + 1); |
|
|
const node = { |
|
|
x, |
|
|
y, |
|
|
layer: layerIdx, |
|
|
index: i, |
|
|
radius: 0, |
|
|
targetRadius: 3.5 + Math.random() * 1.5, |
|
|
pulse: Math.random() * Math.PI * 2, |
|
|
activation: 0, |
|
|
baseActivity: Math.random() * 0.1 |
|
|
}; |
|
|
nodes.push(node); |
|
|
nodeIndex++; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
layers.forEach((layer, layerIdx) => { |
|
|
if (layerIdx < layers.length - 1) { |
|
|
const currentLayerStart = layerStartIndices[layerIdx]; |
|
|
const nextLayerStart = layerStartIndices[layerIdx + 1]; |
|
|
const nextLayerNodes = layers[layerIdx + 1].nodes; |
|
|
|
|
|
for (let i = 0; i < layer.nodes; i++) { |
|
|
for (let j = 0; j < nextLayerNodes; j++) { |
|
|
connections.push({ |
|
|
from: currentLayerStart + i, |
|
|
to: nextLayerStart + j, |
|
|
weight: Math.random(), |
|
|
opacity: 0, |
|
|
activation: 0 |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
nodes.forEach((node, i) => { |
|
|
anime({ |
|
|
targets: node, |
|
|
radius: node.targetRadius, |
|
|
duration: 800, |
|
|
delay: i * 8, |
|
|
easing: 'easeOutElastic(1, .6)' |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
connections.forEach((conn, i) => { |
|
|
anime({ |
|
|
targets: conn, |
|
|
opacity: 1, |
|
|
duration: 400, |
|
|
delay: 300 + i * 1, |
|
|
easing: 'easeOutQuad' |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
startForwardPass(); |
|
|
setInterval(startForwardPass, 2500 + Math.random() * 1000); |
|
|
}, 1000); |
|
|
}; |
|
|
|
|
|
|
|
|
const activationPatterns = [ |
|
|
|
|
|
(inputNodes) => inputNodes, |
|
|
|
|
|
|
|
|
(inputNodes) => [inputNodes[Math.floor(Math.random() * inputNodes.length)]], |
|
|
|
|
|
|
|
|
(inputNodes) => inputNodes.slice(0, Math.ceil(inputNodes.length / 2)), |
|
|
|
|
|
|
|
|
(inputNodes) => inputNodes.slice(Math.floor(inputNodes.length / 2)), |
|
|
|
|
|
|
|
|
(inputNodes) => inputNodes.filter((_, i) => i % 2 === 0), |
|
|
|
|
|
|
|
|
(inputNodes) => { |
|
|
const num = 2 + Math.floor(Math.random() * 2); |
|
|
return [...inputNodes].sort(() => Math.random() - 0.5).slice(0, num); |
|
|
}, |
|
|
|
|
|
|
|
|
(inputNodes) => inputNodes.slice(0, 3 + Math.floor(Math.random() * 3)) |
|
|
]; |
|
|
|
|
|
const startForwardPass = () => { |
|
|
const inputNodes = nodes.filter(n => n.layer === 0); |
|
|
|
|
|
|
|
|
const pattern = activationPatterns[Math.floor(Math.random() * activationPatterns.length)]; |
|
|
const activeInputs = pattern(inputNodes); |
|
|
|
|
|
|
|
|
activeInputs.forEach((node, idx) => { |
|
|
anime({ |
|
|
targets: node, |
|
|
activation: 0.8 + Math.random() * 0.2, |
|
|
duration: 200, |
|
|
delay: idx * 60, |
|
|
easing: 'easeOutQuad', |
|
|
complete: () => { |
|
|
anime({ |
|
|
targets: node, |
|
|
activation: node.baseActivity, |
|
|
duration: 250, |
|
|
delay: 400, |
|
|
easing: 'easeInQuad' |
|
|
}); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
for (let layerIdx = 0; layerIdx < layers.length - 1; layerIdx++) { |
|
|
setTimeout(() => { |
|
|
propagateLayer(layerIdx); |
|
|
}, 250 + layerIdx * 350); |
|
|
} |
|
|
}; |
|
|
|
|
|
const propagateLayer = (fromLayerIdx) => { |
|
|
const fromNodes = nodes.filter(n => n.layer === fromLayerIdx); |
|
|
const toNodes = nodes.filter(n => n.layer === fromLayerIdx + 1); |
|
|
|
|
|
const layerConnections = connections.filter(c => { |
|
|
const fromNode = nodes[c.from]; |
|
|
const toNode = nodes[c.to]; |
|
|
return fromNode.layer === fromLayerIdx && toNode.layer === fromLayerIdx + 1; |
|
|
}); |
|
|
|
|
|
|
|
|
layerConnections.forEach((conn, idx) => { |
|
|
const fromNode = nodes[conn.from]; |
|
|
const activationStrength = fromNode.activation * conn.weight; |
|
|
|
|
|
if (activationStrength > 0.2) { |
|
|
anime({ |
|
|
targets: conn, |
|
|
activation: activationStrength, |
|
|
duration: 300, |
|
|
delay: idx * 1, |
|
|
easing: 'easeOutQuad', |
|
|
complete: () => { |
|
|
anime({ |
|
|
targets: conn, |
|
|
activation: 0, |
|
|
duration: 250, |
|
|
easing: 'easeInQuad' |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (Math.random() < 0.3) { |
|
|
createParticle(conn, activationStrength); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
toNodes.forEach(toNode => { |
|
|
const toNodeIdx = nodes.indexOf(toNode); |
|
|
const incomingConns = layerConnections.filter(c => c.to === toNodeIdx); |
|
|
|
|
|
let sum = 0; |
|
|
incomingConns.forEach(conn => { |
|
|
const fromNode = nodes[conn.from]; |
|
|
sum += fromNode.activation * conn.weight; |
|
|
}); |
|
|
|
|
|
const activation = Math.min(1, sum / incomingConns.length * 1.5); |
|
|
|
|
|
if (activation > 0.25) { |
|
|
anime({ |
|
|
targets: toNode, |
|
|
activation: activation, |
|
|
duration: 200, |
|
|
easing: 'easeOutQuad', |
|
|
complete: () => { |
|
|
anime({ |
|
|
targets: toNode, |
|
|
activation: toNode.baseActivity, |
|
|
duration: 400, |
|
|
delay: 300, |
|
|
easing: 'easeInQuad' |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
}, 150); |
|
|
}; |
|
|
|
|
|
const createParticle = (connection, strength) => { |
|
|
const fromNode = nodes[connection.from]; |
|
|
const toNode = nodes[connection.to]; |
|
|
if (!fromNode || !toNode) return; |
|
|
|
|
|
const particle = { |
|
|
fromX: fromNode.x, |
|
|
fromY: fromNode.y, |
|
|
toX: toNode.x, |
|
|
toY: toNode.y, |
|
|
progress: 0, |
|
|
strength: strength, |
|
|
size: 1.5 + strength * 1.5, |
|
|
trail: [] |
|
|
}; |
|
|
|
|
|
particles.push(particle); |
|
|
|
|
|
anime({ |
|
|
targets: particle, |
|
|
progress: 1, |
|
|
duration: 350, |
|
|
easing: 'easeInOutQuad', |
|
|
complete: () => { |
|
|
|
|
|
const idx = particles.indexOf(particle); |
|
|
if (idx > -1) particles.splice(idx, 1); |
|
|
} |
|
|
}); |
|
|
}; |
|
|
|
|
|
const draw = () => { |
|
|
|
|
|
ctx.clearRect(0, 0, width, height); |
|
|
|
|
|
|
|
|
connections.forEach(conn => { |
|
|
if (conn.opacity < 0.01) return; |
|
|
|
|
|
const fromNode = nodes[conn.from]; |
|
|
const toNode = nodes[conn.to]; |
|
|
if (!fromNode || !toNode) return; |
|
|
|
|
|
const baseOpacity = conn.opacity * conn.weight * 0.5; |
|
|
const activeOpacity = conn.activation; |
|
|
const totalOpacity = Math.max(baseOpacity, activeOpacity); |
|
|
|
|
|
if (totalOpacity < 0.01) return; |
|
|
|
|
|
const isActive = conn.activation > 0.1; |
|
|
const connectionColor = isActive ? colors.connectionActive : colors.connection; |
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(fromNode.x, fromNode.y); |
|
|
ctx.lineTo(toNode.x, toNode.y); |
|
|
|
|
|
const rgb = connectionColor.match(/[\d.]+/g); |
|
|
ctx.strokeStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${totalOpacity})`; |
|
|
ctx.lineWidth = isActive ? 1.5 : 0.8; |
|
|
ctx.stroke(); |
|
|
}); |
|
|
|
|
|
|
|
|
particles.forEach(particle => { |
|
|
const x = particle.fromX + (particle.toX - particle.fromX) * particle.progress; |
|
|
const y = particle.fromY + (particle.toY - particle.fromY) * particle.progress; |
|
|
|
|
|
|
|
|
particle.trail.push({ x, y }); |
|
|
if (particle.trail.length > 5) particle.trail.shift(); |
|
|
|
|
|
particle.trail.forEach((point, i) => { |
|
|
const alpha = (i / particle.trail.length) * particle.strength; |
|
|
const size = particle.size * alpha * 0.6; |
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.arc(point.x, point.y, size, 0, Math.PI * 2); |
|
|
const rgb = colors.particle.match(/[\d.]+/g); |
|
|
ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha * 0.5})`; |
|
|
ctx.fill(); |
|
|
}); |
|
|
|
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.arc(x, y, particle.size, 0, Math.PI * 2); |
|
|
ctx.fillStyle = colors.particle; |
|
|
ctx.shadowBlur = 8; |
|
|
ctx.shadowColor = colors.particle; |
|
|
ctx.fill(); |
|
|
ctx.shadowBlur = 0; |
|
|
}); |
|
|
|
|
|
|
|
|
nodes.forEach((node, i) => { |
|
|
if (node.radius < 0.1) return; |
|
|
|
|
|
node.pulse += 0.015; |
|
|
const pulseSize = 1 + Math.sin(node.pulse) * 0.08; |
|
|
const activationBoost = node.activation * 1.8; |
|
|
const finalRadius = node.radius * pulseSize + activationBoost; |
|
|
|
|
|
|
|
|
if (node.activation > 0.15) { |
|
|
const glowRadius = finalRadius * 4; |
|
|
const gradient = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, glowRadius); |
|
|
const glowAlpha = node.activation * 0.5; |
|
|
gradient.addColorStop(0, colors.nodeGlow.replace(/[\d.]+\)$/, `${glowAlpha})`)); |
|
|
gradient.addColorStop(1, colors.nodeGlow.replace(/[\d.]+\)$/, '0)')); |
|
|
ctx.beginPath(); |
|
|
ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2); |
|
|
ctx.fillStyle = gradient; |
|
|
ctx.fill(); |
|
|
} |
|
|
|
|
|
|
|
|
const t = Math.min(1, node.activation / 0.8); |
|
|
|
|
|
const baseRgb = colors.node.match(/[\d.]+/g); |
|
|
const activeRgb = colors.nodeActive.match(/[\d.]+/g); |
|
|
const r = parseFloat(baseRgb[0]) + (parseFloat(activeRgb[0]) - parseFloat(baseRgb[0])) * t; |
|
|
const g = parseFloat(baseRgb[1]) + (parseFloat(activeRgb[1]) - parseFloat(baseRgb[1])) * t; |
|
|
const b = parseFloat(baseRgb[2]) + (parseFloat(activeRgb[2]) - parseFloat(baseRgb[2])) * t; |
|
|
const a = parseFloat(baseRgb[3]) + (parseFloat(activeRgb[3]) - parseFloat(baseRgb[3])) * t; |
|
|
|
|
|
|
|
|
ctx.beginPath(); |
|
|
ctx.arc(node.x, node.y, finalRadius, 0, Math.PI * 2); |
|
|
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`; |
|
|
ctx.fill(); |
|
|
|
|
|
|
|
|
if (node.activation > 0.4) { |
|
|
ctx.beginPath(); |
|
|
ctx.arc(node.x, node.y, finalRadius * 0.4, 0, Math.PI * 2); |
|
|
ctx.fillStyle = colors.accent.replace(/[\d.]+\)$/, `${node.activation})`); |
|
|
ctx.fill(); |
|
|
} |
|
|
}); |
|
|
|
|
|
requestAnimationFrame(draw); |
|
|
}; |
|
|
|
|
|
|
|
|
if (window.ResizeObserver) { |
|
|
const ro = new ResizeObserver(resize); |
|
|
ro.observe(container); |
|
|
} else { |
|
|
window.addEventListener('resize', resize); |
|
|
} |
|
|
|
|
|
resize(); |
|
|
draw(); |
|
|
}; |
|
|
|
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', () => ensureAnime(bootstrap), { once: true }); |
|
|
} else { |
|
|
ensureAnime(bootstrap); |
|
|
} |
|
|
})(); |
|
|
</script> |