CyberSys/fruit-fly-simulation
0
1import { requestBytes } from './data-loader.js';2/** Soma coordinates projected into canvas space for rendering and hit tests. */3export class BrainView {4 constructor(canvas, onPulse, onSelection) {5 this.canvas = canvas;6 this.ctx = canvas.getContext('2d');7 this.base = document.createElement('canvas');8 this.bc = this.base.getContext('2d');9 this.selectedLayer = document.createElement('canvas');10 this.sc = this.selectedLayer.getContext('2d');11 this.fireLayer = document.createElement('canvas');12 this.fc = this.fireLayer.getContext('2d');13 this.selectionDirty = true;14 this.onPulse = onPulse;15 this.onSelection = onSelection;16 this.neurons = [];17 this.points = [];18 this.selection = new Set();19 this.brush = 21;20 this.mode = 'paint';21 this.pulseProfile = 'paint';22 this.replacePulse = false;23 this.projection = 'brain';24 this.tick = 0;25 this.lastTickAt = performance.now();26 this.hover = null;27 this.enabled = false;28 this.spark = document.createElement('canvas');29 this.spark.width = this.spark.height = 48;30 const glow = this.spark.getContext('2d'),31 gradient = glow.createRadialGradient(24, 24, 0, 24, 24, 24);32 gradient.addColorStop(0, '#fff8df');33 gradient.addColorStop(0.08, '#ffe59d');34 gradient.addColorStop(0.22, '#ffbf57ad');35 gradient.addColorStop(0.55, '#ffaa3329');36 gradient.addColorStop(1, '#ff922000');37 glow.fillStyle = gradient;38 glow.fillRect(0, 0, 48, 48);39 this.observer = new ResizeObserver(() => this.resize());40 this.observer.observe(canvas);41 this.down = (e) => {42 if (!this.enabled || !this.neurons.length || e.button !== 0) return;43 e.preventDefault();44 canvas.focus();45 canvas.setPointerCapture(e.pointerId);46 this.pointer = e.pointerId;47 this.dragging = true;48 this.last = null;49 this.segment(e);50 };51 this.move = (e) => {52 if (!this.enabled) return;53 this.hover = this.local(e);54 if (this.dragging && this.pointer === e.pointerId) this.segment(e);55 this.tooltip();56 };57 this.up = (e) => {58 if (this.pointer !== e.pointerId) return;59 this.dragging = false;60 this.last = null;61 this.pointer = null;62 if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);63 this.finish();64 };65 this.leave = () => {66 if (!this.dragging) this.hover = null;67 document.getElementById('neuron-tooltip').hidden = true;68 };69 canvas.addEventListener('pointerdown', this.down);70 canvas.addEventListener('pointermove', this.move);71 canvas.addEventListener('pointerup', this.up);72 canvas.addEventListener('pointercancel', this.up);73 canvas.addEventListener('pointerleave', this.leave);74 this.animate = this.animate.bind(this);75 this.frame = requestAnimationFrame(this.animate);76 }77 async load() {78 const bytes = await requestBytes('./data/neurons.json.gz');79 const json = await new Response(80 new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),81 ).text();82 this.neurons = JSON.parse(json);83 this.resize();84 return this.neurons;85 }86 resize() {87 const rect = this.canvas.getBoundingClientRect();88 this.w = Math.max(1, rect.width);89 this.h = Math.max(1, rect.height);90 const dpr = Math.min(2, devicePixelRatio || 1);91 for (const c of [this.canvas, this.base, this.selectedLayer, this.fireLayer]) {92 c.width = Math.round(this.w * dpr);93 c.height = Math.round(this.h * dpr);94 }95 this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);96 this.bc.setTransform(dpr, 0, 0, dpr, 0, 0);97 this.sc.setTransform(dpr, 0, 0, dpr, 0, 0);98 this.fc.setTransform(dpr, 0, 0, dpr, 0, 0);99 this.layout();100 }101 layout() {102 this.selectionDirty = true;103 // Projection changes the view; hit tests retain the original neuron indices.104 const brain = this.projection === 'brain',105 angle = brain ? (25 * Math.PI) / 180 : 0;106 const centerX = brain ? -48075.50325 : -48000,107 centerY = brain ? 37479.76546015124 : 72500;108 const spanX = brain ? 90971.2755 : 92000,109 spanY = brain ? 41180.64216351028 : 127000;110 const scale = Math.min(Math.max(1, this.w - 36) / spanX, Math.max(1, this.h - 36) / spanY);111 const sine = Math.sin(angle),112 cosine = Math.cos(angle);113 this.points = [];114 this.byIndex = new Map();115 this.grid = new Map();116 this.cell = 24;117 this.neurons.forEach((r, i) => {118 const p = r[6];119 if (!p || p[2] > (brain ? 58000 : 136000) || p[2] < 9000) return;120 const x = this.w / 2 + (-p[0] - centerX) * scale,121 y = this.h / 2 + (p[1] * sine + p[2] * cosine - centerY) * scale,122 v = { i, x, y };123 this.points.push(v);124 this.byIndex.set(i, v);125 const k = `${Math.floor(x / this.cell)},${Math.floor(y / this.cell)}`;126 if (!this.grid.has(k)) this.grid.set(k, []);127 this.grid.get(k).push(v);128 });129 this.bc.clearRect(0, 0, this.w, this.h);130 this.bc.fillStyle = '#add5f1';131 this.bc.globalAlpha = 0.72;132 this.bc.beginPath();133 for (const p of this.points) {134 this.bc.moveTo(p.x + 0.65, p.y);135 this.bc.arc(p.x, p.y, 0.65, 0, Math.PI * 2);136 }137 this.bc.fill();138 this.bc.globalAlpha = 1;139 }140 tooltip() {141 const tip = document.getElementById('neuron-tooltip');142 if (this.dragging || !this.hover) {143 tip.hidden = true;144 return;145 }146 const { x, y } = this.hover;147 let nearest,148 dist = 36;149 const gx = Math.floor(x / this.cell),150 gy = Math.floor(y / this.cell);151 for (let a = gx - 1; a <= gx + 1; a++)152 for (let b = gy - 1; b <= gy + 1; b++)153 for (const p of this.grid.get(`${a},${b}`) ?? []) {154 const d = (p.x - x) ** 2 + (p.y - y) ** 2;155 if (d < dist) {156 nearest = p;157 dist = d;158 }159 }160 if (!nearest) {161 tip.hidden = true;162 return;163 }164 const r = this.neurons[nearest.i];165 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]}`;166 tip.hidden = false;167 tip.style.left = Math.max(8, Math.min(this.w - 240, x + 18)) + 'px';168 tip.style.top = Math.max(6, Math.min(this.h - 82, y + 18)) + 'px';169 }170 local(e) {171 const r = this.canvas.getBoundingClientRect();172 return { x: e.clientX - r.left, y: e.clientY - r.top };173 }174 segment(e) {175 const point = this.local(e),176 start = this.last ?? point;177 const dx = point.x - start.x,178 dy = point.y - start.y;179 const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / Math.max(2, this.brush * 0.3)));180 for (let k = 0; k <= steps; k++)181 this.stamp(start.x + (dx * k) / steps, start.y + (dy * k) / steps);182 this.last = point;183 this.onSelection(this.selection.size);184 }185 stamp(x, y) {186 this.pulseProfile = 'paint';187 this.replacePulse = false;188 this.selectionDirty = true;189 const r = this.brush;190 for (let gx = Math.floor((x - r) / this.cell); gx <= Math.floor((x + r) / this.cell); gx++)191 for (let gy = Math.floor((y - r) / this.cell); gy <= Math.floor((y + r) / this.cell); gy++)192 for (const p of this.grid.get(`${gx},${gy}`) ?? []) {193 if ((p.x - x) ** 2 + (p.y - y) ** 2 <= r * r) {194 if (this.mode === 'erase') this.selection.delete(p.i);195 else this.selection.add(p.i);196 }197 }198 }199 finish() {200 if (this.enabled && this.mode === 'paint' && this.selection.size) this.pulse();201 }202 pulse() {203 if (!this.enabled || !this.selection.size) return;204 this.pulseAt = performance.now();205 this.pulseTick = this.tick;206 this.onPulse(Uint32Array.from(this.selection), {207 profile: this.pulseProfile,208 replace: this.replacePulse,209 });210 }211 preset(indices, profile = 'paint') {212 this.pulseProfile = profile;213 this.replacePulse = true;214 this.selectionDirty = true;215 this.selection = new Set(indices);216 this.onSelection(this.selection.size);217 this.pulse();218 }219 clear() {220 this.pulseProfile = 'paint';221 this.replacePulse = false;222 this.selectionDirty = true;223 this.selection.clear();224 this.onSelection(0);225 }226 result(indices, counts, tick) {227 this.tick = tick;228 this.lastTickAt = performance.now();229 const c = this.fc;230 c.clearRect(0, 0, this.w, this.h);231 c.fillStyle = '#ffd180';232 for (let bucket = 0; bucket < 3; bucket++) {233 c.globalAlpha = [0.55, 0.8, 1][bucket];234 c.beginPath();235 for (let j = 0; j < indices.length; j++) {236 if (Math.min(2, Math.floor((counts[j] - 1) / 3)) !== bucket) continue;237 const p = this.byIndex.get(indices[j]);238 if (!p) continue;239 c.moveTo(p.x + 1.4, p.y);240 c.arc(p.x, p.y, 1.4, 0, Math.PI * 2);241 }242 c.fill();243 }244 c.globalAlpha = 1;245 let halos = 0;246 for (let j = 0; j < indices.length && halos < 48; j++) {247 if (counts[j] < 2) continue;248 const p = this.byIndex.get(indices[j]);249 if (!p) continue;250 const radius = 5 + Math.min(6, counts[j]) * 1.4;251 c.drawImage(this.spark, p.x - radius, p.y - radius, radius * 2, radius * 2);252 halos++;253 }254 }255 animate(now) {256 if (this.disposed) return;257 this.frame = requestAnimationFrame(this.animate);258 if (document.hidden) return;259 const c = this.ctx;260 c.clearRect(0, 0, this.w, this.h);261 c.drawImage(this.base, 0, 0, this.w, this.h);262 const age = (now - (this.pulseAt ?? -10000)) / 1000,263 pulse =264 (this.pulseProfile === 'turn'265 ? Math.exp(266 -Math.max(0, (this.tick - (this.pulseTick ?? this.tick)) * 0.0001 - 0.65) / 0.4,267 )268 : Math.exp(-age * 1.7)) *269 (1 + 0.18 * Math.sin(age * 11));270 if (this.selectionDirty) {271 const s = this.sc;272 s.clearRect(0, 0, this.w, this.h);273 s.fillStyle = '#bba0ff';274 s.beginPath();275 for (const i of this.selection) {276 const p = this.byIndex.get(i);277 if (!p) continue;278 s.moveTo(p.x + 1, p.y);279 s.arc(p.x, p.y, 1, 0, Math.PI * 2);280 }281 s.fill();282 this.selectionDirty = false;283 }284 c.globalAlpha = 0.34 + 0.66 * pulse;285 c.drawImage(this.selectedLayer, 0, 0, this.w, this.h);286 c.globalAlpha = Math.max(0, 1 - (now - this.lastTickAt) / 700);287 c.drawImage(this.fireLayer, 0, 0, this.w, this.h);288 c.globalAlpha = 1;289 if (this.hover) {290 c.strokeStyle = this.mode === 'erase' ? '#eab2b2bb' : '#b696ffdb';291 c.lineWidth = 1.6;292 c.beginPath();293 c.arc(this.hover.x, this.hover.y, this.brush, 0, Math.PI * 2);294 c.stroke();295 c.fillStyle = this.mode === 'erase' ? '#ffc1c109' : '#ab83ff0d';296 c.fill();297 }298 }299 reset() {300 this.fc.clearRect(0, 0, this.w, this.h);301 this.tick = 0;302 this.clear();303 }304 dispose() {305 this.disposed = true;306 cancelAnimationFrame(this.frame);307 this.observer.disconnect();308 for (const [type, fn] of [309 ['pointerdown', this.down],310 ['pointermove', this.move],311 ['pointerup', this.up],312 ['pointercancel', this.up],313 ['pointerleave', this.leave],314 ])315 this.canvas.removeEventListener(type, fn);316 }317}318 