UNPKG

@anvaka/streamlines

Version:
213 lines (180 loc) 6.31 kB
/** * Computes streamlines of a vector field based on http://web.cs.ucdavis.edu/~ma/SIGGRAPH02/course23/notes/papers/Jobard.pdf */ import Vector from './lib/Vector.js'; import createLookupGrid from './lib/createLookupGrid.js'; import createStreamlineIntegrator from './lib/streamLineIntegrator.js'; import renderTo from './lib/renderTo.js'; export { renderTo }; export default computeStreamlines; var STATE_INIT = 0; var STATE_STREAMLINE = 1; var STATE_PROCESS_QUEUE = 2; var STATE_DONE = 3; var STATE_SEED_STREAMLINE = 4; // Cross-environment performance timer (works in node and browser) const __perf = (typeof globalThis !== 'undefined' && globalThis.performance && typeof globalThis.performance.now === 'function') ? globalThis.performance : { now: () => Date.now() }; function computeStreamlines(protoOptions) { var options = Object.create(null); if (!protoOptions) throw new Error('Configuration is required to compute streamlines'); if (!protoOptions.boundingBox) { console.warn('No bounding box passed to streamline. Creating default one'); options.boundingBox = { left: -5, top: -5, width: 10, height: 10 }; } else { options.boundingBox = {}; Object.assign(options.boundingBox, protoOptions.boundingBox); } normalizeBoundingBox(options.boundingBox); var boundingBox = options.boundingBox; options.vectorField = protoOptions.vectorField; options.onStreamlineAdded = protoOptions.onStreamlineAdded; options.onPointAdded = protoOptions.onPointAdded; options.forwardOnly = protoOptions.forwardOnly; if (!protoOptions.seed) { options.seed = new Vector( Math.random() * boundingBox.width + boundingBox.left, Math.random() * boundingBox.height + boundingBox.top ); } else if (Array.isArray(protoOptions.seed)) { var seed = protoOptions.seed.shift(); options.seed = new Vector(seed.x, seed.y); options.seedArray = protoOptions.seed; } else { options.seed = new Vector(protoOptions.seed.x, protoOptions.seed.y); } // Separation between streamlines. Naming according to the paper. options.dSep = protoOptions.dSep > 0 ? protoOptions.dSep : 1 / Math.max(boundingBox.width, boundingBox.height); // When should we stop integrating a streamline. options.dTest = protoOptions.dTest > 0 ? protoOptions.dTest : options.dSep * 0.5; // Lookup grid helps to quickly tell if there are points nearby var grid = createLookupGrid(boundingBox, options.dSep); // Integration time step. options.timeStep = protoOptions.timeStep > 0 ? protoOptions.timeStep : 0.01; options.stepsPerIteration = protoOptions.stepsPerIteration > 0 ? protoOptions.stepsPerIteration : 10; options.maxTimePerIteration = protoOptions.maxTimePerIteration > 0 ? protoOptions.maxTimePerIteration : 1000; var stepsPerIteration = options.stepsPerIteration; var resolve; var state = STATE_INIT; var finishedStreamlineIntegrators = []; var streamlineIntegrator = createStreamlineIntegrator( options.seed, grid, options ); var disposed = false; var running = false; var nextTimeout; // It is asynchronous. If this is used in a browser we don't want to freeze the UI thread. // On the other hand, if you need this to be sync - we can extend the API. Just let me know. return { run: run, getGrid: getGrid, dispose: dispose }; function getGrid() { return grid; } function run() { if (running) return; running = true; nextTimeout = setTimeout(nextStep, 0); return new Promise(assignResolve); } function assignResolve(pResolve) { resolve = pResolve; } function dispose() { disposed = true; clearTimeout(nextTimeout); } function nextStep() { if (disposed) return; var maxTimePerIteration = options.maxTimePerIteration; var start = __perf.now(); for (var i = 0; i < stepsPerIteration; ++i) { if (state === STATE_INIT) initProcessing(); if (state === STATE_STREAMLINE) continueStreamline(); if (state === STATE_PROCESS_QUEUE) processQueue(); if (state === STATE_SEED_STREAMLINE) seedStreamline(); if (__perf.now() - start > maxTimePerIteration) break; if (state === STATE_DONE) { resolve(options); return; } } nextTimeout = setTimeout(nextStep, 0); } function initProcessing() { var streamLineCompleted = streamlineIntegrator.next(); if (streamLineCompleted) { addStreamLineToQueue(); state = STATE_PROCESS_QUEUE; } } function seedStreamline() { var currentStreamLine = finishedStreamlineIntegrators[0]; var validCandidate = currentStreamLine.getNextValidSeed(); if (validCandidate) { streamlineIntegrator = createStreamlineIntegrator( validCandidate, grid, options ); state = STATE_STREAMLINE; } else { finishedStreamlineIntegrators.shift(); state = STATE_PROCESS_QUEUE; } } function processQueue() { if (finishedStreamlineIntegrators.length === 0) { state = STATE_DONE; } else { state = STATE_SEED_STREAMLINE; } } function continueStreamline() { var isDone = streamlineIntegrator.next(); if (isDone) { addStreamLineToQueue(); state = STATE_SEED_STREAMLINE; } } function addStreamLineToQueue() { var streamLinePoints = streamlineIntegrator.getStreamline(); if (streamLinePoints.length > 1) { finishedStreamlineIntegrators.push(streamlineIntegrator); if (options.onStreamlineAdded) options.onStreamlineAdded(streamLinePoints, options); } } } function normalizeBoundingBox(bbox) { var requiredBoxMessage = 'Bounding box {left, top, width, height} is required'; if (!bbox) throw new Error(requiredBoxMessage); assertNumber(bbox.left, requiredBoxMessage); assertNumber(bbox.top, requiredBoxMessage); if (typeof bbox.size === 'number') { bbox.width = bbox.size; bbox.height = bbox.size; } assertNumber(bbox.width, requiredBoxMessage); assertNumber(bbox.height, requiredBoxMessage); if (bbox.width <= 0 || bbox.height <= 0) throw new Error('Bounding box cannot be empty'); } function assertNumber(x, msg) { if (typeof x !== 'number' || Number.isNaN(x)) throw new Error(msg); }