diff --git a/examples/collaborativeSync.js b/examples/collaborativeSync.js new file mode 100644 index 0000000..9a7492b --- /dev/null +++ b/examples/collaborativeSync.js @@ -0,0 +1,652 @@ +;(function () { + // guard against double injection + if (window.__boostlet_collab_injected) return + window.__boostlet_collab_injected = true + + const BOOSTLET_URL = 'https://boostlet.org/dist/boostlet.min.js' + const SIGNAL_URL = 'wss://aydenbronsdon.com/signal' + + // load boostlet if not already present then wait for nv + if (window.Boostlet) { + waitForNv() + } else { + const boostletScript = document.createElement('script') + boostletScript.src = BOOSTLET_URL + boostletScript.onload = waitForNv + document.head.appendChild(boostletScript) + } + + function waitForNv() { + // poll until niivue is ready and at least one volume slot exists + const poll = setInterval(() => { + try { + Boostlet.init() + if (Boostlet.framework.name !== 'niivue') return + const nv = Boostlet.framework.instance + if (!nv || nv.volumes === undefined) return + clearInterval(poll) + run(nv) + } catch(e) {} + }, 300) + } + + function run(nv) { + // the peer who creates the room (no room param in url) is the canonical peer + const params = new URLSearchParams(location.search) + const startsRoom = !params.get('room') + const roomId = params.get('room') || generateRoomId() + const sharedVolumeUrl = params.get('volume') + const peers = new Map() + + let selfId = null + let roomPeers = [] + let canonicalPeerId = null + let isCanonicalPeer = startsRoom + let myVolumeHash = null + let canonicalHash = null + // flag set while applying a remote update so we do not echo it back + let applyingRemote = false + let triedSharedVolume = false + let lastUploadPromptAt = 0 + + let drawMode = null + let brushSize = 3 + let label = 1 + // tracks last known crosshair position to diff against in syncLoop + let lastPos = Array.from((nv.scene && nv.scene.crosshairPos) || [0, 0, 0]) + + if (startsRoom) { + const shareParams = new URLSearchParams(location.search) + const base = nv.volumes && nv.volumes[0] + shareParams.set('room', roomId) + if (base && base.url && !base.url.startsWith('blob:')) { + shareParams.set('volume', base.url) + } + history.replaceState(null, '', `${location.pathname}?${shareParams.toString()}${location.hash}`) + Boostlet.hint('session started share this url with your peers', 4000) + } + + // open websocket to signaling server for offer/answer/ice exchange + const signal = new WebSocket(`${SIGNAL_URL}?room=${encodeURIComponent(roomId)}`) + + setupDrawing() + setupSliceSync() + setupLocationSync() + setupKeyHandlers() + + onBaseVolumeChanged(nv, async (baseVolume) => { + if (!baseVolume || !baseVolume.img) return + myVolumeHash = await hashVolume(baseVolume) + if (isCanonicalPeer) { + canonicalHash = myVolumeHash + Boostlet.hint('volume ready', 2000) + } + ensureDrawBitmap() + evaluateAllPeers() + sendHashCheckToAll() + }) + + signal.onerror = () => { + Boostlet.hint('could not reach signaling server', 4000) + } + + signal.onmessage = async (raw) => { + let msg + try { msg = JSON.parse(raw.data) } catch { return } + try { + if (msg.type === 'room-state') { handleRoomState(msg); return } + if (msg.type === 'peer-joined') { await handlePeerJoined(msg.peerId); return } + if (msg.type === 'peer-disconnected') { removePeer(msg.peerId); return } + if (!msg.from) return + if (msg.type === 'offer') await handleOffer(msg.from, msg.payload) + if (msg.type === 'answer') await handleAnswer(msg.from, msg.payload) + if (msg.type === 'ice') await handleIce(msg.from, msg.payload) + } catch (err) { console.error(err) } + } + + requestAnimationFrame(syncLoop) + + // server sends room state on join so we know our own id and existing peers + function handleRoomState(msg) { + selfId = msg.peerId + roomPeers = msg.peers.concat(selfId) + updateCanonicalPeer() + } + + async function handlePeerJoined(peerId) { + if (!roomPeers.includes(peerId)) roomPeers.push(peerId) + updateCanonicalPeer() + await makeOffer(peerId) + } + + // the earliest peer in the list is canonical and owns the reference volume hash + function updateCanonicalPeer() { + canonicalPeerId = roomPeers[0] || selfId + isCanonicalPeer = canonicalPeerId === selfId + if (isCanonicalPeer && myVolumeHash) { + canonicalHash = myVolumeHash + sendHashCheckToAll() + } + evaluateAllPeers() + } + + async function makeOffer(peerId) { + if (!peerId || peerId === selfId) return + const peer = getPeer(peerId, true, true) + if (peer.conn.signalingState !== 'stable') return + const offer = await peer.conn.createOffer() + await peer.conn.setLocalDescription(offer) + sendSignal('offer', peerId, offer) + } + + async function handleOffer(peerId, offer) { + const peer = getPeer(peerId, false, false) + await peer.conn.setRemoteDescription(offer) + peer.remoteDescSet = true + // flush any candidates that arrived before the remote description was set + await flushCandidates(peer) + const answer = await peer.conn.createAnswer() + await peer.conn.setLocalDescription(answer) + sendSignal('answer', peerId, answer) + } + + async function handleAnswer(peerId, answer) { + const peer = peers.get(peerId) + if (!peer) return + await peer.conn.setRemoteDescription(answer) + peer.remoteDescSet = true + await flushCandidates(peer) + } + + // buffer candidates until remote description is ready + async function handleIce(peerId, candidate) { + if (!candidate || !peers.has(peerId)) return + const peer = peers.get(peerId) + if (peer.remoteDescSet) { + await peer.conn.addIceCandidate(candidate) + } else { + peer.pendingCandidates.push(candidate) + } + } + + async function flushCandidates(peer) { + for (const candidate of peer.pendingCandidates) { + await peer.conn.addIceCandidate(candidate) + } + peer.pendingCandidates = [] + } + + function getPeer(peerId, createChannel, shouldSendSnapshot) { + if (peers.has(peerId)) { + const peer = peers.get(peerId) + if (shouldSendSnapshot) peer.shouldSendSnapshot = true + if (createChannel) createDataChannel(peerId, peer) + return peer + } + const conn = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) + const peer = { + conn, channel: null, pendingCandidates: [], + remoteDescSet: false, verified: false, hash: null, + shouldSendSnapshot: Boolean(shouldSendSnapshot) + } + peers.set(peerId, peer) + conn.onicecandidate = (e) => { if (e.candidate) sendSignal('ice', peerId, e.candidate) } + conn.ondatachannel = (e) => { wireDataChannel(peerId, e.channel) } + conn.onconnectionstatechange = () => { + if (conn.connectionState === 'failed' || conn.connectionState === 'closed') removePeer(peerId) + } + if (createChannel) createDataChannel(peerId, peer) + return peer + } + + function createDataChannel(peerId, peer) { + if (peer.channel && peer.channel.readyState !== 'closed') return + const channel = peer.conn.createDataChannel('boostlet-collab') + wireDataChannel(peerId, channel) + } + + function wireDataChannel(peerId, channel) { + const peer = peers.get(peerId) + if (!peer) { channel.close(); return } + peer.channel = channel + channel.binaryType = 'arraybuffer' + + channel.onopen = async () => { + // joiner tries to auto load the shared volume url from the room link + if (!isCanonicalPeer && sharedVolumeUrl && !triedSharedVolume && !(nv.volumes && nv.volumes[0])) { + triedSharedVolume = true + const ok = await tryLoadSharedVolume(sharedVolumeUrl) + if (!ok) promptManualUpload() + } + if (myVolumeHash) { + sendHashCheck(peerId) + } else { + Boostlet.hint('connected load a volume to begin verification', 3000) + } + } + + channel.onmessage = (e) => handleDataMessage(peerId, e) + channel.onclose = () => { + const current = peers.get(peerId) + if (!current || current.channel !== channel) return + current.channel = null + current.verified = false + } + } + + function handleDataMessage(peerId, e) { + const peer = peers.get(peerId) + if (!peer) return + if (typeof e.data !== 'string') { + if (peer.verified) handleDrawSnapshotBinary(e.data) + return + } + let msg + try { msg = JSON.parse(e.data) } catch { return } + if (msg.type === 'hash-check') { handleHashCheck(peerId, msg.hash); return } + if (!peer.verified) return + if (msg.type === 'crosshair') handleCrosshairUpdate(msg) + if (msg.type === 'sliceType') handleSliceTypeUpdate(msg) + if (msg.type === 'draw') handleDrawUpdate(msg) + if (msg.type === 'draw-commit') handleDrawCommit() + } + + // peers exchange sha256 hashes to confirm they have the same volume before syncing + function handleHashCheck(peerId, hash) { + const peer = peers.get(peerId) + if (!peer || !hash) return + peer.hash = hash + if (!canonicalHash && (!canonicalPeerId || peerId === canonicalPeerId)) canonicalHash = hash + if (canonicalHash && !myVolumeHash) { + Boostlet.hint('please load a volume to join the session', 4000) + promptManualUpload() + } + if (canonicalHash && myVolumeHash && myVolumeHash !== canonicalHash) { + Boostlet.hint('volume mismatch please upload the same file', 5000) + promptManualUpload() + } + evaluateAllPeers() + } + + function evaluateAllPeers() { + peers.forEach((peer, peerId) => { + const wasVerified = peer.verified + peer.verified = Boolean( + myVolumeHash && canonicalHash && + myVolumeHash === canonicalHash && + peer.hash === canonicalHash + ) + if (peer.verified && !wasVerified) { + Boostlet.hint(`${verifiedCount()} peer${verifiedCount() === 1 ? '' : 's'} verified sync active`, 2000) + if (isCanonicalPeer && peer.shouldSendSnapshot) { + sendInitialSync(peerId) + peer.shouldSendSnapshot = false + } + } + if (!peer.verified && wasVerified) Boostlet.hint('peer volume mismatch sync paused', 3000) + }) + } + + function verifiedCount() { + let count = 0 + peers.forEach((peer) => { if (peer.verified) count++ }) + return count + } + + function hasVerifiedPeer() { + for (const peer of peers.values()) { + if (peer.verified) return true + } + return false + } + + function sendInitialSync(peerId) { + ensureDrawBitmap() + sendToPeer(peerId, { type: 'sliceType', sliceType: nv.opts.sliceType }) + sendToPeer(peerId, { type: 'crosshair', crosshairPos: Array.from(nv.scene.crosshairPos) }) + sendDrawSnapshot(peerId) + } + + function sendHashCheckToAll() { + peers.forEach((_, peerId) => sendHashCheck(peerId)) + } + + function sendHashCheck(peerId) { + if (!myVolumeHash) return + sendToPeer(peerId, { type: 'hash-check', hash: myVolumeHash }, false) + } + + function sendSignal(type, to, payload) { + if (signal.readyState !== WebSocket.OPEN) return + signal.send(JSON.stringify({ type, to, payload })) + } + + function sendToPeer(peerId, msg, requireVerified = true) { + const peer = peers.get(peerId) + if (!peer || !peer.channel || peer.channel.readyState !== 'open') return + if (requireVerified && !peer.verified) return + try { peer.channel.send(JSON.stringify(msg)) } catch (err) { console.error(err) } + } + + function broadcast(msg) { + const str = JSON.stringify(msg) + peers.forEach((peer) => { + if (!peer.verified || !peer.channel || peer.channel.readyState !== 'open') return + try { peer.channel.send(str) } catch (err) { console.error(err) } + }) + } + + function sendDrawSnapshot(peerId) { + const buffer = makeDrawSnapshotBuffer() + if (!buffer) return + const peer = peers.get(peerId) + if (!peer || !peer.channel || peer.channel.readyState !== 'open' || !peer.verified) return + try { peer.channel.send(buffer) } catch (err) { console.error(err) } + } + + function broadcastDrawSnapshot() { + const buffer = makeDrawSnapshotBuffer() + if (!buffer) return + peers.forEach((peer) => { + if (!peer.verified || !peer.channel || peer.channel.readyState !== 'open') return + try { peer.channel.send(buffer) } catch (err) { console.error(err) } + }) + } + + function makeDrawSnapshotBuffer() { + ensureDrawBitmap() + if (!nv.drawBitmap || !hasDrawBitmapPaint()) return null + const view = nv.drawBitmap + return (view.buffer && view.byteLength !== undefined) + ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) + : Uint8Array.from(view).buffer + } + + function hasDrawBitmapPaint() { + if (!nv.drawBitmap) return false + for (let i = 0; i < nv.drawBitmap.length; i++) { + if (nv.drawBitmap[i] !== 0) return true + } + return false + } + + async function tryLoadSharedVolume(url) { + try { + const res = await fetch(url, { method: 'HEAD', mode: 'cors' }) + if (!res.ok) throw new Error('not reachable') + await nv.loadVolumes([{ url }]) + return true + } catch { return false } + } + + function promptManualUpload() { + const now = Date.now() + if (now - lastUploadPromptAt < 5000) return + lastUploadPromptAt = now + const input = document.createElement('input') + input.type = 'file' + input.accept = '.nii,.nii.gz' + input.onchange = async (e) => { + const file = e.target.files[0] + if (!file) return + await nv.loadVolumes([{ url: URL.createObjectURL(file), name: file.name }]) + } + input.click() + } + + function setupDrawing() { + nv.drawOpacity = 1.0 + ensureDrawBitmap() + } + + function ensureDrawBitmap() { + if (nv.drawBitmap || !nv.back) return + nv.setDrawingEnabled(true) + nv.setDrawingEnabled(false) + } + + function setupSliceSync() { + let lastSliceType = nv.opts.sliceType + // intercept slice type changes via property descriptor if the property is configurable + const sliceDescriptor = Object.getOwnPropertyDescriptor(nv.opts, 'sliceType') + if (!sliceDescriptor || sliceDescriptor.configurable !== false) { + Object.defineProperty(nv.opts, 'sliceType', { + get() { return lastSliceType }, + set(val) { + lastSliceType = val + if (applyingRemote || !hasVerifiedPeer()) return + broadcast({ type: 'sliceType', sliceType: val }) + }, + configurable: true, + enumerable: sliceDescriptor ? sliceDescriptor.enumerable : true + }) + } else { + // fall back to wrapping the callback if the descriptor is not configurable + const prevSliceHandler = typeof nv.onSliceTypeChange === 'function' ? nv.onSliceTypeChange : null + nv.onSliceTypeChange = function (sliceType) { + if (prevSliceHandler) prevSliceHandler.call(this, sliceType) + if (applyingRemote || !hasVerifiedPeer()) return + broadcast({ type: 'sliceType', sliceType }) + } + } + } + + function setupLocationSync() { + // chain onto existing handler so other boostlets keep working + const prevHandler = nv.onLocationChange + nv.onLocationChange = function (loc) { + if (prevHandler) prevHandler.call(this, loc) + const drawPosition = loc && loc.vox + ensureDrawBitmap() + // paint and broadcast if the user is actively drawing + if (drawMode && drawPosition && !applyingRemote) { + const vox = Array.from(drawPosition) + paintAt(vox, drawMode, brushSize, label) + broadcast({ type: 'draw', vox, brushSize, label, mode: drawMode }) + } + } + } + + function setupKeyHandlers() { + window.addEventListener('keydown', (e) => { + if (e.key === '1') { drawMode = 'paint'; if (nv.canvas) nv.canvas.style.cursor = 'crosshair'; e.preventDefault(); return } + if (e.key === '2') { drawMode = 'erase'; if (nv.canvas) nv.canvas.style.cursor = 'crosshair'; e.preventDefault(); return } + if (e.key === '[') { brushSize = Math.max(1, brushSize - 1); e.preventDefault(); return } + if (e.key === ']') { brushSize = Math.min(25, brushSize + 1); e.preventDefault(); return } + + if (e.code === 'KeyZ' && !e.repeat) { + ensureDrawBitmap() + if (nv.drawBitmap && typeof nv.drawUndo === 'function') { + nv.drawUndo() + if (typeof nv.refreshDrawing === 'function') nv.refreshDrawing(true) + if (nv.drawBitmap) { + const view = nv.drawBitmap + const buffer = (view.buffer && view.byteLength !== undefined) + ? view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) + : Uint8Array.from(view).buffer + peers.forEach((peer) => { + if (!peer.verified || !peer.channel || peer.channel.readyState !== 'open') return + try { peer.channel.send(buffer) } catch (err) { console.error(err) } + }) + } + } + e.preventDefault() + } + }) + + window.addEventListener('keyup', (e) => { + if (e.key !== '1' && e.key !== '2') return + if (drawMode && typeof nv.drawAddUndoBitmap === 'function') { + nv.drawAddUndoBitmap() + broadcast({ type: 'draw-commit' }) + } + drawMode = null + if (nv.canvas) nv.canvas.style.cursor = 'default' + e.preventDefault() + }) +} + + // rAF loop diffs crosshair position and broadcasts only on change + function syncLoop() { + const cur = nv.scene && nv.scene.crosshairPos + if (!applyingRemote && hasVerifiedPeer() && cur) { + if (cur[0] !== lastPos[0] || cur[1] !== lastPos[1] || cur[2] !== lastPos[2]) { + lastPos = [cur[0], cur[1], cur[2]] + broadcast({ type: 'crosshair', crosshairPos: lastPos }) + } + } + requestAnimationFrame(syncLoop) + } + + function handleCrosshairUpdate(state) { + if (!state.crosshairPos) return + applyingRemote = true + try { + nv.scene.crosshairPos = new Float32Array(state.crosshairPos) + // update lastPos here so syncLoop does not re broadcast the remote position + lastPos = Array.from(state.crosshairPos) + if (typeof nv.createOnLocationChange === 'function') nv.createOnLocationChange() + if (typeof nv.drawScene === 'function') nv.drawScene() + } finally { applyingRemote = false } + } + + function handleSliceTypeUpdate(state) { + if (state.sliceType === undefined || nv.opts.sliceType === state.sliceType) return + applyingRemote = true + try { + if (typeof nv.setSliceType === 'function') { + nv.setSliceType(state.sliceType) + } else { + nv.opts.sliceType = state.sliceType + } + } finally { applyingRemote = false } + } + + function handleDrawUpdate(state) { + if (!state.vox) return + applyingRemote = true + try { + paintAt(state.vox, state.mode === 'erase' ? 'erase' : 'paint', Number(state.brushSize) || brushSize, Number(state.label) || label) + } finally { applyingRemote = false } + } + + function handleDrawCommit() { + ensureDrawBitmap() + if (!nv.drawBitmap || typeof nv.drawAddUndoBitmap !== 'function') return + applyingRemote = true + try { nv.drawAddUndoBitmap() } finally { applyingRemote = false } + } + + // full bitmap snapshots are sent as raw arraybuffer not json + function handleDrawSnapshotBinary(data) { + ensureDrawBitmap() + if (!nv.drawBitmap || !(data instanceof ArrayBuffer)) return + const src = new Uint8Array(data) + if (src.byteLength !== nv.drawBitmap.byteLength) { + Boostlet.hint('draw snapshot size mismatch', 3000) + return + } + const dst = new Uint8Array(nv.drawBitmap.buffer, nv.drawBitmap.byteOffset, nv.drawBitmap.byteLength) + applyingRemote = true + try { dst.set(src); nv.refreshDrawing(true) } finally { applyingRemote = false } + } + + function getDims() { + return { dx: nv.back.dims[1], dy: nv.back.dims[2], dz: nv.back.dims[3] } + } + + function getSliceAxis() { + const st = nv.opts.sliceType + if (st === 1) return 1 + if (st === 2) return 0 + return 2 + } + + function getPlaneSize() { + const { dx, dy, dz } = getDims() + const axis = getSliceAxis() + if (axis === 2) return { w: dx, h: dy } + if (axis === 1) return { w: dx, h: dz } + return { w: dy, h: dz } + } + + function toVoxel(x, y, s) { + const { dx, dy } = getDims() + const axis = getSliceAxis() + if (axis === 2) return x + y * dx + s * dx * dy + if (axis === 1) return x + s * dx + y * dx * dy + return s + x * dx + y * dx * dy + } + + function posToPlane(pos) { + const axis = getSliceAxis() + if (axis === 2) return { x: pos[0], y: pos[1] } + if (axis === 1) return { x: pos[0], y: pos[2] } + return { x: pos[1], y: pos[2] } + } + + function paintAt(pos, paintMode = drawMode, size = brushSize, drawLabel = label) { + ensureDrawBitmap() + if (!nv.drawBitmap) return + const { x: cx, y: cy } = posToPlane(pos) + const s = pos[getSliceAxis()] + const { w, h } = getPlaneSize() + const val = paintMode === 'paint' ? drawLabel : 0 + for (let dy = -size; dy <= size; dy++) { + for (let dx = -size; dx <= size; dx++) { + if (dx * dx + dy * dy > size * size) continue + const x = cx + dx + const y = cy + dy + if (x < 0 || x >= w || y < 0 || y >= h) continue + nv.drawBitmap[toVoxel(x, y, s)] = val + } + } + nv.refreshDrawing(true) + } + + function removePeer(peerId) { + const peer = peers.get(peerId) + if (peer) { + if (peer.channel) peer.channel.close() + peer.conn.close() + peers.delete(peerId) + Boostlet.hint('peer disconnected', 2000) + } + roomPeers = roomPeers.filter(id => id !== peerId) + updateCanonicalPeer() + } + } + + async function hashVolume(volume) { + // use volume.img directly not volume.img.buffer to avoid byteOffset issues + const digest = await crypto.subtle.digest('SHA-256', volume.img) + return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('') + } + + function generateRoomId() { + const bytes = new Uint8Array(6) + crypto.getRandomValues(bytes) + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') + } + + function onBaseVolumeChanged(nv, callback) { + let lastRef = null + // deduplicate calls so the callback only fires when the base volume reference changes + function emit(base) { + if (!base || base === lastRef) return + lastRef = base + callback(base) + } + if (nv.volumes && nv.volumes.length > 0) emit(nv.volumes[0]) + // prefer the event api when available otherwise wrap the callback + if (typeof nv.addEventListener === 'function') { + nv.addEventListener('imageLoaded', () => emit(nv.volumes && nv.volumes[0])) + return + } + const prev = nv.onImageLoaded + nv.onImageLoaded = function (volume) { + if (prev) prev.call(this, volume) + emit(nv.volumes && nv.volumes[0]) + } + } +})() \ No newline at end of file diff --git a/examples/numpyBoostlet.js b/examples/numpyBoostlet.js new file mode 100644 index 0000000..0940367 --- /dev/null +++ b/examples/numpyBoostlet.js @@ -0,0 +1,221 @@ +const CATEGORY = "Utility"; + +// guard so clicking the bookmarklet twice doesnt break anything +if (typeof window._NumpyBoostletLoaded === 'undefined') { +window._NumpyBoostletLoaded = true; + +(function() { + +const NUMPY_TS_URL = 'https://cdn.jsdelivr.net/npm/numpy-ts@1.3.0/dist/numpy-ts.browser.js'; +const ACE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.33.0/ace.js'; + +// load boostlet first then poll until niivue is detected +const boostletScript = document.createElement('script'); +boostletScript.type = 'text/javascript'; +boostletScript.src = 'https://boostlet.org/dist/boostlet.min.js'; +boostletScript.onload = function() { + var poll = setInterval(function() { + try { + Boostlet.init(); + clearInterval(poll); + setup(); + } catch(e) {} + }, 300); +}; +document.head.appendChild(boostletScript); + +async function setup() { + if (Boostlet.framework.name !== 'niivue') { + alert('numpyBoostlet only supports niivue'); + return; + } + + // shortcut so user code can write Boostlet.nv instead of Boostlet.framework.instance + Boostlet.nv = Boostlet.framework.instance; + + // load numpyts and expose it globally + window.np = await import(NUMPY_TS_URL); + + // wraps the full volume typed array into a numpyts ndarray + // no slope or intercept applied here + Boostlet.to_np = function() { + const img = Boostlet.nv.volumes[0].img; + return np.array(Array.from(img), 'float32'); + }; + + // writes a numpyts ndarray or plain typed array back into vol.img and renders + Boostlet.from_np = function(arr) { + const vol = Boostlet.nv.volumes[0]; + const src = (arr && arr.data) ? arr.data : arr; + vol.img.set(src); + Boostlet.nv.updateGLVolume(); + }; + + // load ace then build the editor panel + const aceScript = document.createElement('script'); + aceScript.src = ACE_URL; + aceScript.onload = function() { plot(); }; + document.head.appendChild(aceScript); +} + +// snapshot of vol.img saved before each run so undo can restore it +window._numpySnapshot = null; + +function runCode() { + const outputDiv = document.getElementById('nb-output'); + const vol = Boostlet.nv.volumes[0]; + + // save current state before running + window._numpySnapshot = vol.img.slice(); + outputDiv.innerHTML = ''; + + // redirect console.log to the output div for this run + const origLog = console.log; + console.log = function(msg) { outputDiv.innerHTML += msg + '
'; }; + + // wrap in async so user code can await if needed + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + new AsyncFunction(window._numpyEditor.getValue())().then(() => { + console.log = origLog; + }).catch(err => { + outputDiv.innerHTML += '' + err.toString() + ''; + console.log = origLog; + }); +} + +function undoCode() { + if (!window._numpySnapshot) return; + Boostlet.nv.volumes[0].img.set(window._numpySnapshot); + Boostlet.nv.updateGLVolume(); + window._numpySnapshot = null; + document.getElementById('nb-output').innerHTML = 'undo applied'; +} + +function injectStyles() { + const style = document.createElement('style'); + style.textContent = ` + #nb-panel { + position: fixed; top: 40px; left: 40px; z-index: 2147483647; + width: 600px; height: 500px; min-width: 300px; min-height: 200px; font-family: monospace; + background: #1a1a1a; border: 1px solid #444; border-radius: 6px; + box-shadow: 0 4px 24px rgba(0,0,0,0.6); + resize: both; overflow: hidden; + display: flex; flex-direction: column; + } + #nb-titlebar { + padding: 8px 12px; background: #111; + border-radius: 6px 6px 0 0; border-bottom: 1px solid #333; + display: flex; align-items: center; justify-content: space-between; + user-select: none; cursor: move; + } + #nb-titlebar span { color: #aaa; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; } + #nb-titlebar span em { color: #555; font-style: normal; } + #nb-close { color: #666; cursor: pointer; font-size: 16px; } + #nb-editor-div { width: 100%; flex: 1; min-height: 100px; overflow: hidden; } + #nb-toolbar { + padding: 6px 10px; background: #111; border-top: 1px solid #333; + display: flex; align-items: center; gap: 8px; + } + #nb-toolbar span { color: #555; font-size: 11px; } + #nb-run-btn, #nb-undo-btn { + border: none; border-radius: 4px; + padding: 5px 16px; font-family: monospace; font-size: 13px; cursor: pointer; + } + #nb-run-btn { background: #2a6; color: #fff; } + #nb-undo-btn { background: #333; color: #aaa; border: 1px solid #555; } + #nb-output { + padding: 8px 12px; min-height: 32px; max-height: 120px; overflow-y: auto; + color: #7cf; font-size: 12px; border-top: 1px solid #333; + background: #0d0d0d; border-radius: 0 0 6px 6px; + } + `; + document.head.appendChild(style); +} + +function plot() { + const existing = document.getElementById('nb-panel'); + if (existing) existing.remove(); + + injectStyles(); + + const div = document.createElement('div'); + div.id = 'nb-panel'; + div.innerHTML = ` +
+ numpy boostlet  Boostlet.nv • Boostlet.to_np() • Boostlet.from_np(arr) • np + +
+
+
+ + + ctrl+enter +
+
+ `; + document.body.appendChild(div); + + const editor = ace.edit('nb-editor-div'); + editor.setTheme('ace/theme/monokai'); + editor.session.setMode('ace/mode/javascript'); + editor.setFontSize(13); + editor.setOption('wrap', true); + editor.setValue( +`// Available API: +// Boostlet.nv — the live NiiVue instance +// Boostlet.to_np() — wraps vol.img into a numpyts ndarray +// Boostlet.from_np(arr)— writes an ndarray or typed array back and re-renders +// np — numpyts, loaded globally + +// --- Example 1: plain typed-array manipulation (no np needed) --- +const vol = Boostlet.nv.volumes[0]; +const img = vol.img; + +// clamp every voxel to half its current value +for (let i = 0; i < img.length; i++) { + img[i] = img[i] * 0.5; +} +Boostlet.nv.updateGLVolume(); + +// --- Example 2: using numpyts --- +// const arr = Boostlet.to_np(); +// const scaled = np.multiply(arr, np.array([2.0], 'float32')); +// Boostlet.from_np(scaled);`, + -1 + ); + window._numpyEditor = editor; + + // keep ace redrawn when the panel is resized + new ResizeObserver(() => editor.resize()).observe(document.getElementById('nb-editor-div')); + + editor.commands.addCommand({ + name: 'run', + bindKey: { win: 'Ctrl-Enter', mac: 'Cmd-Enter' }, + exec: runCode + }); + + document.getElementById('nb-run-btn').addEventListener('click', runCode); + document.getElementById('nb-undo-btn').addEventListener('click', undoCode); + document.getElementById('nb-close').addEventListener('click', () => div.remove()); + + // drag is bound to the titlebar only so the editor stays interactive + const titlebar = document.getElementById('nb-titlebar'); + titlebar.addEventListener('mousedown', function(e) { + if (e.target.id === 'nb-close') return; + const startX = e.clientX, startY = e.clientY; + const startLeft = div.offsetLeft, startTop = div.offsetTop; + function onMove(e) { + div.style.left = (startLeft + e.clientX - startX) + 'px'; + div.style.top = (startTop + e.clientY - startY) + 'px'; + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); +} + +})(); +} \ No newline at end of file diff --git a/index.html b/index.html index 176490d..3410efc 100644 --- a/index.html +++ b/index.html @@ -229,7 +229,7 @@ window.onload = function() { - var examples = ['sobel.js', 'segmentanything.js', 'imageCaptioning.js', 'meshAR.js', 'fibersAR.js', 'plotly.js', 'trako.js', 'melanoma.js', 'webllm.js', 'brainchop.js', 'CatVSDog.js', 'PixelExplorer.js', 'roiStats.js']; + var examples = ['sobel.js', 'segmentanything.js', 'imageCaptioning.js', 'meshAR.js', 'fibersAR.js', 'plotly.js', 'trako.js', 'melanoma.js', 'webllm.js', 'brainchop.js', 'CatVSDog.js', 'PixelExplorer.js', 'roiStats.js', 'numpyBoostlet.js', 'collaborativeSync.js']; var baseurl = 'https://raw.githubusercontent.com/mpsych/boostlet/main/examples/'; @@ -325,6 +325,10 @@

+

+ +

+