UNPKG

@niivue/niivue

Version:

minimal webgl2 nifti image viewer

1,312 lines (1,191 loc) 513 kB
import { mat4, vec2, vec3, vec4 } from 'gl-matrix' import packageJson from '../../package.json' with { type: 'json' } import { TEXTURE_CONSTANTS } from './texture-constants' import { orientCube } from '@/orientCube' import { NiivueObject3D } from '@/niivue-object3D' import { LoadFromUrlParams, MeshType, NVMesh, NVMeshLayer } from '@/nvmesh' import defaultMatCap from '@/matcaps/Shiny.jpg' import defaultFontPNG from '@/fonts/Roboto-Regular.png' import defaultFontMetrics from '@/fonts/Roboto-Regular.json' with { type: 'json' } import { ColorMap, cmapper, COLORMAP_TYPE } from '@/colortables' import * as glUtils from '@/niivue/core/gl' import * as CoordTransform from '@/niivue/core/CoordinateTransform' import * as ShaderManager from '@/niivue/core/ShaderManager' import * as VolumeManager from '@/niivue/data/VolumeManager' import * as VolumeTexture from '@/niivue/data/VolumeTexture' import * as VolumeColormap from '@/niivue/data/VolumeColormap' import * as VolumeModulation from '@/niivue/data/VolumeModulation' import * as VolumeLayerRenderer from '@/niivue/data/VolumeLayerRenderer' import * as MeshManager from '@/niivue/data/MeshManager' import * as ConnectomeManager from '@/niivue/data/ConnectomeManager' import * as FileLoader from '@/niivue/data/FileLoader' import * as SliceRenderer from '@/niivue/rendering/SliceRenderer' import * as VolumeRenderer from '@/niivue/rendering/VolumeRenderer' import * as MeshRenderer from '@/niivue/rendering/MeshRenderer' import * as SceneRenderer from '@/niivue/rendering/SceneRenderer' import * as UIElementRenderer from '@/niivue/rendering/UIElementRenderer' import * as EventController from '@/niivue/interaction/EventController' import * as MouseController from '@/niivue/interaction/MouseController' import * as TouchController from '@/niivue/interaction/TouchController' import * as KeyboardController from '@/niivue/interaction/KeyboardController' import * as WheelController from '@/niivue/interaction/WheelController' import * as DragModeManager from '@/niivue/interaction/DragModeManager' import * as DropHandler from '@/niivue/interaction/DropHandler' import * as SliceNavigation from '@/niivue/navigation/SliceNavigation' import * as LayoutManager from '@/niivue/navigation/LayoutManager' import * as CameraController from '@/niivue/navigation/CameraController' import * as ClipPlaneManager from '@/niivue/navigation/ClipPlaneManager' import * as DrawingManager from '@/niivue/drawing/DrawingManager' import * as PenTool from '@/niivue/drawing/PenTool' import * as ShapeTool from '@/niivue/drawing/ShapeTool' import * as FloodFillTool from '@/niivue/drawing/FloodFillTool' import * as ImageProcessing from '@/niivue/processing/ImageProcessing' import { createMask, computeDescriptiveStats, scaleImageData } from '@/niivue/descriptives' import { NVDocument, NVConfigOptions, Scene, SLICE_TYPE, PEN_TYPE, SHOW_RENDER, DRAG_MODE, MULTIPLANAR_TYPE, DEFAULT_OPTIONS, ExportDocumentData, INITIAL_SCENE_DATA, MouseEventConfig, TouchEventConfig, CompletedMeasurement, CompletedAngle } from '@/nvdocument' import { LabelTextAlignment, LabelLineTerminator, NVLabel3D, NVLabel3DStyle, LabelAnchorPoint, LabelAnchorFlag } from '@/nvlabel' import { FreeSurferConnectome, NVConnectome } from '@/nvconnectome' import { NVImage, NVImageFromUrlOptions, NiiDataType, NiiIntentCode, ImageFromUrlOptions } from '@/nvimage' import { AffineTransform } from '@/nvimage/affineUtils' import { NVUtilities } from '@/nvutilities' import { NiivueEventMap, NiivueEvent, NiivueEventListener, NiivueEventListenerOptions } from '@/events' import { NVMeshUtilities } from '@/nvmesh-utilities' import { Connectome, LegacyConnectome, NVConnectomeNode, NiftiHeader, DragReleaseParams, NiiVueLocation, NiiVueLocationValue, SyncOpts, UIData, FontMetrics, ColormapListEntry, Graph, Descriptive, SliceScale, MvpMatrix2D, MM, SaveImageOptions } from '@/types' import { findBoundarySlices, interpolateMaskSlices, drawUndo } from '@/drawing' import { vertFontShader, fragFontShader, vertBmpShader, fragBmpShader, vertMeshShader, fragMeshDepthShader } from '@/shader-srcs' import { Shader } from '@/shader' import { log } from '@/logger' import { deg2rad, img2ras16, intensityRaw2Scaled, isRadiological, negMinMax, swizzleVec3, tickSpacing, unProject, unpackFloatFromVec4i, readFileAsDataURL } from '@/utils' import NVSerializer from '@/nvserializer' const { version } = packageJson type FontMetricsJsonBounds = { left: number bottom: number right: number top: number } type FontMetricsJson = { atlas: { type: string distanceRange: number size: number width: number height: number yOrigin: string } metrics: { emSize: number lineHeight: number ascender: number descender: number underlineY: number underlineThickness: number } glyphs: Array<{ unicode: number advance: number planeBounds?: FontMetricsJsonBounds atlasBounds?: FontMetricsJsonBounds }> kerning: unknown[] } export { NVMesh, NVMeshFromUrlOptions, NVMeshLayerDefaults } from '@/nvmesh' export { ColorTables as colortables, cmapper } from '@/colortables' export { NVImage, NVImageFromUrlOptions } from '@/nvimage' export { NVZarrHelper, ZarrChunkClient, ZarrChunkCache } from '@/nvimage/zarr' export type { NVZarrHelperOptions, ZarrPyramidInfo, ZarrPyramidLevel, ChunkCoord } from '@/nvimage/zarr' export * from '@/nvimage/affineUtils' // address rollup error - https://github.com/rollup/plugins/issues/71 export * from '@/nvdocument' export { NVUtilities } from '@/nvutilities' export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D, LabelAnchorPoint } from '@/nvlabel' export { NVMeshLoaders } from '@/nvmesh-loaders' export { NVMeshUtilities } from '@/nvmesh-utilities' export { MESH_EXTENSIONS, getFileExt, isMeshExt, getMediaByUrl, registerLoader, getLoader, isDicomExtension, traverseFileTree, readDirectory, readFileAsDataURL, handleDragEnter, handleDragOver } from '@/niivue/data/FileLoader' export type { LoaderRegistry, CustomLoader, GetFileExtOptions, RegisterLoaderParams, MeshLoaderResult } from '@/niivue/data/FileLoader' // 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' export { NiivueEvent } from '@/events' export type { NiivueEventMap, NiivueEventListener, NiivueEventListenerOptions } from '@/events' const { MESH_EXTENSIONS } = FileLoader const { LEFT_MOUSE_BUTTON, CENTER_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON } = MouseController // default SaveImageOptions const defaultSaveImageOptions: SaveImageOptions = { filename: '', isSaveDrawing: false, volumeByIndex: 0 } // Re-export types from FileLoader for backward compatibility export type DicomLoaderInput = FileLoader.DicomLoaderInput export type DicomLoader = FileLoader.DicomLoader /** * 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 extends EventTarget { loaders: FileLoader.LoaderRegistry = {} // create a dicom loader dicomLoader: FileLoader.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 useCustomGradientTexture = false // flag to indicate if a custom gradient texture is used renderGradientValues = false drawTexture: WebGLTexture | null = null // the GPU memory storage of the drawing drawSmoothedTexture: WebGLTexture | null = null // the GPU memory storage of the smoothed drawing paqdTexture: WebGLTexture | null = null // the GPU memory storage of the probabilistic atlas drawUndoBitmaps: Uint8Array[] = [] // array of drawBitmaps for undo drawLut = cmapper.makeDrawLut('$itksnap') // the color lookup table for drawing drawOpacity = 0.8 // opacity of drawing (default) drawRimOpacity = -1.0 // opacity of pixels at edge of drawing (negative value to use drawOpacity) 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 drawShapeStartLocation = [NaN, NaN, NaN] // start location for rectangle/ellipse drawing drawShapePreviewBitmap: Uint8Array | null = null // preview bitmap for shape drawing overlayTexture: WebGLTexture | null = null overlayTextureID: WebGLTexture | null = null sliceMMShader?: Shader slice2DShader?: 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 customSliceShader: Shader | null = null 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 orientShaderPAQD: Shader | null = null surfaceShader: Shader | null = null blurShader: Shader | null = null gradientPrePassShader: 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: FontMetricsJson = defaultFontMetrics // "/fonts/Roboto-Regular.json"; private fontMetrics?: FontMetricsJson private fontMets: FontMetrics | null = null private fontPx = 12 private legendFontScaling = 1 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 private _skipDragInDraw = 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, activeDragMode: null, activeDragButton: null, angleFirstLine: [0.0, 0.0, 0.0, 0.0], angleState: 'none', activeClipPlaneIndex: 0 } #eventsController: AbortController | null = null 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 } customLayout: Array<{ sliceType: SLICE_TYPE position: [number, number, number, number] // left, top, width, height sliceMM?: number }> = [] meshShaders: Array<{ Name: string; Frag: string; shader?: Shader }> = ShaderManager.createDefaultMeshShaders() // TODO just let users use DRAG_MODE instead dragModes = { contrast: DRAG_MODE.contrast, measurement: DRAG_MODE.measurement, angle: DRAG_MODE.angle, 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 = () => {} /** * callback function to run when the 3D zoom level changes * @example * niivue.onZoom3DChange = (zoom) => { * console.log('3D zoom scale: ', zoom) * } */ 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 = () => {} /** * Callback for when any configuration option changes. * @param propertyName - The name of the option that changed. * @param newValue - The new value of the option. * @param oldValue - The previous value of the option. */ onOptsChange: (propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void = () => {} /** Callback when a distance measurement is completed */ onMeasurementCompleted: (measurement: CompletedMeasurement) => void = () => {} /** Callback when an angle measurement is completed */ onAngleCompleted: (angle: CompletedAngle) => void = () => {} /** Callback when the drawing pen value changes */ onPenValueChanged: (penValue: number, isFilledPen: boolean) => void = () => {} /** Callback when the active drawing tool changes */ onDrawingToolChanged: (tool: string, penValue: number, isFilledPen: boolean) => void = () => {} /** Callback when any volume is removed from the scene */ onVolumeRemoved: (volume: NVImage, index: number) => void = () => {} /** Callback when any mesh is removed from the scene */ onMeshRemoved: (mesh: NVMesh) => void = () => {} /** Callback when the slice type (view layout) changes */ onSliceTypeChange: (sliceType: SLICE_TYPE) => void = () => {} /** Callback when the drawing bitmap materially changes */ onDrawingChanged: (action: string) => void = () => {} /** Callback when drawing mode is toggled on or off */ onDrawingEnabled: (enabled: boolean) => void = () => {} /** Callback when volume stacking order changes */ onVolumeOrderChanged: (volumes: NVImage[]) => void = () => {} document = new NVDocument() /** Get the current scene configuration. */ get scene(): Scene { return this.document.scene } /** Get the current visualization options. */ get opts(): NVConfigOptions { return this.document.opts } /** Get the slice mosaic layout string. */ get sliceMosaicString(): string { return this.document.opts.sliceMosaicString || '' } /** Set the slice mosaic layout string. */ set sliceMosaicString(newSliceMosaicString: string) { this.document.opts.sliceMosaicString = newSliceMosaicString } /** * Get whether voxels below minimum intensity are drawn as dark or transparent. * @returns {boolean} True if dark voxels are opaque, false if transparent. */ get isAlphaClipDark(): boolean { return this.document.opts.isAlphaClipDark } /** * Set whether voxels below minimum intensity are drawn as dark or transparent. * @param {boolean} newVal - True to make dark voxels opaque, false for transparent. * @see {@link https://niivue.com/demos/features/segment.html | live demo usage} */ set isAlphaClipDark(newVal: boolean) { 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) { super() // Call EventTarget constructor // 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) // Set up opts change watching this.document.setOptsChangeCallback((propertyName, newValue, oldValue) => { this._emitEvent('optsChange', { propertyName, newValue, oldValue }) this.onOptsChange(propertyName, newValue, oldValue) }) // Set up scene callbacks to emit events this.scene.onZoom3DChange = (zoom: number): void => { this._emitEvent('zoom3DChange', { zoom }) this.onZoom3DChange(zoom) } } /** * Type-safe addEventListener for Niivue events. * Supports all standard EventTarget options including once, capture, passive, and signal with AbortController. * @param type - Event name * @param listener - Event listener function * @param options - Event listener options (capture, once, passive, signal) * @example * ```typescript * niivue.addEventListener('locationChange', (event) => { * console.log('Location changed:', event.detail) * }) * * // With once option * niivue.addEventListener('imageLoaded', handler, { once: true }) * * // With AbortController * const controller = new AbortController() * niivue.addEventListener('locationChange', handler, { signal: controller.signal }) * controller.abort() // removes the listener * ``` */ addEventListener<K extends keyof NiivueEventMap>(type: K, listener: NiivueEventListener<K>, options?: NiivueEventListenerOptions): void addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: NiivueEventListenerOptions): void { super.addEventListener(type, listener as EventListener, options) } /** * Type-safe removeEventListener for Niivue events. * @param type - Event name * @param listener - Event listener function to remove * @param options - Event listener options */ removeEventListener<K extends keyof NiivueEventMap>(type: K, listener: NiivueEventListener<K>, options?: NiivueEventListenerOptions): void removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: NiivueEventListenerOptions): void { super.removeEventListener(type, listener as EventListener, options) } /** * Internal helper to emit events alongside legacy callbacks. * Events fire BEFORE callbacks. * @private */ private _emitEvent<K extends keyof NiivueEventMap>(eventName: K, detail: NiivueEventMap[K]): void { try { const event = new NiivueEvent(eventName, detail) this.dispatchEvent(event) } catch (error) { // Log event listener errors but don't break execution console.error(`Error in ${eventName} event listener:`, error) } } /** * 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 observers and listeners EventController.cleanupResizeObservers(this.resizeObserver, this.canvasObserver, this.resizeEventListener) this.resizeEventListener = null this.resizeObserver = null this.canvasObserver = null // Remove all interaction event listeners EventController.cleanupEventController(this.#eventsController) this.#eventsController = null // Clean up opts change callback this.document.removeOptsChangeCallback() // 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.com/demos/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.com/demos/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)) * @see {@link https://niivue.com/demos/features/dsistudio.html | live demo usage} */ 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) } const { gl, max2D, max3D } = glUtils.initGL(this.canvas, isAntiAlias) this.gl = gl this.uiData.max2D = max2D this.uiData.max3D = max3D log.info('NIIVUE VERSION ', version) log.debug(`Max texture size 2D: ${this.uiData.max2D} 3D: ${this.uiData.max3D}`) // 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) { EventController.applyCanvasResizeStyles(this.canvas) this.canvas.width = this.canvas.offsetWidth this.canvas.height = this.canvas.offsetHeight // Store a reference to the bound event handler function this.resizeEventListener = EventController.createResizeHandler(() => this.resizeListener()) window.addEventListener('resize', this.resizeEventListener) this.resizeObserver = EventController.createResizeObserver(() => this.resizeListener()) this.resizeObserver.observe(this.canvas.parentElement!) // Setup a MutationObserver to detect when canvas is removed from DOM this.canvasObserver = EventController.createCanvasObserver((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.com/demos/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.com/demos/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 } /** * Synchronizes 3D view settings (azimuth, elevation, scale) with another Niivue instance. * @internal */ doSync3d(otherNV: Niivue): void { otherNV.scene.renderAzimuth = this.scene.renderAzimuth otherNV.scene.renderElevation = this.scene.renderElevation otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier } /** * Synchronizes 2D crosshair position and pan settings with another Niivue instance. * @internal */ 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) } } /** * Synchronizes gamma correction setting with another Niivue instance. * @internal */ doSyncZoomPan(otherNV: Niivue): void { otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm) } /** * Synchronizes crosshair position with another Niivue instance. * @internal */ doSyncCrosshair(otherNV: Niivue): void { const thisMM = this.frac2mm(this.scene.crosshairPos) otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM) } /** * Synchronizes cal_min with another Niivue instance, updating GPU volume only if needed. * @internal */ 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() } } /** * Synchronizes cal_max with another Niivue instance, updating GPU volume only if needed. * @internal */ 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() } } /** * Synchronizes slice view type with another Niivue instance. * @internal */ doSyncSliceType(otherNV: Niivue): void { otherNV.setSliceType(this.opts.sliceType) } /** * Synchronizes clip plane settings with another Niivue instance. * @internal */ doSyncClipPlane(otherNV: Niivue): void { otherNV.setClipPlane(this.scene.clipPlaneDepthAziElevs[this.uiData.activeClipPlaneIndex]) } /** * 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]) } // do not redraw for other instances on the same canvas if (this.otherNV[i].canvas !== this.canvas) { this.otherNV[i].drawScene() } this.otherNV[i].createOnLocationChange() } } /** Not documented publicly for now * @internal * 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]) } /** * @internal * Compute point size for screen text that scales with resolution and screen size. * - Keeps physical font size consistent across different DPIs. * - Uses fontSizeScaling to scale with canvas size above a reference threshold. */ textSizePoints(): void { if (this.opts.textHeight >= 0) { log.warn(`textHeight is deprecated (use fontMinPx and fontSizeScaling)`) this.opts.fontMinPx = this.opts.textHeight * 217 this.opts.fontSizeScaling = 0.4 this.opts.textHeight = -1.0 } const dpi = this.uiData.dpr || 1 // basePointSize is defined in screen points (independent of dpi) const basePointSize = this.opts.fontMinPx // Convert canvas width/height to screen points (divide by dpr) const screenWidthPts = this.gl.canvas.width / dpi const screenHeightPts = this.gl.canvas.height / dpi const screenAreaPts = screenWidthPts * screenHeightPts // Reference screen area in points (800×600) const refAreaPts = 800 * 600 const normalizedArea = Math.max(screenAreaPts / refAreaPts, 1) // Power-law scaling const scale = Math.pow(normalizedArea, this.opts.fontSizeScaling) // Convert to pixels: multiply by dpi const fontPx = basePointSize * scale * dpi this.fontPx = fontPx log.debug( `${screenWidthPts.toFixed(0)}x${screenHeightPts.toFixed(0)} pts (dpi=${dpi}) => areaScale=${normalizedArea.toFixed(2)}, ` + `scale=${scale.toFixed(2)}, minPx=${this.opts.fontMinPx} fontScale=${this.opts.fontSizeScaling} fontPx=${fontPx.toFixed(2)}` ) } /** * callback function to handle resize window events, redraws the scene. * @internal */ resizeListener(): void { // Use _gl directly to avoid getter that throws when null if (!EventController.isValidForResize(this.canvas, this._gl)) { return } if (!this.opts.isResizeCanvas) { if (this.opts.forceDevicePixelRatio >= 0) { log.warn('this.opts.forceDevicePixelRatio requires isResizeCanvas') } this.drawScene() return } EventController.applyCanvasResizeStyles(this.canvas) // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html // https://www.khronos.org/webgl/wiki/HandlingHighDPI const resizeResult = EventController.calculateResizeDimensions({ canvas: this.canvas, gl: this.gl, isResizeCanvas: this.opts.isResizeCanvas, forceDevicePixelRatio: this.opts.forceDevicePixelRatio }) this.uiData.dpr = resizeResult.dpr log.debug('devicePixelRatio: ' + this.uiData.dpr) this.canvas.width = resizeResult.width this.canvas.height = resizeResult.height this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height) this.textSizePoints() this.drawScene() } /** * 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 { return EventController.getRelativeMousePosition(event, target) } /** * Returns mouse position relative to the canvas, excluding padding and borders. * @internal */ getNoPaddingNoBorderCanvasRelativeMousePosition(event: MouseEvent, target: EventTarget): { x: number; y: number } | undefined { return EventController.getNoPaddingNoBorderCanvasRelativeMousePosition(event, target) } /** * Disables the default context menu to allow custom right-click behavior. * @internal */ mouseContextMenuListener(e: MouseEvent): void { e.preventDefault() } /** * Handles mouse down events for interaction, segmentation, and connectome label selection. * Routes to appropriate button handler based on click type. * @internal */ mouseDownListener(e: MouseEvent): void { this.uiData.mousedown = true if (!this.eventInBounds(e)) { this.opts.showBoundsBorder = false this.drawScene() return } else if (this.opts.bounds) { this.opts.showBoundsBorder = true } e.preventDefault() // var rect = this.canvas.getBoundingClientRect(); this.drawPenLocation = [NaN, NaN, NaN] this.drawPenAxCorSag = -1 this.drawShapeStartLocation = [NaN, NaN, NaN] // Reset shape start location // record tile where mouse clicked const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas) // reset drag positions used previously (but not during angle measurement second line) if (!(this.opts.dragMode === DRAG_MODE.angle && this.uiData.angleState === 'drawing_second_line')) { this.setDragStart(pos.x, pos.y) this.setDragEnd(pos.x, pos.y) } log.debug('mouse down') log.debug(e) 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, e) return } // find associated mesh for (const mesh of this.meshes) { if (mesh.type !== MeshType.CONNECTOME) { if (Array.isArray(label.points) && label.points.length === 3 && label.points.every(Number.isFinite)) { const [x, y, z] = label.points as [number, number, number] this.scene.crosshairPos = this.mm2frac([x, y, z]) this.updateGLVolume() // this.drawScene() } 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