diff --git a/examples/package-lock.json b/examples/package-lock.json index 4552b0d2..a66204c9 100644 --- a/examples/package-lock.json +++ b/examples/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "^6.9.1", "html-webpack-plugin": "^5.6.0", "mini-css-extract-plugin": "^2.7.7", - "webpack": "^5.90.0", + "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" } @@ -235,30 +235,10 @@ "@types/node": "*" } }, - "node_modules/@types/eslint": { - "version": "8.56.5", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.5.tgz", - "integrity": "sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, "node_modules/@types/express": { @@ -413,9 +393,9 @@ } }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", @@ -435,9 +415,9 @@ "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { @@ -458,15 +438,15 @@ "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { @@ -494,28 +474,28 @@ "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -523,24 +503,24 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -549,12 +529,12 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -639,10 +619,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "peerDependencies": { "acorn": "^8" @@ -1121,9 +1101,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true, "engines": { "node": ">= 0.6" @@ -1489,9 +1469,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.1.tgz", - "integrity": "sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -1655,9 +1635,9 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dev": true, "dependencies": { "accepts": "~1.3.8", @@ -1665,7 +1645,7 @@ "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -4505,9 +4485,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -4527,26 +4507,25 @@ } }, "node_modules/webpack": { - "version": "5.90.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.3.tgz", - "integrity": "sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", + "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", @@ -4554,7 +4533,7 @@ "schema-utils": "^3.2.0", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.0", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { diff --git a/examples/package.json b/examples/package.json index 9178f739..8da3cd76 100644 --- a/examples/package.json +++ b/examples/package.json @@ -21,7 +21,7 @@ "css-loader": "^6.9.1", "html-webpack-plugin": "^5.6.0", "mini-css-extract-plugin": "^2.7.7", - "webpack": "^5.90.0", + "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" } diff --git a/examples/src/Viewer.tsx b/examples/src/Viewer.tsx index 6c6ec0d4..2e59997a 100644 --- a/examples/src/Viewer.tsx +++ b/examples/src/Viewer.tsx @@ -1047,6 +1047,7 @@ class Viewer extends React.Component { backgroundColor={[0, 0, 0]} lockedCamera={false} disableCache={false} + maxCacheSize={Infinity} // means no limit, provide limits in bytes, 1MB = 1000000, 1GB = 1000000000 /> diff --git a/src/constants.ts b/src/constants.ts index 7f53a886..3d12b4b8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,3 +49,8 @@ export const nullAgent = (): AgentData => { subpoints: [], }; }; + +// the size of the header before the agent data in the binary file +export const AGENT_HEADER_SIZE = 3; // frameNumber, time, agentCount + +export const BYTE_SIZE_64_BIT_NUM = 8; diff --git a/src/controller/index.ts b/src/controller/index.ts index eae54ad5..c8ddf2d6 100644 --- a/src/controller/index.ts +++ b/src/controller/index.ts @@ -338,7 +338,6 @@ export default class SimulariumController { this.visData.WaitForFrame(0); this.visData.clearForNewTrajectory(); - this.visData.cancelAllWorkers(); this.stop(); diff --git a/src/simularium/VisData.ts b/src/simularium/VisData.ts index ea72eee5..38d689b9 100644 --- a/src/simularium/VisData.ts +++ b/src/simularium/VisData.ts @@ -1,188 +1,97 @@ -import { compareTimes } from "../util"; +import { noop } from "lodash"; -import * as util from "./ThreadUtil"; -import { - AGENT_OBJECT_KEYS, - AgentData, - FrameData, - VisDataMessage, -} from "./types"; -import { FrontEndError, ErrorLevel } from "./FrontEndError"; -import type { ParsedBundle } from "./VisDataParse"; +import { nullCachedFrame } from "../util"; + +import { VisDataMessage, CachedFrame } from "./types"; import { parseVisDataMessage } from "./VisDataParse"; -import { nullAgent } from "../constants"; +import { VisDataCache } from "./VisDataCache"; +import { ErrorLevel, FrontEndError } from "./FrontEndError"; +import { BYTE_SIZE_64_BIT_NUM } from "../constants"; class VisData { - private frameCache: AgentData[][]; - private frameDataCache: FrameData[]; - private enableCache: boolean; - private webWorker: Worker | null; + public frameCache: VisDataCache; private frameToWaitFor: number; private lockedForFrame: boolean; - private cacheFrame: number; + + private currentFrameNumber: number; public timeStepSize: number; + public onError: (error: FrontEndError) => void; - private static parseOneBinaryFrame(data: ArrayBuffer): ParsedBundle { - const parsedAgentDataArray: AgentData[][] = []; - const frameDataArray: FrameData[] = []; + private static parseOneBinaryFrame(data: ArrayBuffer): CachedFrame { const floatView = new Float32Array(data); const intView = new Uint32Array(data); - const parsedFrameData = { - time: floatView[1], + const frameData: CachedFrame = { + data: data, frameNumber: floatView[0], + time: floatView[1], + agentCount: intView[2], + size: 0, }; - const expectedNumAgents = intView[2]; - frameDataArray.push(parsedFrameData); - - const AGENTS_OFFSET = 3; - - const parsedAgentData: AgentData[] = []; - let j = AGENTS_OFFSET; - for (let i = 0; i < expectedNumAgents; i++) { - const agentData: AgentData = nullAgent(); - - for (let k = 0; k < AGENT_OBJECT_KEYS.length; ++k) { - agentData[AGENT_OBJECT_KEYS[k]] = floatView[j++]; - } - const nSubPoints = agentData["nSubPoints"]; - if (!Number.isInteger(nSubPoints)) { - throw new FrontEndError( - "Your data is malformed, non-integer value found for num-subpoints.", - ErrorLevel.ERROR, - `Number of Subpoints:
${nSubPoints}
` - ); - break; - } - // now read sub points. - for (let k = 0; k < nSubPoints; k++) { - agentData.subpoints.push(floatView[j++]); - } - parsedAgentData.push(agentData); - } - parsedAgentDataArray.push(parsedAgentData); - - return { - parsedAgentDataArray, - frameDataArray, - }; - } - - private setupWebWorker() { - this.webWorker = new Worker( - new URL("../visGeometry/workers/visDataWorker", import.meta.url), - { type: "module" } - ); + const numMetadataFields = Object.keys(frameData).length - 1; // exclude "data" field + frameData.size = + data.byteLength + numMetadataFields * BYTE_SIZE_64_BIT_NUM; - // event.data is of type ParsedBundle - this.webWorker.onmessage = (event) => { - if (!this.enableCache) { - this.frameDataCache = [...event.data.frameDataArray]; - this.frameCache = [...event.data.parsedAgentDataArray]; - return; - } - Array.prototype.push.apply( - this.frameDataCache, - event.data.frameDataArray - ); - Array.prototype.push.apply( - this.frameCache, - event.data.parsedAgentDataArray - ); - }; + return frameData; } public constructor() { - this.webWorker = null; - if (util.ThreadUtil.browserSupportsWebWorkers()) { - this.setupWebWorker(); - } - this.frameCache = []; - this.frameDataCache = []; - this.cacheFrame = -1; - this.enableCache = true; + this.currentFrameNumber = -1; + this.frameCache = new VisDataCache(); this.frameToWaitFor = 0; this.lockedForFrame = false; this.timeStepSize = 0; + + this.onError = noop; } - //get time() { return this.cacheFrame < this.frameDataCache.length ? this.frameDataCache[this.cacheFrame] : -1 } - public get currentFrameData(): FrameData { - if (this.frameDataCache.length > 0) { - if (this.cacheFrame < 0) { - return this.frameDataCache[0]; - } else if (this.cacheFrame >= this.frameDataCache.length) { - return this.frameDataCache[this.frameDataCache.length - 1]; - } else { - return this.frameDataCache[this.cacheFrame]; + public setOnError(onError: (error: FrontEndError) => void): void { + this.onError = onError; + } + + public get currentFrameData(): CachedFrame { + if (!this.frameCache.hasFrames()) { + return nullCachedFrame(); + } + if (this.currentFrameNumber < 0) { + const firstFrame = this.frameCache.getFirstFrame(); + if (firstFrame) { + this.currentFrameNumber = firstFrame.frameNumber; + return firstFrame; + } + } else { + const frame = this.frameCache.getFrameAtFrameNumber( + this.currentFrameNumber + ); + if (frame !== undefined) { + return frame; } } - - return { frameNumber: 0, time: 0 }; + return nullCachedFrame(); } /** * Functions to check update * */ public hasLocalCacheForTime(time: number): boolean { - // TODO: debug compareTimes - if (!this.enableCache) { - return false; - } - if (this.frameDataCache.length < 1) { - return false; - } - - const firstFrameTime = this.frameDataCache[0].time; - const lastFrameTime = - this.frameDataCache[this.frameDataCache.length - 1].time; - - const notLessThanFirstFrameTime = - compareTimes(time, firstFrameTime, this.timeStepSize) !== -1; - const notGreaterThanLastFrameTime = - compareTimes(time, lastFrameTime, this.timeStepSize) !== 1; - return notLessThanFirstFrameTime && notGreaterThanLastFrameTime; + return this.frameCache.containsTime(time); } public gotoTime(time: number): void { - this.cacheFrame = -1; - - // Find the index of the frame that has the time matching our target time - const frameNumber = this.frameDataCache.findIndex((frameData) => { - return compareTimes(frameData.time, time, this.timeStepSize) === 0; - }); - - // frameNumber is -1 if findIndex() above doesn't find a match - if (frameNumber !== -1) { - this.cacheFrame = frameNumber; + const frameNumber = this.frameCache.getFrameAtTime(time)?.frameNumber; + if (frameNumber !== undefined) { + this.currentFrameNumber = frameNumber; } } public atLatestFrame(): boolean { - if (this.cacheFrame === -1 && this.frameCache.length > 0) { - return false; - } - - return this.cacheFrame >= this.frameCache.length - 1; - } - - public currentFrame(): AgentData[] { - if (this.frameCache.length === 0) { - return []; - } else if (this.cacheFrame === -1) { - this.cacheFrame = 0; - return this.frameCache[0]; - } - - return this.cacheFrame < this.frameCache.length - ? this.frameCache[this.cacheFrame] - : Array(); + return this.currentFrameNumber >= this.frameCache.getLastFrameNumber(); } public gotoNextFrame(): void { if (!this.atLatestFrame()) { - this.cacheFrame = this.cacheFrame + 1; + this.currentFrameNumber += 1; } } @@ -195,9 +104,8 @@ class VisData { } public clearCache(): void { - this.frameCache = []; - this.frameDataCache = []; - this.cacheFrame = -1; + this.frameCache.clear(); + this.currentFrameNumber = -1; this.frameToWaitFor = 0; this.lockedForFrame = false; } @@ -206,35 +114,6 @@ class VisData { this.clearCache(); } - public cancelAllWorkers(): void { - // we need to be able to terminate any queued work in the worker during trajectory changeovers - if ( - util.ThreadUtil.browserSupportsWebWorkers() && - this.webWorker !== null - ) { - this.webWorker.terminate(); - this.setupWebWorker(); - } - } - - public setCacheEnabled(cacheEnabled: boolean): void { - this.enableCache = cacheEnabled; - } - - // Add parsed frames to the cache and save the timestamp of the first frame - private addFramesToCache(frames: ParsedBundle): void { - if (!this.enableCache) { - this.frameDataCache = [...frames.frameDataArray]; - this.frameCache = [...frames.parsedAgentDataArray]; - return; - } - Array.prototype.push.apply(this.frameDataCache, frames.frameDataArray); - Array.prototype.push.apply( - this.frameCache, - frames.parsedAgentDataArray - ); - } - private parseAgentsFromVisDataMessage(msg: VisDataMessage): void { /** * visDataMsg = { @@ -259,32 +138,26 @@ class VisData { this.frameToWaitFor = 0; } } - + const parsedMsg: CachedFrame = parseVisDataMessage(visDataMsg); if ( - util.ThreadUtil.browserSupportsWebWorkers() && - this.webWorker !== null + this.frameCache.cacheSizeLimited && + parsedMsg.size > this.frameCache.maxSize ) { - this.webWorker.postMessage(visDataMsg); - } else { - const frames = parseVisDataMessage(visDataMsg); - this.addFramesToCache(frames); + this.frameExceedsCacheSizeError(parsedMsg.size); + return; } + this.addFrameToCache(parsedMsg); } public parseAgentsFromFrameData(msg: VisDataMessage | ArrayBuffer): void { if (msg instanceof ArrayBuffer) { - const frames = VisData.parseOneBinaryFrame(msg); - if ( - frames.frameDataArray.length > 0 && - frames.frameDataArray[0].frameNumber === 0 - ) { + const frame = VisData.parseOneBinaryFrame(msg); + if (frame.frameNumber === 0) { this.clearCache(); // new data has arrived } - this.addFramesToCache(frames); + this.addFrameToCache(frame); return; } - - // handle VisDataMessage this.parseAgentsFromVisDataMessage(msg); } @@ -303,6 +176,26 @@ class VisData { this.parseAgentsFromFrameData(msg); } + + private addFrameToCache(frame: CachedFrame): void { + if ( + this.frameCache.cacheSizeLimited && + frame.size > this.frameCache.maxSize + ) { + this.frameExceedsCacheSizeError(frame.size); + return; + } + this.frameCache.addFrame(frame); + } + + private frameExceedsCacheSizeError(frameSize: number): void { + this.onError( + new FrontEndError( + `Frame size exceeds cache size: ${frameSize} > ${this.frameCache.maxSize}`, + ErrorLevel.ERROR + ) + ); + } } export { VisData }; diff --git a/src/simularium/VisDataCache.ts b/src/simularium/VisDataCache.ts new file mode 100644 index 00000000..bcb6107b --- /dev/null +++ b/src/simularium/VisDataCache.ts @@ -0,0 +1,248 @@ +import { compareTimes } from "../util"; +import { CachedFrame, CacheNode } from "./types"; + +interface VisDataCacheSettings { + maxSize: number; + cacheEnabled: boolean; +} + +class VisDataCache { + private head: CacheNode | null; + private tail: CacheNode | null; + public numFrames: number; + public size: number; + private _maxSize: number; + private _cacheEnabled: boolean; + + constructor(settings?: Partial) { + /** + * maxSize of negative one means no limit on cache size + * disabledCache means only one frame will be stored at a time + * maxSize > 0 and cacheEnabled will cause cache to trim frames + * when incoming frame pushes size over max + */ + this.head = null; + this.tail = null; + this.numFrames = 0; + this.size = 0; + this._maxSize = Infinity; + this._cacheEnabled = true; + + if (settings) { + this.changeSettings(settings); + } + } + + public changeSettings(options: { + maxSize?: number; + cacheEnabled?: boolean; + }): void { + const { maxSize, cacheEnabled } = options; + if (cacheEnabled !== undefined) { + this._cacheEnabled = cacheEnabled; + } + if (maxSize !== undefined) { + this._maxSize = maxSize; + } + } + + public get maxSize(): number { + return this._maxSize; + } + + public get cacheEnabled(): boolean { + return this._cacheEnabled; + } + + public get cacheSizeLimited(): boolean { + return this._maxSize !== Infinity; + } + + public hasFrames(): boolean { + return this.numFrames > 0 && this.head !== null && this.tail !== null; + } + + /** + * Walks the cache looking for node that satisfies condition + * returns the node if found, otherwise returns null, + * starts at head if firstNode is not provided. + */ + private findInLinkedList( + condition: (data: CacheNode) => boolean, + firstNode?: CacheNode + ): CacheNode | undefined { + let currentNode = firstNode || this.head; + while (currentNode) { + if (condition(currentNode)) { + return currentNode; + } + currentNode = currentNode.next; + } + return undefined; + } + + public containsTime(time: number): boolean { + if ( + compareTimes(time, this.getFirstFrameTime(), 0.1) === -1 || + compareTimes(time, this.getLastFrameTime(), 0.1) === 1 + ) { + return false; + } + if (time < this.getFirstFrameTime() || time > this.getLastFrameTime()) { + return false; + } + return !!this.findInLinkedList((node) => node.data.time === time); + } + + public containsFrameAtFrameNumber(frameNumber: number): boolean { + if ( + frameNumber < this.getFirstFrameNumber() || + frameNumber > this.getLastFrameNumber() + ) { + return false; + } + return !!this.findInLinkedList( + (node) => node.data.frameNumber === frameNumber + ); + } + + public getFirstFrame(): CachedFrame | undefined { + return this.head?.data; + } + + public getFirstFrameNumber(): number { + return this.head?.data.frameNumber || -1; + } + + public getFirstFrameTime(): number { + return this.head?.data.time || -1; + } + + public getLastFrame(): CachedFrame | undefined { + return this.tail?.data; + } + + public getLastFrameNumber(): number { + return this.tail?.data.frameNumber || -1; + } + + public getLastFrameTime(): number { + return this.tail?.data.time || -1; + } + + private getFrameAtCondition( + condition: (data: CacheNode) => boolean + ): CachedFrame | undefined { + if (!this.head) { + return; + } + const frame = this.findInLinkedList(condition); + if (frame) { + return frame.data; + } + } + + public getFrameAtTime(time: number): CachedFrame | undefined { + const frame = this.getFrameAtCondition( + (node) => compareTimes(node.data.time, time, 0) === 0 + ); + return frame ? frame : undefined; + } + + public getFrameAtFrameNumber(frameNumber: number): CachedFrame | undefined { + const frame = this.getFrameAtCondition( + (node) => node.data["frameNumber"] === frameNumber + ); + return frame ? frame : undefined; + } + + private assignSingleFrameToCache(data: CachedFrame): void { + const newNode: CacheNode = { + data, + next: null, + prev: null, + }; + + this.head = newNode; + this.tail = newNode; + this.size = data.size; + this.numFrames = 1; + } + + private addFrameToEndOfCache(data: CachedFrame): void { + const newNode: CacheNode = { + data, + next: null, + prev: null, + }; + if (!this.hasFrames()) { + this.assignSingleFrameToCache(data); + return; + } + if (this.tail) { + newNode.prev = this.tail; + this.tail.next = newNode; + this.tail = newNode; + this.numFrames++; + this.size += data.size; + if (this.size > this._maxSize) { + this.trimCache(); + } + } + } + + public addFrame(data: CachedFrame): void { + if (this.size + data.size > this._maxSize) { + this.trimCache(data.size); + } + if (this.hasFrames() && this._cacheEnabled) { + this.addFrameToEndOfCache(data); + return; + } + this.assignSingleFrameToCache(data); + } + + // generalized to remove any node, but in theory + // we should only be removing the head when we trim the cache + // under current assumptions + private removeNode(node: CacheNode): void { + if (this.numFrames === 0 || !this.head || !this.tail) { + return; + } + if (this.numFrames === 1 && this.head === this.tail) { + this.clear(); + return; + } + if (node === this.head && node.next !== null) { + this.head = node.next; + this.head.prev = null; + } else if (node === this.tail && node.prev !== null) { + this.tail = node.prev; + this.tail.next = null; + } else if (node.prev !== null && node.next !== null) { + node.prev.next = node.next; + node.next.prev = node.prev; + } + this.numFrames--; + this.size -= node.data.size; + } + + private trimCache(incomingDataSize?: number): void { + while ( + this.hasFrames() && + this.size + (incomingDataSize || 0) > this._maxSize && + this.head !== null + ) { + this.removeNode(this.head); + } + } + + public clear(): void { + this.head = null; + this.tail = null; + this.numFrames = 0; + this.size = 0; + } +} + +export { VisDataCache }; diff --git a/src/simularium/VisDataParse.ts b/src/simularium/VisDataParse.ts index 0414840a..f06d2425 100644 --- a/src/simularium/VisDataParse.ts +++ b/src/simularium/VisDataParse.ts @@ -1,119 +1,114 @@ -import { - FrameData, - VisDataMessage, - AgentData, - AGENT_OBJECT_KEYS, -} from "./types"; +import { VisDataMessage, AGENT_OBJECT_KEYS, CachedFrame } from "./types"; import { FrontEndError, ErrorLevel } from "./FrontEndError"; +import { AGENT_HEADER_SIZE } from "../constants"; -interface ParsedBundle { - frameDataArray: FrameData[]; - parsedAgentDataArray: AgentData[][]; -} +const FRAME_DATA_SIZE = AGENT_OBJECT_KEYS.length; /** - * Parses a stream of data sent from the backend - * - * To minimize bandwidth, traits/objects are not packed - * 1-1; what arrives is an array of float values - * - * For instance for: - * entity = ( - * trait1 : 4, - * trait2 : 5, - * trait3 : 6, - * ) ... + * This function serves as a translation layer, it takes in a VisDataMessage + * and walks the data counting the agents and converting the number[] to ArrayBuffer + * in order to generate a CachedFrame. * - * what arrives will be: - * [...,4,5,6,...] + * This is used for loading local JSON files, and in the rare case + * that JSON is sent from the backend. Parsing twice (number[] to ArrayBuffer, + * ArrayBuffer to AgentData) is a low concern for performance + * as local files will automatically pre-cache frames and not deal with + * network latency. * - * The traits are assumed to be variable in length, - * and the alorithm to decode them needs to the reverse - * of the algorithm that packed them on the backend - * - * This is more convuluted than sending the JSON objects themselves, - * however these frames arrive multiple times per second. Even a naive - * packing reduces the packet size by ~50%, reducing how much needs to - * paid for network bandwith (and improving the quality & responsiveness - * of the application, since network latency is a major bottle-neck) - * */ - -function parseVisDataMessage(visDataMsg: VisDataMessage): ParsedBundle { - const parsedAgentDataArray: AgentData[][] = []; - const frameDataArray: FrameData[] = []; - visDataMsg.bundleData.forEach((frame) => { - const visData = frame.data; - const parsedAgentData: AgentData[] = []; - const nSubPointsIndex = AGENT_OBJECT_KEYS.findIndex( - (ele) => ele === "nSubPoints" + * todo: VisDataMessage.bundleData should only ever be a single frame + * regardless of whether or not the data is JSON or binary, so we + * should be able to adjust the typing of VisDataMessage to reflect that. + */ + +function parseVisDataMessage(visDataMsg: VisDataMessage): CachedFrame { + const frame = visDataMsg.bundleData[0]; + const visData = [...frame.data]; + + let nSubPoints = visData[AGENT_OBJECT_KEYS.indexOf("nSubPoints")]; + let chunkLength = FRAME_DATA_SIZE + nSubPoints; + + // make ArrayBuffer from number[] to use in cache + const totalSize = calculateBufferSize(frame.data); + const buffer = new ArrayBuffer(totalSize); + const view = new Float32Array(buffer); + + let agentCount = 0; + let offset = 0; + let currentAgentData = visData.slice(offset, offset + chunkLength); + while (currentAgentData.length) { + let writeIndex = AGENT_HEADER_SIZE + offset; + let readIndex = offset; + if (currentAgentData.length < chunkLength) { + throw new FrontEndError( + `Your data is malformed, there are too few entries. Found ${currentAgentData.length} entries, expected ${chunkLength}.`, + ErrorLevel.ERROR + ); + } + + agentCount++; + + // Copy agent data + const agentData = frame.data.slice( + readIndex, + readIndex + FRAME_DATA_SIZE ); + view.set(agentData, writeIndex); + readIndex += FRAME_DATA_SIZE; + writeIndex += FRAME_DATA_SIZE; - const parseOneAgent = (agentArray): AgentData => { - return agentArray.reduce( - (agentData, cur, i) => { - let key; - if (AGENT_OBJECT_KEYS[i]) { - key = AGENT_OBJECT_KEYS[i]; - agentData[key] = cur; - } else if (i < agentArray.length + agentData.nSubPoints) { - agentData.subpoints.push(cur); - } - return agentData; - }, - { subpoints: [] } + // Validate data integrity + if (--readIndex + nSubPoints > frame.data.length) { + throw new FrontEndError( + `Your data is malformed, there are too few entries. Found ${ + frame.data.length + }, expected ${readIndex + nSubPoints}.`, + ErrorLevel.ERROR ); - }; - - while (visData.length) { - const nSubPoints = visData[nSubPointsIndex]; - const chunkLength = AGENT_OBJECT_KEYS.length + nSubPoints; // each array length is variable based on how many subpoints the agent has - if (visData.length < chunkLength) { - const attemptedMapping = AGENT_OBJECT_KEYS.map( - (name, index) => `${name}: ${visData[index]}
` - ); - // will be caught by controller.changeFile(...).catch() - throw new FrontEndError( - "Your data is malformed, there are too few entries.", - ErrorLevel.ERROR, - `Example attempt to parse your data:
${attemptedMapping.join(
-                        ""
-                    )}
` - ); - } - - const agentSubSetArray = visData.splice(0, chunkLength); // cut off the array of 1 agent data from front of the array; - if (agentSubSetArray.length < AGENT_OBJECT_KEYS.length) { - const attemptedMapping = AGENT_OBJECT_KEYS.map( - (name, index) => `${name}: ${agentSubSetArray[index]}
` - ); - // will be caught by controller.changeFile(...).catch() - throw new FrontEndError( - "Your data is malformed, there are less entries than expected for this agent. ", - ErrorLevel.ERROR, - `Example attempt to parse your data:
${attemptedMapping.join(
-                        ""
-                    )}
` - ); - } - - const agent = parseOneAgent(agentSubSetArray); - parsedAgentData.push(agent); } - const frameData: FrameData = { - time: frame.time, - frameNumber: frame.frameNumber, - }; + // Copy subpoints + const subpoints = frame.data.slice(readIndex, readIndex + nSubPoints); + view.set(subpoints, writeIndex); + readIndex += nSubPoints; + writeIndex += nSubPoints; - parsedAgentDataArray.push(parsedAgentData); - frameDataArray.push(frameData); - }); + // Adjust offsets relative to next agent's # of subpoints + offset += chunkLength; + nSubPoints = visData[offset + AGENT_OBJECT_KEYS.indexOf("nSubPoints")]; + chunkLength = FRAME_DATA_SIZE + nSubPoints; + currentAgentData = visData.slice(offset, offset + chunkLength); + } - return { - parsedAgentDataArray, - frameDataArray, + // Write header data + view[0] = frame.frameNumber; + view[1] = frame.time; + view[2] = agentCount; + + const arrayBuffer: ArrayBuffer = view.buffer; + const frameData: CachedFrame = { + data: arrayBuffer, + frameNumber: frame.frameNumber, + time: frame.time, + agentCount: agentCount, + size: totalSize, }; + + return frameData; +} + +function calculateBufferSize(data: number[]): number { + let size = AGENT_HEADER_SIZE * 4; // Header size in bytes + let index = 0; + + while (index < data.length) { + size += FRAME_DATA_SIZE * 4; // Agent header size in bytes + const nSubPoints = + data[index + AGENT_OBJECT_KEYS.indexOf("nSubPoints")]; + size += nSubPoints * 4; // Subpoints size in bytes + index += FRAME_DATA_SIZE + nSubPoints; + } + + return size; } -export { parseVisDataMessage }; -export type { ParsedBundle }; +export { parseVisDataMessage, calculateBufferSize }; diff --git a/src/simularium/types.ts b/src/simularium/types.ts index 21be13fe..4d3191bf 100644 --- a/src/simularium/types.ts +++ b/src/simularium/types.ts @@ -195,3 +195,17 @@ export interface PlotConfig { metricsIdy?: number; scatterPlotMode?: string; } + +export interface CachedFrame { + data: ArrayBuffer; + frameNumber: number; + time: number; + agentCount: number; + size: number; +} + +export interface CacheNode { + data: CachedFrame; + next: CacheNode | null; + prev: CacheNode | null; +} diff --git a/src/test/AgentSimController.test.ts b/src/test/AgentSimController.test.ts index 4b4bb3bd..35730401 100644 --- a/src/test/AgentSimController.test.ts +++ b/src/test/AgentSimController.test.ts @@ -15,10 +15,10 @@ describe("SimulariumController module", () => { }); controller.start(); + controller.gotoTime(2); // allow time for data streaming to occur setTimeout(() => { - controller.gotoTime(2); expect(controller.time()).toEqual(2); done(); }, 500); diff --git a/src/test/DummyRemoteSimulator.ts b/src/test/DummyRemoteSimulator.ts index 0559cdd2..7ebc117a 100644 --- a/src/test/DummyRemoteSimulator.ts +++ b/src/test/DummyRemoteSimulator.ts @@ -38,39 +38,32 @@ export class DummyRemoteSimulator extends RemoteSimulator { setInterval(this.broadcast.bind(this), 200); } - private getDataBundle(frameNumber: number, bundleSize: number): string { + private getDataBundle(frameNumber: number): string { const msg: VisDataMessage = { msgType: NetMessageEnum.ID_VIS_DATA_ARRIVE, bundleStart: frameNumber, - bundleSize: bundleSize, + bundleSize: 1, // backend only sends one frame at a time now bundleData: [], fileName: this.lastRequestedFile, }; - - const bundleData: VisDataFrame[] = []; - for (let i = 0; i < bundleSize; i++) { - const data: VisDataFrame = { - frameNumber: frameNumber, - time: frameNumber * this.timeStep, - data: [ - 1000, - 0, - 43, - Math.cos(frameNumber / 4) * 5, - Math.sin(frameNumber / 4) * 5, - 0, - 0, - 0, - 10, - 1, - 0, - ], - }; - bundleData.push(data); - frameNumber++; - } - - msg.bundleData = bundleData; + const data: VisDataFrame = { + frameNumber: frameNumber, + time: frameNumber, + data: [ + 1000, + 0, + 43, + Math.cos(frameNumber / 4) * 5, + Math.sin(frameNumber / 4) * 5, + 0, + 0, + 0, + 10, + 1, + 0, + ], + }; + msg.bundleData.push(data); return JSON.stringify(msg); } @@ -84,11 +77,10 @@ export class DummyRemoteSimulator extends RemoteSimulator { return; } - const bundleSize = 5; const msg: NetMessage = JSON.parse( - this.getDataBundle(this.frameCounter, bundleSize) + this.getDataBundle(this.frameCounter) ); - this.frameCounter += bundleSize; + this.frameCounter++; this.onJsonIdVisDataArrive(msg); } @@ -149,7 +141,7 @@ export class DummyRemoteSimulator extends RemoteSimulator { this.onTrajectoryFileInfoArrive({ data: JSON.stringify(tfi) }); // Send the first frame of data - const msg: NetMessage = JSON.parse(this.getDataBundle(0, 1)); + const msg: NetMessage = JSON.parse(this.getDataBundle(0)); this.frameCounter++; this.onJsonIdVisDataArrive(msg); }, this.commandLatencyMS); @@ -159,7 +151,7 @@ export class DummyRemoteSimulator extends RemoteSimulator { setTimeout(() => { this.frameCounter = frameNumber; - const msg: NetMessage = JSON.parse(this.getDataBundle(0, 1)); + const msg: NetMessage = JSON.parse(this.getDataBundle(frameNumber)); this.frameCounter; this.onJsonIdVisDataArrive(msg); }, this.commandLatencyMS); @@ -170,7 +162,7 @@ export class DummyRemoteSimulator extends RemoteSimulator { this.frameCounter = time / this.timeStep; const msg: NetMessage = JSON.parse( - this.getDataBundle(this.frameCounter, 1) + this.getDataBundle(this.frameCounter) ); this.frameCounter++; this.onJsonIdVisDataArrive(msg); diff --git a/src/test/SimulariumController.test.ts b/src/test/SimulariumController.test.ts index 4b4bb3bd..02eee257 100644 --- a/src/test/SimulariumController.test.ts +++ b/src/test/SimulariumController.test.ts @@ -15,10 +15,8 @@ describe("SimulariumController module", () => { }); controller.start(); - - // allow time for data streaming to occur + controller.gotoTime(2); setTimeout(() => { - controller.gotoTime(2); expect(controller.time()).toEqual(2); done(); }, 500); diff --git a/src/test/VisData.test.ts b/src/test/VisData.test.ts index aea69945..ce533e87 100644 --- a/src/test/VisData.test.ts +++ b/src/test/VisData.test.ts @@ -1,5 +1,15 @@ -import { VisData, VisDataMessage, NetMessageEnum } from "../simularium"; -import { parseVisDataMessage } from "../simularium/VisDataParse"; +import { + VisData, + VisDataMessage, + NetMessageEnum, + FrontEndError, +} from "../simularium"; +import { + calculateBufferSize, + parseVisDataMessage, +} from "../simularium/VisDataParse"; +import { AGENT_OBJECT_KEYS, CachedFrame } from "../simularium/types"; +import { nullCachedFrame } from "../util"; // Sample data of a single agent of type '7' // moving linearly from (0,0,0) to (5,5,5) @@ -39,92 +49,9 @@ const testData = { ], }; -const parsedData = [ - [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 1, - xrot: 0, - y: 1, - yrot: 0, - z: 1, - zrot: 0, - }, - ], - [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 2, - xrot: 0, - y: 2, - yrot: 22.5, - z: 2, - zrot: 0, - }, - ], - [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 3, - xrot: 0, - y: 3, - yrot: 45, - z: 3, - zrot: 0, - }, - ], - [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 4, - xrot: 0, - y: 4, - yrot: 67.5, - z: 4, - zrot: 0, - }, - ], - [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 5, - xrot: 0, - y: 5, - yrot: 90, - z: 5, - zrot: 0, - }, - ], -]; - describe("VisData module", () => { describe("VisData parse", () => { - test("it returns an array of objects of agent data and time stamp data", () => { + test("it calculates the buffer size correctly for data with no subpoints", () => { const testData = [ 10, //"visType", 15, //"instanceId", @@ -136,49 +63,40 @@ describe("VisData module", () => { 41, //"yrot", 42, //"zrot", 50, //"cr", - 3, + 0, //"nSubPoints", + ]; + const HEADER_SIZE = 3; // frameNumber, time, agentCount + const FRAME_DATA_SIZE = AGENT_OBJECT_KEYS.length; + const expectedSize = (HEADER_SIZE + FRAME_DATA_SIZE) * 4; + const result = calculateBufferSize(testData); + expect(result).toEqual(expectedSize); + }); + test("it calculates the buffer size correctly for data with subpoints", () => { + const testData = [ + 10, //"visType", + 15, //"instanceId", + 20, //"type", + 30, //"x", + 31, //"y", + 32, //"z", + 40, //"xrot", + 41, //"yrot", + 42, //"zrot", + 50, //"cr", + 2, //"nSubPoints", 60, //"subpoint-1", 61, //"subpoint-2", - 62, //"subpoint-3", ]; - const visDataMsg: VisDataMessage = { - msgType: NetMessageEnum.ID_VIS_DATA_ARRIVE, - bundleData: [ - { - data: testData, - frameNumber: 0, - time: 0, - }, - ], - bundleSize: 1, - bundleStart: 0, - fileName: "", - }; - const parsedData = parseVisDataMessage(visDataMsg); - expect(parsedData.frameDataArray).toEqual([ - { frameNumber: 0, time: 0 }, - ]); - expect(parsedData.parsedAgentDataArray).toEqual([ - [ - { - cr: 50, //"cr", - nSubPoints: 3, - subpoints: [60, 61, 62], //"subpoint-1", "subpoint-2", "subpoint-3"], - type: 20, //"type", - instanceId: 15, //"instanceId", - visType: 10, //"visType", - x: 30, //"x", - xrot: 40, //"xrot", - y: 31, //"y", - yrot: 41, //"yrot", - z: 32, //"z", - zrot: 42, //"zrot", - }, - ], - ]); + const HEADER_SIZE = 3; // frameNumber, time, agentCount + const FRAME_DATA_SIZE = AGENT_OBJECT_KEYS.length; + const nSubpoints = testData[10]; + const expectedSize = + (HEADER_SIZE + FRAME_DATA_SIZE + nSubpoints) * 4; + const result = calculateBufferSize(testData); + expect(result).toEqual(expectedSize); }); - test("it throws an error if number of supoints does not match the nSubpoints value", () => { - const tooShort = [ + test("it returns a CachedFrame of header data and an array buffer", () => { + const testData = [ 10, //"visType", 15, //"instanceId", 20, //"type", @@ -189,17 +107,23 @@ describe("VisData module", () => { 41, //"yrot", 42, //"zrot", 50, //"cr", - 10, + 3, 60, //"subpoint-1", 61, //"subpoint-2", 62, //"subpoint-3", - 63, //"subpoint-4", ]; - const visDataMsgTooShort = { + const expectedFrame: CachedFrame = { + data: expect.any(ArrayBuffer), + frameNumber: 0, + time: 0, + agentCount: 1, + size: calculateBufferSize(testData), + }; + const visDataMsg: VisDataMessage = { msgType: NetMessageEnum.ID_VIS_DATA_ARRIVE, bundleData: [ { - data: tooShort, + data: testData, frameNumber: 0, time: 0, }, @@ -208,6 +132,10 @@ describe("VisData module", () => { bundleStart: 0, fileName: "", }; + const result = parseVisDataMessage(visDataMsg); + expect(result).toMatchObject(expectedFrame); + }); + test("should throw error when there are too few subpoints", () => { const tooLong = [ 10, //"visType", 15, //"instanceId", @@ -219,12 +147,12 @@ describe("VisData module", () => { 41, //"yrot", 42, //"zrot", 50, //"cr", - 2, + 4, 60, //"subpoint-1", 61, //"subpoint-2", 62, //"subpoint-3", ]; - const visDataMsgTooLong = { + const visDataMsg: VisDataMessage = { msgType: NetMessageEnum.ID_VIS_DATA_ARRIVE, bundleData: [ { @@ -237,69 +165,41 @@ describe("VisData module", () => { bundleStart: 0, fileName: "", }; - expect(() => { - parseVisDataMessage(visDataMsgTooLong); - }).toThrowError(); - - expect(() => { - parseVisDataMessage(visDataMsgTooShort); - }).toThrowError(); + expect(() => parseVisDataMessage(visDataMsg)).toThrow( + FrontEndError + ); }); - test("currentFrame returns empty frame when cache is empty", () => { + test("currentFrame returns null frame when cache is empty", () => { const visData = new VisData(); - const emptyFrame = visData.currentFrame(); - expect(emptyFrame).toEqual([]); + const emptyFrame = visData.currentFrameData; + expect(emptyFrame).toEqual(nullCachedFrame()); }); test("can request frame from a cache size of 1", () => { - const singleFrame = { + const singleFrame: VisDataMessage = { msgType: 1, bundleSize: 1, bundleStart: 0, bundleData: [ { - frameNumber: 1, + frameNumber: 0, time: 0, data: [1000, 0, 7, 1, 1, 1, 0, 0, 0, 1, 0], }, ], fileName: "", }; - - const parsedSingleFrame = [ - { - cr: 1, - nSubPoints: 0, - subpoints: [], - type: 7, - instanceId: 0, - visType: 1000, - x: 1, - xrot: 0, - y: 1, - yrot: 0, - z: 1, - zrot: 0, - }, - ]; - + const expectedFrame: CachedFrame = { + data: expect.any(ArrayBuffer), + frameNumber: 0, + time: 0, + agentCount: 1, + size: calculateBufferSize(singleFrame.bundleData[0].data), + }; const visData = new VisData(); visData.parseAgentsFromNetData(singleFrame); - const firstFrame = visData.currentFrame(); - expect(firstFrame).toEqual(parsedSingleFrame); - }); - test("parses 5 frame bundle correctly", () => { - const visData = new VisData(); - visData.parseAgentsFromNetData(testData); - expect(visData.atLatestFrame()).toBe(false); - - let i = 0; - while (!visData.atLatestFrame()) { - const currentFrame = visData.currentFrame(); - expect(visData.hasLocalCacheForTime(i * 5)).toBe(true); - expect(currentFrame).toEqual(parsedData[i++]); - visData.gotoNextFrame(); - } + const firstFrame = visData.currentFrameData; + expect(firstFrame).toMatchObject(expectedFrame); }); test("can find frames in cache by time", () => { const visData = new VisData(); diff --git a/src/test/VisDataCache.test.ts b/src/test/VisDataCache.test.ts new file mode 100644 index 00000000..4360aafc --- /dev/null +++ b/src/test/VisDataCache.test.ts @@ -0,0 +1,131 @@ +import { CachedFrame } from "../simularium/types"; +import { VisDataCache } from "../simularium/VisDataCache"; + +describe("VisDataCache", () => { + const dummyDattaBuffer = new ArrayBuffer(100); + const dummyFrameSize = dummyDattaBuffer.byteLength + 8 * 4; + const testFrame0: CachedFrame = { + data: dummyDattaBuffer, + frameNumber: 0, + time: 0, + agentCount: 10, + size: dummyFrameSize, + }; + const testFrame1: CachedFrame = { + data: dummyDattaBuffer, + frameNumber: 1, + time: 1, + agentCount: 10, + size: dummyFrameSize, + }; + const testFrame2: CachedFrame = { + data: dummyDattaBuffer, + frameNumber: 2, + time: 2, + agentCount: 10, + size: dummyFrameSize, + }; + + it("initializes a disabled cache when enabled option is false", () => { + const cache = new VisDataCache({ + cacheEnabled: false, + }); + expect(cache.cacheEnabled).toEqual(false); + }); + it("updating settings can disable the cache", () => { + const cache = new VisDataCache({ + cacheEnabled: true, + }); + cache.changeSettings({ cacheEnabled: false }); + expect(cache.cacheEnabled).toEqual(false); + }); + it("only adds firstFrame and clears the cache when cache is disabled", () => { + const cache = new VisDataCache({ cacheEnabled: false }); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + expect(cache.getFirstFrame()).toEqual(testFrame1); + expect(cache.getLastFrame()).toEqual(testFrame1); + expect(cache.numFrames).toEqual(1); + }); + it("clears cache and adds first frame if addFrame is called and cache is empty", () => { + const cache = new VisDataCache(); + cache.addFrame(testFrame0); + expect(cache.getFirstFrame()).toEqual(testFrame0); + expect(cache.getLastFrame()).toEqual(testFrame0); + expect(cache.numFrames).toEqual(1); + }); + + it("adds frames to end of cache if cache is enabled and has frames in it", () => { + const cache = new VisDataCache({ cacheEnabled: true }); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + expect(cache.getFirstFrame()).toEqual(testFrame0); + expect(cache.getLastFrame()).toEqual(testFrame1); + expect(cache.numFrames).toEqual(2); + }); + + it("trims the cache before adding frames when incoming frame size pushes size over maxSize", () => { + const cache = new VisDataCache({ + cacheEnabled: true, + maxSize: dummyFrameSize * 2 + 1, + }); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + cache.addFrame(testFrame2); + expect(cache.maxSize).toEqual(dummyFrameSize * 2 + 1); + expect(cache.getFirstFrame()).toEqual(testFrame1); + expect(cache.getLastFrame()).toEqual(testFrame2); + expect(cache.numFrames).toEqual(2); + }); + + it("trims the cache when maxSize is updated after intialization", () => { + const cache = new VisDataCache({ + cacheEnabled: true, + }); + cache.changeSettings({ maxSize: dummyFrameSize * 2 + 1 }); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + cache.addFrame(testFrame2); + expect(cache.maxSize).toEqual(dummyFrameSize * 2 + 1); + expect(cache.getFirstFrame()).toEqual(testFrame1); + expect(cache.getLastFrame()).toEqual(testFrame2); + expect(cache.numFrames).toEqual(2); + }); + it("returns true if a frame with the provided time is in the cache", () => { + const cache = new VisDataCache(); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + expect(cache.containsTime(1)).toEqual(true); + }); + it("returns false for containsTime if the provided time is not in the cache", () => { + const cache = new VisDataCache(); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + expect(cache.containsTime(3)).toEqual(false); + }); + it("returns undefined when head is null", () => { + const cache = new VisDataCache(); + expect(cache.getFirstFrame()).toEqual(undefined); + expect(cache.getLastFrame()).toEqual(undefined); + }); + it("returns -1 for first and last frame numbers and time if head or tail is null ", () => { + const cache = new VisDataCache(); + expect(cache.getFirstFrameTime()).toEqual(-1); + expect(cache.getLastFrameTime()).toEqual(-1); + expect(cache.getFirstFrameNumber()).toEqual(-1); + expect(cache.getLastFrameNumber()).toEqual(-1); + }); + + it("gets the correct frame when calling getFrameAtFrameNumber", () => { + const cache = new VisDataCache(); + cache.addFrame(testFrame0); + cache.addFrame(testFrame1); + expect(cache.getFrameAtFrameNumber(1)).toEqual(testFrame1); + }); + it("clears the cache when clear is called", () => { + const cache = new VisDataCache(); + cache.addFrame(testFrame0); + cache.clear(); + expect(cache.numFrames).toEqual(0); + }); +}); diff --git a/src/test/util.test.ts b/src/test/util.test.ts index fef2bcaf..f7474906 100644 --- a/src/test/util.test.ts +++ b/src/test/util.test.ts @@ -1,4 +1,10 @@ -import { checkAndSanitizePath, compareTimes } from "../util"; +import { FrontEndError } from "../simularium"; +import { + checkAndSanitizePath, + compareTimes, + getAgentDataFromBuffer, + getNextAgentOffset, +} from "../util"; describe("util", () => { describe("compareTimes", () => { @@ -69,4 +75,189 @@ describe("util", () => { expect(result).toEqual(`/${path}`); }); }); + + describe("getNextAgentOffest", () => { + test("it returns the correct offset based on nSubPoints of the first agent", () => { + const twoAgentTestData = [ + 10, //"visType" (agent 1) + 15, //"instanceId" (agent 1) + 20, //"type" (agent 1) + 30, //"x" (agent 1) + 31, //"y" (agent 1) + 32, //"z" (agent 1) + 40, //"xrot" (agent 1) + 41, //"yrot" (agent 1) + 42, //"zrot" (agent 1) + 50, //"cr" (agent 1) + 3, // "nSubPoints" (agent 1) + 60, //"subpoint-1" (agent 1) + 61, //"subpoint-2" (agent 1) + 62, //"subpoint-3" (agent 1) + + 11, //"visType" (agent 2) + 16, //"instanceId" (agent 2) + 21, //"type" (agent 2) + 33, //"x" (agent 2) + 34, //"y" (agent 2) + 35, //"z" (agent 2) + 43, //"xrot" (agent 2) + 44, //"yrot" (agent 2) + 45, //"zrot" (agent 2) + 51, //"cr" (agent 2) + 2, // "nSubPoints" (agent 2) + 63, //"subpoint-1" (agent 2) + 64, //"subpoint-2" (agent 2) + ]; + const view = new Float32Array(twoAgentTestData); + + // Offset for the first agent is 0 + const firstAgentOffset = 0; + const nextOffset = getNextAgentOffset(view, firstAgentOffset); + + // The first agent has 11 standard fields + 3 subpoints, so the next offset should be 13 + expect(nextOffset).toBe(14); + }); + + describe("getAgentDataFromBuffer", () => { + test("it returns the correct AgentData object for a single cached agent", () => { + const singleAgentTestData = [ + 10, //"visType", + 15, //"instanceId", + 20, //"type", + 30, //"x", + 31, //"y", + 32, //"z", + 40, //"xrot", + 41, //"yrot", + 42, //"zrot", + 50, //"cr", + 3, + 60, //"subpoint-1", + 61, //"subpoint-2", + 62, //"subpoint-3", + ]; + const view = new Float32Array(singleAgentTestData); + const parsedData = getAgentDataFromBuffer(view, 0); + expect(parsedData).toEqual({ + visType: 10, //"visType", + instanceId: 15, //"instanceId", + type: 20, //"type", + x: 30, //"x", + y: 31, //"y", + z: 32, //"z", + xrot: 40, //"xrot", + yrot: 41, //"yrot", + zrot: 42, //"zrot", + cr: 50, //"cr", + nSubPoints: 3, + subpoints: [60, 61, 62], //"subpoint-1", "subpoint-2", "subpoint-3"], + }); + }); + test("it returns the correct AgentData object for the second agent based on updated offset", () => { + const twoAgentTestData = [ + 10, //"visType" (agent 1) + 15, //"instanceId" (agent 1) + 20, //"type" (agent 1) + 30, //"x" (agent 1) + 31, //"y" (agent 1) + 32, //"z" (agent 1) + 40, //"xrot" (agent 1) + 41, //"yrot" (agent 1) + 42, //"zrot" (agent 1) + 50, //"cr" (agent 1) + 3, // "nSubPoints" (agent 1) + 60, //"subpoint-1" (agent 1) + 61, //"subpoint-2" (agent 1) + 62, //"subpoint-3" (agent 1) + + 11, //"visType" (agent 2) + 16, //"instanceId" (agent 2) + 21, //"type" (agent 2) + 33, //"x" (agent 2) + 34, //"y" (agent 2) + 35, //"z" (agent 2) + 43, //"xrot" (agent 2) + 44, //"yrot" (agent 2) + 45, //"zrot" (agent 2) + 51, //"cr" (agent 2) + 2, // "nSubPoints" (agent 2) + 63, //"subpoint-1" (agent 2) + 64, //"subpoint-2" (agent 2) + ]; + const view = new Float32Array(twoAgentTestData); + + // Get the offset for the second agent + const firstAgentOffset = 0; + const secondAgentOffset = getNextAgentOffset( + view, + firstAgentOffset + ); + + // Parse the second agent data + const secondAgentData = getAgentDataFromBuffer( + view, + secondAgentOffset + ); + + // Check that the second agent's data is parsed correctly + expect(secondAgentData).toEqual({ + visType: 11, //"visType" + instanceId: 16, //"instanceId" + type: 21, //"type" + x: 33, //"x" + y: 34, //"y" + z: 35, //"z" + xrot: 43, //"xrot" + yrot: 44, //"yrot" + zrot: 45, //"zrot" + cr: 51, //"cr" + nSubPoints: 2, + subpoints: [63, 64], //"subpoint-1", "subpoint-2" + }); + }); + test("it throws an error if the data doesn't have the right shape", () => { + // Test with not enough data for the agent object keys + const invalidTestData = [ + 10, + 15, + 20, + 30, + 31, + 32, + 40, + 41, // Missing other values like zrot, cr, nSubPoints, etc. + ]; + const view = new Float32Array(invalidTestData); + + // Expect the function to throw an error when trying to parse this invalid data + expect(() => getAgentDataFromBuffer(view, 0)).toThrow( + FrontEndError + ); + }); + + test("it throws an error if the subpoints exceed available data", () => { + const incompleteSubpointsTestData = [ + 10, //"visType", + 15, //"instanceId", + 20, //"type", + 30, //"x", + 31, //"y", + 32, //"z", + 40, //"xrot", + 41, //"yrot", + 42, //"zrot", + 50, //"cr", + 3, //"nSubPoints" + 60, //"subpoint-1", + 61, // Missing "subpoint-3" + ]; + const view = new Float32Array(incompleteSubpointsTestData); + + // Expect the function to throw an error because there aren't enough subpoints + expect(() => getAgentDataFromBuffer(view, 0)).toThrow( + FrontEndError + ); + }); + }); + }); }); diff --git a/src/util.ts b/src/util.ts index ac7d0140..acdb612e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,9 @@ import type { ISimulariumFile } from "./simularium/ISimulariumFile"; import JsonFileReader from "./simularium/JsonFileReader"; import BinaryFileReader from "./simularium/BinaryFileReader"; +import { AGENT_OBJECT_KEYS, AgentData, CachedFrame } from "./simularium/types"; +import { nullAgent } from "./constants"; +import { FrontEndError } from "./simularium"; export const compareTimes = ( time1: number, @@ -74,3 +77,55 @@ export function loadSimulariumFile(file: Blob): Promise { } }); } + +export const nullCachedFrame = (): CachedFrame => { + return { + data: new ArrayBuffer(0), + frameNumber: -1, + time: -1, + agentCount: -1, + size: -1, + }; +}; + +export const getAgentDataFromBuffer = ( + view: Float32Array, + offset: number +): AgentData => { + // Check if the buffer has enough data for the AGENT_OBJECT_KEYS + if (offset + AGENT_OBJECT_KEYS.length > view.length) { + throw new FrontEndError( + "Invalid offset: Not enough data in the buffer for agent data." + ); + } + const agentData: AgentData = nullAgent(); + for (let i = 0; i < AGENT_OBJECT_KEYS.length; i++) { + agentData[AGENT_OBJECT_KEYS[i]] = view[offset + i]; + } + const nSubPoints = agentData["nSubPoints"]; + + // Check if the buffer has enough data for subpoints + const subpointsStart = offset + AGENT_OBJECT_KEYS.length; + const subpointsEnd = subpointsStart + nSubPoints; + + if (subpointsEnd > view.length) { + throw new FrontEndError( + "Invalid offset: Not enough data in the buffer for subpoints." + ); + } + agentData.subpoints = Array.from( + view.subarray( + offset + AGENT_OBJECT_KEYS.length, + offset + AGENT_OBJECT_KEYS.length + nSubPoints + ) + ); + return agentData; +}; + +export const getNextAgentOffset = ( + view: Float32Array, + currentOffset: number +): number => { + const nSubPoints = view[currentOffset + AGENT_OBJECT_KEYS.length - 1]; + return currentOffset + AGENT_OBJECT_KEYS.length + nSubPoints; +}; diff --git a/src/viewport/index.tsx b/src/viewport/index.tsx index a6904518..e8c99a97 100644 --- a/src/viewport/index.tsx +++ b/src/viewport/index.tsx @@ -47,6 +47,7 @@ type ViewportProps = { onRecordedMovie?: (blob: Blob) => void; // providing this callback enables movie recording disableCache?: boolean; onFollowObjectChanged?: (agentData: AgentData) => void; // passes agent data about the followed agent to the front end + maxCacheSize?: number; } & Partial; const defaultProps = { @@ -126,10 +127,14 @@ class Viewport extends React.Component< this.handleTimeChange = this.handleTimeChange.bind(this); this.visGeometry = new VisGeometry(loggerLevel); + this.props.simulariumController.visData.frameCache.changeSettings({ + cacheEnabled: !props.disableCache, + maxSize: props.maxCacheSize, + }); + if (props.onError) { + this.props.simulariumController.visData.setOnError(props.onError); + } this.props.simulariumController.visData.clearCache(); - this.props.simulariumController.visData.setCacheEnabled( - !this.props.disableCache - ); this.visGeometry.createMaterials(props.agentColors); this.vdomRef = React.createRef(); this.lastRenderTime = Date.now(); @@ -176,7 +181,6 @@ class Viewport extends React.Component< onError, agentColors, } = this.props; - // Update TrajectoryFileInfo format to latest version const trajectoryFileInfo: TrajectoryFileInfo = updateTrajectoryFileInfoFormat(msg, onError); @@ -378,9 +382,9 @@ class Viewport extends React.Component< this.visGeometry.toggleControls(lockedCamera); } if (prevProps.disableCache !== disableCache) { - this.props.simulariumController.visData.setCacheEnabled( - !disableCache - ); + this.props.simulariumController.visData.frameCache.changeSettings({ + cacheEnabled: !disableCache, + }); } if (prevState.showRenderParamsGUI !== this.state.showRenderParamsGUI) { if (this.state.showRenderParamsGUI) { @@ -600,8 +604,8 @@ class Viewport extends React.Component< const changes: ColorAssignment[] = []; appliedColors.forEach((agent) => { const agentIds = this.selectionInterface.getAgentIdsByNamesAndTags([ - { name: agent.name, tags: [] }, - ]); + { name: agent.name, tags: [] }, + ]); changes.push({ agentIds, color: agent.color }); agent.displayStates.forEach((state) => { const stateIds = @@ -640,13 +644,15 @@ class Viewport extends React.Component< return; } - - if (visData.currentFrameData.time != this.lastRenderedAgentTime) { - const currentAgents = visData.currentFrame(); - if (currentAgents.length > 0) { - this.dispatchUpdatedTime(visData.currentFrameData); - this.visGeometry.update(currentAgents); - this.lastRenderedAgentTime = visData.currentFrameData.time; + const currentFrame = visData.currentFrameData; + if (currentFrame.time != this.lastRenderedAgentTime) { + if (currentFrame.agentCount > 0) { + this.dispatchUpdatedTime({ + time: currentFrame.time, + frameNumber: currentFrame.frameNumber, + }); + this.visGeometry.update(currentFrame); + this.lastRenderedAgentTime = currentFrame.time; this.updateFollowObjectData(); } } diff --git a/src/visGeometry/VisAgent.ts b/src/visGeometry/VisAgent.ts index a6f021e8..efe0aac0 100644 --- a/src/visGeometry/VisAgent.ts +++ b/src/visGeometry/VisAgent.ts @@ -47,6 +47,13 @@ export default class VisAgent { this.fiberCurve = undefined; } + public resetAgent(): void { + this.active = false; + this.hidden = false; + this.followed = false; + this.fiberCurve = undefined; + } + public resetMesh(): void { this.followed = false; this.highlighted = false; diff --git a/src/visGeometry/index.ts b/src/visGeometry/index.ts index 0b1c8e1f..f8c93b03 100644 --- a/src/visGeometry/index.ts +++ b/src/visGeometry/index.ts @@ -43,10 +43,12 @@ import { DEFAULT_CAMERA_Z_POSITION, DEFAULT_CAMERA_SPEC, nullAgent, + AGENT_HEADER_SIZE, } from "../constants"; import { AgentData, AgentDisplayDataWithGeometry, + CachedFrame, CameraSpec, Coordinates3d, EncodedTypeMapping, @@ -66,7 +68,12 @@ import { MeshLoadRequest, PDBGeometry, } from "./types"; -import { checkAndSanitizePath } from "../util"; +import { + checkAndSanitizePath, + getAgentDataFromBuffer, + getNextAgentOffset, + nullCachedFrame, +} from "../util"; import ColorHandler from "./ColorHandler"; const MAX_PATH_LEN = 32; @@ -120,6 +127,7 @@ class VisGeometry { public followObjectId: number; public visAgents: VisAgent[]; public visAgentInstances: Map; + private availableAgentPool: VisAgent[] = []; public fixLightsToCamera: boolean; public highlightedIds: number[]; public hiddenIds: number[]; @@ -147,7 +155,7 @@ class VisGeometry { public colorHandler: ColorHandler; public renderer: SimulariumRenderer; public legacyRenderer: LegacyRenderer; - public currentSceneAgents: AgentData[]; + public currentSceneData: CachedFrame; public colorsData: Float32Array; public lightsGroup: Group; public agentPathGroup: Group; @@ -189,6 +197,7 @@ class VisGeometry { this.followObjectId = NO_AGENT; this.visAgents = []; this.visAgentInstances = new Map(); + this.availableAgentPool = []; this.fixLightsToCamera = true; this.highlightedIds = []; this.hiddenIds = []; @@ -262,7 +271,7 @@ class VisGeometry { this.tickIntervalLength = 0; this.boxNearZ = 0; this.boxFarZ = 100; - this.currentSceneAgents = []; + this.currentSceneData = nullCachedFrame(); this.colorsData = new Float32Array(0); this.lodBias = 0; this.lodDistanceStops = [100, 200, 400, Number.MAX_VALUE]; @@ -460,19 +469,19 @@ class VisGeometry { .addInput(settings, "lodBias", { min: 0, max: 4, step: 1 }) .on("change", (event) => { this.lodBias = event.value; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); }); lodFolder.addInput(settings, "lod0").on("change", (event) => { this.lodDistanceStops[0] = event.value; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); }); lodFolder.addInput(settings, "lod1").on("change", (event) => { this.lodDistanceStops[1] = event.value; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); }); lodFolder.addInput(settings, "lod2").on("change", (event) => { this.lodDistanceStops[2] = event.value; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); }); this.renderer.setupGui(this.gui); } @@ -502,7 +511,7 @@ class VisGeometry { this.constructInstancedFibers(); } - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } private constructInstancedFibers() { @@ -691,7 +700,7 @@ class VisGeometry { visAgent.setFollowed(true); } } - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } public unfollow(): void { @@ -700,12 +709,12 @@ class VisGeometry { public setVisibleByIds(hiddenIds: number[]): void { this.hiddenIds = hiddenIds; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } public setHighlightByIds(ids: number[]): void { this.highlightedIds = ids; - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } public dehighlight(): void { @@ -746,7 +755,7 @@ class VisGeometry { } } - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } private setupControls(disableControls: boolean): void { @@ -1143,7 +1152,7 @@ class VisGeometry { newColorData.colorArray ); }); - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } /** @@ -1219,7 +1228,7 @@ class VisGeometry { this.logger.info(errorMessage); } }); - this.updateScene(this.currentSceneAgents); + this.updateScene(this.currentSceneData); } public setTickIntervalLength(axisLength: number): void { @@ -1531,8 +1540,10 @@ class VisGeometry { /** * Update Scene **/ - private updateScene(agents: AgentData[]): void { - this.currentSceneAgents = agents; + private updateScene(frameData: CachedFrame): void { + this.currentSceneData = frameData; + const view = new Float32Array(frameData.data); + const agentCount = frameData.agentCount; // values for updating agent path let dx = 0, @@ -1543,51 +1554,49 @@ class VisGeometry { lastz = 0; this.legacyRenderer.beginUpdate(this.scene); - this.fibers.beginUpdate(); this.geometryStore.forEachMesh((agentGeo) => { agentGeo.geometry.instances.beginUpdate(); }); - // these lists must be emptied on every scene update. + + // Clear draw lists this.agentsWithPdbsToDraw = []; this.agentPdbsToDraw = []; - // First, mark ALL inactive and invisible. - // Note this implies a memory leak of sorts: - // the number of agent instances can only grow during one trajectory run. - // We just hide the unused ones. - // Worst case is if each frame uses completely different (incrementing) instance ids. - for (let i = 0; i < MAX_MESHES && i < this.visAgents.length; i += 1) { - const visAgent = this.visAgents[i]; - visAgent.hideAndDeactivate(); + // Mark all agents as inactive and invisible + for (let i = 0; i < MAX_MESHES && i < this.visAgents.length; i++) { + this.visAgents[i].hideAndDeactivate(); } - agents.forEach((agentData) => { + let offset = AGENT_HEADER_SIZE; + const newVisAgentInstances = new Map(); + for (let i = 0; i < agentCount; i++) { + const agentData = getAgentDataFromBuffer(view, offset); const visType = agentData.visType; const instanceId = agentData.instanceId; const typeId = agentData.type; + lastx = agentData.x; lasty = agentData.y; lastz = agentData.z; - // look up last agent with this instanceId. let visAgent = this.visAgentInstances.get(instanceId); const path = this.findPathForAgent(instanceId); - if (path) { - if (visAgent) { - lastx = visAgent.agentData.x; - lasty = visAgent.agentData.y; - lastz = visAgent.agentData.z; - } + if (path && visAgent) { + lastx = visAgent.agentData.x; + lasty = visAgent.agentData.y; + lastz = visAgent.agentData.z; } if (!visAgent) { - visAgent = this.createAgent(); + if (this.availableAgentPool.length > 0) { + visAgent = this.availableAgentPool.pop() as VisAgent; + } else { + visAgent = this.createAgent(); + } visAgent.agentData.instanceId = instanceId; - //visAgent.mesh.userData = { id: instanceId }; this.visAgentInstances.set(instanceId, visAgent); - // set hidden so that it is revealed later in this function: visAgent.hidden = true; } @@ -1598,7 +1607,6 @@ class VisGeometry { } visAgent.active = true; - // update the agent! visAgent.agentData = agentData; @@ -1610,13 +1618,13 @@ class VisGeometry { const isHidden = this.hiddenIds.includes(visAgent.agentData.type); visAgent.setHidden(isHidden); if (visAgent.hidden) { - return; + offset = getNextAgentOffset(view, offset); + continue; } visAgent.setColor( this.colorHandler.getColorInfoForAgentType(typeId) ); - // if not fiber... if (visType === VisTypes.ID_VIS_TYPE_DEFAULT) { const response = this.getGeoForAgentType(typeId); @@ -1624,7 +1632,8 @@ class VisGeometry { this.logger.warn( `No mesh nor pdb available for ${typeId}? Should be unreachable code` ); - return; + offset = getNextAgentOffset(view, offset); + continue; } const { geometry, displayType } = response; if (geometry && displayType === GeometryDisplayType.PDB) { @@ -1659,7 +1668,16 @@ class VisGeometry { } else if (visType === VisTypes.ID_VIS_TYPE_FIBER) { this.addFiberToDrawList(typeId, visAgent, agentData); } - }); + newVisAgentInstances.set(instanceId, visAgent); + offset = getNextAgentOffset(view, offset); + } + for (const [key, visAgent] of this.visAgentInstances) { + if (!newVisAgentInstances.has(key)) { + visAgent.resetAgent(); + this.availableAgentPool.push(visAgent); + } + } + this.visAgentInstances = newVisAgentInstances; this.fibers.endUpdate(); this.geometryStore.forEachMesh((agentGeo) => { @@ -1876,7 +1894,7 @@ class VisGeometry { // remove current scene agents. this.visAgentInstances.clear(); this.visAgents = []; - this.currentSceneAgents = []; + this.currentSceneData = nullCachedFrame(); this.dehighlight(); } @@ -1905,7 +1923,7 @@ class VisGeometry { } } - public update(agents: AgentData[]): void { + public update(agents: CachedFrame): void { this.updateScene(agents); } } diff --git a/src/visGeometry/workers/visDataWorker.ts b/src/visGeometry/workers/visDataWorker.ts index f4d94c4b..a594a03d 100644 --- a/src/visGeometry/workers/visDataWorker.ts +++ b/src/visGeometry/workers/visDataWorker.ts @@ -1,16 +1,7 @@ -import { parseVisDataMessage } from "../../simularium/VisDataParse"; - self.addEventListener( "message", (e: MessageEvent) => { - const visDataMsg = e.data; - const { frameDataArray, parsedAgentDataArray } = - parseVisDataMessage(visDataMsg); - - postMessage({ - frameDataArray, - parsedAgentDataArray, - }); + postMessage(e.data); }, false );