UNPKG

@chinhui/niivue

Version:

minimal webgl2 nifti image viewer

1,472 lines (1,378 loc) 141 kB
import { Shader } from "./shader.js"; import * as mat from "gl-matrix"; import { vertSliceShader, fragSliceShader } from "./shader-srcs.js"; import { vertLineShader, fragLineShader } from "./shader-srcs.js"; import { vertRenderShader, fragRenderShader } from "./shader-srcs.js"; import { vertColorbarShader, fragColorbarShader } from "./shader-srcs.js"; import { vertFontShader, fragFontShader, vertBmpShader, fragBmpShader, } from "./shader-srcs.js"; import { vertOrientShader, vertPassThroughShader, fragPassThroughShader, fragOrientShaderU, fragOrientShaderI, fragOrientShaderF, fragOrientShader, fragOrientShaderAtlas, fragRGBOrientShader, fragOrientShaderLookuptable, vertMeshShader, fragMeshShader, fragMeshToonShader, fragMeshOutlineShader, fragMeshHemiShader, fragMeshMatteShader, fragMeshShaderSHBlue, vertFiberShader, fragFiberShader, vertSurfaceShader, fragSurfaceShader, fragDepthPickingShader, fragVolumePickingShader, } from "./shader-srcs.js"; import { Subject } from "rxjs"; import { NiivueObject3D } from "./niivue-object3D.js"; import { NiivueShader3D } from "./niivue-shader3D"; import { NVImage } from "./nvimage.js"; import { NVMesh } from "./nvmesh.js"; export { NVMesh } from "./nvmesh.js"; export { NVImage } from "./nvimage"; import { Log } from "./logger"; import defaultFontPNG from "./fonts/Roboto-Regular.png"; import defaultFontMetrics from "./fonts/Roboto-Regular.json"; import { colortables } from "./colortables"; const log = new Log(); const cmapper = new colortables(); /** * @typedef {Object} NiivueOptions * @property {number} [options.textHeight=0.3] the text height for orientation labels (0 to 1). Zero for no text labels * @property {number} [options.colorbarHeight=0.05] size of colorbar. 0 for no colorbars, fraction of Nifti j dimension * @property {number} [options.colorBarMargin=0.05] padding around colorbar when displayed * @property {number} [options.crosshairWidth=1] crosshair size. Zero for no crosshair * @property {array} [options.backColor=[0,0,0,1]] the background color. RGBA values from 0 to 1. Default is black * @property {array} [options.crosshairColor=[1,0,0,1]] the crosshair color. RGBA values from 0 to 1. Default is red * @property {array} [options.selectionBoxColor=[1,1,1,0.5]] the selection box color when the intensty selection box is shown (right click and drag). RGBA values from 0 to 1. Default is transparent white * @property {array} [options.clipPlaneColor=[1,1,1,0.5]] the color of the visible clip plane. RGBA values from 0 to 1. Default is white * @property {boolean} [options.trustCalMinMax=true] true/false whether to trust the nifti header values for cal_min and cal_max. Trusting them results in faster loading because we skip computing these values from the data * @property {string} [options.clipPlaneHotKey="KeyC"] the keyboard key used to cycle through clip plane orientations. The default is "c" * @property {string} [options.viewModeHotKey="KeyV"] the keyboard key used to cycle through view modes. The default is "v" * @property {number} [options.keyDebounceTime=50] the keyUp debounce time in milliseconds. The default is 50 ms. You must wait this long before a new hot-key keystroke will be registered by the event listener * @property {boolean} [options.isRadiologicalConvention=false] whether or not to use radiological convention in the display * @property {string} [options.logging=false] turn on logging or not (true/false) * @property {string} [options.loadingText="waiting on images..."] the loading text to display when there is a blank canvas and no images * @property {boolean} [options.dragAndDropEnabled=true] whether or not to allow file and url drag and drop on the canvas */ /** * @class Niivue * @type Niivue * @description * Niivue can be attached to a canvas. An instance of Niivue contains methods for * loading and rendering NIFTI image data in a WebGL 2.0 context. * @constructor * @param {NiivueOptions} [options={}] options object to set modifiable Niivue properties * @example * let niivue = new Niivue({crosshairColor: [0,1,0,0.5], textHeight: 0.5}) // a see-through green crosshair, and larger text labels */ export function Niivue(options = {}) { this.opts = {}; // will be populate with opts or defaults when a new Niivue object instance is created this.defaults = { textHeight: 0.06, // 0 for no text, fraction of canvas min(height,width) colorbarHeight: 0.05, // 0 for no colorbars, fraction of Nifti j dimension crosshairWidth: 1, // 0 for no crosshairs show3Dcrosshair: false, backColor: [0, 0, 0, 1], crosshairColor: [1, 0, 0, 1], selectionBoxColor: [1, 1, 1, 0.5], clipPlaneColor: [0.7, 0, 0.7, 0.5], colorBarMargin: 0.05, // x axis margin arount color bar, clip space coordinates trustCalMinMax: true, // trustCalMinMax: if true do not calculate cal_min or cal_max if set in image header. If false, always calculate display intensity range. clipPlaneHotKey: "KeyC", // keyboard short cut to activate the clip plane viewModeHotKey: "KeyV", // keyboard shortcut to switch view modes keyDebounceTime: 50, // default debounce time used in keyup listeners isNearestInterpolation: false, isAtlasOutline: false, isRadiologicalConvention: false, logging: false, loadingText: "waiting for images...", dragAndDropEnabled: true, drawingEnabled: false, // drawing disabled by default penValue: 1, // sets drawing color. see "drawPt" isFilledPen: false, thumbnail: "", }; this.canvas = null; // the canvas element on the page this.gl = null; // the gl context this.colormapTexture = null; this.volumeTexture = null; this.drawTexture = null; //the GPU memory storage of the drawing this.drawBitmap = null; //the CPU memory storage of the drawing this.drawOpacity = 0.8; this.drawPenLocation = [NaN, NaN, NaN]; this.drawPenAxCorSag = -1; //do not allow pen to drag between Sagittal/Coronal/Axial this.drawPenFillPts = []; //store mouse points for filled pen this.overlayTexture = null; this.overlayTextureID = []; this.sliceShader = null; this.lineShader = null; this.renderShader = null; this.pickingShader = null; this.colorbarShader = null; this.fontShader = null; this.bmpShader = null; this.bmpTexture = null; //thumbnail WebGLTexture object this.bmpTextureWH = 1.0; //thumbnail width/height ratio this.passThroughShader = null; this.orientShaderAtlasU = null; this.orientShaderU = null; this.orientShaderI = null; this.orientShaderF = null; this.orientShaderRGBU = null; this.surfaceShader = null; this.meshShader = null; this.genericVAO = null; //used for 2D slices, 2D lines, 2D Fonts this.unusedVAO = null; this.crosshairs3D = null; this.pickingSurfaceShader = null; this.DEFAULT_FONT_GLYPH_SHEET = defaultFontPNG; //"/fonts/Roboto-Regular.png"; this.DEFAULT_FONT_METRICS = defaultFontMetrics; //"/fonts/Roboto-Regular.json"; this.fontMets = null; this.sliceTypeAxial = 0; this.sliceTypeCoronal = 1; this.sliceTypeSagittal = 2; this.sliceTypeMultiplanar = 3; this.sliceTypeRender = 4; this.sliceType = this.sliceTypeMultiplanar; // sets current view in webgl canvas this.scene = {}; this.syncOpts = {}; this.readyForSync = false; this.scene.renderAzimuth = 110; //-45; this.scene.renderElevation = 10; //-165; //15; this.scene.crosshairPos = [0.5, 0.5, 0.5]; this.scene.clipPlane = [0, 0, 0, 0]; this.scene.clipPlaneDepthAziElev = [2, 0, 0]; this.scene.mousedown = false; this.scene.touchdown = false; this.scene.mouseButtonLeft = 0; this.scene.mouseButtonRight = 2; this.scene.mouseButtonLeftDown = false; this.scene.mouseButtonRightDown = false; this.scene.mouseDepthPicker = false; this.scene.prevX = 0; this.scene.prevY = 0; this.scene.currX = 0; this.scene.currY = 0; this.back = {}; // base layer; defines image space to work in. Defined as this.volumes[0] in Niivue.loadVolumes this.overlays = []; // layers added on top of base image (e.g. masks or stat maps). Essentially everything after this.volumes[0] is an overlay. So is this necessary? this.volumes = []; // all loaded images. Can add in the ability to push or slice as needed this.meshes = []; this.furthestVertexFromOrigin = 100; this.volScaleMultiplier = 1.0; this.volScale = []; this.vox = []; this.mousePos = [0, 0]; this.numScreenSlices = 0; // e.g. for multiplanar view, 3 simultaneous slices: axial, coronal, sagittal this.screenSlices = [ //location and type of each 2D slice on screen, allows clicking to detect position { leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial }, { leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial }, { leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial }, { leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial }, ]; this.isDragging = false; this.dragStart = [0.0, 0.0]; this.dragEnd = [0.0, 0.0]; this.dragClipPlaneStartDepthAziElev = [0, 0, 0]; this.lastTwoTouchDistance = 0; this.otherNV = null; // another niivue instance that we wish to sync postion with this.volumeObject3D = null; this.pivot3D = [0, 0, 0]; //center for rendering rotation this.furthestFromPivot = 10.0; //most distant point from pivot this.intensityRange$ = new Subject(); // an array this.scene.location$ = new Subject(); // object with properties: {mm: [N N N], vox: [N N N], frac: [N N N]} this.scene.loading$ = new Subject(); // whether or not the scene is loading this.imageLoaded$ = new Subject(); this.currentClipPlaneIndex = 0; this.lastCalled = new Date().getTime(); this.multiTouchGesture = false; this.gestureInterval = null; this.selectedObjectId = -1; this.CLIP_PLANE_ID = 1; this.VOLUME_ID = 254; this.DISTANCE_FROM_CAMERA = -0.54; this.meshShaders = [ { Name: "Phong", Frag: fragMeshShader, }, { Name: "Matte", Frag: fragMeshMatteShader, }, { Name: "Harmonic", Frag: fragMeshShaderSHBlue, }, { Name: "Hemispheric", Frag: fragMeshHemiShader, }, { Name: "Outline", Frag: fragMeshOutlineShader, }, { Name: "Toon", Frag: fragMeshToonShader, }, ]; this.initialized = false; // loop through known Niivue properties // if the user supplied opts object has a // property listed in the known properties, then set // Niivue.opts.<prop> to that value, else apply defaults. for (let prop in this.defaults) { this.opts[prop] = options[prop] === undefined ? this.defaults[prop] : options[prop]; } if (this.opts.drawingEnabled) { this.createEmptyDrawing(); } this.loadingText = this.opts.loadingText; log.setLogLevel(this.opts.logging); // maping of keys (event strings) to rxjs subjects this.eventsToSubjects = { location: this.scene.location$, loading: this.scene.loading$, imageLoaded: this.imageLoaded$, intensityRange: this.intensityRange$, }; // rxjs subscriptions. Keeping a reference array like this allows us to unsubscribe later this.subscriptions = []; } /** * attach the Niivue instance to the webgl2 canvas by element id * @param {string} id the id of an html canvas element * @example niivue = new Niivue().attachTo('gl') * @example niivue.attachTo('gl') */ Niivue.prototype.attachTo = async function (id) { await this.attachToCanvas(document.getElementById(id)); log.debug("attached to element with id: ", id); return this; }; // attachTo // on handles matching event strings (event) with know rxjs subjects within NiiVue. // if the event string exists (e.g. 'location') then the corrsponding rxjs subject reference // is extracted from this.eventsToSubjects and the callback passed as the second argument to NiiVue.on // is added to the subsciptions to the next method. These callbacks are called whenever subject.next is called within // various NiiVue methods. /** * register a callback function to run when known Niivue events happen * @param {("location")} event the name of the event to watch for. Event names are shown in the type column * @param {function} callback the function to call when the event happens * @example * niivue = new Niivue() * * // 'location' update event is fired when the crosshair changes position via user input * function doSomethingWithLocationData(data){ * // data has the shape {mm: [N, N, N], vox: [N, N, N], frac: [N, N, N], values: this.volumes.map(v => {return val})} * //... * } * niivue.on('location', doSomethingWithLocationData) * niivue.on('intensityRange', callback) * niivue.on('imageLoaded', callback) */ Niivue.prototype.on = function (event, callback) { let knownEvents = Object.keys(this.eventsToSubjects); if (knownEvents.indexOf(event) == -1) { return; } let subject = this.eventsToSubjects[event]; let subscription = subject.subscribe({ next: (data) => callback(data), }); this.subscriptions.push({ [event]: subscription }); }; /** * off unsubscribes events and subjects (the opposite of on) * @param {("location")} event the name of the event to watch for. Event names are shown in the type column * @example * niivue = new Niivue() * niivue.off('location') */ Niivue.prototype.off = function (event) { let knownEvents = Object.keys(this.eventsToSubjects); if (knownEvents.indexOf(event) == -1) { return; } let nsubs = this.subscriptions.length; for (let i = 0; i < nsubs; i++) { let key = Object.keys(this.subscriptions[i])[0]; if (key === event) { this.subscriptions[i][event].unsubscribe(); this.subscriptions.splice(i, 1); return; } } }; /** * attach the Niivue instance to a canvas element directly * @param {object} canvas the canvas element reference * @example * niivue = new Niivue() * niivue.attachToCanvas(document.getElementById(id)) */ Niivue.prototype.attachToCanvas = async function (canvas) { this.canvas = canvas; this.gl = this.canvas.getContext("webgl2"); if (!this.gl) { alert( "unable to get webgl2 context. Perhaps this browser does not support webgl2" ); log.warn( "unable to get webgl2 context. Perhaps this browser does not support webgl2" ); } // set parent background container to black (default empty canvas color) // avoids white cube around image in 3D render mode this.canvas.parentElement.style.backgroundColor = "black"; // fill all space in parent this.canvas.style.width = "100%"; this.canvas.style.height = "100%"; this.canvas.width = this.canvas.offsetWidth; this.canvas.height = this.canvas.offsetHeight; window.addEventListener("resize", this.resizeListener.bind(this)); // must bind 'this' niivue object or else 'this' becomes 'window' this.registerInteractions(); // attach mouse click and touch screen event handlers for the canvas await this.init(); this.drawScene(); return this; }; /** * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another. * @param {object} otherNV the other Niivue instance that is the main controller * @example * niivue1 = new Niivue() * niivue2 = new Niivue() * niivue2.syncWith(niivue1) */ Niivue.prototype.syncWith = function ( otherNV, syncOpts = { "2d": true, "3d": true } ) { // this.scene.renderAzimuth = 120; // this.scene.renderElevation = 15; // this.scene.crosshairPos = [0.5, 0.5, 0.5]; // this.scene.clipPlane = [0, 0, 0, 0]; this.otherNV = otherNV; this.syncOpts = syncOpts; }; Niivue.prototype.sync = function () { if (!this.otherNV || typeof this.otherNV === "undefined") { return; } if (!this.otherNV.readyForSync || !this.readyForSync) { return; } let thisMM = this.frac2mm(this.scene.crosshairPos); if (this.syncOpts["2d"]) { this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM); } if (this.syncOpts["3d"]) { console.log("3d sync"); this.otherNV.scene.renderAzimuth = this.scene.renderAzimuth; this.otherNV.scene.renderElevation = this.scene.renderElevation; } this.otherNV.drawScene(); }; /* Not documented publicly for now * test if two arrays have equal values for each element * @param {Array} a the first array * @param {Array} b the second array * @example Niivue.arrayEquals(a, b) */ Niivue.prototype.arrayEquals = function (a, b) { return ( Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]) ); }; //handle window resizing // note: no test yet Niivue.prototype.resizeListener = function () { this.canvas.style.width = "100%"; this.canvas.style.height = "100%"; this.canvas.width = this.canvas.offsetWidth; this.canvas.height = this.canvas.offsetHeight; this.drawScene(); }; /* Not included in public docs * The following two functions are to address offset issues * https://stackoverflow.com/questions/42309715/how-to-correctly-pass-mouse-coordinates-to-webgl * note: no test yet */ Niivue.prototype.getRelativeMousePosition = function (event, target) { target = target || event.target; var rect = target.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, }; }; // not included in public docs // assumes target or event.target is canvas // note: no test yet Niivue.prototype.getNoPaddingNoBorderCanvasRelativeMousePosition = function ( event, target ) { target = target || event.target; var pos = this.getRelativeMousePosition(event, target); pos.x = (pos.x * target.width) / target.clientWidth; pos.y = (pos.y * target.height) / target.clientHeight; return pos; }; // not included in public docs // handler for context menu (right click) // here, we disable the normal context menu so that // we can use some custom right click events // note: no test yet Niivue.prototype.mouseContextMenuListener = function (e) { e.preventDefault(); }; // not included in public docs // handler for all mouse button presses // note: no test yet Niivue.prototype.mouseDownListener = function (e) { e.preventDefault(); // var rect = this.canvas.getBoundingClientRect(); this.drawPenLocation = [NaN, NaN, NaN]; this.drawPenAxCorSag = -1; this.scene.mousedown = true; if (e.button === this.scene.mouseButtonLeft) { this.scene.mouseButtonLeftDown = true; this.mouseLeftButtonHandler(e); } else if (e.button === this.scene.mouseButtonRight) { this.scene.mouseButtonRightDown = true; this.mouseRightButtonHandler(e); } }; // not included in public docs // handler for mouse left button down // note: no test yet Niivue.prototype.mouseLeftButtonHandler = function (e) { let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition( e, this.gl.canvas ); this.mouseClick(pos.x, pos.y); this.mouseDown(pos.x, pos.y); }; // not included in public docs // handler for mouse right button down // note: no test yet Niivue.prototype.mouseRightButtonHandler = function (e) { this.isDragging = true; let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition( e, this.gl.canvas ); this.dragStart[0] = pos.x; this.dragStart[1] = pos.y; this.dragClipPlaneStartDepthAziElev = this.scene.clipPlaneDepthAziElev; return; }; // not included in public docs Niivue.prototype.calculateMinMaxVoxIdx = function (array) { if (array.length > 2) { throw new Error("array must not contain more than two values"); } return [ Math.floor(Math.min(array[0], array[1])), Math.floor(Math.max(array[0], array[1])), ]; }; // not included in public docs function intensityRaw2Scaled(hdr, raw) { if (hdr.scl_slope === 0) hdr.scl_slope = 1.0; return raw * hdr.scl_slope + hdr.scl_inter; } // not included in public docs // note: no test yet Niivue.prototype.calculateNewRange = function (volIdx = 0) { if (this.sliceType === this.sliceTypeRender) { return; } if ( this.dragStart[0] === this.dragEnd[0] && this.dragStart[1] === this.dragEnd[1] ) return; // calculate our box let frac = this.canvasPos2frac([this.dragStart[0], this.dragStart[1]]); let startVox = this.frac2vox(frac, volIdx); frac = this.canvasPos2frac([this.dragEnd[0], this.dragEnd[1]]); let endVox = this.frac2vox(frac, volIdx); let hi = -Number.MAX_VALUE, lo = Number.MAX_VALUE; let xrange; let yrange; let zrange; xrange = this.calculateMinMaxVoxIdx([startVox[0], endVox[0]]); yrange = this.calculateMinMaxVoxIdx([startVox[1], endVox[1]]); zrange = this.calculateMinMaxVoxIdx([startVox[2], endVox[2]]); // for our constant dimension we add one so that the for loop runs at least once if (startVox[0] - endVox[0] === 0) { xrange[1] = startVox[0] + 1; } else if (startVox[1] - endVox[1] === 0) { yrange[1] = startVox[1] + 1; } else if (startVox[2] - endVox[2] === 0) { zrange[1] = startVox[2] + 1; } const hdr = this.volumes[volIdx].hdr; const img = this.volumes[volIdx].img; const xdim = hdr.dims[1]; const ydim = hdr.dims[2]; for (let z = zrange[0]; z < zrange[1]; z++) { let zi = z * xdim * ydim; for (let y = yrange[0]; y < yrange[1]; y++) { let yi = y * xdim; for (let x = xrange[0]; x < xrange[1]; x++) { let index = zi + yi + x; if (lo > img[index]) { lo = img[index]; } if (hi < img[index]) { hi = img[index]; } } } } if (lo >= hi) return; //no variability or outside volume var mnScale = intensityRaw2Scaled(hdr, lo); var mxScale = intensityRaw2Scaled(hdr, hi); this.volumes[volIdx].cal_min = mnScale; this.volumes[volIdx].cal_max = mxScale; this.intensityRange$.next(this.volumes[volIdx]); //reference to volume to access cal_min and cal_max }; // not included in public docs // handler for mouse button up (all buttons) // note: no test yet Niivue.prototype.mouseUpListener = function () { this.scene.mousedown = false; this.scene.mouseButtonRightDown = false; this.scene.mouseButtonLeftDown = false; if (this.drawPenFillPts.length > 0) this.drawPenFilled(); this.drawPenLocation = [NaN, NaN, NaN]; this.drawPenAxCorSag = -1; if (this.isDragging) { this.isDragging = false; this.calculateNewRange(); this.refreshLayers(this.volumes[0], 0, this.volumes.length); } this.drawScene(); }; // not included in public docs Niivue.prototype.checkMultitouch = function (e) { if (this.scene.touchdown && !this.multiTouchGesture) { var rect = this.canvas.getBoundingClientRect(); this.mouseClick( e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); this.mouseDown( e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); } }; // not included in public docs // handler for single finger touch event (like mouse down) // note: no test yet Niivue.prototype.touchStartListener = function (e) { e.preventDefault(); this.scene.touchdown = true; if (this.scene.touchdown && e.touches.length < 2) { this.multiTouchGesture = false; } else { this.multiTouchGesture = true; } setTimeout(this.checkMultitouch.bind(this), 1, e); }; // not included in public docs // handler for touchend (finger lift off screen) // note: no test yet Niivue.prototype.touchEndListener = function () { this.scene.touchdown = false; this.lastTwoTouchDistance = 0; this.multiTouchGesture = false; }; // not included in public docs // handler for mouse move over canvas // note: no test yet Niivue.prototype.mouseMoveListener = function (e) { // move crosshair and change slices if mouse click and move if (this.scene.mousedown) { let pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition( e, this.gl.canvas ); if (this.scene.mouseButtonLeftDown) { this.mouseClick(pos.x, pos.y); this.mouseMove(pos.x, pos.y); } else if (this.scene.mouseButtonRightDown) { this.dragEnd[0] = pos.x; this.dragEnd[1] = pos.y; } this.drawScene(); this.scene.prevX = this.scene.currX; this.scene.prevY = this.scene.currY; } }; // not included in public docs // note: should update this to accept a volume index to reset a selected volume rather than only the background (TODO) // reset brightness and contrast to global min and max // note: no test yet Niivue.prototype.resetBriCon = function () { //this.volumes[0].cal_min = this.volumes[0].global_min; //this.volumes[0].cal_max = this.volumes[0].global_max; // don't reset bri/con if the user is in 3D mode and double clicks if (this.sliceType === this.sliceTypeRender) { this.scene.mouseDepthPicker = true; this.drawScene(); return; } this.volumes[0].cal_min = this.volumes[0].robust_min; this.volumes[0].cal_max = this.volumes[0].robust_max; this.intensityRange$.next(this.volumes[0]); this.refreshLayers(this.volumes[0], 0, this.volumes.length); this.drawScene(); }; // not included in public docs // handler for touch move (moving finger on screen) // note: no test yet Niivue.prototype.touchMoveListener = function (e) { if (this.scene.touchdown && e.touches.length < 2) { var rect = this.canvas.getBoundingClientRect(); this.mouseClick( e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); this.mouseMove( e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); } else { // Check this event for 2-touch Move/Pinch/Zoom gesture this.handlePinchZoom(e); } }; // not included in public docs Niivue.prototype.handlePinchZoom = function (e) { if (e.targetTouches.length == 2 && e.changedTouches.length == 2) { var dist = Math.hypot( e.touches[0].pageX - e.touches[1].pageX, e.touches[0].pageY - e.touches[1].pageY ); var rect = this.canvas.getBoundingClientRect(); this.mousePos = [ e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top, ]; // scroll 2D slices if (dist < this.lastTwoTouchDistance) { // this.volScaleMultiplier = Math.max(0.5, this.volScaleMultiplier * 0.95); this.sliceScroll2D( -0.01, e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); } else { // this.volScaleMultiplier = Math.min(2.0, this.volScaleMultiplier * 1.05); this.sliceScroll2D( 0.01, e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top ); } // this.drawScene(); this.lastTwoTouchDistance = dist; } }; // not included in public docs // handler for keyboard shortcuts Niivue.prototype.keyUpListener = function (e) { if (e.code === this.opts.clipPlaneHotKey) { if (this.sliceType != this.sliceTypeRender) { return; } let now = new Date().getTime(); let elapsed = now - this.lastCalled; if (elapsed > this.opts.keyDebounceTime) { this.currentClipPlaneIndex = (this.currentClipPlaneIndex + 1) % 7; switch (this.currentClipPlaneIndex) { case 0: //NONE this.scene.clipPlaneDepthAziElev = [2, 0, 0]; break; case 1: //left a 270 e 0 //this.scene.clipPlane = [1, 0, 0, 0]; this.scene.clipPlaneDepthAziElev = [0, 270, 0]; break; case 2: //right a 90 e 0 this.scene.clipPlaneDepthAziElev = [0, 90, 0]; break; case 3: //posterior a 0 e 0 this.scene.clipPlaneDepthAziElev = [0, 0, 0]; break; case 4: //anterior a 0 e 0 this.scene.clipPlaneDepthAziElev = [0, 180, 0]; break; case 5: //inferior a 0 e -90 this.scene.clipPlaneDepthAziElev = [0, 0, -90]; break; case 6: //superior: a 0 e 90' this.scene.clipPlaneDepthAziElev = [0, 0, 90]; break; } this.setClipPlane(this.scene.clipPlaneDepthAziElev); // e.preventDefault(); } this.lastCalled = now; } else if (e.code === this.opts.viewModeHotKey) { let now = new Date().getTime(); let elapsed = now - this.lastCalled; if (elapsed > this.opts.keyDebounceTime) { this.setSliceType((this.sliceType + 1) % 5); // 5 total slice types this.lastCalled = now; } } }; Niivue.prototype.keyDownListener = function (e) { if (e.code === "KeyH" && this.sliceType === this.sliceTypeRender) { this.setRenderAzimuthElevation( this.scene.renderAzimuth - 1, this.scene.renderElevation ); } else if (e.code === "KeyL" && this.sliceType === this.sliceTypeRender) { this.setRenderAzimuthElevation( this.scene.renderAzimuth + 1, this.scene.renderElevation ); } else if (e.code === "KeyJ" && this.sliceType === this.sliceTypeRender) { this.setRenderAzimuthElevation( this.scene.renderAzimuth, this.scene.renderElevation + 1 ); } else if (e.code === "KeyK" && this.sliceType === this.sliceTypeRender) { this.setRenderAzimuthElevation( this.scene.renderAzimuth, this.scene.renderElevation - 1 ); } else if (e.code === "KeyH" && this.sliceType !== this.sliceTypeRender) { this.scene.crosshairPos[0] = this.scene.crosshairPos[0] - 0.001; this.drawScene(); } else if (e.code === "KeyL" && this.sliceType !== this.sliceTypeRender) { this.scene.crosshairPos[0] = this.scene.crosshairPos[0] + 0.001; this.drawScene(); } else if ( e.code === "KeyU" && this.sliceType !== this.sliceTypeRender && e.ctrlKey ) { this.scene.crosshairPos[2] = this.scene.crosshairPos[2] + 0.001; this.drawScene(); } else if ( e.code === "KeyD" && this.sliceType !== this.sliceTypeRender && e.ctrlKey ) { this.scene.crosshairPos[2] = this.scene.crosshairPos[2] - 0.001; this.drawScene(); } else if (e.code === "KeyJ" && this.sliceType !== this.sliceTypeRender) { this.scene.crosshairPos[1] = this.scene.crosshairPos[1] - 0.001; this.drawScene(); } else if (e.code === "KeyK" && this.sliceType !== this.sliceTypeRender) { this.scene.crosshairPos[1] = this.scene.crosshairPos[1] + 0.001; this.drawScene(); } else if (e.code === "ArrowLeft") { // only works for background (first loaded image is index 0) this.setFrame4D(this.volumes[0].id, this.volumes[0].frame4D - 1); } else if (e.code === "ArrowRight") { // only works for background (first loaded image is index 0) this.setFrame4D(this.volumes[0].id, this.volumes[0].frame4D + 1); } }; // not included in public docs // handler for scroll wheel events (slice scrolling) // note: no test yet Niivue.prototype.wheelListener = function (e) { // scroll 2D slices e.preventDefault(); e.stopPropagation(); var rect = this.canvas.getBoundingClientRect(); if (e.deltaY < 0) { this.sliceScroll2D(-0.01, e.clientX - rect.left, e.clientY - rect.top); } else { this.sliceScroll2D(0.01, e.clientX - rect.left, e.clientY - rect.top); } }; // not included in public docs // setup interactions with the canvas. Mouse clicks and touches // note: no test yet Niivue.prototype.registerInteractions = function () { // add mousedown this.canvas.addEventListener("mousedown", this.mouseDownListener.bind(this)); // add mouseup this.canvas.addEventListener("mouseup", this.mouseUpListener.bind(this)); // add mouse move this.canvas.addEventListener("mousemove", this.mouseMoveListener.bind(this)); // add touchstart this.canvas.addEventListener( "touchstart", this.touchStartListener.bind(this) ); // add touchend this.canvas.addEventListener("touchend", this.touchEndListener.bind(this)); // add touchmove this.canvas.addEventListener("touchmove", this.touchMoveListener.bind(this)); // add scroll wheel this.canvas.addEventListener("wheel", this.wheelListener.bind(this)); // add context event disabler this.canvas.addEventListener( "contextmenu", this.mouseContextMenuListener.bind(this) ); // add double click this.canvas.addEventListener("dblclick", this.resetBriCon.bind(this)); // drag and drop support this.canvas.addEventListener( "dragenter", this.dragEnterListener.bind(this), false ); this.canvas.addEventListener( "dragover", this.dragOverListener.bind(this), false ); this.canvas.addEventListener("drop", this.dropListener.bind(this), false); // add keyup this.canvas.setAttribute("tabindex", 0); this.canvas.addEventListener("keyup", this.keyUpListener.bind(this), false); this.canvas.focus(); // keydown this.canvas.addEventListener( "keydown", this.keyDownListener.bind(this), false ); }; // not included in public docs Niivue.prototype.dragEnterListener = function (e) { e.stopPropagation(); e.preventDefault(); }; // not included in public docs Niivue.prototype.dragOverListener = function (e) { e.stopPropagation(); e.preventDefault(); }; Niivue.prototype.getFileExt = function (fullname, upperCase = true) { var re = /(?:\.([^.]+))?$/; let ext = re.exec(fullname)[1]; ext = ext.toUpperCase(); if (ext === "GZ") { ext = re.exec(fullname.slice(0, -3))[1]; //img.trk.gz -> img.trk ext = ext.toUpperCase(); } return upperCase ? ext : ext.toLowerCase(); // developer can choose to have extentions as upper or lower }; // getFleExt // not included in public docs Niivue.prototype.dropListener = async function (e) { e.stopPropagation(); e.preventDefault(); // don't do anything if drag and drop has been turned off if (!this.opts.dragAndDropEnabled) { return; } const filesToLoad = []; const urlsToLoad = []; const dt = e.dataTransfer; const url = dt.getData("text/uri-list"); if (url) { urlsToLoad.push(url); let volume = await NVImage.loadFromUrl({ url: url }); this.setVolume(volume); } else { //const files = dt.files; const items = dt.items; if (items.length > 0) { // adding or replacing if (!e.shiftKey) { this.volumes = []; this.overlays = []; this.meshes = []; } for (const item of items) { const entry = item.getAsEntry || item.webkitGetAsEntry(); if (entry.isFile) { let ext = this.getFileExt(entry.name); if (ext === "PNG") { entry.file((file) => { this.loadBmpTexture(file); }); continue; } let pairedImageData = ""; // check for afni HEAD BRIK pair if (entry.name.lastIndexOf("HEAD") !== -1) { for (const pairedItem of items) { const pairedEntry = pairedItem.getAsEntry || pairedItem.webkitGetAsEntry(); let fileBaseName = entry.name.substring( 0, entry.name.lastIndexOf("HEAD") ); let pairedItemBaseName = pairedEntry.name.substring( 0, pairedEntry.name.lastIndexOf("BRIK") ); if (fileBaseName === pairedItemBaseName) { pairedImageData = pairedEntry; } } } if (entry.name.lastIndexOf("BRIK") !== -1) { continue; } if ( ext === "ASC" || ext === "DFS" || ext === "FSM" || ext === "PIAL" || ext === "ORIG" || ext === "INFLATED" || ext === "SMOOTHWM" || ext === "SPHERE" || ext === "WHITE" || ext === "GII" || ext === "MZ3" || ext === "OBJ" || ext === "OFF" || ext === "PLY" || ext === "SRF" || ext === "STL" || ext === "TCK" || ext === "TRACT" || ext === "TRK" || ext === "TRX" || ext === "VTK" ) { entry.file(async (file) => { let mesh = await NVMesh.loadFromFile({ file: file, gl: this.gl, name: file.name, }); this.scene.loading$.next(false); this.addMesh(mesh); }); continue; } entry.file(async (file) => { // if we have paired header/img data if (pairedImageData !== "") { pairedImageData.file(async (imgfile) => { let volume = await NVImage.loadFromFile({ file: file, urlImgData: imgfile, }); this.addVolume(volume); }); } else { // else, just a single file to load (not a pair) let volume = await NVImage.loadFromFile({ file: file, urlImgData: pairedImageData, }); this.addVolume(volume); } }); } else if (entry.isDirectory) { /* let reader = entry.createReader(); var allFilesInDir = []; let n = 0; let readEntries = () => { n = n + 1; //console.log('called ', n, ' times') reader.readEntries(async (entries) => { //console.log(entries) if (!entries.length) { let volume = await NVImage.loadFromFile({ file: allFilesInDir, // an array of file objects urlImgData: null, // nothing isDICOMDIR: true, // signify that this is a dicom directory }); this.addVolume(volume); } else { for (let i = 0; i < entries.length; i++) { if (!entries[i].isFile) continue; if (entries[i].size < 256) continue; if (entries[i].name.startsWith(".")) continue; //hidden, e.g. .DS_Store console.log("adding " + entries[i].name); entries[i].file((file) => { allFilesInDir.push(file); }); } readEntries(); } }); }; readEntries(); */ } } } } //this.createEmptyDrawing(); this.drawScene(); //<- this seems to be required if you drag and drop a mesh, not a volume }; Niivue.prototype.setRadiologicalConvention = function ( isRadiologicalConvention ) { this.opts.isRadiologicalConvention = isRadiologicalConvention; }; Niivue.prototype.getRadiologicalConvention = function () { return this.opts.isRadiologicalConvention; }; /** * add a new volume to the canvas * @param {NVImage} volume the new volume to add to the canvas * @example * niivue = new Niivue() * niivue.addVolume(NVImage.loadFromUrl({url:'./someURL.nii.gz'})) */ Niivue.prototype.addVolume = function (volume) { this.volumes.push(volume); let idx = this.volumes.length === 1 ? 0 : this.volumes.length - 1; this.setVolume(volume, idx); this.imageLoaded$.next(volume); // pass reference to the loaded NVImage (the volume) }; /** * add a new mesh to the canvas * @param {NVMesh} mesh the new mesh to add to the canvas * @example * niivue = new Niivue() * niivue.addMesh(NVMesh.loadFromUrl({url:'./someURL.gii'})) */ Niivue.prototype.addMesh = function (mesh) { this.meshes.push(mesh); let idx = this.meshes.length === 1 ? 0 : this.meshes.length - 1; this.setMesh(mesh, idx); this.imageLoaded$.next(mesh); // pass reference to the loaded NVImage (the volume) }; /** * get the index of a volume by its unique id. unique ids are assigned to the NVImage.id property when a new NVImage is created. * @param {string} id the id string to search for * @example * niivue = new Niivue() * niivue.getVolumeIndexByID(someVolume.id) */ Niivue.prototype.getVolumeIndexByID = function (id) { let n = this.volumes.length; for (let i = 0; i < n; i++) { let id_i = this.volumes[i].id; if (id_i === id) { return i; } } return -1; // -1 signals that no valid index was found for a volume with the given id }; Niivue.prototype.saveImage = async function (fnm, isSaveDrawing = false) { if (!this.back.hasOwnProperty("dims")) { console.log("No voxelwise image open"); return false; } if (isSaveDrawing) { if (!this.drawBitmap) { console.log("No drawing open"); return false; } let perm = this.volumes[0].permRAS; if (perm[0] === 1 && perm[1] === 2 && perm[2] === 3) { await this.volumes[0].saveToDisk(fnm, this.drawBitmap); // createEmptyDrawing return true; } else { let dims = this.volumes[0].hdr.dims; //reverse to original //reverse RAS to native space, layout is mrtrix MIF format // for details see NVImage.readMIF() let layout = [0, 0, 0]; for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (Math.abs(perm[i]) - 1 !== j) continue; layout[j] = i * Math.sign(perm[i]); } } let stride = 1; let instride = [1, 1, 1]; let inflip = [false, false, false]; for (let i = 0; i < layout.length; i++) { for (let j = 0; j < layout.length; j++) { let a = Math.abs(layout[j]); if (a != i) continue; instride[j] = stride; //detect -0: https://medium.com/coding-at-dawn/is-negative-zero-0-a-number-in-javascript-c62739f80114 if (layout[j] < 0 || Object.is(layout[j], -0)) inflip[j] = true; stride *= dims[j + 1]; } } //lookup table for flips and stride offsets: const range = (start, stop, step) => Array.from( { length: (stop - start) / step + 1 }, (_, i) => start + i * step ); let xlut = range(0, dims[1] - 1, 1); if (inflip[0]) xlut = range(dims[1] - 1, 0, -1); for (let i = 0; i < dims[1]; i++) xlut[i] *= instride[0]; let ylut = range(0, dims[2] - 1, 1); if (inflip[1]) ylut = range(dims[2] - 1, 0, -1); for (let i = 0; i < dims[2]; i++) ylut[i] *= instride[1]; let zlut = range(0, dims[3] - 1, 1); if (inflip[2]) zlut = range(dims[3] - 1, 0, -1); for (let i = 0; i < dims[3]; i++) zlut[i] *= instride[2]; //convert data let inVs = new Uint8Array(this.drawBitmap); let outVs = new Uint8Array(dims[1] * dims[2] * dims[3]); let j = 0; for (let z = 0; z < dims[3]; z++) { for (let y = 0; y < dims[2]; y++) { for (let x = 0; x < dims[1]; x++) { outVs[j] = inVs[xlut[x] + ylut[y] + zlut[z]]; j++; } //for x } //for y } //for z await this.volumes[0].saveToDisk(fnm, outVs); return true; } //if native image not RAS } //save bitmap drawing await this.volumes[0].saveToDisk(fnm); return true; }; Niivue.prototype.getMeshIndexByID = function (id) { let n = this.meshes.length; for (let i = 0; i < n; i++) { let id_i = this.meshes[i].id; if (id_i === id) { return i; } } return -1; // -1 signals that no valid index was found for a volume with the given id }; Niivue.prototype.setMeshProperty = function (id, key, val) { let idx = this.getMeshIndexByID(id); if (idx < 0) { log.warn("setMeshProperty() id not loaded", id); return; } this.meshes[idx].setProperty(key, val, this.gl); this.updateGLVolume(); }; Niivue.prototype.setMeshLayerProperty = function (mesh, layer, key, val) { let idx = this.getMeshIndexByID(mesh); if (idx < 0) { log.warn("setMeshLayerProperty() id not loaded", mesh); return; } this.meshes[idx].setLayerProperty(layer, key, val, this.gl); this.updateGLVolume(); }; Niivue.prototype.setRenderAzimuthElevation = function (a, e) { this.scene.renderAzimuth = a; this.scene.renderElevation = e; this.drawScene(); }; // mouseMove() /** * get the index of an overlay by its unique id. unique ids are assigned to the NVImage.id property when a new NVImage is created. * @param {string} id the id string to search for * @see NiiVue#getVolumeIndexByID * @example * niivue = new Niivue() * niivue.getOverlayIndexByID(someVolume.id) */ Niivue.prototype.getOverlayIndexByID = function (id) { let n = this.overlays.length; for (let i = 0; i < n; i++) { let id_i = this.overlays[i].id; if (id_i === id) { return i; } } return -1; // -1 signals that no valid index was found for an overlay with the given id }; /** * set the index of a volume. This will change it's ordering and appearance if there are multiple volumes loaded. * @param {NVImage} volume the volume to update * @param {number} [toIndex=0] the index to move the volume to. The default is the background (0 index) * @example * niivue = new Niivue() * niivue.setVolume(someVolume, 1) // move it to the second position in the array of loaded volumes (0 is the first position) */ Niivue.prototype.setVolume = function (volume, toIndex = 0) { this.volumes.map((v) => { log.debug(v.name); }); let numberOfLoadedImages = this.volumes.length; if (toIndex > numberOfLoadedImages) { return; } let volIndex = this.getVolumeIndexByID(volume.id); if (toIndex === 0) { this.volumes.splice(volIndex, 1); this.volumes.unshift(volume); this.back = this.volumes[0]; this.overlays = this.volumes.slice(1); } else if (toIndex < 0) { // -1 to remove a volume this.volumes.splice(this.getVolumeIndexByID(volume.id), 1); //this.volumes = this.overlays this.back = this.volumes[0]; if (this.volumes.length > 1) { this.overlays = this.volumes.slice(1); } else { this.overlays = []; } } else { this.volumes.splice(volIndex, 1); this.volumes.splice(toIndex, 0, volume); this.overlays = this.volumes.slice(1); this.back = this.volumes[0]; } this.updateGLVolume(); this.volumes.map((v) => { log.debug(v.name); }); }; Niivue.prototype.setMesh = function (mesh, toIndex = 0) { this.meshes.map((m) => { log.debug("MESH: ", m.name); }); let numberOfLoadedMeshes = this.meshes.length; if (toIndex > numberOfLoadedMeshes) { return; } let meshIndex = this.getMeshIndexByID(mesh.id); if (toIndex === 0) { this.meshes.splice(meshIndex, 1); this.meshes.unshift(mesh); } else if (toIndex < 0) { this.meshes.splice(this.getMeshIndexByID(mesh.id), 1); } else { this.meshes.splice(meshIndex, 1); this.meshes.splice(toIndex, 0, mesh); } this.updateGLVolume(); this.meshes.map((m) => { log.debug(m.name); }); }; Niivue.prototype.removeVolume = function (volume) { this.setVolume(volume, -1); }; Niivue.prototype.removeMesh = function (mesh) { this.setMesh(mesh, -1); }; /** * Move a volume to the bottom of the stack of loaded volumes. The volume will become the background * @param {NVImage} volume the volume to move * @example * niivue = new Niivue() * niivue.moveVolumeToBottom(this.volumes[3]) // move the 4th volume to the 0 position. It will be the new background */ Niivue.prototype.moveVolumeToBottom = function (volume) { this.setVolume(volume, 0); }; /** * Move a volume up one index position in the stack of loaded volumes. This moves it up one layer * @param {NVImage} volume the volume to move * @example * niivue = new Niivue() * niivue.moveVolumeUp(this.volumes[0]) // move the background image to the second index position (it was 0 index, now will be 1) */ Niivue.prototype.moveVolumeUp = function (volume) { let volIdx = this.getVolumeIndexByID(volume.id); this.setVolume(volume, volIdx + 1); }; /** * Move a volume down one index position in the stack of loaded volumes. This moves it down one layer * @param {NVImage} volume the volume to move * @example * niivue = new Niivue() * niivue.moveVolumeDown(this.volumes[1]) // move the second image to the background position (it was 1 index, now will be 0) */ Niivue.prototype.moveVolumeDown = function (volume) { let volIdx = this.getVolumeIndexByID(volume.id); this.setVolume(volume, volIdx - 1); }; /** * Move a volume to the top position in the stack of loaded volumes. This will be the top layer * @param {NVImage} volume the volume to move * @example * niivue = new Niivue() * niivue.moveVolumeToTop(this.volumes[0]) // move the background image to the top layer position */ Niivue.prototype.moveVolumeToTop = function (volume) { this.setVolume(volume, this.volumes.length - 1); }; // not included in public docs // update mouse position from new mouse down coordinates // note: no test yet Niivue.prototype.mouseDown = function mouseDown(x, y) { if (this.sliceType != this.sliceTypeRender) return; this.mousePos = [x, y]; }; // mouseDown() // not included in public docs // note: no test yet Niivue.prototype.mouseMove = function mouseMove(x, y) { if (this.sliceType != this.sliceTypeRender) return; this.scene.renderAzimuth += x - this.mousePos[0]; this.scene.renderElevation += y - this.mousePos[1]; this.mousePos = [x, y]; this.drawScene(); }; // mouseMove() /** * convert spherical AZIMUTH, ELEVATION to Cartesian * @param {number} azimuth azimuth number * @param {number} elevation elevation number * @returns {array} the converted [x, y, z] coordinates * @example * niivue = new Niivue() * xyz = niivue.sph2cartDeg(42, 42) */ Niivue.prototype.sph2cartDeg = function sph2cartDeg(azimuth, elevation) { //convert spherical AZIMUTH,ELEVATION,RANGE to Cartesion //see Matlab's [x,y,z] = sph2cart(THETA,PHI,R) // reverse with cart2sph let Phi = -elevation * (Math.PI / 180); let Theta = ((azimuth - 90) % 360) * (Math.PI / 180); let ret = [ Math.cos(Phi) * Math.cos(Theta), Math.cos(Phi) * Math.sin(Theta), Math.sin(Phi), ]; let len = Math.sqrt(ret[0] * ret[0] + ret[1] * ret[1] + ret[2] * ret[2]); if (len <= 0.0) return ret; ret[0] /= len; ret[1] /= len; ret[2] /= len; return ret; }; // sph2cartDeg() /** *