@niivue/niivue 0.44.2 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,7 +79,14 @@ import {
79
79
  INITIAL_SCENE_DATA
80
80
  } from '../nvdocument.js'
81
81
 
82
- import { LabelTextAlignment, LabelLineTerminator, NVLabel3D, NVLabel3DStyle } from '../nvlabel.js'
82
+ import {
83
+ LabelTextAlignment,
84
+ LabelLineTerminator,
85
+ NVLabel3D,
86
+ NVLabel3DStyle,
87
+ LabelAnchorPoint,
88
+ LabelAnchorFlag
89
+ } from '../nvlabel.js'
83
90
  import { FreeSurferConnectome, NVConnectome } from '../nvconnectome.js'
84
91
  import {
85
92
  NVImage,
@@ -90,6 +97,7 @@ import {
90
97
  ImageFromUrlOptions
91
98
  } from '../nvimage/index.js'
92
99
  import { NVUtilities } from '../nvutilities.js'
100
+ import { NVMeshUtilities } from '../nvmesh-utilities.js'
93
101
  import {
94
102
  Connectome,
95
103
  LegacyConnectome,
@@ -122,7 +130,7 @@ export { NVImage, NVImageFromUrlOptions } from '../nvimage/index.js'
122
130
  // address rollup error - https://github.com/rollup/plugins/issues/71
123
131
  export * from '../nvdocument.js'
124
132
  export { NVUtilities } from '../nvutilities.js'
125
- export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D } from '../nvlabel.js'
133
+ export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D, LabelAnchorPoint } from '../nvlabel.js'
126
134
  export { NVMeshLoaders } from '../nvmesh-loaders.js'
127
135
  export { NVMeshUtilities } from '../nvmesh-utilities.js'
128
136
 
@@ -322,6 +330,7 @@ const defaultSaveImageOptions: SaveImageOptions = {
322
330
  * let niivue = new Niivue({crosshairColor: [0,1,0,0.5], textHeight: 0.5}) // a see-through green crosshair, and larger text labels
323
331
  */
324
332
  export class Niivue {
333
+ loaders = {}
325
334
  canvas: HTMLCanvasElement | null = null // the reference to the canvas element on the page
326
335
  _gl: WebGL2RenderingContext | null = null // the gl context
327
336
  isBusy = false // flag to indicate if the scene is busy drawing
@@ -956,10 +965,18 @@ export class Niivue {
956
965
  const thisMM = this.frac2mm(this.scene.crosshairPos)
957
966
  // if this.otherNV is an object, then it is a single Niivue instance
958
967
  if (this.otherNV instanceof Niivue) {
968
+ // gamma not dependent on 2d/3d
969
+ const thisGamma = this.scene.gamma
970
+ const otherGamma = this.otherNV.scene.gamma
971
+ if (thisGamma !== otherGamma) {
972
+ this.otherNV.setGamma(thisGamma)
973
+ }
974
+ // options specific to 2d rendering
959
975
  if (this.syncOpts['2d']) {
960
976
  this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM)
961
977
  this.otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
962
978
  }
979
+ // options specific to 3d rendering
963
980
  if (this.syncOpts['3d']) {
964
981
  this.otherNV.scene.renderAzimuth = this.scene.renderAzimuth
965
982
  this.otherNV.scene.renderElevation = this.scene.renderElevation
@@ -972,6 +989,12 @@ export class Niivue {
972
989
  if (this.otherNV[i] === this) {
973
990
  continue
974
991
  }
992
+ // gamma not dependent on 2d/3d
993
+ const thisGamma = this.scene.gamma
994
+ const otherGamma = this.otherNV[i].scene.gamma
995
+ if (thisGamma !== otherGamma) {
996
+ this.otherNV[i].setGamma(thisGamma)
997
+ }
975
998
  if (this.syncOpts['2d']) {
976
999
  this.otherNV[i].scene.crosshairPos = this.otherNV[i].mm2frac(thisMM)
977
1000
  this.otherNV[i].scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
@@ -1104,6 +1127,11 @@ export class Niivue {
1104
1127
  const [x, y] = [pos.x * this.uiData.dpr!, pos.y * this.uiData.dpr!]
1105
1128
  const label = this.getLabelAtPoint([x, y])
1106
1129
  if (label) {
1130
+ // check for user defined onclick handler
1131
+ if (label.onClick) {
1132
+ label.onClick(label)
1133
+ return
1134
+ }
1107
1135
  // find associated mesh
1108
1136
  for (const mesh of this.meshes) {
1109
1137
  if (mesh.type !== MeshType.CONNECTOME) {
@@ -1452,6 +1480,16 @@ export class Niivue {
1452
1480
  this.drawScene()
1453
1481
  this.uiData.prevX = this.uiData.currX
1454
1482
  this.uiData.prevY = this.uiData.currY
1483
+ } else if (!this.uiData.mousedown && this.opts.clickToSegment) {
1484
+ const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas)
1485
+ // ignore if mouse moves outside of tile of initial click
1486
+ if (!pos) {
1487
+ return
1488
+ }
1489
+ const x = pos.x * this.uiData.dpr!
1490
+ const y = pos.y * this.uiData.dpr!
1491
+ this.mousePos = [x, y]
1492
+ this.drawScene()
1455
1493
  }
1456
1494
  }
1457
1495
 
@@ -1740,6 +1778,13 @@ export class Niivue {
1740
1778
  if (ext === 'GZ') {
1741
1779
  ext = re.exec(fullname.slice(0, -3))![1] // img.trk.gz -> img.trk
1742
1780
  ext = ext.toUpperCase()
1781
+ } else if (ext === 'CBOR') {
1782
+ // we want to keep cbor WITH the extension before it.
1783
+ // e.g. if fullname is img.iwi.cbor, we want the ext to be iwi.cbor
1784
+ const endExt = ext
1785
+ ext = re.exec(fullname.slice(0, -5))![1] // img.iwi.cbor -> iwi.cbor
1786
+ ext = ext.toUpperCase()
1787
+ ext = `${ext}.${endExt}`
1743
1788
  }
1744
1789
  return upperCase ? ext : ext.toLowerCase() // developer can choose to have extensions as upper or lower
1745
1790
  }
@@ -1762,6 +1807,33 @@ export class Niivue {
1762
1807
 
1763
1808
  async addVolumesFromUrl(imageOptionsArray: ImageFromUrlOptions[]): Promise<NVImage[]> {
1764
1809
  const promises = imageOptionsArray.map(async (imageItem) => {
1810
+ // first check this.loaders to see if the user has
1811
+ // registered a custom loader for this file type.
1812
+ // if so, use that loader to load the file.
1813
+ const ext = this.getFileExt(imageItem.url)
1814
+ if (this.loaders[ext]) {
1815
+ let itemToLoad: string | Uint8Array | ArrayBuffer = imageItem.url
1816
+ const toExt = this.loaders[ext].toExt
1817
+ let name = imageItem.name || imageItem.url
1818
+ // in case the name is a url, just get the basename without the slashes
1819
+ name = name.split('/').pop()
1820
+ // if url is a string fetch the file first
1821
+ if (typeof imageItem.url === 'string') {
1822
+ const url = imageItem.url
1823
+ try {
1824
+ const response = await fetch(url)
1825
+ if (!response.ok) {
1826
+ throw new Error(`Failed to load file: ${response.statusText}`)
1827
+ }
1828
+ itemToLoad = await response.arrayBuffer()
1829
+ } catch (error) {
1830
+ throw new Error(`Failed to load url ${url}: ${error}`)
1831
+ }
1832
+ }
1833
+ const buffer = await this.loaders[ext].loader(itemToLoad)
1834
+ imageItem.url = buffer
1835
+ imageItem.name = `${name}.${toExt}`
1836
+ }
1765
1837
  const imageOptions = {
1766
1838
  url: imageItem.url!,
1767
1839
  headers: imageItem.headers,
@@ -1917,6 +1989,41 @@ export class Niivue {
1917
1989
  })
1918
1990
  }
1919
1991
 
1992
+ /* useLoader
1993
+
1994
+ registers an external file loader for niivue to use when reading files.
1995
+
1996
+ the loader must return an array buffer of the file contents for niivue
1997
+ to parse and the extension of the file so niivue can infer the file type to load.
1998
+
1999
+ example:
2000
+
2001
+ const myCustomLoader = (File) => {
2002
+ // ... do parsing here ...
2003
+ return {
2004
+ arrayBuffer: ArrayBuffer,
2005
+ fileExt: 'iwi.cbor',
2006
+ positions: Float32Array | [], // allows for mesh loading to mz3
2007
+ indices: Uint32Array | [], // allows for mesh loading to mz3
2008
+ }
2009
+
2010
+ nv.useLoader({
2011
+ loader: myCustomLoader,
2012
+ fileExt: 'iwi.cbor',
2013
+ toExt: 'nii'
2014
+ })
2015
+
2016
+ */
2017
+ useLoader(loader: unknown, fileExt: string, toExt: string): void {
2018
+ this.loaders = {
2019
+ ...this.loaders,
2020
+ [fileExt.toUpperCase()]: {
2021
+ loader,
2022
+ toExt
2023
+ }
2024
+ }
2025
+ }
2026
+
1920
2027
  // not included in public docs
1921
2028
  dropListener(e: DragEvent): void {
1922
2029
  e.stopPropagation()
@@ -3766,6 +3873,45 @@ export class Niivue {
3766
3873
  return this.document.download(fileName, compress)
3767
3874
  }
3768
3875
 
3876
+ // generic loadImages that wraps loadVolumes and loadMeshes
3877
+ async loadImages(images: Array<ImageFromUrlOptions | LoadFromUrlParams>): Promise<this> {
3878
+ const volumes = []
3879
+ const meshes = []
3880
+ for (const image of images) {
3881
+ if ('url' in image) {
3882
+ const ext = this.getFileExt(image.url)
3883
+ // check this.loaders to see if a user has register
3884
+ // a custom loader for this file extension
3885
+ if (this.loaders[ext]) {
3886
+ // check if the loader type property is a volume or mesh
3887
+ // by using the toExt property
3888
+ const toExt = this.loaders[ext].toExt.toUpperCase()
3889
+ if (MESH_EXTENSIONS.includes(toExt)) {
3890
+ meshes.push(image)
3891
+ } else {
3892
+ volumes.push(image)
3893
+ }
3894
+ // continue to the next image
3895
+ continue
3896
+ }
3897
+
3898
+ if (MESH_EXTENSIONS.includes(ext.toUpperCase())) {
3899
+ meshes.push(image)
3900
+ } else {
3901
+ volumes.push(image)
3902
+ }
3903
+ }
3904
+ }
3905
+ if (volumes.length > 0) {
3906
+ await this.loadVolumes(volumes as ImageFromUrlOptions[])
3907
+ }
3908
+ if (meshes.length > 0) {
3909
+ await this.loadMeshes(meshes as LoadFromUrlParams[])
3910
+ }
3911
+
3912
+ return this
3913
+ }
3914
+
3769
3915
  /**
3770
3916
  * load an array of volume objects
3771
3917
  * @param volumeList - the array of objects to load. each object must have a resolvable "url" property at a minimum
@@ -3790,30 +3936,33 @@ export class Niivue {
3790
3936
  this.gl.clear(this.gl.COLOR_BUFFER_BIT)
3791
3937
  // if more than one volume, then fetch them all simultaneously
3792
3938
  // using addVolumesFromUrl (note the "s" in "Volumes")
3793
- if (volumeList.length > 1) {
3794
- await this.addVolumesFromUrl(volumeList)
3795
- return this
3796
- }
3797
- const imageOptions = {
3798
- url: volumeList[0].url!,
3799
- headers: volumeList[0].headers,
3800
- name: volumeList[0].name,
3801
- colormap: volumeList[0].colormap ? volumeList[0].colormap : volumeList[0].colorMap,
3802
- colormapNegative: volumeList[0].colormapNegative
3803
- ? volumeList[0].colormapNegative
3804
- : volumeList[0].colorMapNegative,
3805
- opacity: volumeList[0].opacity,
3806
- urlImgData: volumeList[0].urlImgData,
3807
- cal_min: volumeList[0].cal_min,
3808
- cal_max: volumeList[0].cal_max,
3809
- trustCalMinMax: this.opts.trustCalMinMax,
3810
- isManifest: volumeList[0].isManifest,
3811
- frame4D: volumeList[0].frame4D,
3812
- limitFrames4D: volumeList[0].limitFrames4D || this.opts.limitFrames4D,
3813
- colorbarVisible: volumeList[0].colorbarVisible
3814
- }
3815
- await this.addVolumeFromUrl(imageOptions)
3939
+ // if (volumeList.length > 1) {
3940
+ // await this.addVolumesFromUrl(volumeList)
3941
+ // return this
3942
+ // }
3943
+
3944
+ await this.addVolumesFromUrl(volumeList)
3816
3945
  return this
3946
+ // const imageOptions = {
3947
+ // url: volumeList[0].url!,
3948
+ // headers: volumeList[0].headers,
3949
+ // name: volumeList[0].name,
3950
+ // colormap: volumeList[0].colormap ? volumeList[0].colormap : volumeList[0].colorMap,
3951
+ // colormapNegative: volumeList[0].colormapNegative
3952
+ // ? volumeList[0].colormapNegative
3953
+ // : volumeList[0].colorMapNegative,
3954
+ // opacity: volumeList[0].opacity,
3955
+ // urlImgData: volumeList[0].urlImgData,
3956
+ // cal_min: volumeList[0].cal_min,
3957
+ // cal_max: volumeList[0].cal_max,
3958
+ // trustCalMinMax: this.opts.trustCalMinMax,
3959
+ // isManifest: volumeList[0].isManifest,
3960
+ // frame4D: volumeList[0].frame4D,
3961
+ // limitFrames4D: volumeList[0].limitFrames4D || this.opts.limitFrames4D,
3962
+ // colorbarVisible: volumeList[0].colorbarVisible
3963
+ // }
3964
+ // await this.addVolumeFromUrl(imageOptions)
3965
+ // return this
3817
3966
  }
3818
3967
 
3819
3968
  /**
@@ -3844,7 +3993,34 @@ export class Niivue {
3844
3993
  */
3845
3994
  async addMeshesFromUrl(meshOptions: LoadFromUrlParams[]): Promise<NVMesh[]> {
3846
3995
  const promises = meshOptions.map(async (meshItem) => {
3996
+ // first check this.loaders to see if the user has
3997
+ // registered a custom loader for this file type.
3998
+ // if so, use that loader to load the file.
3847
3999
  const ext = this.getFileExt(meshItem.url)
4000
+ if (this.loaders[ext]) {
4001
+ let itemToLoad: string | Uint8Array | ArrayBuffer = meshItem.url
4002
+ const toExt = this.loaders[ext].toExt
4003
+ let name = meshItem.name || meshItem.url
4004
+ // in case the name is a url, just get the basename without the slashes
4005
+ name = name.split('/').pop()
4006
+ if (typeof meshItem.url === 'string') {
4007
+ const url = meshItem.url
4008
+ try {
4009
+ const response = await fetch(url)
4010
+ if (!response.ok) {
4011
+ throw new Error(`Failed to load file: ${response.statusText}`)
4012
+ }
4013
+ itemToLoad = await response.arrayBuffer()
4014
+ } catch (error) {
4015
+ throw new Error(`Failed to load url ${url}: ${error}`)
4016
+ }
4017
+ }
4018
+ const { positions, indices } = await this.loaders[ext].loader(itemToLoad)
4019
+ meshItem.name = `${name}.${toExt}`
4020
+ const mz3 = NVMeshUtilities.createMZ3(positions, indices, false)
4021
+ meshItem.buffer = mz3
4022
+ // return await this.loadFromArrayBuffer(mz3, meshItem.name)
4023
+ }
3848
4024
  if (ext === 'JCON' || ext === 'JSON') {
3849
4025
  const response = await fetch(meshItem.url, {})
3850
4026
  const json = await response.json()
@@ -3893,17 +4069,17 @@ export class Niivue {
3893
4069
 
3894
4070
  // if more than one mesh, then fetch them all simultaneously
3895
4071
  // using addMeshesFromUrl (note the "s" in "Meshes")
3896
- if (meshList.length > 1) {
3897
- await this.addMeshesFromUrl(meshList)
3898
- this.updateGLVolume()
3899
- this.drawScene()
3900
- return this
3901
- }
3902
-
3903
- await this.addMeshFromUrl(meshList[0])
4072
+ // if (meshList.length > 1) {
4073
+ await this.addMeshesFromUrl(meshList)
3904
4074
  this.updateGLVolume()
3905
4075
  this.drawScene()
3906
4076
  return this
4077
+ // }
4078
+
4079
+ // await this.addMeshFromUrl(meshList[0])
4080
+ // this.updateGLVolume()
4081
+ // this.drawScene()
4082
+ // return this
3907
4083
  }
3908
4084
 
3909
4085
  /**
@@ -6801,6 +6977,7 @@ export class Niivue {
6801
6977
  * @see {@link https://niivue.github.io/niivue/features/colormaps.html | live demo usage}
6802
6978
  */
6803
6979
  setGamma(gamma = 1.0): void {
6980
+ this.scene.gamma = gamma
6804
6981
  cmapper.gamma = gamma
6805
6982
  this.updateGLVolume()
6806
6983
  }
@@ -7212,7 +7389,10 @@ export class Niivue {
7212
7389
  this.scene.crosshairPos = vec3.clone(texFrac)
7213
7390
  }
7214
7391
  if (this.opts.drawingEnabled) {
7392
+ // drawing done in voxels
7215
7393
  const pt = this.frac2vox(this.scene.crosshairPos) as [number, number, number]
7394
+ // radius given in mm
7395
+ const ptMM = this.frac2mm(this.scene.crosshairPos)
7216
7396
  // if click-to-segment enabled
7217
7397
  if (this.opts.clickToSegment) {
7218
7398
  const radius = this.opts.clickToSegmentRadius
@@ -7222,11 +7402,15 @@ export class Niivue {
7222
7402
  this.drawPenAxCorSag = axCorSag
7223
7403
  for (let i = 1; i <= steps; i++) {
7224
7404
  const angle = (i / steps) * 2 * Math.PI
7225
- let xVox = pt[0] + radius * Math.cos(angle)
7226
- let yVox = pt[1] + radius * Math.sin(angle)
7227
- const zVox = pt[2]
7228
- xVox = Math.round(xVox)
7229
- yVox = Math.round(yVox)
7405
+ // get the x,y,z in mm since radius given in mm
7406
+ const xMM = ptMM[0] + radius * Math.cos(angle)
7407
+ const yMM = ptMM[1] + radius * Math.sin(angle)
7408
+ const zMM = ptMM[2]
7409
+ // convert x,y,z in mm to voxels for drawing
7410
+ const xVox = this.back.mm2vox([xMM, yMM, zMM])[0]
7411
+ const yVox = this.back.mm2vox([xMM, yMM, zMM])[1]
7412
+ const zVox = this.back.mm2vox([xMM, yMM, zMM])[2]
7413
+ // draw the point
7230
7414
  this.drawPt(xVox, yVox, zVox, this.opts.penValue)
7231
7415
  this.drawPenFillPts.push([xVox, yVox, pt[2]])
7232
7416
  // fill in the circle if we are at the last step.
@@ -7556,9 +7740,26 @@ export class Niivue {
7556
7740
  return labels
7557
7741
  }
7558
7742
 
7743
+ getConnectomeLabels(): NVLabel3D[] {
7744
+ const connectomes = this.meshes.filter((m) => m.type === MeshType.CONNECTOME)
7745
+ const meshNodes = connectomes.flatMap((m) => m.nodes as NVConnectomeNode[])
7746
+ const meshLabels = meshNodes.map((n) => n.label)
7747
+ // filter our undefined labels
7748
+ const definedMeshLabels = meshLabels.filter((l): l is NVLabel3D => l !== undefined)
7749
+ // get all of our non-anchored labels
7750
+ const nonAnchoredLabels = this.document.labels.filter((l) => l.anchor == null || l.anchor === LabelAnchorPoint.NONE)
7751
+ // get the unique set of unanchored labels
7752
+ const nonAnchoredLabelSet = new Set(definedMeshLabels)
7753
+ for (const label of nonAnchoredLabels) {
7754
+ nonAnchoredLabelSet.add(label)
7755
+ }
7756
+
7757
+ return Array.from(nonAnchoredLabelSet)
7758
+ }
7759
+
7559
7760
  getBulletMarginWidth(): number {
7560
7761
  let bulletMargin = 0
7561
- const labels = this.getAllLabels()
7762
+ const labels = this.getConnectomeLabels()
7562
7763
  if (labels.length === 0) {
7563
7764
  return 0
7564
7765
  }
@@ -7583,7 +7784,7 @@ export class Niivue {
7583
7784
  }
7584
7785
 
7585
7786
  getLegendPanelWidth(): number {
7586
- const labels = this.getAllLabels()
7787
+ const labels = this.getConnectomeLabels()
7587
7788
  if (!this.opts.showLegend || labels.length === 0) {
7588
7789
  return 0
7589
7790
  }
@@ -7614,7 +7815,7 @@ export class Niivue {
7614
7815
  }
7615
7816
 
7616
7817
  getLegendPanelHeight(): number {
7617
- const labels = this.getAllLabels()
7818
+ const labels = this.getConnectomeLabels()
7618
7819
  let height = 0
7619
7820
  const scale = 1.0 // we may want to make this adjustable in the future
7620
7821
  const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
@@ -8129,7 +8330,11 @@ export class Niivue {
8129
8330
 
8130
8331
  // not included in public docs
8131
8332
  // show text labels for L/R, A/P, I/S dimensions
8132
- drawSliceOrientationText(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE): void {
8333
+ drawSliceOrientationText(
8334
+ leftTopWidthHeight: number[],
8335
+ axCorSag: SLICE_TYPE,
8336
+ padLeftTop: number[] = [NaN, NaN]
8337
+ ): void {
8133
8338
  this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
8134
8339
  let topText = 'S'
8135
8340
  if (axCorSag === SLICE_TYPE.AXIAL) {
@@ -8143,9 +8348,32 @@ export class Niivue {
8143
8348
  this.drawTextRightBelow([leftTopWidthHeight[0], leftTopWidthHeight[1]], leftText + topText)
8144
8349
  return
8145
8350
  }
8146
- this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8147
-
8148
- this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8351
+ let drawBelow = true
8352
+ let drawRight = true
8353
+ if (!isNaN(padLeftTop[0])) {
8354
+ const ht = this.opts.textHeight * this.gl.canvas.height + 2
8355
+ if (padLeftTop[1] > ht) {
8356
+ this.drawTextBelow(
8357
+ [leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1] + padLeftTop[1] - ht],
8358
+ topText
8359
+ )
8360
+ drawBelow = false
8361
+ }
8362
+ const wid = this.textWidth(ht, leftText) + 2
8363
+ if (padLeftTop[0] > wid) {
8364
+ this.drawTextRight(
8365
+ [leftTopWidthHeight[0] + padLeftTop[0] - wid, leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5],
8366
+ leftText
8367
+ )
8368
+ drawRight = false
8369
+ }
8370
+ }
8371
+ if (drawBelow) {
8372
+ this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8373
+ }
8374
+ if (drawRight) {
8375
+ this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8376
+ }
8149
8377
  }
8150
8378
 
8151
8379
  // not included in public docs
@@ -8188,7 +8416,7 @@ export class Niivue {
8188
8416
 
8189
8417
  // not included in public docs
8190
8418
  // draw 2D tile
8191
- draw2D(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8419
+ draw2DMain(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8192
8420
  let frac2mmTexture = this.volumes[0].frac2mm!.slice()
8193
8421
  let screen = this.screenFieldOfViewExtendedMM(axCorSag)
8194
8422
  let mesh2ortho = mat4.create()
@@ -8217,8 +8445,10 @@ export class Niivue {
8217
8445
  elevation = isRadiolgical ? -90 : 90
8218
8446
  }
8219
8447
  const gl = this.gl
8448
+ let isStretchToScreen = false
8220
8449
  if (leftTopWidthHeight[2] === 0 || leftTopWidthHeight[3] === 0) {
8221
8450
  // only one tile: stretch tile to fill whole screen.
8451
+ isStretchToScreen = true
8222
8452
  const pixPerMMw = gl.canvas.width / screen.fovMM[0]
8223
8453
  const pixPerMMh = gl.canvas.height / screen.fovMM[1]
8224
8454
  const pixPerMMmin = Math.min(pixPerMMw, pixPerMMh)
@@ -8377,10 +8607,42 @@ export class Niivue {
8377
8607
  // no crossbars for mosaic view
8378
8608
  this.drawCrosshairs3D(false, 0.15, obj.modelViewProjectionMatrix, true, this.opts.isSliceMM)
8379
8609
  }
8380
- this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8610
+ if (isStretchToScreen) {
8611
+ // issue1065
8612
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8613
+ }
8381
8614
  this.readyForSync = true
8382
8615
  }
8383
8616
 
8617
+ draw2D(
8618
+ leftTopWidthHeight: number[],
8619
+ axCorSag: SLICE_TYPE,
8620
+ customMM = NaN,
8621
+ imageWidthHeight: number[] = [NaN, NaN]
8622
+ ): void {
8623
+ const padLeftTop = [NaN, NaN]
8624
+ if (isNaN(imageWidthHeight[0])) {
8625
+ this.draw2DMain(leftTopWidthHeight, axCorSag, customMM)
8626
+ } else {
8627
+ // inset as padded in tile
8628
+ const ltwh = leftTopWidthHeight.slice()
8629
+ padLeftTop[0] = Math.floor(0.5 * (ltwh[2] - imageWidthHeight[0]))
8630
+ padLeftTop[1] = Math.floor(0.5 * (ltwh[3] - imageWidthHeight[1]))
8631
+ ltwh[0] += padLeftTop[0]
8632
+ ltwh[1] += padLeftTop[1]
8633
+ ltwh[2] = imageWidthHeight[0]
8634
+ ltwh[3] = imageWidthHeight[1]
8635
+ this.draw2DMain(ltwh, axCorSag, customMM)
8636
+ }
8637
+ if (customMM === Infinity || customMM === -Infinity || axCorSag === SLICE_TYPE.RENDER) {
8638
+ return
8639
+ }
8640
+ if (leftTopWidthHeight[2] !== 0 && leftTopWidthHeight[3] !== 0) {
8641
+ // issue1065
8642
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag, padLeftTop)
8643
+ }
8644
+ }
8645
+
8384
8646
  // not included in public docs
8385
8647
  // determine 3D model view projection matrix
8386
8648
  calculateMvpMatrix(_unused: unknown, leftTopWidthHeight = [0, 0, 0, 0], azimuth: number, elevation: number): mat4[] {
@@ -9079,7 +9341,13 @@ export class Niivue {
9079
9341
  * @param style - label style
9080
9342
  * @param point - 3D point on the model
9081
9343
  */
9082
- addLabel(text: string, style: NVLabel3DStyle, points?: number[] | number[][]): NVLabel3D {
9344
+ addLabel(
9345
+ text: string,
9346
+ style: NVLabel3DStyle,
9347
+ points?: number[] | number[][],
9348
+ anchor?: LabelAnchorPoint,
9349
+ onClick?: (label: NVLabel3D) => void
9350
+ ): NVLabel3D {
9083
9351
  const defaultStyle = {
9084
9352
  textColor: this.opts.legendTextColor,
9085
9353
  textScale: 1.0,
@@ -9091,7 +9359,7 @@ export class Niivue {
9091
9359
  bulletColor: this.opts.legendTextColor
9092
9360
  }
9093
9361
  const labelStyle = style ? { ...defaultStyle, ...style } : { ...defaultStyle }
9094
- const label = new NVLabel3D(text, labelStyle, points)
9362
+ const label = new NVLabel3D(text, { ...labelStyle }, points, anchor, onClick)
9095
9363
  this.document.labels.push(label)
9096
9364
  return label
9097
9365
  }
@@ -9114,6 +9382,75 @@ export class Niivue {
9114
9382
  }
9115
9383
 
9116
9384
  getLabelAtPoint(screenPoint: [number, number]): NVLabel3D | null {
9385
+ const scale = 1.0
9386
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9387
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
9388
+
9389
+ // get all non-connectome labels
9390
+ for (const label of this.document.labels) {
9391
+ if (label.anchor == null || label.anchor === LabelAnchorPoint.NONE) {
9392
+ continue
9393
+ }
9394
+
9395
+ const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9396
+ const textHeight = this.textHeight(labelSize, label.text)
9397
+ const textWidth = this.textWidth(labelSize, label.text)
9398
+
9399
+ if (label.anchor & LabelAnchorFlag.LEFT) {
9400
+ if (screenPoint[0] > textWidth) {
9401
+ continue
9402
+ }
9403
+ }
9404
+
9405
+ if (label.anchor & LabelAnchorFlag.CENTER) {
9406
+ if (screenPoint[0] < (this.gl.canvas.width - textWidth) / 2) {
9407
+ continue
9408
+ }
9409
+
9410
+ if (screenPoint[0] > (this.gl.canvas.width + textWidth) / 2) {
9411
+ continue
9412
+ }
9413
+ }
9414
+
9415
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
9416
+ if (screenPoint[0] < this.gl.canvas.width - textWidth) {
9417
+ continue
9418
+ }
9419
+ }
9420
+
9421
+ if (label.anchor & LabelAnchorFlag.TOP) {
9422
+ if (screenPoint[1] < verticalMargin / 2) {
9423
+ continue
9424
+ }
9425
+
9426
+ if (screenPoint[1] > textHeight + verticalMargin / 2) {
9427
+ continue
9428
+ }
9429
+ }
9430
+
9431
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
9432
+ if (screenPoint[1] < (this.gl.canvas.height - textHeight - verticalMargin) / 2) {
9433
+ continue
9434
+ }
9435
+
9436
+ if (screenPoint[1] > (this.gl.canvas.height + textHeight - verticalMargin / 2) / 2) {
9437
+ continue
9438
+ }
9439
+ }
9440
+
9441
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
9442
+ if (screenPoint[1] < this.gl.canvas.height - textHeight - verticalMargin) {
9443
+ continue
9444
+ }
9445
+
9446
+ if (screenPoint[1] > this.gl.canvas.height - verticalMargin / 2) {
9447
+ continue
9448
+ }
9449
+ }
9450
+
9451
+ // label passed all tests
9452
+ return label
9453
+ }
9117
9454
  log.debug('screenPoint', screenPoint)
9118
9455
  const panelHeight = this.getLegendPanelHeight()
9119
9456
  const panelWidth = this.getLegendPanelWidth()
@@ -9129,10 +9466,7 @@ export class Niivue {
9129
9466
  return null
9130
9467
  }
9131
9468
 
9132
- const scale = 1.0
9133
- const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9134
-
9135
- const labels = this.getAllLabels()
9469
+ const labels = this.getConnectomeLabels()
9136
9470
  for (const label of labels) {
9137
9471
  const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9138
9472
  const textHeight = this.textHeight(labelSize, label.text)
@@ -9170,11 +9504,11 @@ export class Niivue {
9170
9504
  draw3DLabel(
9171
9505
  label: NVLabel3D,
9172
9506
  pos: vec2,
9173
- mvpMatrix: mat4,
9174
- leftTopWidthHeight: number[],
9175
- bulletMargin = 0,
9176
- legendWidth: number,
9177
- secondPass = false
9507
+ mvpMatrix?: mat4,
9508
+ leftTopWidthHeight?: number[],
9509
+ bulletMargin?: number,
9510
+ legendWidth?: number,
9511
+ secondPass?: boolean
9178
9512
  ): void {
9179
9513
  const text = label.text
9180
9514
  const left = pos[0]
@@ -9200,17 +9534,19 @@ export class Niivue {
9200
9534
 
9201
9535
  let textLeft = left
9202
9536
 
9203
- if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
9204
- const textWidth = this.textWidth(label.style.textScale, label.text) * size
9205
- if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
9206
- textLeft = left + legendWidth - size * 1.5 - textWidth
9537
+ if (legendWidth) {
9538
+ if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
9539
+ const textWidth = this.textWidth(label.style.textScale, label.text) * size
9540
+ if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
9541
+ textLeft = left + legendWidth - size * 1.5 - textWidth
9542
+ } else {
9543
+ const remaining = legendWidth - (bulletMargin || size)
9544
+ textLeft += (remaining - textWidth) / 2
9545
+ }
9207
9546
  } else {
9208
- const remaining = legendWidth - (bulletMargin || size)
9209
- textLeft += (remaining - textWidth) / 2
9547
+ // textLeft += size / 2;
9548
+ textLeft += bulletMargin
9210
9549
  }
9211
- } else {
9212
- // textLeft += size / 2;
9213
- textLeft += bulletMargin
9214
9550
  }
9215
9551
 
9216
9552
  this.drawText([textLeft, top], text, label.style.textScale, label.style.textColor)
@@ -9218,7 +9554,7 @@ export class Niivue {
9218
9554
 
9219
9555
  // not included in public docs
9220
9556
  draw3DLabels(mvpMatrix: mat4, leftTopWidthHeight: number[], secondPass = false): void {
9221
- const labels = this.getAllLabels()
9557
+ const labels = this.getConnectomeLabels()
9222
9558
  if (!this.opts.showLegend || labels.length === 0) {
9223
9559
  return
9224
9560
  }
@@ -9268,6 +9604,59 @@ export class Niivue {
9268
9604
  }
9269
9605
  }
9270
9606
 
9607
+ drawAnchoredLabels(): void {
9608
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * 1.0
9609
+ const anchoredLabels = this.document.labels.filter((l) => l.anchor != null && l.anchor !== LabelAnchorPoint.NONE)
9610
+ for (const label of anchoredLabels) {
9611
+ const text = label.text
9612
+ const textHeight = this.textHeight(label.style.textScale, text) * size
9613
+ const textWidth = this.textWidth(label.style.textScale, text) * size
9614
+ let left: number
9615
+ let top: number
9616
+
9617
+ const scale = 1.0 // we may want to make this adjustable in the future
9618
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
9619
+ const rectHeightDiff = verticalMargin
9620
+ let rectWidthDiff = verticalMargin / 4
9621
+ let rectHorizontalOffset = 0
9622
+ let rectVerticalOffset = 0
9623
+
9624
+ if (label.anchor & LabelAnchorFlag.LEFT) {
9625
+ left = 0
9626
+ }
9627
+
9628
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
9629
+ left = this.canvas.width - textWidth
9630
+ rectHorizontalOffset -= verticalMargin / 4
9631
+ }
9632
+
9633
+ if (label.anchor & LabelAnchorFlag.CENTER) {
9634
+ left = (this.canvas.width - textWidth) / 2
9635
+ rectHorizontalOffset -= verticalMargin / 4
9636
+ rectWidthDiff += verticalMargin / 4
9637
+ }
9638
+
9639
+ if (label.anchor & LabelAnchorFlag.TOP) {
9640
+ top = 0
9641
+ }
9642
+
9643
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
9644
+ top = (this.canvas.height - textHeight - verticalMargin) / 2
9645
+ rectVerticalOffset -= verticalMargin / 4
9646
+ }
9647
+
9648
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
9649
+ top = this.canvas.height - textHeight - verticalMargin
9650
+ rectVerticalOffset -= verticalMargin / 4
9651
+ }
9652
+ this.drawRect(
9653
+ [left + rectHorizontalOffset, top + rectVerticalOffset, textWidth + rectWidthDiff, textHeight + rectHeightDiff],
9654
+ label.style.backgroundColor
9655
+ )
9656
+ this.draw3DLabel(label, [left, top])
9657
+ }
9658
+ }
9659
+
9271
9660
  // not included in public docs
9272
9661
  draw3D(
9273
9662
  leftTopWidthHeight = [0, 0, 0, 0],
@@ -9360,6 +9749,7 @@ export class Niivue {
9360
9749
  this.readyForSync = true
9361
9750
  this.sync()
9362
9751
  this.draw3DLabels(mvpMatrix, relativeLTWH, true)
9752
+
9363
9753
  return posString
9364
9754
  }
9365
9755
 
@@ -10295,7 +10685,14 @@ export class Niivue {
10295
10685
  }
10296
10686
  }
10297
10687
  const isDrawPenDown = isFinite(this.drawPenLocation[0]) && this.opts.drawingEnabled
10688
+
10298
10689
  const { volScale } = this.sliceScale()
10690
+ const actualScale = volScale.slice()
10691
+ if (this.opts.multiplanarEqualSize) {
10692
+ volScale[0] = 1
10693
+ volScale[1] = 1
10694
+ volScale[2] = 1
10695
+ }
10299
10696
  if (typeof this.opts.multiplanarPadPixels !== 'number') {
10300
10697
  log.debug('multiplanarPadPixels must be numeric')
10301
10698
  }
@@ -10340,44 +10737,49 @@ export class Niivue {
10340
10737
  isDrawGrid = true
10341
10738
  }
10342
10739
  }
10740
+ let ltwh = ltwh2x2
10343
10741
  if (isDrawColumn) {
10344
- let ltwh = ltwh1x3
10742
+ ltwh = ltwh1x3
10345
10743
  if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh1x4[4] >= ltwh1x3[4])) {
10346
10744
  ltwh = ltwh1x4
10347
10745
  } else {
10348
10746
  isDraw3D = false
10349
10747
  }
10350
- const sX = volScale[0] * ltwh[4]
10351
- const sY = volScale[1] * ltwh[4]
10352
- const sZ = volScale[2] * ltwh[4]
10353
- const sMx = mx * ltwh[4]
10748
+ } else if (isDrawRow) {
10749
+ ltwh = ltwh3x1
10750
+ if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
10751
+ ltwh = ltwh4x1
10752
+ } else {
10753
+ isDraw3D = false
10754
+ }
10755
+ }
10756
+ const sX = volScale[0] * ltwh[4]
10757
+ const sY = volScale[1] * ltwh[4]
10758
+ const sZ = volScale[2] * ltwh[4]
10759
+ const actualX = actualScale[0] * ltwh[4]
10760
+ const actualY = actualScale[1] * ltwh[4]
10761
+ const actualZ = actualScale[2] * ltwh[4]
10762
+ if (isDrawColumn) {
10354
10763
  // draw axial
10355
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
10764
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10356
10765
  // draw coronal
10357
- this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1)
10766
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1, NaN, [actualX, actualZ])
10358
10767
  // draw sagittal
10359
- this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2)
10768
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2, NaN, [actualY, actualZ])
10360
10769
  if (isDraw3D) {
10770
+ const sMx = mx * ltwh[4]
10361
10771
  this.draw3D([ltwh[0], ltwh[1] + sY + sZ + sZ + pad * 3, sMx, sMx])
10362
10772
  }
10363
10773
  } else if (isDrawRow) {
10364
- let ltwh = ltwh3x1
10365
- if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
10366
- ltwh = ltwh4x1
10367
- } else {
10368
- isDraw3D = false
10369
- }
10370
- const sX = volScale[0] * ltwh[4]
10371
- const sY = volScale[1] * ltwh[4]
10372
- const sZ = volScale[2] * ltwh[4]
10373
10774
  // draw axial
10374
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
10775
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10375
10776
  // draw coronal
10376
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1)
10777
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10377
10778
  // draw sagittal
10378
- this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2)
10779
+ this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10379
10780
  if (isDraw3D) {
10380
- this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], ltwh[3], ltwh[3]])
10781
+ const sMx = mx * ltwh[4]
10782
+ this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], sMx, sMx])
10381
10783
  }
10382
10784
  } else if (isDrawGrid) {
10383
10785
  // did the user turn off 3D render view in multiplanar?
@@ -10388,16 +10790,12 @@ export class Niivue {
10388
10790
  if (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO) {
10389
10791
  isDraw3D = true
10390
10792
  }
10391
- const ltwh = ltwh2x2
10392
- const sX = volScale[0] * ltwh[4]
10393
- const sY = volScale[1] * ltwh[4]
10394
- const sZ = volScale[2] * ltwh[4]
10395
10793
  // draw axial
10396
- this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0)
10794
+ this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0, NaN, [actualX, actualY])
10397
10795
  // draw coronal
10398
- this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1)
10796
+ this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10399
10797
  // draw sagittal
10400
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2)
10798
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10401
10799
  if (isDraw3D) {
10402
10800
  this.draw3D([ltwh[0] + sX + pad, ltwh[1] + sZ + pad, sY, sY])
10403
10801
  }
@@ -10463,11 +10861,37 @@ export class Niivue {
10463
10861
  height
10464
10862
  ])
10465
10863
  }
10864
+
10865
+ // draw circle at mouse position if clickToSegment is enabled
10866
+ if (this.opts.clickToSegment) {
10867
+ const x = this.mousePos[0]
10868
+ const y = this.mousePos[1]
10869
+ // determine the tile the mouse is hovering in
10870
+ const tileIdx = this.tileIndex(x, y)
10871
+ // if a valid tile index, draw the circle
10872
+ if (tileIdx > -1) {
10873
+ // get fov in mm for this plane presented in the tile
10874
+ const fovMM = this.screenSlices[tileIdx].fovMM
10875
+ // get the left, top, width, height of the tile in pixels
10876
+ const ltwh = this.screenSlices[tileIdx].leftTopWidthHeight
10877
+ // calculate the pixel to mm scale so we can draw the circle
10878
+ // in pixels (so it is highres) but with the radius specified in mm
10879
+ const pixPerMM = ltwh[2] / fovMM[0]
10880
+ // get the crosshair color, but replace the alpha because we want it to be transparent
10881
+ // no matter what. We want to see the image data underneath the circle.
10882
+ const color = this.opts.crosshairColor
10883
+ const segmentCursorColor = [color[0], color[1], color[2], 0.4]
10884
+ const radius = this.opts.clickToSegmentRadius * pixPerMM
10885
+ this.drawCircle([x - radius, y - radius, radius * 2, radius * 2], segmentCursorColor, 1)
10886
+ }
10887
+ }
10888
+
10466
10889
  const pos = this.frac2mm([this.scene.crosshairPos[0], this.scene.crosshairPos[1], this.scene.crosshairPos[2]])
10467
10890
 
10468
10891
  posString = pos[0].toFixed(2) + '×' + pos[1].toFixed(2) + '×' + pos[2].toFixed(2)
10469
10892
  this.readyForSync = true // by the time we get here, all volumes should be loaded and ready to be drawn. We let other niivue instances know that we can now reliably sync draw calls (images are loaded)
10470
10893
  this.sync()
10894
+ this.drawAnchoredLabels()
10471
10895
  return posString
10472
10896
  }
10473
10897