UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,608 lines (1,486 loc) 446 kB
import { mat4, vec2, vec3, vec4 } from 'gl-matrix' import { version } from '../../package.json' import { Shader } from '../shader.js' import { log } from '../logger.js' import { vertOrientCubeShader, fragOrientCubeShader, vertSliceMMShader, fragSliceMMShader, fragSliceV1Shader, vertRectShader, vertLineShader, vertLine3DShader, fragRectShader, fragRectOutlineShader, vertRenderShader, fragRenderShader, fragRenderGradientShader, fragRenderGradientValuesShader, fragRenderSliceShader, vertColorbarShader, fragColorbarShader, vertFontShader, fragFontShader, vertCircleShader, fragCircleShader, vertBmpShader, fragBmpShader, vertOrientShader, vertPassThroughShader, fragPassThroughShader, vertGrowCutShader, fragGrowCutShader, fragOrientShaderU, fragOrientShaderI, fragOrientShaderF, fragOrientShader, fragOrientShaderAtlas, fragRGBOrientShader, vertMeshShader, fragMeshShader, fragMeshToonShader, fragMeshMatcapShader, fragMeshOutlineShader, fragMeshEdgeShader, fragMeshShaderCrevice, fragMeshDiffuseEdgeShader, fragMeshHemiShader, fragMeshMatteShader, fragMeshDepthShader, fragMeshShaderSHBlue, fragMeshSpecularEdgeShader, vertFlatMeshShader, fragFlatMeshShader, vertFiberShader, fragFiberShader, vertSurfaceShader, fragSurfaceShader, fragVolumePickingShader, blurVertShader, blurFragShader, sobelBlurFragShader, sobelFirstOrderFragShader, sobelSecondOrderFragShader } from '../shader-srcs.js' import { orientCube } from '../orientCube.js' import { NiivueObject3D } from '../niivue-object3D.js' import { LoadFromUrlParams, MeshType, NVMesh, NVMeshLayer } from '../nvmesh.js' import defaultMatCap from '../matcaps/Shiny.jpg' import defaultFontPNG from '../fonts/Roboto-Regular.png' import defaultFontMetrics from '../fonts/Roboto-Regular.json' import { ColorMap, cmapper } from '../colortables.js' import { NVDocument, NVConfigOptions, Scene, SLICE_TYPE, SHOW_RENDER, DRAG_MODE, // DRAG_MODE_SECONDARY is the same as DRAG_MODE. DRAG_MODE may be deprecated. DRAG_MODE_PRIMARY, COLORMAP_TYPE, MULTIPLANAR_TYPE, DEFAULT_OPTIONS, ExportDocumentData, INITIAL_SCENE_DATA } from '../nvdocument.js' import { LabelTextAlignment, LabelLineTerminator, NVLabel3D, NVLabel3DStyle, LabelAnchorPoint, LabelAnchorFlag } from '../nvlabel.js' import { FreeSurferConnectome, NVConnectome } from '../nvconnectome.js' import { NVImage, NVImageFromUrlOptions, NiiDataType, NiiIntentCode, ImageFromUrlOptions } from '../nvimage/index.js' import { NVUtilities } from '../nvutilities.js' import { NVMeshUtilities } from '../nvmesh-utilities.js' import { Connectome, LegacyConnectome, NVConnectomeNode, NiftiHeader, DragReleaseParams, NiiVueLocation, NiiVueLocationValue, SyncOpts } from '../types.js' import { toNiivueObject3D } from '../nvimage/RenderingUtils.js' import { clamp, decodeRLE, deg2rad, encodeRLE, img2ras16, intensityRaw2Scaled, isRadiological, negMinMax, swizzleVec3, tickSpacing, unProject, unpackFloatFromVec4i, readFileAsDataURL } from './utils.js' export { NVMesh, NVMeshFromUrlOptions, NVMeshLayerDefaults } from '../nvmesh.js' export { ColorTables as colortables, cmapper } from '../colortables.js' export { NVImage, NVImageFromUrlOptions } from '../nvimage/index.js' // export { NVDocument, SLICE_TYPE, DocumentData } from '../nvdocument.js' // address rollup error - https://github.com/rollup/plugins/issues/71 export * from '../nvdocument.js' export { NVUtilities } from '../nvutilities.js' export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D, LabelAnchorPoint } from '../nvlabel.js' export { NVMeshLoaders } from '../nvmesh-loaders.js' export { NVMeshUtilities } from '../nvmesh-utilities.js' // same rollup error as above during npm run dev, and during the umd build // TODO: at least remove the umd build when AFNI do not need it anymore export * from '../types.js' type FontMetrics = { distanceRange: number size: number mets: Record< number, { xadv: number uv_lbwh: number[] lbwh: number[] } > } type ColormapListEntry = { name: string min: number max: number isColorbarFromZero: boolean negative: boolean visible: boolean invert: boolean } type Graph = { LTWH: number[] plotLTWH?: number[] opacity: number vols: number[] autoSizeMultiplanar: boolean normalizeValues: boolean isRangeCalMinMax: boolean backColor?: number[] lineColor?: number[] textColor?: number[] lineThickness?: number lineAlpha?: number lines?: number[][] selectedColumn?: number lineRGB?: number[][] } type Descriptive = { mean: number stdev: number nvox: number volumeMM3: number volumeML: number min: number max: number meanNot0: number stdevNot0: number nvoxNot0: number minNot0: number maxNot0: number cal_min: number cal_max: number robust_min: number robust_max: number area: number | null } type SliceScale = { volScale: number[] vox: number[] longestAxis: number dimsMM: vec3 } type MvpMatrix2D = { modelViewProjectionMatrix: mat4 modelMatrix: mat4 normalMatrix: mat4 leftTopMM: number[] fovMM: number[] } type MM = { mnMM: vec3 mxMM: vec3 rotation: mat4 fovMM: vec3 } /** * mesh file formats that can be loaded */ const MESH_EXTENSIONS = [ 'ASC', 'BYU', 'DFS', 'FSM', 'PIAL', 'ORIG', 'INFLATED', 'SMOOTHWM', 'SPHERE', 'WHITE', 'G', 'GEO', 'GII', 'ICO', 'MZ3', 'NV', 'OBJ', 'OFF', 'PLY', 'SRF', 'STL', 'TCK', 'TRACT', 'TRI', 'TRK', 'TT', 'TRX', 'VTK', 'WRL', 'X3D', 'JCON', 'JSON' ] // mouse button codes const LEFT_MOUSE_BUTTON = 0 const CENTER_MOUSE_BUTTON = 1 const RIGHT_MOUSE_BUTTON = 2 // https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants // gl.TEXTURE0..31 are constants 0x84C0..0x84DF = 33984..34015 // https://github.com/niivue/niivue/blob/main/docs/development-notes/webgl.md // persistent textures const TEXTURE0_BACK_VOL = 33984 const TEXTURE1_COLORMAPS = 33985 const TEXTURE2_OVERLAY_VOL = 33986 const TEXTURE3_FONT = 33987 const TEXTURE4_THUMBNAIL = 33988 const TEXTURE5_MATCAP = 33989 const TEXTURE6_GRADIENT = 33990 const TEXTURE7_DRAW = 33991 // subsequent textures only used transiently const TEXTURE8_GRADIENT_TEMP = 33992 const TEXTURE9_ORIENT = 33993 const TEXTURE10_BLEND = 33994 const TEXTURE11_GC_BACK = 33995 const TEXTURE12_GC_STRENGTH0 = 33996 const TEXTURE13_GC_STRENGTH1 = 33997 const TEXTURE14_GC_LABEL0 = 33998 const TEXTURE15_GC_LABEL1 = 33999 type UIData = { mousedown: boolean touchdown: boolean mouseButtonLeftDown: boolean mouseButtonCenterDown: boolean mouseButtonRightDown: boolean mouseDepthPicker: boolean clickedTile: number pan2DxyzmmAtMouseDown: vec4 prevX: number prevY: number currX: number currY: number currentTouchTime: number lastTouchTime: number touchTimer: NodeJS.Timeout | null doubleTouch: boolean isDragging: boolean dragStart: number[] dragEnd: number[] dragClipPlaneStartDepthAziElev: number[] lastTwoTouchDistance: number multiTouchGesture: boolean dpr?: number windowX: number // used to track mouse position for DRAG_MODE_PRIMARY.windowing windowY: number // used to track mouse position for DRAG_MODE_PRIMARY.windowing } type SaveImageOptions = { filename: string isSaveDrawing: boolean volumeByIndex: number } // default SaveImageOptions const defaultSaveImageOptions: SaveImageOptions = { filename: '', isSaveDrawing: false, volumeByIndex: 0 } export type DicomLoaderInput = ArrayBuffer | ArrayBuffer[] | File[] export type DicomLoader = { loader: (data: DicomLoaderInput) => Promise<Array<{ name: string; data: ArrayBuffer }>> toExt: string } /** * 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. * * @example * let niivue = new Niivue({crosshairColor: [0,1,0,0.5], textHeight: 0.5}) // a see-through green crosshair, and larger text labels */ export class Niivue { loaders = {} // create a dicom loader dicomLoader: DicomLoader | null = null // { // loader: (data: DicomLoaderInput) => { // return new Promise<{name: string; data: ArrayBuffer}[]>((resolve, reject) => { // reject('No DICOM loader provided') // }) // }, // toExt: 'nii' // } canvas: HTMLCanvasElement | null = null // the reference to the canvas element on the page _gl: WebGL2RenderingContext | null = null // the gl context isBusy = false // flag to indicate if the scene is busy drawing needsRefresh = false // flag to indicate if the scene needs to be redrawn colormapTexture: WebGLTexture | null = null // the GPU memory storage of the colormap colormapLists: ColormapListEntry[] = [] // one entry per colorbar: min, max, tic volumeTexture: WebGLTexture | null = null // the GPU memory storage of the volume gradientTexture: WebGLTexture | null = null // 3D texture for volume rendering lighting gradientTextureAmount = 0.0 renderGradientValues = false drawTexture: WebGLTexture | null = null // the GPU memory storage of the drawing drawUndoBitmaps: Uint8Array[] = [] // array of drawBitmaps for undo drawLut = cmapper.makeDrawLut('$itksnap') // the color lookup table for drawing drawOpacity = 0.8 // opacity of drawing (default) clickToSegmentIsGrowing = false // flag to indicate if the clickToSegment flood fill growing is in progress with left mouse down + drag clickToSegmentGrowingBitmap: Uint8Array | null = null // the bitmap of the growing flood fill clickToSegmentXY = [0, 0] // the x,y location of the clickToSegment flood fill renderDrawAmbientOcclusion = 0.4 colorbarHeight = 0 // height in pixels, set when colorbar is drawn drawPenLocation = [NaN, NaN, NaN] drawPenAxCorSag = -1 // do not allow pen to drag between Sagittal/Coronal/Axial drawFillOverwrites = true // if true, fill overwrites existing drawing drawPenFillPts: number[][] = [] // store mouse points for filled pen overlayTexture: WebGLTexture | null = null overlayTextureID: WebGLTexture | null = null sliceMMShader?: Shader sliceV1Shader?: Shader orientCubeShader?: Shader orientCubeShaderVAO: WebGLVertexArrayObject | null = null rectShader?: Shader rectOutlineShader?: Shader renderShader?: Shader lineShader?: Shader line3DShader?: Shader passThroughShader?: Shader renderGradientShader?: Shader renderGradientValuesShader?: Shader renderSliceShader?: Shader renderVolumeShader?: Shader pickingMeshShader?: Shader pickingImageShader?: Shader colorbarShader?: Shader fontShader: Shader | null = null fiberShader?: Shader fontTexture: WebGLTexture | null = null circleShader?: Shader matCapTexture: WebGLTexture | null = null bmpShader: Shader | null = null bmpTexture: WebGLTexture | null = null // thumbnail WebGLTexture object thumbnailVisible = false bmpTextureWH = 1.0 // thumbnail width/height ratio growCutShader?: Shader orientShaderAtlasU: Shader | null = null orientShaderAtlasI: Shader | null = null orientShaderU: Shader | null = null orientShaderI: Shader | null = null orientShaderF: Shader | null = null orientShaderRGBU: Shader | null = null surfaceShader: Shader | null = null blurShader: Shader | null = null sobelBlurShader: Shader | null = null sobelFirstOrderShader: Shader | null = null sobelSecondOrderShader: Shader | null = null genericVAO: WebGLVertexArrayObject | null = null // used for 2D slices, 2D lines, 2D Fonts unusedVAO = null crosshairs3D: NiivueObject3D | null = null private DEFAULT_FONT_GLYPH_SHEET = defaultFontPNG // "/fonts/Roboto-Regular.png"; private DEFAULT_FONT_METRICS = defaultFontMetrics // "/fonts/Roboto-Regular.json"; private fontMetrics?: typeof defaultFontMetrics private fontMets: FontMetrics | null = null backgroundMasksOverlays = 0 overlayOutlineWidth = 0 // float, 0 for none overlayAlphaShader = 1 // float, 1 for opaque position?: vec3 extentsMin?: vec3 extentsMax?: vec3 // ResizeObserver private resizeObserver: ResizeObserver | null = null private resizeEventListener: (() => void) | null = null private canvasObserver: MutationObserver | null = null // syncOpts: Record<string, unknown> = {} syncOpts: SyncOpts = { '3d': false, // legacy option '2d': false, // legacy option zoomPan: false, cal_min: false, cal_max: false, clipPlane: false, gamma: false, sliceType: false, crosshair: false } readyForSync = false // UI Data uiData: UIData = { mousedown: false, touchdown: false, mouseButtonLeftDown: false, mouseButtonCenterDown: false, mouseButtonRightDown: false, mouseDepthPicker: false, clickedTile: -1, pan2DxyzmmAtMouseDown: [0, 0, 0, 1], prevX: 0, prevY: 0, currX: 0, currY: 0, currentTouchTime: 0, lastTouchTime: 0, touchTimer: null, doubleTouch: false, isDragging: false, dragStart: [0.0, 0.0], dragEnd: [0.0, 0.0], dragClipPlaneStartDepthAziElev: [0, 0, 0], lastTwoTouchDistance: 0, multiTouchGesture: false, windowX: 0, windowY: 0 } back: NVImage | null = null // base layer; defines image space to work in. Defined as this.volumes[0] in Niivue.loadVolumes overlays: NVImage[] = [] // layers added on top of base image (e.g. masks or stat maps). Essentially everything after this.volumes[0] is an overlay. So is necessary? deferredVolumes: ImageFromUrlOptions[] = [] deferredMeshes: LoadFromUrlParams[] = [] furthestVertexFromOrigin = 100 volScale: number[] = [] vox: number[] = [] mousePos = [0, 0] screenSlices: Array<{ leftTopWidthHeight: number[] axCorSag: SLICE_TYPE sliceFrac: number AxyzMxy: number[] leftTopMM: number[] fovMM: number[] screen2frac?: number[] }> = [] // empty array cuboidVertexBuffer?: WebGLBuffer otherNV: Niivue[] | null = null // another niivue instance that we wish to sync position with volumeObject3D: NiivueObject3D | null = null pivot3D = [0, 0, 0] // center for rendering rotation furthestFromPivot = 10.0 // most distant point from pivot currentClipPlaneIndex = 0 lastCalled = new Date().getTime() selectedObjectId = -1 CLIP_PLANE_ID = 1 VOLUME_ID = 254 DISTANCE_FROM_CAMERA = -0.54 graph: Graph = { LTWH: [0, 0, 640, 480], opacity: 0.0, vols: [0], // e.g. timeline for background volume only, e.g. [0,2] for first and third volumes autoSizeMultiplanar: false, normalizeValues: false, isRangeCalMinMax: false } meshShaders: Array<{ Name: string; Frag: string; shader?: Shader }> = [ { Name: 'Phong', Frag: fragMeshShader }, { Name: 'Matte', Frag: fragMeshMatteShader }, { Name: 'Harmonic', Frag: fragMeshShaderSHBlue }, { Name: 'Hemispheric', Frag: fragMeshHemiShader }, { Name: 'Crevice', Frag: fragMeshShaderCrevice }, { Name: 'Edge', Frag: fragMeshEdgeShader }, { Name: 'Diffuse', Frag: fragMeshDiffuseEdgeShader }, { Name: 'Outline', Frag: fragMeshOutlineShader }, { Name: 'Specular', Frag: fragMeshSpecularEdgeShader }, { Name: 'Toon', Frag: fragMeshToonShader }, { Name: 'Flat', Frag: fragFlatMeshShader }, { Name: 'Matcap', Frag: fragMeshMatcapShader } ] // TODO just let users use DRAG_MODE instead dragModes = { contrast: DRAG_MODE.contrast, measurement: DRAG_MODE.measurement, none: DRAG_MODE.none, pan: DRAG_MODE.pan, slicer3D: DRAG_MODE.slicer3D, callbackOnly: DRAG_MODE.callbackOnly } // TODO just let users use SLICE_TYPE instead sliceTypeAxial = SLICE_TYPE.AXIAL sliceTypeCoronal = SLICE_TYPE.CORONAL sliceTypeSagittal = SLICE_TYPE.SAGITTAL sliceTypeMultiplanar = SLICE_TYPE.MULTIPLANAR sliceTypeRender = SLICE_TYPE.RENDER // Event listeners /** * callback function to run when the right mouse button is released after dragging * @example * niivue.onDragRelease = () => { * console.log('drag ended') * } */ onDragRelease: (params: DragReleaseParams) => void = () => {} // function to call when contrast drag is released by default. Can be overridden by user /** * callback function to run when the left mouse button is released * @example * niivue.onMouseUp = () => { * console.log('mouse up') * } */ onMouseUp: (data: Partial<UIData>) => void = () => {} /** * callback function to run when the crosshair location changes * @example * niivue.onLocationChange = (data) => { * console.log('location changed') * console.log('mm: ', data.mm) * console.log('vox: ', data.vox) * console.log('frac: ', data.frac) * console.log('values: ', data.values) * } */ onLocationChange: (location: unknown) => void = () => {} /** * callback function to run when the user changes the intensity range with the selection box action (right click) * @example * niivue.onIntensityChange = (volume) => { * console.log('intensity changed') * console.log('volume: ', volume) * } */ onIntensityChange: (volume: NVImage) => void = () => {} /** * callback function when clickToSegment is enabled and the user clicks on the image. data contains the volume of the segmented region in mm3 and mL * @example * niivue.onClickToSegment = (data) => { * console.log('clicked to segment') * console.log('volume mm3: ', data.mm3) * console.log('volume mL: ', data.mL) * } */ onClickToSegment: (data: { mm3: number; mL: number }) => void = () => {} /** * callback function to run when a new volume is loaded * @example * niivue.onImageLoaded = (volume) => { * console.log('volume loaded') * console.log('volume: ', volume) * } */ onImageLoaded: (volume: NVImage) => void = () => {} /** * callback function to run when a new mesh is loaded * @example * niivue.onMeshLoaded = (mesh) => { * console.log('mesh loaded') * console.log('mesh: ', mesh) * } */ onMeshLoaded: (mesh: NVMesh) => void = () => {} /** * callback function to run when the user changes the volume when a 4D image is loaded * @example * niivue.onFrameChange = (volume, frameNumber) => { * console.log('frame changed') * console.log('volume: ', volume) * console.log('frameNumber: ', frameNumber) * } */ onFrameChange: (volume: NVImage, index: number) => void = () => {} /** * callback function to run when niivue reports an error * @example * niivue.onError = (error) => { * console.log('error: ', error) * } */ onError: () => void = () => {} /// TODO was undocumented onColormapChange: () => void = () => {} /** * callback function to run when niivue reports detailed info * @example * niivue.onInfo = (info) => { * console.log('info: ', info) * } */ onInfo: () => void = () => {} /** * callback function to run when niivue reports a warning * @example * niivue.onWarn = (warn) => { * console.log('warn: ', warn) * } */ onWarn: () => void = () => {} /** * callback function to run when niivue reports a debug message * @example * niivue.onDebug = (debug) => { * console.log('debug: ', debug) * } */ onDebug: () => void = () => {} /** * callback function to run when a volume is added from a url * @example * niivue.onVolumeAddedFromUrl = (imageOptions, volume) => { * console.log('volume added from url') * console.log('imageOptions: ', imageOptions) * console.log('volume: ', volume) * } */ onVolumeAddedFromUrl: (imageOptions: ImageFromUrlOptions, volume: NVImage) => void = () => {} onVolumeWithUrlRemoved: (url: string) => void = () => {} /** * callback function to run when updateGLVolume is called (most users will not need to use * @example * niivue.onVolumeUpdated = () => { * console.log('volume updated') * } */ onVolumeUpdated: () => void = () => {} /** * callback function to run when a mesh is added from a url * @example * niivue.onMeshAddedFromUrl = (meshOptions, mesh) => { * console.log('mesh added from url') * console.log('meshOptions: ', meshOptions) * console.log('mesh: ', mesh) * } */ onMeshAddedFromUrl: (meshOptions: LoadFromUrlParams, mesh: NVMesh) => void = () => {} // TODO seems redundant with onMeshLoaded onMeshAdded: () => void = () => {} onMeshWithUrlRemoved: (url: string) => void = () => {} // not implemented anywhere... onZoom3DChange: (zoom: number) => void = () => {} /** * callback function to run when the user changes the rotation of the 3D rendering * @example * niivue.onAzimuthElevationChange = (azimuth, elevation) => { * console.log('azimuth: ', azimuth) * console.log('elevation: ', elevation) * } */ onAzimuthElevationChange: (azimuth: number, elevation: number) => void = () => {} /** * callback function to run when the user changes the clip plane * @example * niivue.onClipPlaneChange = (clipPlane) => { * console.log('clipPlane: ', clipPlane) * } */ onClipPlaneChange: (clipPlane: number[]) => void = () => {} onCustomMeshShaderAdded: (fragmentShaderText: string, name: string) => void = () => {} onMeshShaderChanged: (meshIndex: number, shaderIndex: number) => void = () => {} onMeshPropertyChanged: (meshIndex: number, key: string, val: unknown) => void = () => {} onDicomLoaderFinishedWithImages: (files: NVImage[] | NVMesh[]) => void = () => {} /** * callback function to run when the user loads a new NiiVue document * @example * niivue.onDocumentLoaded = (document) => { * console.log('document: ', document) * } */ onDocumentLoaded: (document: NVDocument) => void = () => {} document = new NVDocument() get scene(): Scene { return this.document.scene } get opts(): NVConfigOptions { return this.document.opts } get sliceMosaicString(): string { return this.document.opts.sliceMosaicString || '' } set sliceMosaicString(newSliceMosaicString: string) { this.document.opts.sliceMosaicString = newSliceMosaicString } get isAlphaClipDark(): boolean { return this.document.opts.isAlphaClipDark } set isAlphaClipDark(newVal) { this.document.opts.isAlphaClipDark = newVal } mediaUrlMap: Map<NVImage | NVMesh, string> = new Map() initialized = false currentDrawUndoBitmap: number /** * @param options - options object to set modifiable Niivue properties */ constructor(options: Partial<NVConfigOptions> = DEFAULT_OPTIONS) { // populate Niivue with user supplied options for (const name in options) { // if the user supplied a function for a callback, use it, else use the default callback or nothing if (typeof options[name as keyof typeof options] === 'function') { this[name] = options[name] } else { this.opts[name] = DEFAULT_OPTIONS[name] === undefined ? DEFAULT_OPTIONS[name] : options[name] } } if (this.opts.forceDevicePixelRatio === 0) { this.uiData.dpr = window.devicePixelRatio || 1 } else if (this.opts.forceDevicePixelRatio < 0) { this.uiData.dpr = 1 } else { this.uiData.dpr = this.opts.forceDevicePixelRatio } // now that opts have been parsed, set the current undo to max undo this.currentDrawUndoBitmap = this.opts.maxDrawUndoBitmaps // analogy: cylinder position of a revolver if (this.opts.drawingEnabled) { this.createEmptyDrawing() } if (this.opts.thumbnail.length > 0) { this.thumbnailVisible = true } log.setLogLevel(this.opts.logLevel) } /** * Clean up event listeners and observers * Call this when the Niivue instance is no longer needed. * This will be called when the canvas is detached from the DOM * @example niivue.cleanup(); */ cleanup(): void { // Clean up resize listener if (this.resizeEventListener) { window.removeEventListener('resize', this.resizeEventListener) this.resizeEventListener = null } // Clean up resize observer if (this.resizeObserver) { this.resizeObserver.disconnect() this.resizeObserver = null } // Clean up canvas observer if (this.canvasObserver) { this.canvasObserver.disconnect() this.canvasObserver = null } // Remove all interaction event listeners if (this.canvas && this.opts.interactive) { // Mouse events this.canvas.removeEventListener('mousedown', this.mouseDownListener.bind(this)) this.canvas.removeEventListener('mouseup', this.mouseUpListener.bind(this)) this.canvas.removeEventListener('mousemove', this.mouseMoveListener.bind(this)) // Touch events this.canvas.removeEventListener('touchstart', this.touchStartListener.bind(this)) this.canvas.removeEventListener('touchend', this.touchEndListener.bind(this)) this.canvas.removeEventListener('touchmove', this.touchMoveListener.bind(this)) // Other events this.canvas.removeEventListener('wheel', this.wheelListener.bind(this)) this.canvas.removeEventListener('contextmenu', this.mouseContextMenuListener.bind(this)) this.canvas.removeEventListener('dblclick', this.resetBriCon.bind(this)) // Drag and drop this.canvas.removeEventListener('dragenter', this.dragEnterListener.bind(this)) this.canvas.removeEventListener('dragover', this.dragOverListener.bind(this)) this.canvas.removeEventListener('drop', this.dropListener.bind(this)) // Keyboard events this.canvas.removeEventListener('keyup', this.keyUpListener.bind(this)) this.canvas.removeEventListener('keydown', this.keyDownListener.bind(this)) } // Todo: other cleanup tasks could be added here } get volumes(): NVImage[] { return this.document.volumes } set volumes(volumes) { this.document.volumes = volumes } get meshes(): NVMesh[] { return this.document.meshes } set meshes(meshes) { this.document.meshes = meshes } get drawBitmap(): Uint8Array | null { return this.document.drawBitmap } set drawBitmap(drawBitmap) { this.document.drawBitmap = drawBitmap } get volScaleMultiplier(): number { return this.scene.volScaleMultiplier } set volScaleMultiplier(scale) { this.setScale(scale) } /** * save webgl2 canvas as png format bitmap * @param filename - filename for screen capture * @example niivue.saveScene('test.png'); * @see {@link https://niivue.github.io/niivue/features/ui.html | live demo usage} */ async saveScene(filename = 'niivue.png'): Promise<void> { function saveBlob(blob: Blob, name: string): void { const a = document.createElement('a') document.body.appendChild(a) a.style.display = 'none' const url = window.URL.createObjectURL(blob) a.href = url a.download = name a.click() a.remove() } const canvas = this.canvas if (!canvas) { throw new Error('canvas not defined') } this.drawScene() canvas.toBlob((blob) => { if (!blob) { return } if (filename === '') { filename = `niivue-screenshot-${new Date().toString()}.png` filename = filename.replace(/\s/g, '_') } saveBlob(blob, filename) }) } /** * attach the Niivue instance to the webgl2 canvas by element id * @param id - the id of an html canvas element * @param isAntiAlias - determines if anti-aliasing is requested (if not specified, AA usage depends on hardware) * @example niivue = new Niivue().attachTo('gl') * @example await niivue.attachTo('gl') * @see {@link https://niivue.github.io/niivue/features/basic.multiplanar.html | live demo usage} */ async attachTo(id: string, isAntiAlias = null): Promise<this> { await this.attachToCanvas(document.getElementById(id) as HTMLCanvasElement, isAntiAlias) log.debug('attached to element with id: ', id) return this } /** * attach the Niivue instance to a canvas element directly * @param canvas - the canvas element reference * @example * niivue = new Niivue() * await niivue.attachToCanvas(document.getElementById(id)) */ async attachToCanvas(canvas: HTMLCanvasElement, isAntiAlias: boolean | null = null): Promise<this> { this.canvas = canvas if (isAntiAlias === null) { isAntiAlias = navigator.hardwareConcurrency > 6 log.debug('AntiAlias ', isAntiAlias, ' Threads ', navigator.hardwareConcurrency) } this.gl = this.canvas.getContext('webgl2', { alpha: true, antialias: isAntiAlias }) log.info('NIIVUE VERSION ', version) // 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 if (this.opts.isResizeCanvas) { this.canvas.style.width = '100%' this.canvas.style.height = '100%' this.canvas.style.display = 'block' this.canvas.width = this.canvas.offsetWidth this.canvas.height = this.canvas.offsetHeight // Store a reference to the bound event handler function this.resizeEventListener = (): void => { requestAnimationFrame(() => { this.resizeListener() }) } window.addEventListener('resize', this.resizeEventListener) this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { this.resizeListener() }) }) this.resizeObserver.observe(this.canvas.parentElement!) // Setup a MutationObserver to detect when canvas is removed from DOM this.canvasObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if ( mutation.type === 'childList' && mutation.removedNodes.length > 0 && Array.from(mutation.removedNodes).includes(this.canvas) ) { this.cleanup() break } } }) this.canvasObserver.observe(this.canvas.parentElement!, { childList: true }) } if (this.opts.interactive) { 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 otherNV - the other Niivue instance that is the main controller * @example * niivue1 = new Niivue() * niivue2 = new Niivue() * niivue2.syncWith(niivue1) * @deprecated use broadcastTo instead * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage} */ syncWith(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void { // if otherNV is not an array, make it an array of one if (!(otherNV instanceof Array)) { otherNV = [otherNV] } this.otherNV = otherNV this.syncOpts = { ...syncOpts } } /** * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to others. useful for using one canvas to drive another. * @param otherNV - the other Niivue instance(s) * @example * niivue1 = new Niivue() * niivue2 = new Niivue() * niivue3 = new Niivue() * niivue1.broadcastTo(niivue2) * niivue1.broadcastTo([niivue2, niivue3]) * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage} */ broadcastTo(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void { // if otherNV is a single instance then make it an array of one if (!(otherNV instanceof Array)) { otherNV = [otherNV] } this.otherNV = otherNV this.syncOpts = syncOpts } doSync3d(otherNV: Niivue): void { otherNV.scene.renderAzimuth = this.scene.renderAzimuth otherNV.scene.renderElevation = this.scene.renderElevation otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier } // both crosshair and zoomPan doSync2d(otherNV: Niivue): void { const thisMM = this.frac2mm(this.scene.crosshairPos) otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM) otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm) } doSyncGamma(otherNV: Niivue): void { // gamma not dependent on 2d/3d const thisGamma = this.scene.gamma const otherGamma = otherNV.scene.gamma if (thisGamma !== otherGamma) { otherNV.setGamma(thisGamma) } } doSyncZoomPan(otherNV: Niivue): void { otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm) } doSyncCrosshair(otherNV: Niivue): void { const thisMM = this.frac2mm(this.scene.crosshairPos) otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM) } doSyncCalMin(otherNV: Niivue): void { // only call updateGLVolume if the cal_min is different // because updateGLVolume is expensive, but required to update the volume if (this.volumes[0].cal_min !== otherNV.volumes[0].cal_min) { otherNV.volumes[0].cal_min = this.volumes[0].cal_min otherNV.updateGLVolume() } } doSyncCalMax(otherNV: Niivue): void { // only call updateGLVolume if the cal_max is different // because updateGLVolume is expensive, but required to update the volume if (this.volumes[0].cal_max !== otherNV.volumes[0].cal_max) { otherNV.volumes[0].cal_max = this.volumes[0].cal_max otherNV.updateGLVolume() } } doSyncSliceType(otherNV: Niivue): void { otherNV.setSliceType(this.opts.sliceType) } doSyncClipPlane(otherNV: Niivue): void { otherNV.setClipPlane(this.scene.clipPlaneDepthAziElev) } /** * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another. * @internal * @example * niivue1 = new Niivue() * niivue2 = new Niivue() * niivue2.syncWith(niivue1) * niivue2.sync() */ sync(): void { if (!this.gl || !this.otherNV || typeof this.otherNV === 'undefined') { return } // canvas must have focus to send messages issue706 if (!(this.gl.canvas as HTMLCanvasElement).matches(':focus')) { return } for (let i = 0; i < this.otherNV.length; i++) { if (this.otherNV[i] === this) { continue } // gamma if (this.syncOpts.gamma) { this.doSyncGamma(this.otherNV[i]) } // crosshair if (this.syncOpts.crosshair) { this.doSyncCrosshair(this.otherNV[i]) } // zoomPan if (this.syncOpts.zoomPan) { this.doSyncZoomPan(this.otherNV[i]) } // sliceType if (this.syncOpts.sliceType) { this.doSyncSliceType(this.otherNV[i]) } // cal_min if (this.syncOpts.cal_min) { this.doSyncCalMin(this.otherNV[i]) } // cal_max if (this.syncOpts.cal_max) { this.doSyncCalMax(this.otherNV[i]) } // clipPlane if (this.syncOpts.clipPlane) { this.doSyncClipPlane(this.otherNV[i]) } // legacy 2d option for multiple properties if (this.syncOpts['2d']) { this.doSync2d(this.otherNV[i]) } // legacy 3d option for multiple properties if (this.syncOpts['3d']) { this.doSync3d(this.otherNV[i]) } this.otherNV[i].drawScene() this.otherNV[i].createOnLocationChange() } } /** Not documented publicly for now * test if two arrays have equal values for each element * @param a - the first array * @param b - the second array * @example Niivue.arrayEquals(a, b) * * TODO this should maybe just use array-equal from NPM */ arrayEquals(a: unknown[], b: unknown[]): boolean { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]) } /** * callback function to handle resize window events, redraws the scene. * @internal */ resizeListener(): void { if (!this.canvas || !this.gl) { return } if (!this.opts.isResizeCanvas) { if (this.opts.forceDevicePixelRatio >= 0) { log.warn('this.opts.forceDevicePixelRatio requires isResizeCanvas') } this.drawScene() return } this.canvas.style.width = '100%' this.canvas.style.height = '100%' this.canvas.style.display = 'block' // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html // https://www.khronos.org/webgl/wiki/HandlingHighDPI if (this.opts.forceDevicePixelRatio === 0) { this.uiData.dpr = window.devicePixelRatio || 1 } else if (this.opts.forceDevicePixelRatio < 0) { this.uiData.dpr = 1 } else { this.uiData.dpr = this.opts.forceDevicePixelRatio } log.debug('devicePixelRatio: ' + this.uiData.dpr) if ('width' in this.canvas.parentElement!) { this.canvas.width = (this.canvas.parentElement.width as number) * this.uiData.dpr // @ts-expect-error not sure why height is not defined for HTMLElement this.canvas.height = this.canvas.parentElement.height * this.uiData.dpr } else { this.canvas.width = this.canvas.offsetWidth * this.uiData.dpr this.canvas.height = this.canvas.offsetHeight * this.uiData.dpr } this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height) 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 */ /** * callback to handle mouse move events relative to the canvas * @internal * @returns the mouse position relative to the canvas */ getRelativeMousePosition(event: MouseEvent, target?: EventTarget | null): { x: number; y: number } | undefined { target = target || event.target if (!target) { return } // @ts-expect-error -- not sure how this works, this would be an EventTarget? const 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 getNoPaddingNoBorderCanvasRelativeMousePosition( event: MouseEvent, target: EventTarget ): { x: number; y: number } | undefined { target = target || event.target const pos = this.getRelativeMousePosition(event, target) 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 mouseContextMenuListener(e: MouseEvent): void { e.preventDefault() } // not included in public docs // handler for all mouse button presses // note: no test yet mouseDownListener(e: MouseEvent): void { e.preventDefault() // var rect = this.canvas.getBoundingClientRect(); this.drawPenLocation = [NaN, NaN, NaN] this.drawPenAxCorSag = -1 this.uiData.mousedown = true // reset drag positions used previously. this.setDragStart(0, 0) this.setDragEnd(0, 0) log.debug('mouse down') log.debug(e) // record tile where mouse clicked const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas) if (!pos) { return } const [x, y] = [pos.x * this.uiData.dpr!, pos.y * this.uiData.dpr!] if (this.opts.clickToSegment) { this.clickToSegmentXY = [x, y] } const label = this.getLabelAtPoint([x, y]) if (label) { // check for user defined onclick handler if (label.onClick) { label.onClick(label) return } // find associated mesh for (const mesh of this.meshes) { if (mesh.type !== MeshType.CONNECTOME) { continue } for (const node of mesh.nodes as NVConnectomeNode[]) { if (node.label === label) { this.scene.crosshairPos = this.mm2frac([node.x, node.y, node.z]) this.updateGLVolume() this.drawScene() } } } } this.uiData.clickedTile = this.tileIndex(x, y) // respond to different types of mouse clicks if (e.button === LEFT_MOUSE_BUTTON && e.shiftKey) { this.uiData.mouseButtonCenterDown = true this.mouseCenterButtonHandler(e) } else if (e.button === LEFT_MOUSE_BUTTON) { this.uiData.mouseButtonLeftDown = true this.mouseLeftButtonHandler(e) } else if (e.button === RIGHT_MOUSE_BUTTON) { this.uiData.mouseButtonRightDown = true this.mouseRightButtonHandler(e) } else if (e.button === CENTER_MOUSE_BUTTON) { this.uiData.mouseButtonCenterDown = true this.mouseCenterButtonHandler(e) } } // not included in public docs // handler for mouse left button down // note: no test yet mouseLeftButtonHandler(e: MouseEvent): void { // need to check for control key here in case the user want to override the drag mode if (e.ctrlKey || this.opts.dragModePrimary === DRAG_MODE_PRIMARY.crosshair) { const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas) this.mouseDown(pos!.x, pos!.y) this.mouseClick(pos!.x, pos!.y) } else if (this.opts.dragModePrimary === DRAG_MODE_PRIMARY.windowing) { // save the state of the x and y mouse coordinates for the next comparison on mouse move this.uiData.windowX = e.x this.uiData.windowY = e.y } } // not included in public docs // handler for mouse center button down // note: no test yet mouseCenterButtonHandler(e: MouseEvent): void { const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas) this.mousePos = [pos!.x * this.uiData.dpr!, pos!.y * this.uiData.dpr!] if (this.opts.dragMode === DRAG_MODE.none) { return } this.setDragStart(pos!.x, pos!.y) if (!this.uiData.isDragging) { this.uiData.pan2DxyzmmAtMouseDown = vec4.clone(this.scene.pan2Dxyzmm) } this.uiData.isDragging = true this.uiData.dragClipPlaneStartDepthAziElev = this.scene.clipPlaneDepthAziElev } // not included in public docs // handler for mouse right button down // note: no test yet mouseRightButtonHandler(e: MouseEvent): void { // this.uiData.isDragging = true; const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas) this.mousePos = [pos!.x * this.uiData.dpr!, pos!.y * this.uiData.dpr!] if (this.opts.dragMode === DRAG_MODE.none) { return } this.setDragStart(pos!.x, pos!.y) if (!this.uiData.isDragging) { this.uiData.pan2DxyzmmAtMouseDown = vec4.clone(this.scene.pan2Dxyzmm) } this.uiData.isDragging = true this.uiData.dragClipPlaneStartDepthAziElev = this.scene.clipPlaneDepthAziElev } /** * calculate the the min and max voxel indices from an array of two values (used in selecting intensities with the selection box) * @param array - an array of two values * @returns an array of two values representing the min and max voxel indices */ calculateMinMaxVoxIdx(array: number[]): number[] { 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 // note: no test yet calculateNewRange({ volIdx = 0 } = {}): void { if (this.opts.sliceType === SLICE_TYPE.RENDER && this.sliceMosaicString.length < 1) { return } if (this.uiData.dragStart[0] === this.uiData.dragEnd[0] && this.uiData.dragStart[1] === this.uiData.dragEnd[1]) { return } // calculate our box let frac = this.canvasPos2frac([this.uiData.dragStart[0], this.uiData.dragStart[1]]) if (frac[0] < 0) { return } const startVox = this.frac2vox(frac, volIdx) frac = this.canvasPos2frac([this.uiData.dragEnd[0], this.uiData.dragEnd[1]]) if (frac[0] < 0) { return } const endVox = this.frac2vox(frac, volIdx) let hi = -Number.MAX_VALUE let lo = Number.MAX_VALUE const xrange = this.calculateMinMaxVoxIdx([startVox[0], endVox[0]]) const yrange = this.calculateMinMaxVoxIdx([startVox[1], endVox[1]]) const 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 if (!hdr || !img) { return } const xdim = hdr.dims[1] const ydim = hdr.dims[2] for (let z = zrange[0]; z < zrange[1]; z++) { const zi = z * xdim * ydim for (let y = yrange[0]; y < yrange[1]; y++) { const yi = y * xdim for (let x = xrange[0]; x < xrange[1]; x++) { const 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 const mnScale = intensityRaw2Scaled(hdr, lo) const mxScale = intensityRaw2Scaled(hdr, hi) this.volumes[volIdx].cal_min = mnScale this.volumes[volIdx].cal_max = mxScale this.onIntensityChange(this.volumes[volIdx]) } generateMouseUpCallback(fracStart: vec3, fracEnd: vec3): void { // calculate details for callback const tileStart = this.tileIndex(this.uiData.dragStart[0], this.uiData.dragStart[1]) const tileEnd = this.tileIndex(this.uiData.dragEnd[0], this.uiData.dragEnd[1]) // a tile index of -1 indicates invalid: drag not constrained to one tile let tileIdx = -1 if (tileStart === tileEnd) { tileIdx = tileEnd } let axCorSag = -1 if (tileIdx >= 0) { axCorSag = this.screenSlices[tileIdx].axCorSag } const mmStart = this.frac2mm(fracStart) const mmEnd = this.frac2mm(fracEnd) const v = vec3.create() vec3.sub(v, vec3.fromValues(mmStart[0], mmStart[1], mmStart[2]), vec3.fromValues(mmEnd[0], mmEnd[1], mmEnd[2])) const mmLength = vec3.len(v) const voxStart = this.frac2vox(fracStart) const voxEnd = this.frac2vox(fracEnd) this.onDragRelease({ fracStart, fracEnd, voxStart, voxEnd, mmStart, mmEnd, mmLength, tileIdx, axCorSag }) } // not included in public docs // handler for mouse button up (all buttons) // note: no test yet mouseUpListener(): void { function isFunction(test: unknown): boolean { return Object.prototype.toString.call(test).indexOf('Function') > -1 } // let fracPos = this.canvasPos2frac(this.mousePos); const uiData = { mouseButtonRightDown: this.uiData.mouseButtonRightDown, mouseButtonCenterDown: this.uiData.mouseButtonCenterDown, isDragging: this.uiData.isDragging, mousePos: this.mousePos, fracPos: this.canvasPos2frac(this.mousePos) // xyzMM: this.frac2mm(fracPos), } this.uiData.mousedown = false this.uiData.mouseButtonRightDown = false const wasCenterDown = this.uiData.mouseButtonCenterDown this.uiData.mouseButtonCenterDown = false this.uiData.mouseButtonLeftDown = false if (this.drawPenFillPts.length > 0) { this.drawPenFilled() } this.drawPenLocation = [NaN, NaN, NaN] this.drawPenAxCorSag = -1 if (isFunction(this.onMouseUp)) { this.onMouseUp(uiData) } if (this.uiData.isDragging) { this.uiData.isDragging = false if (this.opts.dragMode === DRAG_MODE.callbackOnly) { this.drawScene() } // hide selectionbox const fracStart = this.canvasPos2frac([this.uiData.dragStart[0], this.uiData.dragStart[1]]) const fracEnd = this.canvasPos2frac([this.uiData.dragEnd[0], this.uiData.dragEnd[1]]) this.generateMouseUpCallback(fracStart, fracEnd) // if roiSelection drag mode if (this.opts.dragMode === DRAG_MODE.roiSelection) { // do not call drawScene so that the selection box remains visible return } if (this.opts.dragMode !== DRAG_MODE.contrast) { return } if (wasCenterDown) { return } if (this.uiData.dragStart[0] === this.uiData.dragEnd[0] && this.uiData.dragStart[1] === this.uiData.dragEnd[1]) { return } this.calculateNewRange({ volIdx: 0 }) this.refreshLayers(this.volumes[0], 0) } this.drawScene() } // not included in public docs checkMultitouch(e: TouchEvent): void { if (this.uiData.touchdown && !this.uiData.multiTouchGesture) { const rect = this.canvas!.getBoundingClientRect() this.mouseDown(e.touches[0].clientX - rect.left, e.touches[0].clientY - rect.top) this.mouseClick(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 touchStartListener(e: TouchEvent): void { e.preventDefault() if (!this.uiData.touchTimer) { this.uiData.touchTimer = setTimeout(() => { // thi