Spaces:
Running
Running
Download src/brain-view.js from lordhet/fruit-fly-simulation: direct link, hf CLI and curl.
- Browser
- Download file 11.2 kB
-
https://huggingface.co/spaces/lordhet/fruit-fly-simulation/resolve/main/src/brain-view.js
- Command line
-
hf download hf://spaces/lordhet/fruit-fly-simulation/src/brain-view.js
-
curl -L -o brain-view.js https://huggingface.co/spaces/lordhet/fruit-fly-simulation/resolve/main/src/brain-view.js
11.2 kB
| import { requestBytes } from './data-loader.js'; | |
| /** Soma coordinates projected into canvas space for rendering and hit tests. */ | |
| export class BrainView { | |
| constructor(canvas, onPulse, onSelection) { | |
| this.canvas = canvas; | |
| this.ctx = canvas.getContext('2d'); | |
| this.base = document.createElement('canvas'); | |
| this.bc = this.base.getContext('2d'); | |
| this.selectedLayer = document.createElement('canvas'); | |
| this.sc = this.selectedLayer.getContext('2d'); | |
| this.fireLayer = document.createElement('canvas'); | |
| this.fc = this.fireLayer.getContext('2d'); | |
| this.selectionDirty = true; | |
| this.onPulse = onPulse; | |
| this.onSelection = onSelection; | |
| this.neurons = []; | |
| this.points = []; | |
| this.selection = new Set(); | |
| this.brush = 21; | |
| this.mode = 'paint'; | |
| this.pulseProfile = 'paint'; | |
| this.replacePulse = false; | |
| this.projection = 'brain'; | |
| this.tick = 0; | |
| this.lastTickAt = performance.now(); | |
| this.hover = null; | |
| this.enabled = false; | |
| this.spark = document.createElement('canvas'); | |
| this.spark.width = this.spark.height = 48; | |
| const glow = this.spark.getContext('2d'), | |
| gradient = glow.createRadialGradient(24, 24, 0, 24, 24, 24); | |
| gradient.addColorStop(0, '#fff8df'); | |
| gradient.addColorStop(0.08, '#ffe59d'); | |
| gradient.addColorStop(0.22, '#ffbf57ad'); | |
| gradient.addColorStop(0.55, '#ffaa3329'); | |
| gradient.addColorStop(1, '#ff922000'); | |
| glow.fillStyle = gradient; | |
| glow.fillRect(0, 0, 48, 48); | |
| this.observer = new ResizeObserver(() => this.resize()); | |
| this.observer.observe(canvas); | |
| this.down = (e) => { | |
| if (!this.enabled || !this.neurons.length || e.button !== 0) return; | |
| e.preventDefault(); | |
| canvas.focus(); | |
| canvas.setPointerCapture(e.pointerId); | |
| this.pointer = e.pointerId; | |
| this.dragging = true; | |
| this.last = null; | |
| this.segment(e); | |
| }; | |
| this.move = (e) => { | |
| if (!this.enabled) return; | |
| this.hover = this.local(e); | |
| if (this.dragging && this.pointer === e.pointerId) this.segment(e); | |
| this.tooltip(); | |
| }; | |
| this.up = (e) => { | |
| if (this.pointer !== e.pointerId) return; | |
| this.dragging = false; | |
| this.last = null; | |
| this.pointer = null; | |
| if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); | |
| this.finish(); | |
| }; | |
| this.leave = () => { | |
| if (!this.dragging) this.hover = null; | |
| document.getElementById('neuron-tooltip').hidden = true; | |
| }; | |
| canvas.addEventListener('pointerdown', this.down); | |
| canvas.addEventListener('pointermove', this.move); | |
| canvas.addEventListener('pointerup', this.up); | |
| canvas.addEventListener('pointercancel', this.up); | |
| canvas.addEventListener('pointerleave', this.leave); | |
| this.animate = this.animate.bind(this); | |
| this.frame = requestAnimationFrame(this.animate); | |
| } | |
| async load() { | |
| const bytes = await requestBytes('./data/neurons.json.gz'); | |
| const json = await new Response( | |
| new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')), | |
| ).text(); | |
| this.neurons = JSON.parse(json); | |
| this.resize(); | |
| return this.neurons; | |
| } | |
| resize() { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.w = Math.max(1, rect.width); | |
| this.h = Math.max(1, rect.height); | |
| const dpr = Math.min(2, devicePixelRatio || 1); | |
| for (const c of [this.canvas, this.base, this.selectedLayer, this.fireLayer]) { | |
| c.width = Math.round(this.w * dpr); | |
| c.height = Math.round(this.h * dpr); | |
| } | |
| this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| this.bc.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| this.sc.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| this.fc.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| this.layout(); | |
| } | |
| layout() { | |
| this.selectionDirty = true; | |
| // Projection changes the view; hit tests retain the original neuron indices. | |
| const brain = this.projection === 'brain', | |
| angle = brain ? (25 * Math.PI) / 180 : 0; | |
| const centerX = brain ? -48075.50325 : -48000, | |
| centerY = brain ? 37479.76546015124 : 72500; | |
| const spanX = brain ? 90971.2755 : 92000, | |
| spanY = brain ? 41180.64216351028 : 127000; | |
| const scale = Math.min(Math.max(1, this.w - 36) / spanX, Math.max(1, this.h - 36) / spanY); | |
| const sine = Math.sin(angle), | |
| cosine = Math.cos(angle); | |
| this.points = []; | |
| this.byIndex = new Map(); | |
| this.grid = new Map(); | |
| this.cell = 24; | |
| this.neurons.forEach((r, i) => { | |
| const p = r[6]; | |
| if (!p || p[2] > (brain ? 58000 : 136000) || p[2] < 9000) return; | |
| const x = this.w / 2 + (-p[0] - centerX) * scale, | |
| y = this.h / 2 + (p[1] * sine + p[2] * cosine - centerY) * scale, | |
| v = { i, x, y }; | |
| this.points.push(v); | |
| this.byIndex.set(i, v); | |
| const k = `${Math.floor(x / this.cell)},${Math.floor(y / this.cell)}`; | |
| if (!this.grid.has(k)) this.grid.set(k, []); | |
| this.grid.get(k).push(v); | |
| }); | |
| this.bc.clearRect(0, 0, this.w, this.h); | |
| this.bc.fillStyle = '#add5f1'; | |
| this.bc.globalAlpha = 0.72; | |
| this.bc.beginPath(); | |
| for (const p of this.points) { | |
| this.bc.moveTo(p.x + 0.65, p.y); | |
| this.bc.arc(p.x, p.y, 0.65, 0, Math.PI * 2); | |
| } | |
| this.bc.fill(); | |
| this.bc.globalAlpha = 1; | |
| } | |
| tooltip() { | |
| const tip = document.getElementById('neuron-tooltip'); | |
| if (this.dragging || !this.hover) { | |
| tip.hidden = true; | |
| return; | |
| } | |
| const { x, y } = this.hover; | |
| let nearest, | |
| dist = 36; | |
| const gx = Math.floor(x / this.cell), | |
| gy = Math.floor(y / this.cell); | |
| for (let a = gx - 1; a <= gx + 1; a++) | |
| for (let b = gy - 1; b <= gy + 1; b++) | |
| for (const p of this.grid.get(`${a},${b}`) ?? []) { | |
| const d = (p.x - x) ** 2 + (p.y - y) ** 2; | |
| if (d < dist) { | |
| nearest = p; | |
| dist = d; | |
| } | |
| } | |
| if (!nearest) { | |
| tip.hidden = true; | |
| return; | |
| } | |
| const r = this.neurons[nearest.i]; | |
| tip.textContent = `${r[1] || 'Unclassified type'}${r[3] ? ' · ' + (r[3] === 'L' ? 'Left' : r[3] === 'R' ? 'Right' : r[3]) : ''}\n${r[4] || 'Unknown transmitter'}\nDataset ID ${r[0]}`; | |
| tip.hidden = false; | |
| tip.style.left = Math.max(8, Math.min(this.w - 240, x + 18)) + 'px'; | |
| tip.style.top = Math.max(6, Math.min(this.h - 82, y + 18)) + 'px'; | |
| } | |
| local(e) { | |
| const r = this.canvas.getBoundingClientRect(); | |
| return { x: e.clientX - r.left, y: e.clientY - r.top }; | |
| } | |
| segment(e) { | |
| const point = this.local(e), | |
| start = this.last ?? point; | |
| const dx = point.x - start.x, | |
| dy = point.y - start.y; | |
| const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / Math.max(2, this.brush * 0.3))); | |
| for (let k = 0; k <= steps; k++) | |
| this.stamp(start.x + (dx * k) / steps, start.y + (dy * k) / steps); | |
| this.last = point; | |
| this.onSelection(this.selection.size); | |
| } | |
| stamp(x, y) { | |
| this.pulseProfile = 'paint'; | |
| this.replacePulse = false; | |
| this.selectionDirty = true; | |
| const r = this.brush; | |
| for (let gx = Math.floor((x - r) / this.cell); gx <= Math.floor((x + r) / this.cell); gx++) | |
| for (let gy = Math.floor((y - r) / this.cell); gy <= Math.floor((y + r) / this.cell); gy++) | |
| for (const p of this.grid.get(`${gx},${gy}`) ?? []) { | |
| if ((p.x - x) ** 2 + (p.y - y) ** 2 <= r * r) { | |
| if (this.mode === 'erase') this.selection.delete(p.i); | |
| else this.selection.add(p.i); | |
| } | |
| } | |
| } | |
| finish() { | |
| if (this.enabled && this.mode === 'paint' && this.selection.size) this.pulse(); | |
| } | |
| pulse() { | |
| if (!this.enabled || !this.selection.size) return; | |
| this.pulseAt = performance.now(); | |
| this.pulseTick = this.tick; | |
| this.onPulse(Uint32Array.from(this.selection), { | |
| profile: this.pulseProfile, | |
| replace: this.replacePulse, | |
| }); | |
| } | |
| preset(indices, profile = 'paint') { | |
| this.pulseProfile = profile; | |
| this.replacePulse = true; | |
| this.selectionDirty = true; | |
| this.selection = new Set(indices); | |
| this.onSelection(this.selection.size); | |
| this.pulse(); | |
| } | |
| clear() { | |
| this.pulseProfile = 'paint'; | |
| this.replacePulse = false; | |
| this.selectionDirty = true; | |
| this.selection.clear(); | |
| this.onSelection(0); | |
| } | |
| result(indices, counts, tick) { | |
| this.tick = tick; | |
| this.lastTickAt = performance.now(); | |
| const c = this.fc; | |
| c.clearRect(0, 0, this.w, this.h); | |
| c.fillStyle = '#ffd180'; | |
| for (let bucket = 0; bucket < 3; bucket++) { | |
| c.globalAlpha = [0.55, 0.8, 1][bucket]; | |
| c.beginPath(); | |
| for (let j = 0; j < indices.length; j++) { | |
| if (Math.min(2, Math.floor((counts[j] - 1) / 3)) !== bucket) continue; | |
| const p = this.byIndex.get(indices[j]); | |
| if (!p) continue; | |
| c.moveTo(p.x + 1.4, p.y); | |
| c.arc(p.x, p.y, 1.4, 0, Math.PI * 2); | |
| } | |
| c.fill(); | |
| } | |
| c.globalAlpha = 1; | |
| let halos = 0; | |
| for (let j = 0; j < indices.length && halos < 48; j++) { | |
| if (counts[j] < 2) continue; | |
| const p = this.byIndex.get(indices[j]); | |
| if (!p) continue; | |
| const radius = 5 + Math.min(6, counts[j]) * 1.4; | |
| c.drawImage(this.spark, p.x - radius, p.y - radius, radius * 2, radius * 2); | |
| halos++; | |
| } | |
| } | |
| animate(now) { | |
| if (this.disposed) return; | |
| this.frame = requestAnimationFrame(this.animate); | |
| if (document.hidden) return; | |
| const c = this.ctx; | |
| c.clearRect(0, 0, this.w, this.h); | |
| c.drawImage(this.base, 0, 0, this.w, this.h); | |
| const age = (now - (this.pulseAt ?? -10000)) / 1000, | |
| pulse = | |
| (this.pulseProfile === 'turn' | |
| ? Math.exp( | |
| -Math.max(0, (this.tick - (this.pulseTick ?? this.tick)) * 0.0001 - 0.65) / 0.4, | |
| ) | |
| : Math.exp(-age * 1.7)) * | |
| (1 + 0.18 * Math.sin(age * 11)); | |
| if (this.selectionDirty) { | |
| const s = this.sc; | |
| s.clearRect(0, 0, this.w, this.h); | |
| s.fillStyle = '#bba0ff'; | |
| s.beginPath(); | |
| for (const i of this.selection) { | |
| const p = this.byIndex.get(i); | |
| if (!p) continue; | |
| s.moveTo(p.x + 1, p.y); | |
| s.arc(p.x, p.y, 1, 0, Math.PI * 2); | |
| } | |
| s.fill(); | |
| this.selectionDirty = false; | |
| } | |
| c.globalAlpha = 0.34 + 0.66 * pulse; | |
| c.drawImage(this.selectedLayer, 0, 0, this.w, this.h); | |
| c.globalAlpha = Math.max(0, 1 - (now - this.lastTickAt) / 700); | |
| c.drawImage(this.fireLayer, 0, 0, this.w, this.h); | |
| c.globalAlpha = 1; | |
| if (this.hover) { | |
| c.strokeStyle = this.mode === 'erase' ? '#eab2b2bb' : '#b696ffdb'; | |
| c.lineWidth = 1.6; | |
| c.beginPath(); | |
| c.arc(this.hover.x, this.hover.y, this.brush, 0, Math.PI * 2); | |
| c.stroke(); | |
| c.fillStyle = this.mode === 'erase' ? '#ffc1c109' : '#ab83ff0d'; | |
| c.fill(); | |
| } | |
| } | |
| reset() { | |
| this.fc.clearRect(0, 0, this.w, this.h); | |
| this.tick = 0; | |
| this.clear(); | |
| } | |
| dispose() { | |
| this.disposed = true; | |
| cancelAnimationFrame(this.frame); | |
| this.observer.disconnect(); | |
| for (const [type, fn] of [ | |
| ['pointerdown', this.down], | |
| ['pointermove', this.move], | |
| ['pointerup', this.up], | |
| ['pointercancel', this.up], | |
| ['pointerleave', this.leave], | |
| ]) | |
| this.canvas.removeEventListener(type, fn); | |
| } | |
| } | |