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