@niivue/niivue
Version:
minimal webgl2 nifti image viewer
1,312 lines (1,191 loc) • 513 kB
text/typescript
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