diff --git a/src/epiviz.gl/data-processor.js b/src/epiviz.gl/data-processor.js index cfbcf23..0e4ce96 100644 --- a/src/epiviz.gl/data-processor.js +++ b/src/epiviz.gl/data-processor.js @@ -17,7 +17,9 @@ class DataProcessor { console.log("Loading data..."); - new SpecificationProcessor(specification, this.indexData.bind(this)); + const processesSpecification = new SpecificationProcessor(specification); + + this.indexData(processesSpecification); } /** @@ -49,30 +51,28 @@ class DataProcessor { console.log("Reading data..."); // Process the global data in the specification processor - if (specificationHelper.data) { - for (let track of specificationHelper.tracks) { - if (!track.hasOwnData) { - const geometryMapper = new GeometryMapper(specificationHelper, track); - - let currentPoint = track.getNextDataPoint(); - while (currentPoint) { - geometryMapper.modifyGeometry(currentPoint.geometry); - - this.data[ - this.index.add( - currentPoint.geometry.coordinates[0], - currentPoint.geometry.coordinates[1], - currentPoint.geometry.coordinates[0] + - currentPoint.geometry.dimensions[0], - currentPoint.geometry.coordinates[1] + - currentPoint.geometry.dimensions[1] - ) - ] = currentPoint; - - currentPoint = track.getNextDataPoint(); - } - break; + for (let track of specificationHelper.tracks) { + if (!track.hasOwnData) { + const geometryMapper = new GeometryMapper(specificationHelper, track); + + let currentPoint = track.getNextDataPoint(); + while (currentPoint) { + geometryMapper.modifyGeometry(currentPoint.geometry); + + this.data[ + this.index.add( + currentPoint.geometry.coordinates[0], + currentPoint.geometry.coordinates[1], + currentPoint.geometry.coordinates[0] + + currentPoint.geometry.dimensions[0], + currentPoint.geometry.coordinates[1] + + currentPoint.geometry.dimensions[1] + ) + ] = currentPoint; + + currentPoint = track.getNextDataPoint(); } + break; } } @@ -114,15 +114,14 @@ class DataProcessor { * @returns closest point or undefined */ getClosestPoint(point) { - let indices = this.index.neighbors(point[0], point[1], 1, 0) - let pointToReturn = - this.data[indices]; + let indices = this.index.neighbors(point[0], point[1], 1, 0); + let pointToReturn = this.data[indices]; let distance = 0; let isInside = true; if (pointToReturn === undefined) { - indices = this.index.neighbors(point[0], point[1], 1, 5) - if(indices.length === 0) { - indices = this.index.neighbors(point[0], point[1], 1) + indices = this.index.neighbors(point[0], point[1], 1, 5); + if (indices.length === 0) { + indices = this.index.neighbors(point[0], point[1], 1); } pointToReturn = this.data[indices]; distance = Math.sqrt( @@ -146,12 +145,11 @@ class DataProcessor { const largerX = Math.max(points[0], points[2]); const largerY = Math.max(points[1], points[3]); - let indices = this.index - .search(smallerX, smallerY, largerX, largerY) - - let tpoints = indices.map((i) => this.data[i]); + let indices = this.index.search(smallerX, smallerY, largerX, largerY); + + let tpoints = indices.map((i) => this.data[i]); - return {indices, "points": tpoints}; + return { indices, points: tpoints }; } /** @@ -199,12 +197,12 @@ class DataProcessor { simplifiedBoundingPolygon ); - if (tbool) findices.push(candidatePoints.indices[i]) + if (tbool) findices.push(candidatePoints.indices[i]); return tbool; }); - return {"indices": findices, "points": fpoints} + return { indices: findices, points: fpoints }; } } diff --git a/src/epiviz.gl/manager-worker.js b/src/epiviz.gl/manager-worker.js new file mode 100644 index 0000000..fd9923a --- /dev/null +++ b/src/epiviz.gl/manager-worker.js @@ -0,0 +1,87 @@ +import { + DATA_WORKER_NAME, + WEBGL_WORKER_NAME, + cloneArrayBuffer, + fetchArrayBuffer, + prepareData, + transformSpecification, +} from "./utilities"; + +const webglWorker = new Worker( + new URL("offscreen-webgl-worker.js", import.meta.url), + { type: "module" } +); + +webglWorker.onmessage = (message) => { + // This is a hack to get around the fact that the webgl worker + // is not able to request animation frames. Instead, we will + // request animation frames from the manager worker and then + // pass the message along to the webgl worker. + if (message.data?.command === "requestAnimationFrame") { + requestAnimationFrame(() => { + webglWorker.postMessage({ type: "animate" }); + }); + return; + } + + self.postMessage({ + worker: WEBGL_WORKER_NAME, + data: message.data, + }); +}; + +const dataWorker = new Worker( + new URL("data-processor-worker.js", import.meta.url), + { type: "module" } +); + +dataWorker.onmessage = (message) => { + self.postMessage({ + worker: DATA_WORKER_NAME, + data: message.data, + }); +}; + +self.onmessage = (message) => { + const { worker, data, action } = message.data; + + if (action === "setSpecification") { + transformSpecification(data.specification).then( + ({ specification, buffers }) => { + const clonedBuffers = buffers.map(cloneArrayBuffer); + + webglWorker.postMessage( + { + type: "specification", + specification, + }, + clonedBuffers + ); + + dataWorker.postMessage( + { + type: "init", + specification, + }, + buffers + ); + } + ); + return; + } + + switch (worker) { + case WEBGL_WORKER_NAME: + if (data.type === "init") { + webglWorker.postMessage(data, [data.canvas]); + } else { + webglWorker.postMessage(data); + } + break; + case DATA_WORKER_NAME: + dataWorker.postMessage(data); + break; + default: + throw new Error(`Unknown worker: ${worker}`); + } +}; diff --git a/src/epiviz.gl/offscreen-webgl-worker.js b/src/epiviz.gl/offscreen-webgl-worker.js index f480600..3608421 100644 --- a/src/epiviz.gl/offscreen-webgl-worker.js +++ b/src/epiviz.gl/offscreen-webgl-worker.js @@ -19,6 +19,9 @@ self.onmessage = (message) => { ? new OffscreenWebGLDrawer(message.data) : new WebGLDrawer(message.data); break; + case "animate": + self.drawer.animate(); + break; case "viewport": self.drawer.receiveViewport(message.data); break; @@ -38,6 +41,6 @@ self.onmessage = (message) => { self.drawer.gl.viewport(0, 0, message.data.width, message.data.height); break; default: - console.error(`Received unknown message type: ${message.type}`); + console.error(`Received unknown message type: ${message.data.type}`); } }; diff --git a/src/epiviz.gl/specification-processor.js b/src/epiviz.gl/specification-processor.js index c4b5317..c6bd2d9 100644 --- a/src/epiviz.gl/specification-processor.js +++ b/src/epiviz.gl/specification-processor.js @@ -102,34 +102,15 @@ class SpecificationProcessor { * @param {Object} specification user defined specification * @param {Function} callback function to call after all the data has been loaded */ - constructor(specification, callback) { + constructor(specification) { this.index = 0; this.specification = specification; - if (typeof specification.defaultData === "string") { - // data is a url to get - this.dataPromise = fetch(specification.defaultData) - .then((response) => response.text()) - .then((text) => (this.data = text.split("\n"))); - } else if (specification.defaultData) { - // default data is defined, assumed to be an object - this.data = specification.defaultData; - this.isInlineData = true; - } - this.tracks = specification.tracks.map((track) => new Track(this, track)); - - const allPromises = this.tracks - .map((track) => track.dataPromise) - .filter((p) => p); // Removes undefined - if (this.dataPromise) { - allPromises.push(this.dataPromise); - } + this.tracks = specification.tracks.map( + (track) => new Track(specification, track) + ); this.xScale = getScaleForSpecification("x", specification); this.yScale = getScaleForSpecification("y", specification); - - // When all tracks have acquired their data, call the callback - // TODO: Allow tracks to be processed while waiting for others, need to keep in mind order - Promise.all(allPromises).then(() => callback(this)); } /** @@ -154,33 +135,49 @@ class Track { */ constructor(specification, track) { this.track = track; - - if (typeof track.data === "string") { - // Track has its own data to GET - this.dataPromise = fetch(track.data) - .then((response) => response.text()) - .then((text) => { - this.data = text.split(/[\n\r]+/); - this.processHeadersAndMappers(); - this.hasOwnData = true; - }); - } else if (track.data) { - // Track has its own inline data - this.data = track.data; - this.isInlineData = true; - this.processHeadersAndMappers(); + if (track.data) { + // Track has its own data + let data; + if (track.data.isInlineData) { + // Track has its own inline data + data = {}; + for (let i = 0; i < track.data.keys.length; i++) { + const key = track.data.keys[i]; + data[key] = new Int8Array(track.data.defaultDataBuffers[i]); + } + } else { + // Track has its own data file + const decodedData = new TextDecoder("utf-8").decode( + new Uint8Array(track.data.defaultDataBuffers[0]) + ); + data = decodedData.split("\n"); + } + this.data = data; this.hasOwnData = true; - } else if (specification.data) { + this.isInlineData = track.data.isInlineData; + this.processHeadersAndMappers(); + } else if (specification.defaultData) { // Track does not have its own data, but the specification has default data - this.data = specification.data; - this.isInlineData = specification.isInlineData; + let defaultData; + if (specification.defaultData.isInlineData) { + // Specification has inline data + defaultData = {}; + for (let i = 0; i < specification.defaultData.keys.length; i++) { + const key = specification.defaultData.keys[i]; + defaultData[key] = new Int8Array( + specification.defaultData.defaultDataBuffers[i] + ); + } + } else { + // Specification has data file + const decodedData = new TextDecoder("utf-8").decode( + new Uint8Array(specification.defaultData.defaultDataBuffers[0]) + ); + defaultData = decodedData.split("\n"); + } + this.isInlineData = specification.defaultData.isInlineData; + this.data = defaultData; this.processHeadersAndMappers(); - } else if (specification.dataPromise) { - // Track does not have its own data, but the specification is GETting default data - specification.dataPromise.then(() => { - this.data = specification.data; - this.processHeadersAndMappers(); - }); } else { console.error( `Could not find data (no defaultData in specification and no data specified for this track) for track ${track}.` @@ -333,7 +330,7 @@ class Track { } else { return () => DEFAULT_CHANNELS[channel].value; } - }; + } } /** diff --git a/src/epiviz.gl/utilities.js b/src/epiviz.gl/utilities.js index 35a952e..09cc4f5 100644 --- a/src/epiviz.gl/utilities.js +++ b/src/epiviz.gl/utilities.js @@ -168,6 +168,8 @@ const getPixelMeasurement = (cssMeasurement) => { return isNaN(asFloat) ? false : asFloat; }; +const WEBGL_WORKER_NAME = "offscreen-webgl-worker.js"; +const DATA_WORKER_NAME = "data-processor-worker.js"; const DEFAULT_MARGIN = "50px"; const DEFAULT_WIDTH = "100%"; const DEFAULT_HEIGHT = DEFAULT_WIDTH; @@ -264,7 +266,152 @@ const getQuadraticBezierCurveForPoints = (P0, P1, P2) => { return (t) => [x(t), y(t)]; }; +/** + * Clones an ArrayBuffer. + * + * This function creates a new ArrayBuffer and copies the content of the source + * ArrayBuffer into the new one. + * + * @param {ArrayBuffer} src - The source ArrayBuffer to be cloned. + * @returns {ArrayBuffer} The new cloned ArrayBuffer. + */ +function cloneArrayBuffer(src) { + const dst = new ArrayBuffer(src.byteLength); + new Uint8Array(dst).set(new Uint8Array(src)); + return dst; +} + +/** + * Fetches an ArrayBuffer from a URL. + * + * This function sends a HTTP GET request to the provided URL and returns the + * response body as an ArrayBuffer. + * + * @param {string} url - The URL to fetch the ArrayBuffer from. + * @returns {Promise} A promise that resolves to the fetched ArrayBuffer. + */ +async function fetchArrayBuffer(url) { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + return buffer; +} + +/** + * Prepares the data from the defaultData object. + * + * This function converts the arrays in the defaultData object to Int8Array and + * stores them in an ArrayBuffer or SharedArrayBuffer depending on the availability. + * It then returns an object containing the keys, ArrayBuffers and a flag indicating + * that the data is inline. + * + * @param {Object} defaultData - The defaultData object containing the data arrays. + * @returns {Object} An object containing the keys, ArrayBuffers and a flag indicating inline data. + */ +function prepareData(defaultData) { + const data = {}; + Object.keys(defaultData).forEach((key) => { + const typedArray = new ( + typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : ArrayBuffer + )(defaultData[key].length); + data[key] = new Int8Array(typedArray); + data[key].set(defaultData[key]); + }); + + const buffers = Object.values(data).map((typedArray) => typedArray.buffer); + + return { + keys: Object.keys(data), + defaultDataBuffers: buffers, + isInlineData: true, + }; +} + +/** + * Transform a specification object. + * + * This function processes a specification object by fetching the ArrayBuffers + * for defaultData and track data if they are URLs or converting them to + * ArrayBuffers if they are inline data. The function returns an object + * containing the processed specification and the ArrayBuffers. + * + * @param {Object} specification - The specification object to process. + * @returns {Promise} A promise that resolves to an object containing + * the processed specification and the ArrayBuffers. + */ +async function transformSpecification(specification) { + const transformedSpecification = Object.assign({}, specification); // Clone specification + + // Process defaultData + if (specification.defaultData) { + if (typeof specification.defaultData === "string") { + transformedSpecification.defaultData = fetchArrayBuffer( + specification.defaultData + ).then((buffer) => ({ + keys: ["defaultData"], + defaultDataBuffers: [buffer], + isInlineData: false, + })); + } else { + transformedSpecification.defaultData = Promise.resolve( + prepareData(specification.defaultData) + ); + } + } + + // Process tracks + if (Array.isArray(specification.tracks)) { + specification.tracks.forEach((track, i) => { + if (track.data) { + if (typeof track.data === "string") { + transformedSpecification.tracks[i].data = fetchArrayBuffer( + track.data + ).then((buffer) => ({ + keys: [`defaultData`], + defaultDataBuffers: [buffer], + isInlineData: false, + })); + } else { + transformedSpecification.tracks[i].data = Promise.resolve( + prepareData(track.data) + ); + } + } + }); + } + + // Resolve all promises + if ( + transformedSpecification.defaultData && + transformedSpecification.defaultData instanceof Promise + ) { + transformedSpecification.defaultData = + await transformedSpecification.defaultData; + } + if (Array.isArray(transformedSpecification.tracks)) { + for (let i = 0; i < transformedSpecification.tracks.length; i++) { + if (transformedSpecification.tracks[i].data instanceof Promise) { + transformedSpecification.tracks[i].data = await transformedSpecification + .tracks[i].data; + } + } + } + + return { + specification: transformedSpecification, + buffers: [ + ...(transformedSpecification.defaultData?.defaultDataBuffers || []), + ...transformedSpecification.tracks.flatMap( + (track) => track.data?.defaultDataBuffers || [] + ), + ], + }; +} + export { + prepareData, + cloneArrayBuffer, + fetchArrayBuffer, + transformSpecification, scale, rgbToHex, rgbStringToHex, @@ -275,4 +422,6 @@ export { getQuadraticBezierCurveForPoints, DEFAULT_WIDTH, DEFAULT_HEIGHT, + WEBGL_WORKER_NAME, + DATA_WORKER_NAME, }; diff --git a/src/epiviz.gl/webgl-drawer.js b/src/epiviz.gl/webgl-drawer.js index 3b1d890..f4c5643 100644 --- a/src/epiviz.gl/webgl-drawer.js +++ b/src/epiviz.gl/webgl-drawer.js @@ -92,11 +92,10 @@ class WebGLCanvasDrawer extends Drawer { */ setSpecification(specification) { super.render(); // Cancels current animation frame - // Populate buffers needs a trackShader built to know what buffers to fill this.trackShaders = VertexShader.fromSpecification(specification); - - new SpecificationProcessor(specification, this.populateBuffers.bind(this)); + const processedSpecification = new SpecificationProcessor(specification); + this.populateBuffers(processedSpecification); } /** @@ -143,7 +142,7 @@ class WebGLCanvasDrawer extends Drawer { animate() { if (!this.needsAnimation) { // Prevent pointless animation if canvas does not change - this.lastFrame = requestAnimationFrame(this.animate.bind(this)); + self.postMessage({ command: "requestAnimationFrame" }); this.tick(); return; } @@ -194,7 +193,7 @@ class WebGLCanvasDrawer extends Drawer { }); this.needsAnimation = false; - this.lastFrame = requestAnimationFrame(this.animate.bind(this)); + self.postMessage({ command: "requestAnimationFrame" }); this.tick(); } diff --git a/src/epiviz.gl/webgl-vis.js b/src/epiviz.gl/webgl-vis.js index 15ede2b..2198f69 100644 --- a/src/epiviz.gl/webgl-vis.js +++ b/src/epiviz.gl/webgl-vis.js @@ -5,6 +5,8 @@ import { getDimAndMarginStyleForSpecification, DEFAULT_HEIGHT, DEFAULT_WIDTH, + WEBGL_WORKER_NAME, + DATA_WORKER_NAME, } from "./utilities"; class WebGLVis { @@ -41,10 +43,13 @@ class WebGLVis { * @param {Number} height in pixels to resize the canvas to */ setCanvasSize(width, height) { - this.webglWorker.postMessage({ - type: "resize", - width, - height, + this.managerWorker.postMessage({ + worker: WEBGL_WORKER_NAME, + data: { + type: "resize", + width, + height, + }, }); this.canvas.style.width = width; @@ -73,63 +78,75 @@ class WebGLVis { } const offscreenCanvas = this.canvas.transferControlToOffscreen(); + this.dataWorkerStream = []; - this.webglWorker = new Worker( - new URL("offscreen-webgl-worker.js", import.meta.url), + this.managerWorker = new Worker( + new URL("manager-worker.js", import.meta.url), { type: "module" } ); - this.webglWorker.postMessage( - { - type: "init", - canvas: offscreenCanvas, - displayFPSMeter, - }, - [offscreenCanvas] - ); - // Allow OffScreenWebGLDrawer to tick FPS meter - this.webglWorker.onmessage = (e) => { - if (e.data.type === "tick") { - this.meter.tick(); - } - }; + this.managerWorker.onmessage = (message) => { + const { worker, data } = message.data; + switch (worker) { + case WEBGL_WORKER_NAME: + switch (data.type) { + case "tick": + this.meter.tick(); + break; + } + break; + case DATA_WORKER_NAME: + { + // Construct the message in same format as it was before + const messageToSend = { + ...message, + data, + }; + if (data.type === "getClosestPoint") { + if (data.closestPoint === undefined) { + return; + } + this.parent.dispatchEvent( + new CustomEvent("pointHovered", { detail: messageToSend }) + ); + } else if (data.type === "getClickPoint") { + if (data.closestPoint === undefined) { + return; + } + this.parent.dispatchEvent( + new CustomEvent("pointClicked", { detail: messageToSend }) + ); + } else if ( + data.type === "selectBox" || + data.type === "selectLasso" + ) { + this.parent.dispatchEvent( + new CustomEvent("onSelectionEnd", { detail: messageToSend }) + ); + this.dataWorkerStream.push(messageToSend); + } + } + break; - this.webglWorker.onerror = (e) => { - throw e; + default: + console.error(`Received unknown worker name: ${worker}`); + } }; - this.dataWorkerStream = []; - this.dataWorker = new Worker( - new URL("data-processor-worker.js", import.meta.url), - { type: "module" } + this.managerWorker.postMessage( + { + worker: WEBGL_WORKER_NAME, + data: { + type: "init", + canvas: offscreenCanvas, + displayFPSMeter, + }, + }, + [offscreenCanvas] ); - this.dataWorker.onmessage = (message) => { - if (message.data.type === "getClosestPoint") { - if (message.data.closestPoint === undefined) { - return; - } - this.parent.dispatchEvent( - new CustomEvent("pointHovered", { detail: message }) - ); - } else if (message.data.type === "getClickPoint") { - if (message.data.closestPoint === undefined) { - return; - } - this.parent.dispatchEvent( - new CustomEvent("pointClicked", { detail: message }) - ); - } else if ( - message.data.type === "selectBox" || - message.data.type === "selectLasso" - ) { - this.parent.dispatchEvent( - new CustomEvent("onSelectionEnd", { detail: message }) - ); - this.dataWorkerStream.push(message); - console.log(this.dataWorkerStream); - } - }; - this.dataWorker.onerror = (e) => { + + this.managerWorker.onerror = (e) => { + console.log("Error in manager worker", e); throw e; }; @@ -191,8 +208,13 @@ class WebGLVis { this._setMargins(specification); this.mouseReader.setSpecification(specification); this.sendDrawerState(this.mouseReader.getViewport()); - this.webglWorker.postMessage({ type: "specification", specification }); - this.dataWorker.postMessage({ type: "init", specification }); + + this.managerWorker.postMessage({ + worker: null, + data: { specification }, + action: "setSpecification", + }); + return true; } @@ -201,7 +223,11 @@ class WebGLVis { return false; } - this.webglWorker.postMessage({ type: "specification", specification }); + this.managerWorker.postMessage({ + worker: null, + data: { type: "specification", specification }, + action: "setSpecification", + }); return true; } @@ -211,16 +237,22 @@ class WebGLVis { * @param {Object} viewport likely from this.mouseReader.getViewport() */ sendDrawerState(viewport) { - this.webglWorker.postMessage({ type: "viewport", ...viewport }); + this.managerWorker.postMessage({ + worker: WEBGL_WORKER_NAME, + data: { type: "viewport", ...viewport }, + }); } /** * Calls render in the drawer. */ forceDrawerRender() { - this.webglWorker.postMessage({ - type: "render", - ...this.mouseReader.getViewport(), + this.managerWorker.postMessage({ + worker: WEBGL_WORKER_NAME, + data: { + type: "render", + ...this.mouseReader.getViewport(), + }, }); } @@ -235,9 +267,15 @@ class WebGLVis { */ selectPoints(points) { if (points.length === 4) { - this.dataWorker.postMessage({ type: "selectBox", points }); + this.managerWorker.postMessage({ + worker: DATA_WORKER_NAME, + data: { type: "selectBox", points }, + }); } else if (points.length >= 6) { - this.dataWorker.postMessage({ type: "selectLasso", points }); + this.managerWorker.postMessage({ + worker: DATA_WORKER_NAME, + data: { type: "selectLasso", points }, + }); } } @@ -248,9 +286,12 @@ class WebGLVis { * @param {Array} point to get closest point to */ getClosestPoint(point) { - this.dataWorker.postMessage({ - type: "getClosestPoint", - point, + this.managerWorker.postMessage({ + worker: DATA_WORKER_NAME, + data: { + type: "getClosestPoint", + point, + }, }); } @@ -261,9 +302,12 @@ class WebGLVis { * @param {Array} point to get closest point to */ getClickPoint(point) { - this.dataWorker.postMessage({ - type: "getClickPoint", - point, + this.managerWorker.postMessage({ + worker: DATA_WORKER_NAME, + data: { + type: "getClickPoint", + point, + }, }); }