UNPKG

@jonobr1/force-directed-graph

Version:

GPU supercharged attraction-graph visualizations for the web built on top of Three.js

509 lines (441 loc) 15 kB
/** * Factory for creating inline texture processing workers * This solves bundling issues by creating workers from Blob URLs */ /** * Creates worker code as a string for inline worker creation * @param {string} wasmUrl - URL to the WASM file (resolved relative to main module) */ function createWorkerCode(wasmUrl) { return ` let wasmModule = null; let wasmReady = false; const MAX_TEXTURE_SIZE = 4096; const MAX_BUFFER_BYTES = 512 * 1024 * 1024; class InputValidationError extends Error { constructor(message) { super(message); this.name = 'InputValidationError'; } } class WasmMemoryError extends Error { constructor(message) { super(message); this.name = 'WasmMemoryError'; } } function buildLinkTextureData(links, nodeAmount, textureSize) { const totalElements = textureSize * textureSize; const linksData = new Float32Array(totalElements * 4); const linkRangesData = new Float32Array(totalElements * 4); const linksByNode = Array.from({ length: nodeAmount }, () => []); const packedLinks = []; for (let i = 0; i < links.length; i++) { const link = links[i]; const sourceIndex = link.sourceIndex; const targetIndex = link.targetIndex; const isValid = Number.isInteger(sourceIndex) && Number.isInteger(targetIndex) && sourceIndex >= 0 && targetIndex >= 0 && sourceIndex < nodeAmount && targetIndex < nodeAmount; if (!isValid) { continue; } linksByNode[sourceIndex].push(link); if (targetIndex !== sourceIndex) { linksByNode[targetIndex].push(link); } } for (let i = 0; i < nodeAmount; i++) { const incident = linksByNode[i]; const rangeOffset = i * 4; linkRangesData[rangeOffset + 0] = packedLinks.length; linkRangesData[rangeOffset + 1] = incident.length; for (let j = 0; j < incident.length; j++) { packedLinks.push(incident[j]); } } if (packedLinks.length > totalElements) { throw new Error( \`Packed links (\${packedLinks.length}) exceed texture capacity (\${totalElements}).\` ); } for (let i = 0; i < packedLinks.length; i++) { const link = packedLinks[i]; const sourceIndex = link.sourceIndex; const targetIndex = link.targetIndex; const linkOffset = i * 4; linksData[linkOffset + 0] = (sourceIndex % textureSize) / textureSize; linksData[linkOffset + 1] = Math.floor(sourceIndex / textureSize) / textureSize; linksData[linkOffset + 2] = (targetIndex % textureSize) / textureSize; linksData[linkOffset + 3] = Math.floor(targetIndex / textureSize) / textureSize; } return { linksData, linkRangesData, packedLinkAmount: packedLinks.length, }; } function getPackedLinkRequirement(links, nodeAmount) { let packed = 0; for (let i = 0; i < links.length; i++) { const link = links[i]; if (!link || typeof link !== 'object') { continue; } const sourceIndex = link.sourceIndex; const targetIndex = link.targetIndex; const isValid = Number.isInteger(sourceIndex) && Number.isInteger(targetIndex) && sourceIndex >= 0 && targetIndex >= 0 && sourceIndex < nodeAmount && targetIndex < nodeAmount; if (!isValid) { continue; } packed += sourceIndex === targetIndex ? 1 : 2; } return packed; } function validateInput(data) { const { nodes, links, textureSize, frustumSize } = data; if (!Array.isArray(nodes)) { throw new InputValidationError('Invalid input: nodes must be an array'); } if (!Array.isArray(links)) { throw new InputValidationError('Invalid input: links must be an array'); } if (!Number.isInteger(textureSize) || textureSize <= 0) { throw new InputValidationError('Invalid input: textureSize must be a positive integer'); } if (textureSize > MAX_TEXTURE_SIZE) { throw new InputValidationError( 'Invalid input: textureSize ' + textureSize + ' exceeds max ' + MAX_TEXTURE_SIZE ); } if ((textureSize & (textureSize - 1)) !== 0) { throw new InputValidationError('Invalid input: textureSize must be a power of 2'); } if (!Number.isFinite(frustumSize) || frustumSize <= 0) { throw new InputValidationError('Invalid input: frustumSize must be a finite positive number'); } const totalElements = textureSize * textureSize; const nodesDataSize = nodes.length * 4 * 4; const linksDataSize = links.length * 2 * 4; const positionsSize = totalElements * 4 * 4; const linksTextureSize = totalElements * 4 * 4; const linkRangesTextureSize = totalElements * 4 * 4; const requiredPackedLinks = getPackedLinkRequirement(links, nodes.length); const totalBytes = nodesDataSize + linksDataSize + positionsSize + linksTextureSize + linkRangesTextureSize; if (requiredPackedLinks > totalElements) { throw new InputValidationError( 'Packed links (' + requiredPackedLinks + ') exceed texture capacity (' + totalElements + ')' ); } if (totalBytes > MAX_BUFFER_BYTES) { throw new WasmMemoryError( 'Input requires ' + totalBytes + ' bytes, exceeding ' + MAX_BUFFER_BYTES + ' byte worker limit' ); } return { totalElements, nodesDataSize, linksDataSize, positionsSize, linksTextureSize, linkRangesTextureSize, }; } function formatProcessingError(error) { if (error instanceof InputValidationError) { return { type: 'validation', message: error.message }; } if (error instanceof WasmMemoryError) { return { type: 'memory', message: error.message }; } const message = error && error.message ? error.message : String(error); if (/out of memory|memory access|WebAssembly\\.Memory|allocation/i.test(message)) { return { type: 'memory', message: 'WASM memory failure: ' + message }; } return { type: 'processing', message }; } /** * Initialize WASM module using provided URL */ async function initWasm() { if (wasmReady) return; try { // Load WASM module using the provided URL const wasmResponse = await fetch('${wasmUrl}'); if (!wasmResponse.ok) { throw new Error(\`Failed to fetch WASM: \${wasmResponse.status}\`); } const wasmBytes = await wasmResponse.arrayBuffer(); // AssemblyScript WASM modules need proper imports based on wasm-objdump output const imports = { env: { // env.seed: () -> f64 (for random number generation) seed: () => Math.random(), // env.abort: (i32, i32, i32, i32) -> nil (for error handling) abort: (message, fileName, line, column) => { const error = new Error(\`AssemblyScript abort: \${message} at \${fileName}:\${line}:\${column}\`); console.error(error); throw error; } }, 'texture-processor': { // texture-processor.__heap_base: global i32 __heap_base: new WebAssembly.Global({ value: 'i32', mutable: false }, 1024) } }; const wasmInstance = await WebAssembly.instantiate(wasmBytes, imports); wasmModule = wasmInstance.instance; wasmReady = true; self.postMessage({ type: 'wasm-ready', success: true }); } catch (error) { console.warn('WASM loading failed:', error); self.postMessage({ type: 'wasm-ready', success: false, error: error.message }); } } /** * Process texture data using WASM */ async function processTextures(data) { const { nodes, links, textureSize, frustumSize, requestId } = data; if (!wasmReady) { await initWasm(); } if (!wasmReady) { throw new Error('WASM module failed to initialize'); } const startTime = performance.now(); try { const { totalElements, nodesDataSize, linksDataSize, positionsSize, linksTextureSize, linkRangesTextureSize, } = validateInput(data); const { allocateMemory, freeMemory, processTextures, memory } = wasmModule.exports; if (!allocateMemory || !freeMemory || !processTextures || !memory) { throw new Error('WASM exports are missing required texture processing functions'); } let packedLinkAmount = 0; let nodesDataPtr = 0; let linksDataPtr = 0; let positionsPtr = 0; let linksTexturePtr = 0; let linkRangesTexturePtr = 0; let positionsResult = null; let linksResult = null; let linkRangesResult = null; try { nodesDataPtr = allocateMemory(nodesDataSize); linksDataPtr = allocateMemory(linksDataSize); positionsPtr = allocateMemory(positionsSize); linksTexturePtr = allocateMemory(linksTextureSize); linkRangesTexturePtr = allocateMemory(linkRangesTextureSize); // Prepare and copy node data const wasmMemory = new Uint8Array(memory.buffer); const nodesFloat32 = new Float32Array(nodes.length * 4); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const offset = i * 4; nodesFloat32[offset + 0] = typeof node.x !== 'undefined' ? node.x : NaN; nodesFloat32[offset + 1] = typeof node.y !== 'undefined' ? node.y : NaN; nodesFloat32[offset + 2] = typeof node.z !== 'undefined' ? node.z : NaN; nodesFloat32[offset + 3] = node.isStatic ? 1.0 : 0.0; } wasmMemory.set(new Uint8Array(nodesFloat32.buffer), nodesDataPtr); // Prepare and copy links data const linksInt32 = new Int32Array(links.length * 2); for (let i = 0; i < links.length; i++) { const link = links[i]; const offset = i * 2; linksInt32[offset + 0] = link.sourceIndex; linksInt32[offset + 1] = link.targetIndex; } wasmMemory.set(new Uint8Array(linksInt32.buffer), linksDataPtr); // Process textures in WASM packedLinkAmount = processTextures( nodesDataPtr, nodes.length, linksDataPtr, links.length, textureSize, positionsPtr, linksTexturePtr, linkRangesTexturePtr, frustumSize ); if (packedLinkAmount < 0) { throw new Error('Packed links exceed texture capacity'); } // Extract results const positionsData = new Float32Array(memory.buffer, positionsPtr, totalElements * 4); const linksTextureData = new Float32Array(memory.buffer, linksTexturePtr, totalElements * 4); const linkRangesTextureData = new Float32Array(memory.buffer, linkRangesTexturePtr, totalElements * 4); // Copy results to transferable buffers positionsResult = new Float32Array(positionsData); linksResult = new Float32Array(linksTextureData); linkRangesResult = new Float32Array(linkRangesTextureData); } finally { if (linkRangesTexturePtr) freeMemory(linkRangesTexturePtr); if (linksTexturePtr) freeMemory(linksTexturePtr); if (positionsPtr) freeMemory(positionsPtr); if (linksDataPtr) freeMemory(linksDataPtr); if (nodesDataPtr) freeMemory(nodesDataPtr); } const processingTime = performance.now() - startTime; // Send results back to main thread self.postMessage({ type: 'texture-processed', requestId, success: true, data: { positions: positionsResult, links: linksResult, linkRanges: linkRangesResult, packedLinkAmount, processingTime, memoryUsage: memory.buffer.byteLength } }, [positionsResult.buffer, linksResult.buffer, linkRangesResult.buffer]); } catch (error) { const { type, message } = formatProcessingError(error); self.postMessage({ type: 'texture-processed', requestId, success: false, error: message, errorType: type }); } } /** * Fallback processing without WASM */ function processFallback(data) { const { nodes, links, textureSize, frustumSize, requestId } = data; const startTime = performance.now(); try { const { totalElements } = validateInput(data); const positionsData = new Float32Array(totalElements * 4); // Process positions for (let i = 0; i < totalElements; i++) { const baseIndex = i * 4; if (i < nodes.length) { const node = nodes[i]; const x = typeof node.x !== 'undefined' ? node.x : (Math.random() * 2 - 1); const y = typeof node.y !== 'undefined' ? node.y : (Math.random() * 2 - 1); const z = typeof node.z !== 'undefined' ? node.z : (Math.random() * 2 - 1); positionsData[baseIndex + 0] = x; positionsData[baseIndex + 1] = y; positionsData[baseIndex + 2] = z; positionsData[baseIndex + 3] = node.isStatic ? 1 : 0; } else { const farAway = frustumSize * 10; positionsData[baseIndex + 0] = farAway; positionsData[baseIndex + 1] = farAway; positionsData[baseIndex + 2] = farAway; positionsData[baseIndex + 3] = 0; } } const linkTextureData = buildLinkTextureData(links, nodes.length, textureSize); const processingTime = performance.now() - startTime; self.postMessage({ type: 'texture-processed', requestId, success: true, data: { positions: positionsData, links: linkTextureData.linksData, linkRanges: linkTextureData.linkRangesData, packedLinkAmount: linkTextureData.packedLinkAmount, processingTime, memoryUsage: 0 } }, [positionsData.buffer, linkTextureData.linksData.buffer, linkTextureData.linkRangesData.buffer]); } catch (error) { const { type, message } = formatProcessingError(error); self.postMessage({ type: 'texture-processed', requestId, success: false, error: message, errorType: type }); } } // Message handler self.onmessage = function(event) { const { type, data } = event.data; switch (type) { case 'init': initWasm(); break; case 'process-textures': if (data.useWasm && wasmReady) { processTextures(data); } else { processFallback(data); } break; case 'check-wasm': self.postMessage({ type: 'wasm-status', ready: wasmReady }); break; default: self.postMessage({ type: 'error', error: \`Unknown message type: \${type}\` }); } }; // Initialize WASM on worker start initWasm(); `; } /** * Creates an inline worker using Blob URLs * @param {string} wasmUrl - URL to the WASM file * @returns {Worker} Created worker instance */ export function createInlineWorker(wasmUrl) { const workerCode = createWorkerCode(wasmUrl); const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); const worker = new Worker(workerUrl); // Clean up blob URL when worker terminates worker.addEventListener('error', () => URL.revokeObjectURL(workerUrl)); return worker; }