Source: niivue.js

import * as nii from "nifti-reader-js"
import { Shader } from "./shader.js";
import * as mat from "gl-matrix";
import { vertSliceShader, fragSliceShader } from "./shader-srcs.js";
import { vertLineShader, fragLineShader } from "./shader-srcs.js";
import { vertRenderShader, fragRenderShader } from "./shader-srcs.js";
import { vertColorbarShader, fragColorbarShader } from "./shader-srcs.js";
import { vertFontShader, fragFontShader } from "./shader-srcs.js";
import { vertOrientShader, vertPassThroughShader, fragPassThroughShader, fragOrientShaderU, fragOrientShaderI, fragOrientShaderF, fragOrientShader} from "./shader-srcs.js";

/**
 * @class Niivue
 * @description
 * Documentation is a work in progress
 * @constructor
 * @param {object} [opts] options object to set modifiable properties of the canvas
 * @example
 * // All available properties are listed in the example.
 * // All properties are optional. If omitted, a default will be used from Niivue.defaults
 * opts = {
    textHeight: 0.03,             // 0 for no text, fraction of canvas height
    colorbarHeight: 0.05,         // 0 for no colorbars, fraction of Nifti j dimension
    crosshairWidth: 1,            // 0 for no crosshairs
    backColor: [0, 0, 0, 1],      // [R, G, B, A] range 0..1
    crosshairColor: [1, 0, 0 ,1], // [R, G, B, A] range 0..1
    colorBarMargin: 0.05          // x axis margin arount color bar, fraction of canvas width
  }

  let myNiivue = new Niivue(opts)
 */
export let Niivue = function(opts={}){

  this.opts = {} // will be populate with opts or defaults when a new Niivue object instance is created

  /**
   * @memberof Niivue
   * @property {object} defaults - the default values for all options a user might supply in an opts object
   * @example
   * // The example shows all available use configurable properties.
   * this.defaults = {
    textHeight: 0.03,             // 0 for no text, fraction of canvas height
    colorbarHeight: 0.05,         // 0 for no colorbars, fraction of Nifti j dimension
    crosshairWidth: 1,            // 0 for no crosshairs
    backColor: [0, 0, 0, 1],      // [R, G, B, A] range 0..1
    crosshairColor: [1, 0, 0 ,1], // [R, G, B, A] range 0..1
    colorBarMargin: 0.05          // x axis margin arount color bar, fraction of canvas width
  }

   *
   */
  this.defaults = {
    textHeight: 0.03,  // 0 for no text, fraction of canvas height
    colorbarHeight: 0.05, // 0 for no colorbars, fraction of Nifti j dimension
    crosshairWidth: 1, // 0 for no crosshairs
    backColor: [0, 0, 0, 1],
    crosshairColor: [1, 0, 0 ,1],
    colorBarMargin: 0.05 // x axis margin arount color bar, clip space coordinates
  }
  this.gl = null
  this.colormapTexture = null
  this.volumeTexture = null
  this.overlayTexture = null
  this.sliceShader = null
  this.lineShader = null
  this.renderShader = null
  this.colorbarShader = null
  this.fontShader = null
  this.passThroughShader = null
  this.orientShaderU = null
  this.orientShaderI = null
  this.orientShaderF = null
  this.fontMets = null

  this.sliceTypeAxial = 0
  this.sliceTypeCoronal = 1
  this.sliceTypeSagittal = 2
  this.sliceTypeMultiplanar = 3
  this.sliceTypeRender = 4
  this.sliceType = this.sliceTypeMultiplanar // sets current view in webgl canvas
  this.scene = {}
  this.scene.renderAzimuth = 120
  this.scene.renderElevation = 15
  this.scene.crosshairPos = [0.5, 0.5, 0.5]
  this.scene.clipPlane = [0, 0, 0, 0]
  this.back = {} // base layer; defines image space to work in. Defined as this.volumes[0] in Niivue.loadVolumes
  this.overlays = [] // layers added on top of base image (e.g. masks or stat maps). Essentially everything after this.volumes[0] is an overlay. So is this necessary?
  this.volumes = [] // all loaded images. Can add in the ability to push or slice as needed
  this.backTexture = [];
  this.isRadiologicalConvention = false
  this.volScaleMultiplier = 1
  this.mousePos = [0, 0]
  this.numScreenSlices = 0 // e.g. for multiplanar view, 3 simultaneous slices: axial, coronal, sagittal
  this.screenSlices = [ //location and type of each 2D slice on screen, allows clicking to detect position
  {leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial},
  {leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial},
  {leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial},
  {leftTopWidthHeight: [1, 0, 0, 1], axCorSag: this.sliceTypeAxial}
];
  this.backOpacity = 1.0
  // loop through known Niivue properties
  // if the user supplied opts object has a
  // property listed in the known properties, then set
  // Niivue.opts.<prop> to that value, else apply defaults.
  for (let prop in this.defaults) {
    this.opts[prop] = (opts[prop] === undefined) ? this.defaults[prop] : opts[prop]
  }
}

/**
 * test if two arrays have equal values for each element
 * @param {Array} a the first array
 * @param {Array} b the second array
 * @example Niivue.arrayEquals(a, b)
 */
Niivue.prototype.arrayEquals = function(a, b) {
  return Array.isArray(a) &&
    Array.isArray(b) &&
    a.length === b.length &&
    a.every((val, index) => val === b[index]);
}

// update mouse position from new mouse down coordinates
Niivue.prototype.mouseDown = function mouseDown(x, y) {
  if (this.sliceType != this.sliceTypeRender) return;
	this.mousePos = [x,y];
} // mouseDown()

Niivue.prototype.mouseMove = function mouseMove(x, y) {
	if (this.sliceType != this.sliceTypeRender) return;
	this.scene.renderAzimuth += x - this.mousePos[0];
	this.scene.renderElevation += y - this.mousePos[1];
	this.mousePos = [x,y];
	this.drawScene()
} // mouseMove()

Niivue.prototype.sph2cartDeg = function sph2cartDeg(azimuth, elevation) {
//convert spherical AZIMUTH,ELEVATION,RANGE to Cartesion
//see Matlab's [x,y,z] = sph2cart(THETA,PHI,R)
// reverse with cart2sph
 let Phi = -elevation * (Math.PI/180);
 let Theta = ((azimuth-90) % 360) * (Math.PI/180);
 let ret = [Math.cos(Phi)* Math.cos(Theta), Math.cos(Phi) * Math.sin(Theta), Math.sin(Phi) ];
 let len = Math.sqrt(ret[0] * ret[0] + ret[1] * ret[1] + ret[2] * ret[2] );
 if (len <= 0.0) return ret;
 ret[0] /= len;
 ret[1] /= len;
 ret[2] /= len;
 return ret;
} // sph2cartDeg()

Niivue.prototype.clipPlaneUpdate = function (azimuthElevationDepth) {
	// azimuthElevationDepth is 3 component vector [a, e, d]
	//  azimuth: camera position in degrees around object, typically 0..360 (or -180..+180)
	//  elevation: camera height in degrees, range -90..90
	//  depth: distance of clip plane from center of volume, range 0..~1.73 (e.g. 2.0 for no clip plane)
	if (this.sliceType != this.sliceTypeRender) return;
	let v = this.sph2cartDeg(azimuthElevationDepth[0], azimuthElevationDepth[1]);
	this.scene.clipPlane = [v[0], v[1], v[2], azimuthElevationDepth[2]];
	this.drawScene()
} // clipPlaneUpdate()

Niivue.prototype.setCrosshairColor = function (color) {
  this.opts.crosshairColor = color
  this.drawScene()
} // setCrosshairColor()

Niivue.prototype.sliceScroll2D = function (posChange, x, y, isDelta=true) {
  this.mouseClick(x, y, posChange, isDelta);
} // sliceScroll2D()

Niivue.prototype.setSliceType = function(st) {
  this.sliceType = st
  this.drawScene()
} // setSliceType()

Niivue.prototype.setOpacity = function (volIdx, newOpacity) {
  this.volumes[volIdx].opacity = newOpacity
  if (volIdx === 0){ //background layer opacity set dynamically with shader
    this.backOpacity = newOpacity
    this.drawScene()
    return
  }
  //all overlays are combined as a single texture, so changing opacity to one requires us to refresh textures
  this.updateGLVolume()
  //
} // setOpacity()

Niivue.prototype.setScale = function (scale) {
  this.volScaleMultiplier = scale
  this.drawScene()
} // setScale()

// attach the Niivue instance to the webgl2 canvas by element id
// @example niivue = new Niivue().attachTo('#gl')
Niivue.prototype.attachTo = function (id) {
	this.gl = document.querySelector(id).getContext("webgl2");
	if (!this.gl){
    alert("unable to get webgl2 context. Perhaps this browser does not support webgl2")
    console.log("unable to get webgl2 context. Perhaps this browser does not support webgl2")

  }
  this.init()
  return this
} // attachTo

Niivue.prototype.overlayRGBA = function (volume) {
	let hdr = volume.hdr;
	let vox = hdr.dims[1] * hdr.dims[2] * hdr.dims[3];
	let imgRGBA = new Uint8ClampedArray(vox * 4);
	let radius = 0.2 * Math.min(Math.min(hdr.dims[1], hdr.dims[2]), hdr.dims[3]);
	let halfX = 0.5 * hdr.dims[1];
	let halfY = 0.5 * hdr.dims[2];
	let halfZ = 0.5 * hdr.dims[3];
	let j = 0;
	for (let z = 0; z < hdr.dims[3]; z++) {
		for (let y = 0; y < hdr.dims[2]; y++) {
			for (let x = 0; x < hdr.dims[1]; x++) {
				let dx = (Math.abs(x - halfX));
				let dy = (Math.abs(y - halfY));
				let dz = (Math.abs(z - halfZ));
				let dist = Math.sqrt(dx*dx + dy*dy + dz * dz);
				let v = 0;
				if (dist < radius) v = 255;
				imgRGBA[j++] = 0; //Red
				imgRGBA[j++] = v; //Green
				imgRGBA[j++] = 0; //Blue
				imgRGBA[j++] = v * 0.5; //Alpha
			}
		}
	}
	return imgRGBA;
} // overlayRGBA()

Niivue.prototype.vox2mm = function (XYZ, mtx ) {
	let sform = mat.mat4.clone(mtx);
	mat.mat4.transpose(sform, sform);
	let pos = mat.vec4.fromValues(XYZ[0], XYZ[1], XYZ[2], 1);
	mat.vec4.transformMat4(pos, pos, sform);
	let pos3 = mat.vec3.fromValues(pos[0], pos[1], pos[2]);
	return pos3;
} // vox2mm()

Niivue.prototype.nii2RAS = function (overlayItem) {
  //Transform to orient NIfTI image to Left->Right,Posterior->Anterior,Inferior->Superior (48 possible permutations)
// port of Matlab reorient() https://github.com/xiangruili/dicm2nii/blob/master/nii_viewer.m
// not elegant, as JavaScript arrays are always 1D
let hdr = overlayItem.volume.hdr;
	let a = hdr.affine;
	let absR = mat.mat3.fromValues(Math.abs(a[0][0]),Math.abs(a[0][1]),Math.abs(a[0][2]), Math.abs(a[1][0]),Math.abs(a[1][1]),Math.abs(a[1][2]), Math.abs(a[2][0]),Math.abs(a[2][1]),Math.abs(a[2][2]));
	//1st column = x
	let ixyz = [1, 1, 1];
    if (absR[3] > absR[0]) ixyz[0] = 2;//(absR[1][0] > absR[0][0]) ixyz[0] = 2;
    if ((absR[6] > absR[0]) && (absR[6]> absR[3])) ixyz[0] = 3;//((absR[2][0] > absR[0][0]) && (absR[2][0]> absR[1][0])) ixyz[0] = 3;
	//2nd column = y
	ixyz[1] = 1;
    if (ixyz[0] === 1) {
		if (absR[4] > absR[7]) //(absR[1][1] > absR[2][1])
			ixyz[1] = 2
		else
			ixyz[1] = 3;
	} else if (ixyz[0] === 2) {
       if  (absR[1] > absR[7])//(absR[0][1] > absR[2][1])
          ixyz[1] = 1
       else
           ixyz[1] = 3;
    } else {
       if (absR[1] > absR[4])//(absR[0][1] > absR[1][1])
          ixyz[1] = 1
       else
           ixyz[1] = 2;
    }
    //3rd column = z: constrained as x+y+z = 1+2+3 = 6
    ixyz[2] = 6 - ixyz[1] - ixyz[0];
	let perm = [1,2,3];
    perm[ixyz[0]-1] = 1;
    perm[ixyz[1]-1] = 2;
    perm[ixyz[2]-1] = 3;
	let rotM = mat.mat4.fromValues(a[0][0],a[0][1],a[0][2],a[0][3], a[1][0],a[1][1],a[1][2],a[1][3], a[2][0],a[2][1],a[2][2],a[2][3], 0,0,0,1);
	//n.b. 0.5 in these values to account for voxel centers, e.g. a 3-pixel wide bitmap in unit space has voxel centers at 0.25, 0.5 and 0.75
	overlayItem.mm000 = this.vox2mm([-0.5, -0.5, -0.5], rotM);
	overlayItem.mm100 = this.vox2mm([hdr.dims[1]-0.5, -0.5, -0.5], rotM);
	overlayItem.mm010 = this.vox2mm([-0.5, hdr.dims[2]-0.5, -0.5], rotM);
	overlayItem.mm001 = this.vox2mm([-0.5, -0.5, hdr.dims[3]-0.5], rotM);
	let R = mat.mat4.create();
	mat.mat4.copy(R, rotM);
	for (let i = 0; i < 3; i++)
		for (let j = 0; j < 3; j++)
			R[(i*4)+j] =  rotM[(i*4)+perm[j]-1] ;//rotM[i+(4*(perm[j]-1))];//rotM[i],[perm[j]-1];
	let flip = [0, 0, 0];
    if (R[0] < 0) flip[0] = 1; //R[0][0]
    if (R[5] < 0) flip[1] = 1; //R[1][1]
    if (R[10] < 0) flip[2] = 1; //R[2][2]
	overlayItem.dimsRAS = [hdr.dims[0], hdr.dims[perm[0]], hdr.dims[perm[1]], hdr.dims[perm[2]]];
	overlayItem.pixDimsRAS = [hdr.pixDims[0], hdr.pixDims[perm[0]], hdr.pixDims[perm[1]], hdr.pixDims[perm[2]]];
	if (this.arrayEquals(perm, [1,2,3]) && this.arrayEquals(flip, [0,0,0])) {
		overlayItem.toRAS = mat.mat4.create(); //aka fromValues(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1);
		overlayItem.matRAS = mat.mat4.clone(rotM);
		return; //no rotation required!
	}
	mat.mat4.identity(rotM);
    rotM[0+(0 * 4)] = 1-flip[0]*2;
    rotM[1+(1 * 4)] = 1-flip[1]*2;
    rotM[2+(2 * 4)] = 1-flip[2]*2;
    rotM[3+(0*4)] = ((hdr.dims[perm[0]])-1) * flip[0];
    rotM[3+(1*4)] = ((hdr.dims[perm[1]])-1) * flip[1];
	rotM[3+(2*4)] = ((hdr.dims[perm[2]])-1) * flip[2];
	let residualR = mat.mat4.create();
	mat.mat4.invert(residualR, rotM);
	mat.mat4.multiply(residualR, residualR, R);
	overlayItem.matRAS = mat.mat4.clone(residualR);
    rotM = mat.mat4.fromValues(0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,1);
    rotM[perm[0]-1+(0*4)] = (-flip[0]*2)+1;
    rotM[perm[1]-1+(1*4)] = (-flip[1]*2)+1;
    rotM[perm[2]-1+(2*4)] = (-flip[2]*2)+1;
	rotM[3+(0*4)] = flip[0];
    rotM[3+(1*4)] = flip[1];
	rotM[3+(2*4)] = flip[2];
	overlayItem.toRAS = mat.mat4.clone(rotM);
} // nii2RAS()

// currently: volumeList is an array if objects, each object is a volume that can be loaded
Niivue.prototype.loadVolumes  = function(volumeList) {
  this.volumes = volumeList
  this.back = this.volumes[0] // load first volume as back layer
  this.overlays = this.volumes.slice(1) // remove first element (that is now this.back, all other imgaes are overlays)
  let xhr = []
  let hdr = null
  let img = null
  // for loop to load all volumes in volumeList
  for (let i=0; i<volumeList.length; i++){
    console.log("loading ", volumeList[i].url)
    let url = this.volumes[i].url
    xhr.push(new XMLHttpRequest());
    xhr[i].open("GET", url, true);
    xhr[i].responseType = "arraybuffer";
    xhr[i].onerror = function () {
      console.error("error loading volume ", this.volumes[i].url)
      alert("error loading " + this.volumes[i].url)
    }
    xhr[i].onload = function () {
      let dataBuffer = xhr[i].response;
      hdr = null
      img = null
      if (dataBuffer) {
        hdr = nii.readHeader(dataBuffer);
        if (nii.isCompressed(dataBuffer)) {
          img = nii.readImage(hdr, nii.decompress(dataBuffer));
        } else {
          img = nii.readImage(hdr, dataBuffer);
        }
      } else {
        alert("Unable to load buffer properly from volume?");
        console.log("no buffer?");
      }
      this.volumes[i].volume = {}
      this.volumes[i].volume.hdr = hdr
      this.volumes[i].volume.img = img
      this.volumes[i].opacity = 1;
      this.nii2RAS(this.volumes[i])
      //_overlayItem = overlayItem
      //this.selectColormap(this.volumes[0].colorMap) //only base image for now
      this.updateGLVolume()
    }.bind(this) // bind "this" niivue instance context
    xhr[i].send();
  } // for
		return this
} // loadVolume()


Niivue.prototype.rgbaTex = function(texID, activeID, dims, isInit=false) {
	if (texID)
		this.gl.deleteTexture(texID);
	texID = this.gl.createTexture();
	this.gl.activeTexture(activeID);
	this.gl.bindTexture(this.gl.TEXTURE_3D, texID);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_R, this.gl.CLAMP_TO_EDGE);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
	this.gl.pixelStorei(this.gl.UNPACK_ALIGNMENT, 1)
	this.gl.texStorage3D(this.gl.TEXTURE_3D, 1, this.gl.RGBA8, dims[1], dims[2], dims[3]); //output background dimensions
	if (isInit) {
		let img8 = new Uint8Array(dims[1] * dims[2] * dims[3] * 4);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, dims[1], dims[2], dims[3], this.gl.RGBA, this.gl.UNSIGNED_BYTE, img8);
	}
	return texID;
} // rgbaTex()

Niivue.prototype.loadPng = function(pngName) {
	var pngImage = null;
	pngImage = new Image();
	pngImage.onload = function() {
		//console.log("PNG resolution ", pngImage.width, ",", pngImage.height);
		var pngTexture = this.gl.createTexture();
		this.gl.activeTexture(this.gl.TEXTURE3);
		this.gl.bindTexture(this.gl.TEXTURE_2D, pngTexture);
		// Set the parameters so we can render any size image.
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
		// Upload the image into the texture.
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, pngImage);
	}.bind(this) // bind "this" context to niivue instance
	pngImage.src = pngName;  // MUST BE SAME DOMAIN!!!
	//console.log("loading PNG ", pngName);
} // loadPng()

Niivue.prototype.initText = async function () {
	//load bitmap
	await this.loadPng('fnt.png');
	//create font metrics
	this.fontMets = [];
	for (let id = 0; id < 256; id++) { //clear ASCII codes 0..256
		this.fontMets[id] = {};
		this.fontMets[id].xadv = 0;
		this.fontMets[id].uv_lbwh = [0, 0, 0, 0];
		this.fontMets[id].lbwh = [0, 0, 0, 0];
	}
	//load metrics values: may only sparsely describe range 0..255
	var metrics = [];
	async function fetchMetrics() {
		const response = await fetch('./fnt.json');
		metrics = await response.json();
	}
	await fetchMetrics();
	this.fontMets.distanceRange = metrics.atlas.distanceRange;
	this.fontMets.size = metrics.atlas.size;
	let scaleW = metrics.atlas.width;
	let scaleH = metrics.atlas.height;
	for (let i = 0; i < metrics.glyphs.length; i++) {
		let glyph = metrics.glyphs[i];
		let id = glyph.unicode;
		this.fontMets[id].xadv = glyph.advance;
		if (glyph.planeBounds  === undefined) continue;
		let l = glyph.atlasBounds.left / scaleW;
		let b = ((scaleH - glyph.atlasBounds.top) / scaleH);
		let w = (glyph.atlasBounds.right - glyph.atlasBounds.left) / scaleW;
		let h = (glyph.atlasBounds.top - glyph.atlasBounds.bottom) / scaleH;
		this.fontMets[id].uv_lbwh = [l, b, w, h];
		l = glyph.planeBounds.left;
		b = glyph.planeBounds.bottom;
		w = glyph.planeBounds.right - glyph.planeBounds.left;
		h = glyph.planeBounds.top - glyph.planeBounds.bottom;
		this.fontMets[id].lbwh = [l, b, w, h];
	}
} // initText()

Niivue.prototype.init = async function () {
	//initial setup: only at the startup of the component
  // print debug info (gpu vendor and renderer)
  let debugInfo = this.gl.getExtension('WEBGL_debug_renderer_info');
  let vendor = this.gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
  let renderer = this.gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
  console.log("gpu vendor: ", vendor)
  console.log("gpu renderer: ", renderer)
  this.gl.enable(this.gl.CULL_FACE);
  this.gl.cullFace(this.gl.FRONT);
  this.gl.enable(this.gl.BLEND);
  this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);

  // register volume and overlay textures
  this.rgbaTex(this.volumeTexture, this.gl.TEXTURE0, [2,2,2,2], true);
  this.rgbaTex(this.overlayTexture, this.gl.TEXTURE2, [2,2,2,2], true);

  let cubeStrip = [0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0];
	let vao = this.gl.createVertexArray();
	this.gl.bindVertexArray(vao);
	let vbo = this.gl.createBuffer();
	this.gl.bindBuffer(this.gl.ARRAY_BUFFER, vbo);
	this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(cubeStrip), this.gl.STATIC_DRAW);
	this.gl.enableVertexAttribArray(0);
	this.gl.vertexAttribPointer(0, 3, this.gl.FLOAT, false, 0, 0);

  // slice shader
	this.sliceShader = new Shader(this.gl, vertSliceShader, fragSliceShader);
	this.sliceShader.use(this.gl);
	this.gl.uniform1i(this.sliceShader.uniforms["volume"], 0);
	this.gl.uniform1i(this.sliceShader.uniforms["colormap"], 1);
	this.gl.uniform1i(this.sliceShader.uniforms["overlay"], 2);

  // line shader (crosshair)
	this.lineShader = new Shader(this.gl, vertLineShader, fragLineShader);

  // render shader (3D)
	this.renderShader = new Shader(this.gl, vertRenderShader, fragRenderShader);
	this.renderShader.use(this.gl);
	this.gl.uniform1i(this.renderShader.uniforms["volume"], 0);
	this.gl.uniform1i(this.renderShader.uniforms["colormap"], 1);
	this.gl.uniform1i(this.renderShader.uniforms["overlay"], 2);

  // colorbar shader
	this.colorbarShader = new Shader(this.gl, vertColorbarShader, fragColorbarShader);
	this.colorbarShader.use(this.gl);
	this.gl.uniform1i(this.colorbarShader.uniforms["colormap"], 1);

  // font shader
	//multi-channel signed distance font https://github.com/Chlumsky/msdfgen
	this.fontShader = new Shader(this.gl, vertFontShader, fragFontShader);
	this.fontShader.use(this.gl);
	this.gl.uniform1i(this.fontShader.uniforms["fontTexture"], 3);

  // orientation shaders
  this.passThroughShader  = new Shader(this.gl, vertPassThroughShader, fragPassThroughShader);
  
  //this.passThroughShader  = new Shader(this.gl, vertOrientShader,fragPassThroughShader);
  this.orientShaderU = new Shader(this.gl, vertOrientShader, fragOrientShaderU.concat(fragOrientShader));
  this.orientShaderI = new Shader(this.gl, vertOrientShader, fragOrientShaderI.concat(fragOrientShader));
  this.orientShaderF = new Shader(this.gl, vertOrientShader, fragOrientShaderF.concat(fragOrientShader));
  await this.initText();
  return this
} // init()

Niivue.prototype.updateGLVolume = function() { //load volume or change contrast
  let visibleLayers = 0;
  let numLayers = this.volumes.length
  // loop through loading volumes in this.volume
  this.refreshColormaps()
  for (let i=0; i < numLayers; i++){
    // avoid trying to refresh a volume that isn't ready
    if(!this.volumes[i].toRAS) {
      continue;
    }
    this.refreshLayers(this.volumes[i], visibleLayers, numLayers);
    visibleLayers++;
  }
  this.drawScene();
} // updateVolume()

function intensityRaw2Scaled(hdr, raw) {
  if (hdr.scl_slope === 0) hdr.scl_slope = 1.0;
  return (raw * hdr.scl_slope) + hdr.scl_inter
}

// given an overlayItem and its img TypedArray, calculate 2% and 98% display range if needed
//clone FSL robust_range estimates https://github.com/rordenlab/niimath/blob/331758459140db59290a794350d0ff3ad4c37b67/src/core32.c#L1215
//ToDo: convert to web assembly, this is slow in JavaScript
Niivue.prototype.calMinMaxCore = function(overlayItem, img, percentileFrac=0.02, ignoreZeroVoxels = false){
  let imgRaw
  let hdr = overlayItem.volume.hdr
  if (hdr.datatypeCode === 2)
    imgRaw = new Uint8Array(img)
  else if (hdr.datatypeCode === 4)
    imgRaw = new Int16Array(img)
  else if (hdr.datatypeCode === 16)
    imgRaw = new Float32Array(img)
  else if (hdr.datatypeCode === 64)
    imgRaw = new Float64Array(img)
  else if (hdr.datatypeCode === 512)
    imgRaw = new Uint16Array(img)
  //determine full range: min..max
  let mn=img[0]
  let mx=img[0]
  let nZero = 0
  let nNan = 0
  let nVox = imgRaw.length
  for (let i=0; i < nVox; i++){
    if (isNaN(imgRaw[i])) {
      nNan++
      continue
    }
    if (imgRaw[i] === 0) {
      nZero++
      continue
    }
    mn = Math.min(imgRaw[i],mn)
    mx = Math.max(imgRaw[i],mx)
  }
  var mnScale = intensityRaw2Scaled(hdr, mn)
  var mxScale = intensityRaw2Scaled(hdr, mx)
  if (!ignoreZeroVoxels)
    nZero = 0
  nZero += nNan
  let n2pct = Math.round((nVox - nZero) * percentileFrac)
  if ((n2pct < 1) || (mn === mx)) {
    console.log("no variability in image intensity?")
	return [ mnScale, mxScale, mnScale, mxScale ]
  }
  let nBins = 1001
  let scl = (nBins-1)/(mx-mn)
  let hist = new Array(nBins)
  for (let i = 0; i < nBins; i++)
    hist[i] = 0
  if (ignoreZeroVoxels) {
    for (let i = 0; i <= nVox; i++) {
      if (imgRaw[i] === 0)
        continue
      if (isNaN(imgRaw[i]))
        continue
      hist[ (imgRaw[i]-mn) * scl] ++
    }
  } else {
    for (let i = 0; i <= nVox; i++) {
      if (isNaN(imgRaw[i]))
        continue
      hist[ (imgRaw[i]-mn) * scl] ++
    }
  }
  let n = 0
  let lo = 0
  while (n < n2pct) {
    n += hist[lo]
    lo++
  }
  lo -- //remove final increment
  n = 0
  let hi = nBins
  while (n < n2pct) {
    hi--
    n += hist[hi]
  }
  if (lo == hi) { //MAJORITY are not black or white
    let ok = -1
    while (ok !== 0) {
      if (lo > 0) {
        lo--
        if (hist[lo] > 0) ok = 0
      }
      if ((ok != 0) && (hi < (nBins-1))) {
        hi++
        if (hist[hi] > 0) ok = 0
      }
      if ((lo == 0) && (hi == (nBins-1))) ok = 0
    } //while not ok
  } //if lo == hi
  var pct2 = intensityRaw2Scaled(hdr, (lo)/scl + mn)
  var pct98 = intensityRaw2Scaled(hdr, (hi)/scl + mn)
  console.log("full range %f..%f  (voxels 0 or NaN = %i) robust range %f..%f", mnScale, mxScale, nZero, pct2, pct98)
  if ((overlayItem.volume.hdr.cal_min < overlayItem.volume.hdr.cal_max) && (overlayItem.volume.hdr.cal_min >= mnScale) && (overlayItem.volume.hdr.cal_max <= mxScale)){
    console.log("ignoring robust range: using header cal_min and cal_max")
    pct2 = overlayItem.volume.hdr.cal_min;
    pct98 = overlayItem.volume.hdr.cal_max;
  }
  return [ pct2, pct98, mnScale, mxScale ]
} //sliceScale

Niivue.prototype.calMinMax = function(overlayItem, img, percentileFrac=0.02, ignoreZeroVoxels = false){
	let minMax = this.calMinMaxCore(overlayItem, img, percentileFrac, ignoreZeroVoxels)
	console.log("cal_min, cal_max, global_min, global_max", minMax[0], minMax[1], minMax[2], minMax[3])
	overlayItem.cal_min = minMax[0]
	overlayItem.cal_max = minMax[1]
	overlayItem.global_min = minMax[2]
	overlayItem.global_max = minMax[3]
} // calMinMax()

Niivue.prototype.refreshLayers = function(overlayItem, layer, numLayers) {
	let hdr = overlayItem.volume.hdr
	let img = overlayItem.volume.img
	let opacity = overlayItem.opacity
	let imgRaw
	let outTexture = null;

	let mtx = [];
	if (layer === 0) {
		this.back = {};
		mtx = overlayItem.toRAS;
		opacity = 1.0;
		this.back.matRAS = overlayItem.matRAS;
		this.back.dims = overlayItem.dimsRAS;
		this.back.pixDims = overlayItem.pixDimsRAS;
		outTexture = this.rgbaTex(this.volumeTexture, this.gl.TEXTURE0, overlayItem.dimsRAS); //this.back.dims)
	} else {
		if (this.back.dims === undefined)
			console.log('Fatal error: Unable to render overlay: background dimensions not defined!');
		let f000 = this.mm2frac(overlayItem.mm000); //origin in output space
		let f100 = this.mm2frac(overlayItem.mm100);
		let f010 = this.mm2frac(overlayItem.mm010);
		let f001 = this.mm2frac(overlayItem.mm001);
		f100 = mat.vec3.subtract(f100, f100, f000); // direction of i dimension from origin
		f010 = mat.vec3.subtract(f010, f010, f000); // direction of j dimension from origin
		f001 = mat.vec3.subtract(f001, f001, f000); // direction of k dimension from origin

		mtx = mat.mat4.fromValues(
			f100[0],f100[1],f100[2],f000[0],
			f010[0],f010[1],f010[2],f000[1],
			f001[0],f001[1],f001[2],f000[2],
			0,0,0,1);
		mat.mat4.invert(mtx, mtx);
		//console.log('v2', mtx);
		if (layer === 1) {
			outTexture = this.rgbaTex(this.overlayTexture, this.gl.TEXTURE2, this.back.dims);
			this.backTexture = outTexture;
		} else
			outTexture = this.backTexture;
	}
	let fb = this.gl.createFramebuffer();
	this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, fb);
	this.gl.disable(this.gl.CULL_FACE);
	this.gl.viewport(0, 0, this.back.dims[1], this.back.dims[2]); //output in background dimensions
	this.gl.disable(this.gl.BLEND);
	let tempTex3D = this.gl.createTexture();
	this.gl.activeTexture(this.gl.TEXTURE6); //Temporary 3D Texture
	this.gl.bindTexture(this.gl.TEXTURE_3D, tempTex3D);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_R, this.gl.CLAMP_TO_EDGE);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
	this.gl.texParameteri(this.gl.TEXTURE_3D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
	this.gl.pixelStorei( this.gl.UNPACK_ALIGNMENT, 1 )
	//https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html
	//https://www.khronos.org/registry/OpenGL-Refpages/es3.0/html/glTexStorage3D.xhtml
	let orientShader = this.orientShaderU;
	if (hdr.datatypeCode === 2) { // raw input data
		imgRaw = new Uint8Array(img);
		this.gl.texStorage3D(this.gl.TEXTURE_3D, 6, this.gl.R8UI, hdr.dims[1], hdr.dims[2], hdr.dims[3]);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, hdr.dims[1], hdr.dims[2], hdr.dims[3], this.gl.RED_INTEGER, this.gl.UNSIGNED_BYTE, imgRaw);
	} else if (hdr.datatypeCode === 4) {
		imgRaw = new Int16Array(img);
		this.gl.texStorage3D(this.gl.TEXTURE_3D, 6, this.gl.R16I, hdr.dims[1], hdr.dims[2], hdr.dims[3]);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, hdr.dims[1], hdr.dims[2], hdr.dims[3], this.gl.RED_INTEGER, this.gl.SHORT, imgRaw);
		orientShader = this.orientShaderI;
	} else if (hdr.datatypeCode === 16) {
		imgRaw = new Float32Array(img);
		this.gl.texStorage3D(this.gl.TEXTURE_3D, 6, this.gl.R32F, hdr.dims[1], hdr.dims[2], hdr.dims[3]);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, hdr.dims[1], hdr.dims[2], hdr.dims[3], this.gl.RED, this.gl.FLOAT, imgRaw);
		orientShader = this.orientShaderF;
	} else if (hdr.datatypeCode === 64) {
		imgRaw = new Float64Array(img)
		let img32f = new Float32Array;
		img32f = Float32Array.from(imgRaw);
		this.gl.texStorage3D(this.gl.TEXTURE_3D, 6, this.gl.R32F, hdr.dims[1], hdr.dims[2], hdr.dims[3]);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, hdr.dims[1], hdr.dims[2], hdr.dims[3], this.gl.RED, this.gl.FLOAT, img32f);
		orientShader = this.orientShaderF;
	} else if (hdr.datatypeCode === 512) {
		imgRaw = new Uint16Array(img);
		this.gl.texStorage3D(this.gl.TEXTURE_3D, 6, this.gl.R16UI, hdr.dims[1], hdr.dims[2], hdr.dims[3]);
		this.gl.texSubImage3D(this.gl.TEXTURE_3D, 0, 0, 0, 0, hdr.dims[1], hdr.dims[2], hdr.dims[3], this.gl.RED_INTEGER, this.gl.UNSIGNED_SHORT, imgRaw);
	}
	if (overlayItem.global_min === undefined) //only once, first time volume is loaded
		this.calMinMax(overlayItem, imgRaw)

	//blend texture
	let blendTexture = null;
	if (layer > 1) { //use pass-through shader to copy previous color to temporary 2D texture
		blendTexture = this.rgbaTex(blendTexture, this.gl.TEXTURE5, this.back.dims);
		this.gl.bindTexture(this.gl.TEXTURE_3D, blendTexture);
		let passShader = this.passThroughShader
        passShader.use(this.gl);
        this.gl.uniform1i(passShader.uniforms["in3D"], 2) //overlay volume
        for (let i = 0; i < (this.back.dims[3]); i++) { //output slices
            let coordZ = 1/this.back.dims[3] * (i + 0.5);
            this.gl.uniform1f(passShader.uniforms["coordZ"], coordZ);
            this.gl.framebufferTextureLayer(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, blendTexture, 0, i);
            //this.gl.clear(this.gl.DEPTH_BUFFER_BIT); //exhaustive, so not required
            this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
        }
	} else
		blendTexture = this.rgbaTex(blendTexture, this.gl.TEXTURE5, [2,2,2,2]);		
	orientShader.use(this.gl);
	//this.selectColormap(overlayItem.colorMap)
	//this.refreshColormaps()
	this.gl.activeTexture(this.gl.TEXTURE1);
	this.gl.bindTexture(this.gl.TEXTURE_2D, this.colormapTexture);
	
	this.gl.uniform1f(orientShader.uniforms["cal_min"], overlayItem.cal_min);
	this.gl.uniform1f(orientShader.uniforms["cal_max"], overlayItem.cal_max);
	this.gl.bindTexture(this.gl.TEXTURE_3D, tempTex3D);
	this.gl.uniform1i(orientShader.uniforms["intensityVol"], 6);
	this.gl.uniform1i(orientShader.uniforms["blend3D"], 5);
	this.gl.uniform1i(orientShader.uniforms["colormap"], 1);
	this.gl.uniform1f(orientShader.uniforms["layer"], layer);
	this.gl.uniform1f(orientShader.uniforms["numLayers"], numLayers);
	this.gl.uniform1f(orientShader.uniforms["scl_inter"], hdr.scl_inter);
	this.gl.uniform1f(orientShader.uniforms["scl_slope"],hdr.scl_slope);
	this.gl.uniform1f(orientShader.uniforms["opacity"], opacity);
	this.gl.uniformMatrix4fv(orientShader.uniforms["mtx"], false, mtx)
	for (let i = 0; i < (this.back.dims[3]); i++) { //output slices
		let coordZ = 1/this.back.dims[3] * (i + 0.5);
		this.gl.uniform1f(orientShader.uniforms["coordZ"], coordZ);
		this.gl.framebufferTextureLayer(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, outTexture, 0, i);
		//this.gl.clear(this.gl.DEPTH_BUFFER_BIT); //exhaustive, so not required
		this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	}
	this.gl.deleteTexture(tempTex3D);
	this.gl.deleteTexture(blendTexture);
	this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
	this.gl.deleteFramebuffer(fb);
} // refreshLayers()

Niivue.prototype.colormap = function(lutName = "") {
//function colormap(lutName = "") {
	var lut = this.makeLut([0, 255], [0, 255], [0, 255], [0, 128], [0, 255]); //gray
	if (lutName === "Winter")
		lut = this.makeLut([0, 0, 0], [0, 128, 255], [255, 196, 128], [0, 64, 128], [0, 128, 255]); //winter
	if (lutName === "Warm")
		lut = this.makeLut([255, 255, 255], [127, 196, 254], [0, 0, 0], [0, 64, 128], [0, 128, 255]); //warm
	if (lutName === "Plasma")
		lut = this.makeLut([13, 156, 237, 240], [8, 23, 121, 249], [135, 158, 83, 33], [0, 56, 80, 88], [0, 64, 192, 255]); //plasma
	if (lutName === "Viridis")
		lut = this.makeLut([68, 49, 53, 253], [1, 104, 183, 231], [84, 142, 121, 37], [0, 56, 80, 88], [0, 65, 192, 255]);//viridis
	if (lutName === "Inferno")
		lut = this.makeLut([0, 120, 237, 240], [0, 28, 105, 249], [4, 109, 37, 33], [0, 56, 80, 88], [0, 64, 192, 255]);//inferno
	return lut;
} // colormap()

Niivue.prototype.refreshColormaps = function() {
	let nLayer = this.volumes.length
	if (nLayer < 1) return;
	if (this.colormapTexture !== null)
		this.gl.deleteTexture(this.colormapTexture);
	this.colormapTexture = this.gl.createTexture();
	this.gl.activeTexture(this.gl.TEXTURE1);
	this.gl.bindTexture(this.gl.TEXTURE_2D, this.colormapTexture);
	this.gl.texStorage2D(this.gl.TEXTURE_2D, 1, this.gl.RGBA8, 256, nLayer);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
	//this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
	//this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_R, this.gl.CLAMP_TO_EDGE);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
	this.gl.pixelStorei( this.gl.UNPACK_ALIGNMENT, 1 )
	let luts = this.colormap(this.volumes[0].colorMap);
	if (nLayer > 1) {
		for (let i=1; i<nLayer; i++){
			let lut = this.colormap(this.volumes[i].colorMap);
			let c = new Uint8ClampedArray(luts.length + lut.length);
			c.set(luts);
			c.set(lut, luts.length);
			luts = c;
			//console.log(i, '>>>',this.volumes[i].colorMap)
		}	//colorMap	
	}
	this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, 256, nLayer, this.gl.RGBA, this.gl.UNSIGNED_BYTE, luts);
  return this
} // refreshColormaps()

Niivue.prototype.makeLut = function(Rs, Gs, Bs, As, Is) {
	//create color lookup table provided arrays of reds, greens, blues, alphas and intensity indices
	//intensity indices should be in increasing order with the first value 0 and the last 255.
	// this.makeLut([0, 255], [0, 0], [0,0], [0,128],[0,255]); //red gradient
	var lut = new Uint8ClampedArray(256 * 4);
	for (var i = 0; i < (Is.length - 1); i++) {
		//return a + f * (b - a);
		var idxLo = Is[i];
		var idxHi = Is[i + 1];
		var idxRng = idxHi - idxLo;
		var k = idxLo * 4;
		for (var j = idxLo; j <= idxHi; j++) {
			var f = (j - idxLo) / idxRng;
      lut[k++] = Rs[i] + f * (Rs[i + 1] - Rs[i]); //Red
			lut[k++] = Gs[i] + f * (Gs[i + 1] - Gs[i]); //Green
			lut[k++] = Bs[i] + f * (Bs[i + 1] - Bs[i]); //Blue
			lut[k++] = As[i] + f * (As[i + 1] - As[i]); //Alpha
		}
	}
	return lut;
} // makeLut()

Niivue.prototype.sliceScale = function() {
  var dims = [1.0, this.back.dims[1] * this.back.pixDims[1], this.back.dims[2] * this.back.pixDims[2], this.back.dims[3] * this.back.pixDims[3]];
	var longestAxis = Math.max(dims[1], Math.max(dims[2], dims[3]));
	var volScale = [dims[1] / longestAxis, dims[2] / longestAxis, dims[3] / longestAxis];
	var vox = [this.back.dims[1], this.back.dims[2], this.back.dims[3]];
	return { volScale, vox }
} // sliceScale()

Niivue.prototype.mouseClick = function(x, y, posChange=0, isDelta=true) {
  var posNow
	var posFuture
  if (this.sliceType === this.sliceTypeRender) {
    if (posChange === 0) return;
    if (posChange > 0) this.volScaleMultiplier = Math.min(2.0, this.volScaleMultiplier *  1.1)
    if (posChange < 0) this.volScaleMultiplier = Math.max(0.5, this.volScaleMultiplier *  0.9)
    this.drawScene()
    return
  }
	if ((this.numScreenSlices < 1) || (this.gl.canvas.height < 1) || (this.gl.canvas.width < 1))
		return;
	//mouse click X,Y in screen coordinates, origin at top left
	// webGL clip space L,R,T,B = [-1, 1, 1, 1]
	// n.b. webGL Y polarity reversed
	// https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
	for (let i = 0; i < this.numScreenSlices; i++) {
		var axCorSag = this.screenSlices[i].axCorSag;
		if (axCorSag > this.sliceTypeSagittal) continue;
		var ltwh = this.screenSlices[i].leftTopWidthHeight;
		let isMirror = false;
		if (ltwh[2] < 0) {
			isMirror = true;
			ltwh[0] += ltwh[2];
			ltwh[2] = - ltwh[2];
		}
		var fracX = (x - ltwh[0]) / ltwh[2];
		if (isMirror) fracX = 1.0 - fracX;
		var fracY = 1.0 - ((y - ltwh[1]) / ltwh[3]);
		if ((fracX >= 0.0) && (fracX < 1.0) && (fracY >= 0.0) && (fracY < 1.0)) { //user clicked on slice i
			if ( !isDelta ) {
				this.scene.crosshairPos[2 - axCorSag] = posChange;
				this.drawScene();
				return;
			}
			if ( posChange !== 0) {
				posNow = this.scene.crosshairPos[2 - axCorSag]
				posFuture = posNow + posChange
				if (posFuture > 1) posFuture = 1;
				if (posFuture < 0) posFuture = 0;
				//console.log(scrollVal,':',axCorSag, '>>', posFuture);
				this.scene.crosshairPos[2 - axCorSag] = posFuture;
				this.drawScene()
				return;
			}
			if (axCorSag === this.sliceTypeAxial) {
				this.scene.crosshairPos[0] = fracX;
				this.scene.crosshairPos[1] = fracY;
			}
			if (axCorSag === this.sliceTypeCoronal) {
				this.scene.crosshairPos[0] = fracX;
				this.scene.crosshairPos[2] = fracY;
			}
			if (axCorSag === this.sliceTypeSagittal) {
				this.scene.crosshairPos[1] = fracX;
				this.scene.crosshairPos[2] = fracY;
			}
			this.drawScene()
			return;
		} else {//if click in slice i
      // if x and y are null, likely due to a slider widget sending the posChange (no mouse info in that case)
      if (x === null && y === null){
        this.scene.crosshairPos[2-axCorSag] = posChange
        this.drawScene()
        return
      }
    }
  } //for i: each slice on screen
} // mouseClick()

Niivue.prototype.drawColorbar = function(leftTopWidthHeight) {
	if ((leftTopWidthHeight[2] <= 0) || (leftTopWidthHeight[3] <= 0))
		return;
	//console.log("bar:", leftTopWidthHeight[0], leftTopWidthHeight[1], leftTopWidthHeight[2], leftTopWidthHeight[3]);
	if (this.opts.crosshairWidth > 0) {
		//gl.disable(gl.DEPTH_TEST);
		this.lineShader.use(this.gl)
		this.gl.uniform4fv(this.lineShader.uniforms["lineColor"], this.opts.crosshairColor);
		this.gl.uniform2fv(this.lineShader.uniforms["canvasWidthHeight"], [this.gl.canvas.width, this.gl.canvas.height]);
		let ltwh = [leftTopWidthHeight[0]-1, leftTopWidthHeight[1]-1, leftTopWidthHeight[2]+2, leftTopWidthHeight[3]+2];
		this.gl.uniform4f(this.lineShader.uniforms["leftTopWidthHeight"], ltwh[0], ltwh[1], ltwh[2], ltwh[3]);
		this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	}
	this.colorbarShader.use(this.gl);
	this.gl.activeTexture(this.gl.TEXTURE1);
	this.gl.bindTexture(this.gl.TEXTURE_2D, this.colormapTexture);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
	this.gl.uniform2fv(this.colorbarShader.uniforms["canvasWidthHeight"], [this.gl.canvas.width, this.gl.canvas.height]);
	this.gl.uniform4f(this.colorbarShader.uniforms["leftTopWidthHeight"], leftTopWidthHeight[0], leftTopWidthHeight[1], leftTopWidthHeight[2], leftTopWidthHeight[3]);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
	this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
	//gl.enable(gl.DEPTH_TEST);
} // drawColorbar()

Niivue.prototype.textWidth = function(scale, str) {
	let w = 0;
	var bytes = new Buffer(str);
	for (let i = 0; i < str.length; i++)
		w += scale * this.fontMets[bytes[i]].xadv;
	return w;
} // textWidth()

Niivue.prototype.drawChar = function(xy, scale, char) { //draw single character, never call directly: ALWAYS call from drawText()
	let metrics = this.fontMets[char];
	let l = xy[0] + (scale * metrics.lbwh[0]);
	let b = -(scale * metrics.lbwh[1]);
	let w = (scale * metrics.lbwh[2]);
	let h = (scale * metrics.lbwh[3]);
	let t = xy[1] + (b - h) + scale;
	this.gl.uniform4f(this.fontShader.uniforms["leftTopWidthHeight"], l, t, w, h);
	this.gl.uniform4fv(this.fontShader.uniforms["uvLeftTopWidthHeight"], metrics.uv_lbwh);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	return scale * metrics.xadv;
} // drawChar()

Niivue.prototype.drawText = function(xy, str) { //to right of x, vertically centered on y
	if (this.opts.textHeight <= 0) return;
	this.fontShader.use(this.gl);
	let scale = (this.opts.textHeight * this.gl.canvas.height);
	this.gl.enable(this.gl.BLEND)
	this.gl.uniform2f(this.fontShader.uniforms["canvasWidthHeight"], this.gl.canvas.width, this.gl.canvas.height);
	this.gl.uniform4fv(this.fontShader.uniforms["fontColor"], this.opts.crosshairColor);
	let screenPxRange = scale / this.fontMets.size * this.fontMets.distanceRange;
	screenPxRange = Math.max(screenPxRange, 1.0) //screenPxRange() must never be lower than 1
	this.gl.uniform1f(this.fontShader.uniforms["screenPxRange"], screenPxRange);
	var bytes = new Buffer(str);
	for (let i = 0; i < str.length; i++)
		xy[0] += this.drawChar(xy, scale, bytes[i]);
} // drawText()

Niivue.prototype.drawTextRight = function(xy, str) { //to right of x, vertically centered on y
	if (this.opts.textHeight <= 0) return;
	this.fontShader.use(this.gl)
	xy[1] -= (0.5 * this.opts.textHeight * this.gl.canvas.height);
	this.drawText(xy, str)
} // drawTextRight()

Niivue.prototype.drawTextBelow = function(xy, str) { //horizontally centered on x, below y
	if (this.opts.textHeight <= 0) return;
	this.fontShader.use(this.gl)
	let scale = (this.opts.textHeight * this.gl.canvas.height);
	xy[0] -= 0.5 * this.textWidth(scale, str);
	this.drawText(xy, str)
} // drawTextBelow()

Niivue.prototype.draw2D = function(leftTopWidthHeight, axCorSag) {
	var crossXYZ = [this.scene.crosshairPos[0], this.scene.crosshairPos[1],this.scene.crosshairPos[2]]; //axial: width=i, height=j, slice=k
	if (axCorSag === this.sliceTypeCoronal)
		crossXYZ = [this.scene.crosshairPos[0], this.scene.crosshairPos[2],this.scene.crosshairPos[1]]; //coronal: width=i, height=k, slice=j
	if (axCorSag === this.sliceTypeSagittal)
		crossXYZ = [this.scene.crosshairPos[1], this.scene.crosshairPos[2],this.scene.crosshairPos[0]]; //sagittal: width=j, height=k, slice=i
	let isMirrorLR = ((this.isRadiologicalConvention) && (axCorSag < this.sliceTypeSagittal))
	this.sliceShader.use(this.gl);
	this.gl.uniform1f(this.sliceShader.uniforms["opacity"], this.backOpacity);
	this.gl.uniform1i(this.sliceShader.uniforms["axCorSag"], axCorSag);
	this.gl.uniform1f(this.sliceShader.uniforms["slice"], crossXYZ[2]);
	this.gl.uniform2fv(this.sliceShader.uniforms["canvasWidthHeight"], [this.gl.canvas.width, this.gl.canvas.height]);
	if (isMirrorLR) {
		this.gl.disable(this.gl.CULL_FACE);
		leftTopWidthHeight[2] = - leftTopWidthHeight[2];
		leftTopWidthHeight[0] = leftTopWidthHeight[0] - leftTopWidthHeight[2];
	}
	this.gl.uniform4f(this.sliceShader.uniforms["leftTopWidthHeight"], leftTopWidthHeight[0], leftTopWidthHeight[1], leftTopWidthHeight[2], leftTopWidthHeight[3]);
	//console.log(leftTopWidthHeight);
	//gl.uniform4f(sliceShader.uniforms["leftTopWidthHeight"], leftTopWidthHeight[0], leftTopWidthHeight[1], leftTopWidthHeight[2], leftTopWidthHeight[3]);
	//gl.drawArrays(gl.TRIANGLE_STRIP, 5, 4);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	//record screenSlices to detect mouse click positions
	this.screenSlices[this.numScreenSlices].leftTopWidthHeight = leftTopWidthHeight;
	this.screenSlices[this.numScreenSlices].axCorSag = axCorSag;
	this.numScreenSlices += 1;
	if (this.opts.crosshairWidth <= 0.0) return;
	this.lineShader.use(this.gl)
	this.gl.uniform4fv(this.lineShader.uniforms["lineColor"], this.opts.crosshairColor);
	this.gl.uniform2fv(this.lineShader.uniforms["canvasWidthHeight"], [this.gl.canvas.width, this.gl.canvas.height]);
	//vertical line of crosshair:
	var xleft = leftTopWidthHeight[0] + (leftTopWidthHeight[2] * crossXYZ[0]);
	this.gl.uniform4f(this.lineShader.uniforms["leftTopWidthHeight"], xleft - (0.5*this.opts.crosshairWidth), leftTopWidthHeight[1],  this.opts.crosshairWidth, leftTopWidthHeight[3]);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	//horizontal line of crosshair:
	var xtop = leftTopWidthHeight[1] + (leftTopWidthHeight[3] * (1.0 - crossXYZ[1]));
	this.gl.uniform4f(this.lineShader.uniforms["leftTopWidthHeight"], leftTopWidthHeight[0], xtop - (0.5*this.opts.crosshairWidth), leftTopWidthHeight[2],  this.opts.crosshairWidth);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 5, 4);
	this.gl.enable(this.gl.CULL_FACE);
	if (isMirrorLR)
		this.drawTextRight([leftTopWidthHeight[0] +leftTopWidthHeight[2] + 1, leftTopWidthHeight[1] + (0.5 * leftTopWidthHeight[3]) ], "R");
	else if (axCorSag < this.sliceTypeSagittal)
		this.drawTextRight([leftTopWidthHeight[0] + 1, leftTopWidthHeight[1] + (0.5 * leftTopWidthHeight[3]) ], "L");
	if ( axCorSag === this.sliceTypeAxial)
		this.drawTextBelow([leftTopWidthHeight[0] + (0.5 * leftTopWidthHeight[2]), leftTopWidthHeight[1] + 1 ], "A");
	if ( axCorSag > this.sliceTypeAxial)
		this.drawTextBelow([leftTopWidthHeight[0] + (0.5 * leftTopWidthHeight[2]), leftTopWidthHeight[1] + 1 ], "S");
} // draw2D()

Niivue.prototype.draw3D = function() {
	let {volScale, vox} = this.sliceScale(); // slice scale determined by this.back --> the base image layer
	this.renderShader.use(this.gl);
	let mn = Math.min(this.gl.canvas.width, this.gl.canvas.height)
	if (mn <= 0) return;
	mn *= this.volScaleMultiplier
	let xCenter = this.gl.canvas.width / 2;
	let yCenter = this.gl.canvas.height / 2;
	let xPix = mn
	let yPix = mn
	this.gl.viewport(xCenter-(xPix * 0.5), yCenter - (yPix * 0.5) , xPix, yPix);
	this.gl.clearColor(0.2, 0, 0, 1);
	var m = mat.mat4.create();
	var fDistance = -0.54;
	//https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/depthRange
	// default is 0..1
	// unit cube with corner aligned 
	//this.gl.depthRange(0.1, -fDistance * 3.0); //xerxes
	//modelMatrix *= TMat4.Translate(0, 0, -fDistance);
	mat.mat4.translate(m,m, [0, 0, fDistance]);
	// https://glmatrix.net/docs/module-mat4.html  https://glmatrix.net/docs/mat4.js.html
	var rad = (90-this.scene.renderElevation-volScale[0]) * Math.PI / 180;
	mat.mat4.rotate(m,m, rad, [-1, 0, 0]);
	rad = (this.scene.renderAzimuth) * Math.PI / 180;
	mat.mat4.rotate(m,m, rad, [0, 0, 1]);
	mat.mat4.scale(m, m, volScale); // volume aspect ratio
	mat.mat4.scale(m, m, [0.57, 0.57, 0.57]); //unit cube has maximum 1.73
	
	//compute ray direction
	var inv = mat.mat4.create();
	mat.mat4.invert(inv, m);
	var rayDir4 = mat.vec4.fromValues(0,0,-1,1);
	mat.vec4.transformMat4(rayDir4, rayDir4, inv);
	var rayDir = mat.vec3.fromValues(rayDir4[0],rayDir4[1],rayDir4[2]);
	mat.vec3.normalize(rayDir, rayDir);
	//defuzz, avoid divide by zero
	const tiny = 0.00001;
	if (Math.abs(rayDir[0]) < tiny) rayDir[0] = tiny;
	if (Math.abs(rayDir[1]) < tiny) rayDir[1] = tiny;
	if (Math.abs(rayDir[2]) < tiny) rayDir[2] = tiny;
	//console.log( ">>", renderAzimuth, " : ", renderElevation, ">>>> ", rayDir);
	//this.gl.disable(this.gl.DEPTH_TEST);
	//gl.enable(gl.CULL_FACE);
	//gl.cullFace(gl.FRONT);
	this.gl.enable(this.gl.CULL_FACE);
	this.gl.uniformMatrix4fv(this.renderShader.uniforms["mvpMtx"], false, m);
	this.gl.uniform1f(this.renderShader.uniforms["overlays"], this.overlays);
  this.gl.uniform1f(this.renderShader.uniforms["backOpacity"], this.volumes[0].opacity);
	this.gl.uniform4fv(this.renderShader.uniforms["clipPlane"], this.scene.clipPlane);
	this.gl.uniform3fv(this.renderShader.uniforms["rayDir"], rayDir);
	this.gl.uniform3fv(this.renderShader.uniforms["texVox"], vox);
	this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 14); //cube is 12 triangles, triangle-strip creates n-2 triangles
	let posString = 'azimuth: ' + this.scene.renderAzimuth.toFixed(0)+' elevation: '+this.scene.renderElevation.toFixed(0);
	//bus.$emit('crosshair-pos-change', posString);
	return posString;
} // draw3D()

Niivue.prototype.mm2frac = function(mm ) {
  //given mm, return volume fraction
	//convert from object space in millimeters to normalized texture space XYZ= [0..1, 0..1 ,0..1]
	let mm4 = mat.vec4.fromValues( mm[0], mm[1], mm[2],1);
	let d = this.back.dims;
	let frac = [0, 0, 0];
	if ((d[1] < 1) || (d[2] < 1) || (d[3] < 1))
		return frac;
	let sform = mat.mat4.clone(this.back.matRAS);
	mat.mat4.transpose(sform, sform);
	mat.mat4.invert(sform, sform);
	mat.vec4.transformMat4(mm4, mm4, sform);
	frac[0] = (mm4[0] + 0.5) / d[1];
	frac[1] = (mm4[1] + 0.5) / d[2];
	frac[2] = (mm4[2] + 0.5) / d[3];
	//console.log("mm", mm, " -> frac", frac);
	return frac;
} // mm2frac()

Niivue.prototype.vox2frac = function(vox) {
	//convert from  0-index voxel space [0..dim[1]-1, 0..dim[2]-1, 0..dim[3]-1] to normalized texture space XYZ= [0..1, 0..1 ,0..1]
	//consider dimension with 3 voxels, the voxel centers are at 0.25, 0.5, 0.75 corresponding to 0,1,2
	let frac = [ (vox[0]+0.5)/this.back.dims[1], (vox[1]+0.5)/this.back.dims[2], (vox[2]+0.5)/this.back.dims[3] ]
	return frac
} // vox2frac()

Niivue.prototype.frac2vox = function(frac) {
	//convert from normalized texture space XYZ= [0..1, 0..1 ,0..1] to 0-index voxel space [0..dim[1]-1, 0..dim[2]-1, 0..dim[3]-1]
	//consider dimension with 3 voxels, the voxel centers are at 0.25, 0.5, 0.75 corresponding to 0,1,2
	let vox = [ (frac[0]*this.back.dims[1])-0.5, (frac[1]*this.back.dims[2])-0.5, (frac[2]*this.back.dims[3])-0.5 ]
	return vox
} // frac2vox()

Niivue.prototype.frac2mm = function(frac) {
  //convert from normalized texture space XYZ= [0..1, 0..1 ,0..1] to object space in millimeters
	let pos = mat.vec4.fromValues(frac[0], frac[1], frac[2], 1);
	//let d = overlayItem.volume.hdr.dims;
	let dim = mat.vec4.fromValues(this.back.dims[1], this.back.dims[2],this. back.dims[3], 1);
	let sform = mat.mat4.clone(this.back.matRAS);
	mat.mat4.transpose(sform, sform);
	mat.vec4.mul(pos, pos, dim);
	let shim = mat.vec4.fromValues(-0.5, -0.5, -0.5, 0); //bitmap with 5 voxels scaled 0..1, voxel centers are 0.1,0.3,0.5,0.7,0.9
	mat.vec4.add(pos, pos, shim);
	mat.vec4.transformMat4(pos, pos, sform);
	this.mm2frac(pos);
	return pos;
} // frac2mm()

Niivue.prototype.scaleSlice = function(w, h) {
	let scalePix = this.gl.canvas.clientWidth / w;
	if ((h * scalePix) > this.gl.canvas.clientHeight)
		scalePix = this.gl.canvas.clientHeight / h;
	//canvas space is 0,0...w,h with origin at upper left
	let wPix = w * scalePix;
	let hPix = h * scalePix;
	let leftTopWidthHeight = [(this.gl.canvas.clientWidth-wPix) * 0.5, ((this.gl.canvas.clientHeight-hPix) * 0.5), wPix, hPix];
	//let leftTopWidthHeight = [(gl.canvas.clientWidth-wPix) * 0.5, 80, wPix, hPix];
	return leftTopWidthHeight;
} // scaleSlice()


Niivue.prototype.drawScene = function() {
	this.gl.clearColor(this.opts.backColor[0], this.opts.backColor[1], this.opts.backColor[2], this.opts.backColor[3]);
	this.gl.clear(this.gl.COLOR_BUFFER_BIT);
	let posString = '';
	if(!this.back.dims) // exit if we have nothing to draw
		return
	if (this.sliceType === this.sliceTypeRender) //draw rendering
		return this.draw3D();
	let {volScale} = this.sliceScale();
	this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
	this.numScreenSlices = 0;
	if (this.sliceType === this.sliceTypeAxial) { //draw axial
		let leftTopWidthHeight = this.scaleSlice(volScale[0], volScale[1]);
		this.draw2D(leftTopWidthHeight, 0);
	} else if (this.sliceType === this.sliceTypeCoronal) { //draw coronal
		let leftTopWidthHeight = this.scaleSlice(volScale[0], volScale[2]);
		this.draw2D(leftTopWidthHeight, 1);
	} else if (this.sliceType === this.sliceTypeSagittal) { //draw sagittal
		let leftTopWidthHeight = this.scaleSlice(volScale[1], volScale[2]);
		this.draw2D(leftTopWidthHeight, 2);
	} else { //sliceTypeMultiplanar
		let ltwh = this.scaleSlice(volScale[0]+volScale[1], volScale[1]+volScale[2]);
		let wX = ltwh[2] * volScale[0]/(volScale[0]+volScale[1]);
		let ltwh3x1 = this.scaleSlice(volScale[0]+volScale[0]+volScale[1], Math.max(volScale[1],volScale[2]));
		let wX1 = ltwh3x1[2] * volScale[0]/(volScale[0]+volScale[0]+volScale[1]);
		if (wX1 > wX) {
			let pixScale = (wX1 / volScale[0]);
			let hY1 = volScale[1] * pixScale;
			let hZ1 = volScale[2] * pixScale;
			//draw axial
			this.draw2D([ltwh3x1[0],ltwh3x1[1], wX1, hY1], 0);
			//draw coronal
			this.draw2D([ltwh3x1[0] + wX1,ltwh3x1[1], wX1, hZ1], 1);
			//draw sagittal
			this.draw2D([ltwh3x1[0] + wX1 + wX1,ltwh3x1[1], hY1, hZ1], 2);

		} else {
			let wY = ltwh[2] - wX;
			let hY = ltwh[3] * volScale[1]/(volScale[1]+volScale[2]);
			let hZ = ltwh[3] - hY;
			//draw axial
			this.draw2D([ltwh[0],ltwh[1]+hZ, wX, hY], 0);
			//draw coronal
			this.draw2D([ltwh[0],ltwh[1], wX, hZ], 1);
			//draw sagittal
			this.draw2D([ltwh[0]+wX,ltwh[1], wY, hZ], 2);
			//draw colorbar (optional) // TODO currently only drawing one colorbar, there may be one per overlay + one for the background
			var margin = this.opts.colorBarMargin * hY;
			this.drawColorbar([ltwh[0]+wX+margin, ltwh[1] + hZ + margin, wY - margin - margin, hY * this.opts.colorbarHeight]);
			// drawTextBelow(gl, [ltwh[0]+ wX + (wY * 0.5), ltwh[1] + hZ + margin + hY * colorbarHeight], "Syzygy"); //DEMO
		}
	}
	const pos = this.frac2mm([this.scene.crosshairPos[0],this.scene.crosshairPos[1],this.scene.crosshairPos[2]]);
	posString = pos[0].toFixed(2)+'×'+pos[1].toFixed(2)+'×'+pos[2].toFixed(2);
	this.gl.finish();
	// temporary event bus mechanism. It uses Vue, but it would be ideal to divorce vue from this gl code.
	//bus.$emit('crosshair-pos-change', posString);
	return posString
} // drawScene()