utils/glUtils.js

/**
* @file glUtils.js Utilities for WebGL-based marker drawing
* @author Fredrik Nysjo
* @see {@link glUtils}
*/

/**
 * @namespace glUtils
 * @property {Boolean} _initialized True when glUtils has been initialized
 */
glUtils = {
    _initialized: false,
    _options: {antialias: false, premultipliedAlpha: true, preserveDrawingBuffer: true},
    _markershapes: "misc/markershapes.png",

    // WebGL objects (both shared ones and ones that are per markerset UID)
    _programs: {},
    _buffers: {},
    _vaos: {},
    _textures: {},
    _query: null,

    // Marker settings and info stored per UID (this could perhaps be
    // better handled by having an object per UID that stores all info
    // and is easy to delete when closing a marker tab...)
    _numPoints: {},              // {uid: numPoints, ...}
    _numEdges: {},               // {uid: numEdges, ...}
    _markerScalarRange: {},      // {uid: [minval, maxval], ...}
    _markerScaleFactor: {},      // {uid: float}
    _markerScalarPropertyName: {},  // {uid: string, ...}
    _markerOpacity: {},          // {uid: alpha, ...}
    _markerOutline: {},          // {uid: boolean, ...}
    _useColorFromMarker: {},     // {uid: boolean, ...}
    _useColorFromColormap: {},   // {uid: boolean, ...}
    _useScaleFromMarker: {},     // {uid: boolean, ...}
    _useOpacityFromMarker: {},   // {uid: boolean, ...}
    _usePiechartFromMarker: {},  // {uid: boolean, ...}
    _useShapeFromMarker: {},     // {uid: boolean, ...}
    _colorscaleName: {},         // {uid: colorscaleName, ...}
    _colorscaleData: {},         // {uid: array of RGBA values, ...}
    _barcodeToLUTIndex: {},      // {uid: dict, ...}
    _barcodeToKey: {},           // {uid: dict, ...}
    _collectionItemIndex: {},    // {uid: number, ...}
    _markerInputsCached: {},     // {uid: dict, ...}
    _edgeInputsCached: {},       // {uid: dict, ...}

    // Global marker settings and info
    _markerScale: 1.0,
    _useMarkerScaleFix: true,
    _globalMarkerScale: 1.0,
    _pickingEnabled: false,
    _pickingLocation: [0.0, 0.0],
    _pickedMarker: [-1, -1],
    _showColorbar: true,
    _showMarkerInfo: true,
    _resolutionScale: 1.0,        // If this is set to below 1.0, the WebGL output will be upscaled
    _resolutionScaleActual: 1.0,  // Automatic scaling factor computed from glUtils._resolutionScale
    _useInstancing: true,         // Use instancing and gl.TRIANGLE_STRIP to avoid size limit of gl.POINTS
    _showEdgesExperimental: true,
    _logPerformance: false,       // Use GPU timer queries to log performance
    _piechartPalette: ["#fff100", "#ff8c00", "#e81123", "#ec008c", "#68217a", "#00188f", "#00bcf2", "#00b294", "#009e49", "#bad80a"]
}


glUtils._markersVS = `
    #define SHAPE_INDEX_CIRCLE 7.0
    #define SHAPE_INDEX_CIRCLE_NOSTROKE 16.0
    #define SHAPE_GRID_SIZE 4.0
    #define DISCARD_VERTEX { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); return; }

    uniform mat2 u_viewportTransform;
    uniform vec2 u_canvasSize;
    uniform float u_transformIndex;
    uniform float u_markerScale;
    uniform float u_globalMarkerScale;
    uniform vec2 u_markerScalarRange;
    uniform float u_markerOpacity;
    uniform float u_maxPointSize;
    uniform bool u_useColorFromMarker;
    uniform bool u_useColorFromColormap;
    uniform bool u_usePiechartFromMarker;
    uniform bool u_useShapeFromMarker;
    uniform bool u_alphaPass;
    uniform int u_pickedMarker;
    uniform sampler2D u_colorLUT;
    uniform sampler2D u_colorscale;
    uniform sampler2D u_transformLUT;

    layout(location = 0) in vec4 in_position;
    layout(location = 1) in int in_index;
    layout(location = 2) in float in_scale;
    layout(location = 3) in float in_shape;
    layout(location = 4) in float in_opacity;
    layout(location = 5) in float in_transform;

    out vec4 v_color;
    out vec2 v_shapeOrigin;
    out vec2 v_shapeSector;
    out float v_shapeSize;
    #ifdef USE_INSTANCING
    out highp vec2 v_texCoord;
    #endif  // USE_INSTANCING

    vec3 hex_to_rgb(float v)
    {
        // Extract RGB color from 24-bit hex color stored in float
        v = clamp(v, 0.0, 16777215.0);
        return floor(mod((v + 0.49) / vec3(65536.0, 256.0, 1.0), 256.0)) / 255.0;
    }

    void main()
    {
        float transformIndex = u_transformIndex >= 0.0 ? u_transformIndex : in_transform;
        vec4 imageTransform = texture(u_transformLUT, vec2(transformIndex / 255.0, 0));
        vec2 viewportPos = in_position.xy * imageTransform.xy + imageTransform.zw;
        vec2 ndcPos = viewportPos * 2.0 - 1.0;
        ndcPos.y = -ndcPos.y;
        ndcPos = u_viewportTransform * ndcPos;

        float lutIndex = mod(in_position.z, 4096.0);
        v_color = texture(u_colorLUT, vec2(lutIndex / 4095.0, 0.5));

        if (u_useColorFromMarker || u_useColorFromColormap) {
            vec2 range = u_markerScalarRange;
            float normalized = (in_position.w - range[0]) / (range[1] - range[0]);
            v_color.rgb = texture(u_colorscale, vec2(normalized, 0.5)).rgb;
            if (u_useColorFromMarker) v_color.rgb = hex_to_rgb(in_position.w);
        }

        if (u_useShapeFromMarker && v_color.a > 0.0) {
            // Add one to marker index and normalize, to make things consistent
            // with how marker visibility and shape is stored in the LUT
            v_color.a = (floor(in_position.z / 4096.0) + 1.0) / 255.0;
        }

        if (u_usePiechartFromMarker && v_color.a > 0.0) {
            v_shapeSector[0] = mod(in_shape, 4096.0) / 4095.0;
            v_shapeSector[1] = floor(in_shape / 4096.0) / 4095.0;
            v_color.rgb = hex_to_rgb(in_position.w);
            v_color.a = SHAPE_INDEX_CIRCLE_NOSTROKE / 255.0;
            if (u_pickedMarker == in_index) v_color.a = SHAPE_INDEX_CIRCLE / 255.0;

            // For the alpha pass, we only want to draw the marker once
            float sectorIndex = floor(in_position.z / 4096.0);
            if (u_alphaPass) v_color.a *= float(sectorIndex == 0.0);
        }

        gl_Position = vec4(ndcPos, 0.0, 1.0);
        gl_PointSize = in_scale * u_markerScale * u_globalMarkerScale;
        float alphaFactorSize = clamp(gl_PointSize, 0.2, 1.0); 
        gl_PointSize = clamp(gl_PointSize, 1.0, u_maxPointSize);

        v_shapeOrigin.x = mod((v_color.a + 0.00001) * 255.0 - 1.0, SHAPE_GRID_SIZE);
        v_shapeOrigin.y = floor(((v_color.a + 0.00001) * 255.0 - 1.0) / SHAPE_GRID_SIZE);
        v_shapeSize = gl_PointSize;

    #ifdef USE_INSTANCING
        // Marker will be drawn as a triangle strip, so need to generate
        // texture coordinate and offset the output position depending on
        // which of the four corners we are processing
        v_texCoord = vec2(gl_VertexID & 1, (gl_VertexID >> 1) & 1);
        gl_Position.xy += (v_texCoord * 2.0 - 1.0) * (gl_PointSize / u_canvasSize);
        v_texCoord.y = 1.0 - v_texCoord.y;  // Flip Y-axis to match gl_PointCoord behaviour
    #endif  // USE_INSTANCING

        // Discard point here in vertex shader if marker is hidden
        v_color.a = v_color.a > 0.0 ? in_opacity * u_markerOpacity : 0.0;
        v_color.a *= alphaFactorSize * alphaFactorSize;
        if (v_color.a == 0.0) DISCARD_VERTEX;
    }
`;


glUtils._markersFS = `
    #define UV_SCALE 0.7
    #define SHAPE_GRID_SIZE 4.0

    precision mediump float;

    uniform bool u_markerOutline;
    uniform bool u_usePiechartFromMarker;
    uniform bool u_alphaPass;
    uniform sampler2D u_shapeAtlas;

    in vec4 v_color;
    in vec2 v_shapeOrigin;
    in vec2 v_shapeSector;
    in float v_shapeSize;
    #ifdef USE_INSTANCING
    in highp vec2 v_texCoord;
    #endif  // USE_INSTANCING

    layout(location = 0) out vec4 out_color;

    float sectorToAlpha(vec2 sector, vec2 uv)
    {
        vec2 dir = normalize(uv - 0.5);
        float theta = (atan(dir.x, dir.y) / 3.141592) * 0.5 + 0.5;
        return float(theta > sector[0] && theta < sector[1]);
    }

    float sectorToAlphaAA(vec2 sector, vec2 uv, float delta)
    {
        // This workaround avoids the problem with small pixel-wide
        // gaps that can appear between the first and last sector
        if (uv.y < 0.5 && abs(uv.x - 0.5) < delta) return 1.0;

        float accum = 0.0;
        accum += sectorToAlpha(sector, uv + vec2(-delta, -delta));
        accum += sectorToAlpha(sector, uv + vec2(delta, -delta));
        accum += sectorToAlpha(sector, uv + vec2(-delta, delta));
        accum += sectorToAlpha(sector, uv + vec2(delta, delta));
        return accum / 4.0;
    }

    void main()
    {
    #ifdef USE_INSTANCING
        vec2 uv = (v_texCoord - 0.5) * UV_SCALE + 0.5;
    #else
        vec2 uv = (gl_PointCoord - 0.5) * UV_SCALE + 0.5;
    #endif  // USE_INSTANCING
        uv = (uv + v_shapeOrigin) * (1.0 / SHAPE_GRID_SIZE);

        // Sample shape texture in which the blue channel encodes alpha and the
        // red and green channels encode grayscale for marker shape with and
        // without outline, respectively
        vec4 shapeColor = texture(u_shapeAtlas, uv, -0.5);
        shapeColor = u_markerOutline ? shapeColor.rrrb : shapeColor.gggb;

        // This bias avoids minified markers with outline becoming too dark
        float shapeColorBias = max(0.0, 1.0 - v_shapeSize * 0.2);
        shapeColor.rgb = clamp(shapeColor.rgb + shapeColorBias, 0.0, 1.0);

        if (u_usePiechartFromMarker && !u_alphaPass) {
            float delta = 0.25 / v_shapeSize;
        #ifdef USE_INSTANCING
            shapeColor.a *= sectorToAlphaAA(v_shapeSector, v_texCoord, delta);
        #else
            shapeColor.a *= sectorToAlphaAA(v_shapeSector, gl_PointCoord, delta);
        #endif  // USE_INSTANCING
        }

        out_color = shapeColor * v_color;
        if (out_color.a < 0.004) discard;
    }
`;


glUtils._pickingVS = `
    #define UV_SCALE 0.7
    #define SHAPE_INDEX_CIRCLE_NOSTROKE 16.0
    #define SHAPE_GRID_SIZE 4.0
    #define DISCARD_VERTEX { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); return; }

    #define OP_CLEAR 0
    #define OP_WRITE_INDEX 1

    uniform mat2 u_viewportTransform;
    uniform vec2 u_canvasSize;
    uniform vec2 u_pickingLocation;
    uniform float u_transformIndex;
    uniform float u_markerScale;
    uniform float u_globalMarkerScale;
    uniform float u_markerOpacity;
    uniform float u_maxPointSize;
    uniform bool u_usePiechartFromMarker;
    uniform bool u_useShapeFromMarker;
    uniform int u_op;
    uniform sampler2D u_colorLUT;
    uniform sampler2D u_shapeAtlas;
    uniform sampler2D u_transformLUT;

    layout(location = 0) in vec4 in_position;
    layout(location = 1) in int in_index;
    layout(location = 2) in float in_scale;
    layout(location = 4) in float in_opacity;
    layout(location = 5) in float in_transform;

    out vec4 v_color;

    vec3 hex_to_rgb(float v)
    {
        // Extract RGB color from 24-bit hex color stored in float
        v = clamp(v, 0.0, 16777215.0);
        return floor(mod((v + 0.49) / vec3(65536.0, 256.0, 1.0), 256.0)) / 255.0;
    }

    void main()
    {
        float transformIndex = u_transformIndex >= 0.0 ? u_transformIndex : in_transform;
        vec4 imageTransform = texture(u_transformLUT, vec2(transformIndex / 255.0, 0));
        vec2 viewportPos = in_position.xy * imageTransform.xy + imageTransform.zw;
        vec2 ndcPos = viewportPos * 2.0 - 1.0;
        ndcPos.y = -ndcPos.y;
        ndcPos = u_viewportTransform * ndcPos;

        v_color = vec4(0.0);
        if (u_op == OP_WRITE_INDEX) {
            float lutIndex = mod(in_position.z, 4096.0);
            float shapeID = texture(u_colorLUT, vec2(lutIndex / 4095.0, 0.5)).a;
            if (shapeID == 0.0) DISCARD_VERTEX;

            if (u_useShapeFromMarker) {
                // Add one to marker index and normalize, to make things consistent
                // with how marker visibility and shape is stored in the LUT
                shapeID = (floor(in_position.z / 4096.0) + 1.0) / 255.0;
            }

            if (u_usePiechartFromMarker) {
                shapeID = SHAPE_INDEX_CIRCLE_NOSTROKE / 255.0;

                // For the picking pass, we only want to draw the marker once
                float sectorIndex = floor(in_position.z / 4096.0);
                if (sectorIndex > 0.0) DISCARD_VERTEX;
            }

            vec2 canvasPos = (ndcPos * 0.5 + 0.5) * u_canvasSize;
            canvasPos.y = (u_canvasSize.y - canvasPos.y);  // Y-axis is inverted
            float pointSize = in_scale * u_markerScale * u_globalMarkerScale;
            pointSize = clamp(pointSize, 2.0, u_maxPointSize);

            // Do coarse inside/outside test against bounding box for marker
            vec2 uv = (canvasPos - u_pickingLocation) / pointSize + 0.5;
            uv.y = (1.0 - uv.y);  // Flip y-axis to match gl_PointCoord behaviour
            if (abs(uv.x - 0.5) > 0.5 || abs(uv.y - 0.5) > 0.5) DISCARD_VERTEX;

            // Do fine-grained inside/outside test by sampling the shape texture
            // with alpha encoded in the blue channel
            vec2 shapeOrigin = vec2(0.0);
            shapeOrigin.x = mod((shapeID + 0.00001) * 255.0 - 1.0, SHAPE_GRID_SIZE);
            shapeOrigin.y = floor(((shapeID + 0.00001) * 255.0 - 1.0) / SHAPE_GRID_SIZE);
            uv = (uv - 0.5) * UV_SCALE + 0.5;
            uv = (uv + shapeOrigin) * (1.0 / SHAPE_GRID_SIZE);
            if (texture(u_shapeAtlas, uv).b < 0.5) DISCARD_VERTEX;

            // Also do a quick alpha-test to avoid picking non-visible markers
            if (in_opacity * u_markerOpacity <= 0.0) DISCARD_VERTEX

            // Output marker index encoded as hexadecimal color
            int encoded = in_index + 1;
            v_color.r = float((encoded >> 0) & 255) / 255.0;
            v_color.g = float((encoded >> 8) & 255) / 255.0;
            v_color.b = float((encoded >> 16) & 255) / 255.0;
            v_color.a = float((encoded >> 24) & 255) / 255.0;
        }

        gl_Position = vec4(-0.9999, -0.9999, 0.0, 1.0);
        gl_PointSize = 1.0;
    }
`;


glUtils._pickingFS = `
    precision mediump float;

    in vec4 v_color;

    layout(location = 0) out vec4 out_color;

    void main()
    {
        out_color = v_color;
    }
`;


glUtils._edgesVS = `
    #define THICKNESS_RATIO 0.2

    uniform mat2 u_viewportTransform;
    uniform vec2 u_canvasSize;
    uniform float u_transformIndex;
    uniform float u_markerScale;
    uniform float u_globalMarkerScale;
    uniform float u_markerOpacity;
    uniform float u_maxPointSize;

    uniform sampler2D u_colorLUT;
    uniform sampler2D u_transformLUT;

    layout(location = 0) in vec4 in_position;
    layout(location = 1) in int in_index;
    layout(location = 4) in float in_opacity;
    layout(location = 5) in float in_transform;

    out vec4 v_color;
    out highp vec2 v_texCoord;

    void main()
    {
        float transformIndex0 = u_transformIndex >= 0.0 ? u_transformIndex : mod(in_transform, 256.0);
        float transformIndex1 = u_transformIndex >= 0.0 ? u_transformIndex : floor(in_transform / 256.0);
        vec4 imageTransform0 = texture(u_transformLUT, vec2(transformIndex0 / 255.0, 0));
        vec4 imageTransform1 = texture(u_transformLUT, vec2(transformIndex1 / 255.0, 0));

        vec2 localPos0 = in_position.xy;
        vec2 localPos1 = in_position.zw;

        // Transform 1st edge vertex
        vec2 viewportPos0 = localPos0 * imageTransform0.xy + imageTransform0.zw;
        vec2 ndcPos0 = viewportPos0 * 2.0 - 1.0;
        ndcPos0.y = -ndcPos0.y;
        ndcPos0 = u_viewportTransform * ndcPos0;

        // Transform 2nd edge vertex
        vec2 viewportPos1 = localPos1 * imageTransform1.xy + imageTransform1.zw;
        vec2 ndcPos1 = viewportPos1 * 2.0 - 1.0;
        ndcPos1.y = -ndcPos1.y;
        ndcPos1 = u_viewportTransform * ndcPos1;

        float pointSize = u_markerScale * u_globalMarkerScale;
        pointSize = clamp(pointSize, 1.0, u_maxPointSize);
        float lineThickness = max(0.5, THICKNESS_RATIO * pointSize);
        float lineThicknessAdjusted = lineThickness + 0.25;  // Expanded thickness values,
        float lineThicknessAdjusted2 = lineThickness + 0.5;  // needed for anti-aliasing
        float lineThicknessOpacity = clamp(THICKNESS_RATIO * pointSize + 0.25, 0.5, 1.0);

        vec2 ndcMidpoint = (ndcPos1 + ndcPos0) * 0.5;
        vec2 ndcDeltaU = (ndcPos1 - ndcPos0) * 0.5;
        vec2 canvasDeltaU = ndcDeltaU * u_canvasSize;
        vec2 canvasDeltaV = vec2(-canvasDeltaU.y, canvasDeltaU.x);
        vec2 ndcDeltaV = lineThicknessAdjusted * normalize(canvasDeltaV) / u_canvasSize;

        gl_Position = vec4(ndcMidpoint, 0.0, 1.0);

        // Edge will be drawn as a triangle strip, so need to generate
        // texture coordinate and offset the output position depending on
        // which of the four corners we are processing
        v_texCoord = vec2(gl_VertexID & 1, (gl_VertexID >> 1) & 1);
        v_texCoord.y = ((v_texCoord.y - 0.5) * (lineThicknessAdjusted2 / lineThickness)) + 0.5;
        gl_Position.xy += (v_texCoord.x * 2.0 - 1.0) * ndcDeltaU;
        gl_Position.xy += (v_texCoord.y * 2.0 - 1.0) * ndcDeltaV;

        v_color.rgb = vec3(0.8);  // Use a fixed color (for now)
        v_color.a = in_opacity * u_markerOpacity * lineThicknessOpacity;
    }
`;


glUtils._edgesFS = `
    precision mediump float;

    in vec4 v_color;
    in highp vec2 v_texCoord;

    layout(location = 0) out vec4 out_color;

    float subpixelCoverage(vec2 uv)
    {
        vec2 samples[4];  // Sample locations (from rotated grid)
        samples[0] = vec2(0.25, -0.75); samples[1] = vec2(0.75, 0.25);
        samples[2] = vec2(-0.25, 0.75); samples[3] = vec2(-0.75, -0.25);

        vec2 deltaX = dFdx(uv) * 0.5;
        vec2 deltaY = dFdy(uv) * 0.5;
        float accum = 0.0;
        for (int i = 0; i < 4; ++i) {
            // Check if sample is inside or outside the line for the edge
            vec2 uv_i = uv + samples[i].x * deltaX + samples[i].y * deltaY;
            bool inside = (uv_i.x > 0.0 && uv_i.x < 1.0 && uv_i.y > 0.0 && uv_i.y < 1.0);
            accum += float(inside);
        }
        return accum * (1.0 / 4.0);
    }

    void main()
    {
        out_color.rgb = v_color.rgb;
        out_color.a = v_color.a * subpixelCoverage(v_texCoord);
    }
`;


glUtils._loadShaderProgram = function(gl, vertSource, fragSource, definitions="") {
    const vertShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertShader, "#version 300 es\n" + definitions + vertSource);
    gl.compileShader(vertShader);
    if (!gl.getShaderParameter(vertShader, gl.COMPILE_STATUS)) {
        console.log("Could not compile vertex shader: " + gl.getShaderInfoLog(vertShader));
    }

    const fragShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragShader, "#version 300 es\n" + definitions + fragSource);
    gl.compileShader(fragShader);
    if (!gl.getShaderParameter(fragShader, gl.COMPILE_STATUS)) {
        console.log("Could not compile fragment shader: " + gl.getShaderInfoLog(fragShader));
    }

    const program = gl.createProgram();
    gl.attachShader(program, vertShader);
    gl.attachShader(program, fragShader);
    gl.deleteShader(vertShader);  // Flag shaders for automatic deletion after
    gl.deleteShader(fragShader);  // their program object is destroyed

    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.log("Unable to link shader program: " + gl.getProgramInfoLog(program));
        gl.deleteProgram(program);
        return null;
    }

    return program;
}


glUtils._createMarkerBuffer = function(gl, numBytes) {
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer); 
    gl.bufferData(gl.ARRAY_BUFFER, numBytes, gl.STATIC_DRAW);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    return buffer;
}


// Create a list of normalized sector angles in format (TODO)
glUtils._createPiechartAngles = function(sectors) {
    let angles = [], sum = 0.0;
    for (let i = 0; i < sectors.length; ++i) {
        sum += Number(sectors[i]);
    }
    for (let i = 0; i < sectors.length; ++i) {
        angles[i] = Number(sectors[i]) / sum;
    }
    for (let i = sectors.length - 2; i >= 0; --i) {
        angles[i] += angles[i + 1];
    }
    return angles;
}


/**
 * @summary Create WebGL resources and other objects for drawing marker dataset.
 * @param {String | Number} uid Identifier referencing the marker dataset in dataUtils.
 */
glUtils.loadMarkers = function(uid, forceUpdate) {
    if (!glUtils._initialized) return;
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    let newInputs = {};  // Inputs that will require a vertex buffer update when changed

    // Get marker data and other info like image size
    const markerData = dataUtils.data[uid]["_processeddata"];
    const keyName = newInputs.keyName = dataUtils.data[uid]["_gb_col"];
    const xPosName = newInputs.xPosName = dataUtils.data[uid]["_X"];
    const yPosName = newInputs.yPosName = dataUtils.data[uid]["_Y"];
    let numPoints = markerData[xPosName].length;

    // If new marker data was loaded, we need to assign each barcode an index
    // that we can use with the LUT textures for color, visibility, etc.
    glUtils._updateBarcodeToLUTIndexDict(uid, markerData, keyName);
    const barcodeToLUTIndex = glUtils._barcodeToLUTIndex[uid];

    // Check how the user wants to draw the markers
    const colorPropertyName = newInputs.colorPropertyName = dataUtils.data[uid]["_cb_col"];
    const useColorFromMarker = newInputs.useColorFromMarker = (dataUtils.data[uid]["_cb_col"] != null && dataUtils.data[uid]["_cb_cmap"] == null);
    let hexColor = "#000000";

    const scalarPropertyName = newInputs.scalarPropertyName = dataUtils.data[uid]["_cb_col"];
    const colorscaleName = dataUtils.data[uid]["_cb_cmap"];
    const useColorFromColormap = newInputs.useColorFromColormap = dataUtils.data[uid]["_cb_cmap"] != null;
    let scalarRange = glUtils._markerScalarRange[uid];

    const scalePropertyName = newInputs.scalePropertyName = dataUtils.data[uid]["_scale_col"];
    const useScaleFromMarker = newInputs.useScaleFromMarker = dataUtils.data[uid]["_scale_col"] != null;
    const markerScaleFactor = dataUtils.data[uid]["_scale_factor"];
    
    const markerCoordFactor = newInputs.markerCoordFactor = dataUtils.data[uid]["_coord_factor"];
    
    const sectorsPropertyName = newInputs.sectorsPropertyName = dataUtils.data[uid]["_pie_col"];
    const usePiechartFromMarker = dataUtils.data[uid]["_pie_col"] != null;
    if (dataUtils.data[uid]["_pie_dict"] && sectorsPropertyName) {
        glUtils._piechartPalette = JSON.parse(dataUtils.data[uid]["_pie_dict"])
        if (typeof glUtils._piechartPalette === "object") {
            glUtils._piechartPalette = sectorsPropertyName.split(";").map(function(sector) {
                return glUtils._piechartPalette[sector];
            })
        }
    }
    const piechartPalette = glUtils._piechartPalette;
    let numSectors = 1;

    const shapePropertyName = newInputs.shapePropertyName = dataUtils.data[uid]["_shape_col"];
    const useShapeFromMarker = newInputs.useShapeFromMarker = dataUtils.data[uid]["_shape_col"] != null;
    const numShapes = Object.keys(markerUtils._symbolStrings).length;
    let shapeIndex = 0;

    const opacityPropertyName = newInputs.opacityPropertyName = dataUtils.data[uid]["_opacity_col"];
    const useOpacityFromMarker = newInputs.useOpacityFromMarker = dataUtils.data[uid]["_opacity_col"] != null;
    const markerOpacityFactor = dataUtils.data[uid]["_opacity"];

    const markerOutline = !dataUtils.data[uid]["_no_outline"];

    const collectionItemPropertyName = newInputs.collectionItemPropertyName = dataUtils.data[uid]["_collectionItem_col"];
    const useCollectionItemFromMarker = newInputs.useCollectionItemFromMarker = dataUtils.data[uid]["_collectionItem_col"] != null;
    const collectionItemFixed = newInputs.collectionItemFixed = dataUtils.data[uid]["_collectionItem_fixed"];
    let collectionItemIndex = collectionItemFixed;

    // Additional info about the vertex format. Make sure to update also
    // NUM_BYTES_PER_MARKER and NUM_BYTES_PER_MARKER_SECONDARY when making
    // changes to the format! Two buffers will be used for the vertex data
    // to avoid the 1GB max size limit imposed by QtWebEngine/Chromium.
    const NUM_BYTES_PER_MARKER = 16;
    const NUM_BYTES_PER_MARKER_SECONDARY = 16;
    const POINT_OFFSET = numPoints * 0,
          INDEX_OFFSET = numPoints * 0,
          SCALE_OFFSET = numPoints * 4,
          SHAPE_OFFSET = numPoints * 8;
          OPACITY_OFFSET = numPoints * 12;
          TRANSFORM_OFFSET = numPoints * 14;
    const POINT_LOCATION = 0,
          INDEX_LOCATION = 1,
          SCALE_LOCATION = 2,
          SHAPE_LOCATION = 3,
          OPACITY_LOCATION = 4;
          TRANSFORM_LOCATION = 5;

    const lastInputs = glUtils._markerInputsCached[uid];
    if (forceUpdate || (lastInputs != JSON.stringify(newInputs))) {
        scalarRange = [1e9, -1e9];  // This range will be computed from the data

        // Extract and upload vertex data for markers. For datasets with tens of of
        // millions of points, the vertex data can be quite large, so we upload the
        // data in chunks to the GPU buffer to avoid having to allocate a large
        // temporary buffer in system memory.
        console.time("Generate vertex data");
        let chunkSize = 100000;
        for (let offset = 0; offset < numPoints; offset += chunkSize) {
            // Allocate space for vertex data that will be uploaded to vertex buffer
            if (offset + chunkSize >= numPoints) chunkSize = numPoints - offset;
            // console.log(offset, chunkSize, numPoints);
            let bytedata_point = new Float32Array(chunkSize * 4);
            let bytedata_index = new Int32Array(chunkSize * 1);
            let bytedata_scale = new Float32Array(chunkSize * 1);
            let bytedata_shape = new Float32Array(chunkSize * 1);
            let bytedata_opacity = new Uint16Array(chunkSize * 1);
            let bytedata_transform = new Uint16Array(chunkSize * 1);

            if (usePiechartFromMarker) {
                // For piecharts, we need to create one marker per piechart sector,
                // so also have to allocate additional space for the vertex data
                numSectors = markerData[sectorsPropertyName][0].split(";").length;
                bytedata_point = new Float32Array(chunkSize * numSectors * 4);
                bytedata_index = new Float32Array(chunkSize * numSectors * 1);
                bytedata_scale = new Float32Array(chunkSize * numSectors * 1);
                bytedata_shape = new Float32Array(chunkSize * numSectors * 1);
                bytedata_opacity = new Uint16Array(chunkSize * numSectors * 1);
                bytedata_transform = new Uint16Array(chunkSize * numSectors * 1);

                for (let i = 0; i < chunkSize; ++i) {
                    const markerIndex = i + offset;
                    const sectors = markerData[sectorsPropertyName][markerIndex].split(";");
                    const piechartAngles = glUtils._createPiechartAngles(sectors);
                    const lutIndex = (keyName != null) ? barcodeToLUTIndex[markerData[keyName][markerIndex]] : 0;
                    const opacity = useOpacityFromMarker ? markerData[opacityPropertyName][markerIndex] : 1.0;
                    if (useCollectionItemFromMarker) collectionItemIndex = markerData[collectionItemPropertyName][markerIndex];

                    for (let j = 0; j < numSectors; ++j) {
                        const k = (i * numSectors + j);
                        const sectorIndex = j;
                        hexColor = piechartPalette[j % piechartPalette.length];

                        bytedata_point[4 * k + 0] = markerData[xPosName][markerIndex];
                        bytedata_point[4 * k + 1] = markerData[yPosName][markerIndex];
                        bytedata_point[4 * k + 2] = lutIndex + sectorIndex * 4096.0;
                        bytedata_point[4 * k + 3] = Number("0x" + hexColor.substring(1,7));
                        bytedata_index[k] = markerIndex;  // Store index needed for picking
                        bytedata_scale[k] = useScaleFromMarker ? markerData[scalePropertyName][markerIndex] : 1.0;
                        bytedata_shape[k] =
                            Math.floor((j < numSectors - 1 ? piechartAngles[j + 1] : 0.0) * 4095.0) +
                            Math.floor(piechartAngles[j] * 4095.0) * 4096.0;
                        bytedata_opacity[k] = Math.floor(Math.max(0.0, Math.min(1.0, opacity)) * 65535.0);
                        bytedata_transform[k] = collectionItemIndex;
                    }
                }
            } else {
                for (let i = 0; i < chunkSize; ++i) {
                    const markerIndex = i + offset;
                    const lutIndex = (keyName != null) ? barcodeToLUTIndex[markerData[keyName][markerIndex]] : 0;
                    const opacity = useOpacityFromMarker ? markerData[opacityPropertyName][markerIndex] : 1.0;
                    if (useCollectionItemFromMarker) collectionItemIndex = markerData[collectionItemPropertyName][markerIndex];

                    if (useColorFromMarker) hexColor = markerData[colorPropertyName][i];
                    if (useColorFromColormap) {
                        scalarValue = markerData[scalarPropertyName][markerIndex];
                        // Update scalar range that will be used for normalizing the values
                        scalarRange[0] = Math.min(scalarRange[0], scalarValue);
                        scalarRange[1] = Math.max(scalarRange[1], scalarValue);
                    }
                    if (useShapeFromMarker) {
                        shapeIndex = markerData[shapePropertyName][markerIndex];
                        // Check if shapeIndex is a symbol names that needs to be converted to an index
                        if (isNaN(shapeIndex)) shapeIndex = markerUtils._symbolStrings.indexOf(shapeIndex);
                        shapeIndex = Math.max(0.0, Math.floor(Number(shapeIndex))) % numShapes;
                    }

                    bytedata_point[4 * i + 0] = markerData[xPosName][markerIndex] * markerCoordFactor;
                    bytedata_point[4 * i + 1] = markerData[yPosName][markerIndex] * markerCoordFactor;
                    bytedata_point[4 * i + 2] = lutIndex + Number(shapeIndex) * 4096.0;
                    bytedata_point[4 * i + 3] = useColorFromColormap ? Number(scalarValue)
                                                                     : Number("0x" + hexColor.substring(1,7));
                    bytedata_index[i] = markerIndex;  // Store index needed for picking
                    bytedata_scale[i] = useScaleFromMarker ? markerData[scalePropertyName][markerIndex] : 1.0;
                    bytedata_opacity[i] = Math.floor(Math.max(0.0, Math.min(1.0, opacity)) * 65535.0);
                    bytedata_transform[i] = collectionItemIndex;
                }
            }

            if (!(uid + "_markers" in glUtils._buffers)) {
                document.getElementById(uid + "_menu-UI").addEventListener("input", glUtils.updateColorLUTTextures);
                document.getElementById(uid + "_menu-UI").addEventListener("input", glUtils.draw);
            }

            // Create WebGL objects (if this has not already been done)
            if (!(uid + "_markers" in glUtils._buffers))
                glUtils._buffers[uid + "_markers"] = glUtils._createMarkerBuffer(
                    gl, numPoints * numSectors * NUM_BYTES_PER_MARKER);
            if (!(uid + "_markers_secondary" in glUtils._buffers))
                glUtils._buffers[uid + "_markers_secondary"] = glUtils._createMarkerBuffer(
                    gl, numPoints * numSectors * NUM_BYTES_PER_MARKER_SECONDARY);
            if (!(uid + "_markers" in glUtils._vaos))
                glUtils._vaos[uid + "_markers"] = gl.createVertexArray();
            if (!(uid + "_markers_instanced" in glUtils._vaos))
                glUtils._vaos[uid + "_markers_instanced"] = gl.createVertexArray();
            if (!(uid + "_colorLUT" in glUtils._textures))
                glUtils._textures[uid + "_colorLUT"] = glUtils._createColorLUTTexture(gl);
            if (!(uid + "_colorscale" in glUtils._textures))
                glUtils._textures[uid + "_colorscale"] = glUtils._createColorScaleTexture(gl);

            // Upload chunks of vertex data to buffer
            if (offset == 0) {
                // If the number of sectors used is changed, we have to reallocate the buffers
                {
                    gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers"]);
                    const newBufferSize = numPoints * numSectors * NUM_BYTES_PER_MARKER;
                    const oldBufferSize = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
                    if (newBufferSize != oldBufferSize)
                        gl.bufferData(gl.ARRAY_BUFFER, newBufferSize, gl.STATIC_DRAW);
                }
                {
                    gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers_secondary"]);
                    const newBufferSize = numPoints * numSectors * NUM_BYTES_PER_MARKER_SECONDARY;
                    const oldBufferSize = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
                    if (newBufferSize != oldBufferSize)
                        gl.bufferData(gl.ARRAY_BUFFER, newBufferSize, gl.STATIC_DRAW);
                }
            }
            gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers"]);
            gl.bufferSubData(gl.ARRAY_BUFFER, (POINT_OFFSET + offset * 16) * numSectors, bytedata_point);
            gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers_secondary"]);
            gl.bufferSubData(gl.ARRAY_BUFFER, (INDEX_OFFSET + offset * 4) * numSectors, bytedata_index);
            gl.bufferSubData(gl.ARRAY_BUFFER, (SCALE_OFFSET + offset * 4) * numSectors, bytedata_scale);
            gl.bufferSubData(gl.ARRAY_BUFFER, (SHAPE_OFFSET + offset * 4) * numSectors, bytedata_shape);
            gl.bufferSubData(gl.ARRAY_BUFFER, (OPACITY_OFFSET + offset * 2) * numSectors, bytedata_opacity);
            gl.bufferSubData(gl.ARRAY_BUFFER, (TRANSFORM_OFFSET + offset * 2) * numSectors, bytedata_transform);
            gl.bindBuffer(gl.ARRAY_BUFFER, null);
        }
        console.timeEnd("Generate vertex data");

        // Set up VAO with vertex format for drawing
        gl.bindVertexArray(glUtils._vaos[uid + "_markers"]);
        gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers"]);
        gl.enableVertexAttribArray(POINT_LOCATION);
        gl.vertexAttribPointer(POINT_LOCATION, 4, gl.FLOAT, false, 0, POINT_OFFSET * numSectors);
        gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers_secondary"]);
        gl.enableVertexAttribArray(INDEX_LOCATION);
        gl.vertexAttribIPointer(INDEX_LOCATION, 1, gl.INT, 0, INDEX_OFFSET * numSectors);
        gl.enableVertexAttribArray(SCALE_LOCATION);
        gl.vertexAttribPointer(SCALE_LOCATION, 1, gl.FLOAT, false, 0, SCALE_OFFSET * numSectors);
        gl.enableVertexAttribArray(SHAPE_LOCATION);
        gl.vertexAttribPointer(SHAPE_LOCATION, 1, gl.FLOAT, false, 0, SHAPE_OFFSET * numSectors);
        gl.enableVertexAttribArray(OPACITY_LOCATION);
        gl.vertexAttribPointer(OPACITY_LOCATION, 1, gl.UNSIGNED_SHORT, true, 0, OPACITY_OFFSET * numSectors);
        gl.enableVertexAttribArray(TRANSFORM_LOCATION);
        gl.vertexAttribPointer(TRANSFORM_LOCATION, 1, gl.UNSIGNED_SHORT, false, 0, TRANSFORM_OFFSET * numSectors);
        gl.bindVertexArray(null);

        // Set up 2nd VAO (for experimental instanced drawing)
        gl.bindVertexArray(glUtils._vaos[uid + "_markers_instanced"]);
        gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers"]);
        gl.enableVertexAttribArray(POINT_LOCATION);
        gl.vertexAttribPointer(POINT_LOCATION, 4, gl.FLOAT, false, 0, POINT_OFFSET * numSectors);
        gl.vertexAttribDivisor(POINT_LOCATION, 1);
        gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_markers_secondary"]);
        gl.enableVertexAttribArray(INDEX_LOCATION);
        gl.vertexAttribIPointer(INDEX_LOCATION, 1, gl.INT, 0, INDEX_OFFSET * numSectors);
        gl.vertexAttribDivisor(INDEX_LOCATION, 1);
        gl.enableVertexAttribArray(SCALE_LOCATION);
        gl.vertexAttribPointer(SCALE_LOCATION, 1, gl.FLOAT, false, 0, SCALE_OFFSET * numSectors);
        gl.vertexAttribDivisor(SCALE_LOCATION, 1);
        gl.enableVertexAttribArray(SHAPE_LOCATION);
        gl.vertexAttribPointer(SHAPE_LOCATION, 1, gl.FLOAT, false, 0, SHAPE_OFFSET * numSectors);
        gl.vertexAttribDivisor(SHAPE_LOCATION, 1);
        gl.enableVertexAttribArray(OPACITY_LOCATION);
        gl.vertexAttribPointer(OPACITY_LOCATION, 1, gl.UNSIGNED_SHORT, true, 0, OPACITY_OFFSET * numSectors);
        gl.vertexAttribDivisor(OPACITY_LOCATION, 1);
        gl.enableVertexAttribArray(TRANSFORM_LOCATION);
        gl.vertexAttribPointer(TRANSFORM_LOCATION, 1, gl.UNSIGNED_SHORT, false, 0, TRANSFORM_OFFSET * numSectors);
        gl.vertexAttribDivisor(TRANSFORM_LOCATION, 1);
        gl.bindVertexArray(null);

    }
    glUtils._markerInputsCached[uid] = JSON.stringify(newInputs);

    // Generate separate WebGL resources for drawing graph edges (if markerset
    // contains spatial connectivity data and the user wants to display it)
    const numEdges = glUtils._loadEdges(uid, forceUpdate);

    // Update marker info and LUT + colormap textures
    glUtils._numPoints[uid] = numPoints * numSectors;
    glUtils._numEdges[uid] = numEdges;
    glUtils._markerScalarRange[uid] = scalarRange;
    glUtils._markerScalarPropertyName[uid] = scalarPropertyName;
    glUtils._markerScaleFactor[uid] = markerScaleFactor;
    glUtils._markerOpacity[uid] = markerOpacityFactor;
    glUtils._markerOutline[uid] = markerOutline;
    glUtils._useColorFromMarker[uid] = useColorFromMarker;
    glUtils._useColorFromColormap[uid] = useColorFromColormap;
    glUtils._useScaleFromMarker[uid] = useScaleFromMarker;
    glUtils._useOpacityFromMarker[uid] = useOpacityFromMarker;
    glUtils._usePiechartFromMarker[uid] = usePiechartFromMarker;
    glUtils._useShapeFromMarker[uid] = useShapeFromMarker;
    glUtils._colorscaleName[uid] = colorscaleName;
    glUtils._collectionItemIndex[uid] = collectionItemFixed;
    if (useColorFromColormap) {
        glUtils._updateColorScaleTexture(gl, uid, glUtils._textures[uid + "_colorscale"]);
    }
    glUtils._updateColorbarCanvas();
    glUtils._updateColorLUTTexture(gl, uid, glUtils._textures[uid + "_colorLUT"]);
    markerUtils.updatePiechartLegend();
}


/**
 * @summary Delete WebGL resources and other objects created for drawing marker dataset.
 * @param {String | Number} uid Identifier referencing the marker dataset in dataUtils.
 */
glUtils.deleteMarkers = function(uid) {
    if (!glUtils._initialized) return;
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    if (!(uid in glUtils._numPoints)) return;  // Assume markers are already deleted

    // Delete marker settings and info for UID
    delete glUtils._numPoints[uid];
    delete glUtils._numEdges[uid];
    delete glUtils._markerScaleFactor[uid];
    delete glUtils._markerScalarRange[uid];
    delete glUtils._markerScalarPropertyName[uid];
    delete glUtils._markerOpacity[uid];
    delete glUtils._markerOutline[uid];
    delete glUtils._useColorFromMarker[uid];
    delete glUtils._useColorFromColormap[uid];
    delete glUtils._useScaleFromMarker[uid];
    delete glUtils._useOpacityFromMarker[uid];
    delete glUtils._usePiechartFromMarker[uid];
    delete glUtils._useShapeFromMarker[uid];
    delete glUtils._colorscaleName[uid];
    delete glUtils._colorscaleData[uid];
    delete glUtils._barcodeToLUTIndex[uid];
    delete glUtils._barcodeToKey[uid];
    delete glUtils._collectionItemIndex[uid];
    delete glUtils._markerInputsCached[uid];
    delete glUtils._edgeInputsCached[uid];

    // Clean up WebGL resources
    gl.deleteBuffer(glUtils._buffers[uid + "_markers"]);
    gl.deleteBuffer(glUtils._buffers[uid + "_markers_secondary"]);
    gl.deleteVertexArray(glUtils._vaos[uid + "_markers"]);
    gl.deleteVertexArray(glUtils._vaos[uid + "_markers_instanced"]);
    gl.deleteBuffer(glUtils._buffers[uid + "_edges"]);
    gl.deleteVertexArray(glUtils._vaos[uid + "_edges"]);
    gl.deleteTexture(glUtils._textures[uid + "_colorLUT"]);
    gl.deleteTexture(glUtils._textures[uid + "_colorscale"]);
    delete glUtils._buffers[uid + "_markers"];
    delete glUtils._buffers[uid + "_markers_secondary"];
    delete glUtils._vaos[uid + "_markers"];
    delete glUtils._vaos[uid + "_markers_instanced"];
    delete glUtils._buffers[uid + "_edges"];
    delete glUtils._vaos[uid + "_edges"];
    delete glUtils._textures[uid + "_colorLUT"];
    delete glUtils._textures[uid + "_colorscale"];
    // Make sure colorbar is also deleted from the 2D canvas
    glUtils._updateColorbarCanvas();

    // Make sure piechart legend is deleted if it was used for this UID
    markerUtils.updatePiechartLegend();
}


// Create WebGL resources and other objects for drawing graph edges. Returns
// the number of edges found in the data. This function should only be called
// from within glUtils.loadMarkers().
glUtils._loadEdges = function(uid, forceUpdate) {
    if (!glUtils._initialized) return;
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    let newInputs = {};  // Inputs that will require a vertex buffer update when changed

    // Get marker data and other info like image size
    const markerData = dataUtils.data[uid]["_processeddata"];
    const keyName = newInputs.keyName = dataUtils.data[uid]["_gb_col"];
    const xPosName = newInputs.xPosName = dataUtils.data[uid]["_X"];
    const yPosName = newInputs.yPosName = dataUtils.data[uid]["_Y"];
    const numPoints = markerData[xPosName].length;

    const connectionsPropertyName = newInputs.connectionsPropertyName = dataUtils.data[uid]["_edges_col"];

    // Check how the user wants to draw the edges
    glUtils._updateBarcodeToLUTIndexDict(uid, markerData, keyName);
    const barcodeToLUTIndex = glUtils._barcodeToLUTIndex[uid];
    const markerCoordFactor = newInputs.markerCoordFactor = dataUtils.data[uid]["_coord_factor"];
    const opacityPropertyName = newInputs.opacityPropertyName = dataUtils.data[uid]["_opacity_col"];
    const useOpacityFromMarker = newInputs.useOpacityFromMarker = dataUtils.data[uid]["_opacity_col"] != null;
    const markerOpacityFactor = dataUtils.data[uid]["_opacity"];
    const collectionItemPropertyName = newInputs.collectionItemPropertyName = dataUtils.data[uid]["_collectionItem_col"];
    const useCollectionItemFromMarker = newInputs.useCollectionItemFromMarker = dataUtils.data[uid]["_collectionItem_col"] != null;
    const collectionItemFixed = newInputs.collectionItemFixed = dataUtils.data[uid]["_collectionItem_fixed"];
    let collectionItemIndex = collectionItemFixed;

    // Find out how many edges there are in the data
    let numEdges = 0;
    if (markerData[connectionsPropertyName] != null) {
        for (let markerIndex = 0; markerIndex < numPoints; ++markerIndex) {
            const edges = markerData[connectionsPropertyName][markerIndex].toString().split(";");
            numEdges += edges.length;
        }
    }

    // Additional info about the vertex format. Make sure you update also
    // NUM_BYTES_PER_EDGE when making changes to the format!
    const NUM_BYTES_PER_EDGE = 24;
    const POINT_OFFSET = numEdges * 0,
          INDEX_OFFSET = numEdges * 16,
          OPACITY_OFFSET = numEdges * 20,
          TRANSFORM_OFFSET = numEdges * 22;
    const POINT_LOCATION = 0,      // Re-use the same attribute locations that
          INDEX_LOCATION = 1,      // are used also for drawing the markers
          OPACITY_LOCATION = 4;
          TRANSFORM_LOCATION = 5;

    const lastInputs = glUtils._edgeInputsCached[uid];
    if ((markerData[connectionsPropertyName] != null) &&
        (forceUpdate || (lastInputs != JSON.stringify(newInputs)))) {

        // Extract and upload vertex data for edges. Similar to for the markers, the
        // vertex data can be large, so we upload the data in chunks to the GPU
        // buffer to avoid having to allocate a large temporary buffer in system memory.
        console.time("Generate edge data");
        let chunkSize = 100000;
        let offsetEdges = 0;
        for (let offset = 0; offset < numPoints; offset += chunkSize) {
            if (offset + chunkSize >= numPoints) chunkSize = numPoints - offset;

            // Compute actual chunk size for the edges in the chunk
            let chunkSizeEdges = 0;
            for (let i = 0; i < chunkSize; ++i) {
                const markerIndex = i + offset;
                const edges = markerData[connectionsPropertyName][markerIndex].toString().split(";");
                chunkSizeEdges += edges.length;
            }

            // Allocate arrays for edge data that will be uploaded to vertex buffer
            let bytedata_point = new Float32Array(chunkSizeEdges * 4);
            let bytedata_index = new Int32Array(chunkSizeEdges * 1);
            let bytedata_opacity = new Uint16Array(chunkSizeEdges * 1);
            let bytedata_transform = new Uint16Array(chunkSizeEdges * 1);

            let offsetEdges2 = 0;
            for (let i = 0; i < chunkSize; ++i) {
                const markerIndex = i + offset;
                const lutIndex = (keyName != null) ? barcodeToLUTIndex[markerData[keyName][markerIndex]] : 0;
                const opacity = useOpacityFromMarker ? markerData[opacityPropertyName][markerIndex] : 1.0;
                if (useCollectionItemFromMarker) collectionItemIndex = markerData[collectionItemPropertyName][markerIndex];

                // Generate line segments for edges to neighboring markers
                const edges = markerData[connectionsPropertyName][markerIndex].toString().split(";");
                for (let j = 0; j < edges.length; ++j) {
                    let markerIndex_j = Number(edges[j]);
                    let lutIndex_j = (keyName != null) ? barcodeToLUTIndex[markerData[keyName][markerIndex_j]] : 0;
                    let collectionItemIndex_j = collectionItemFixed;
                    if (useCollectionItemFromMarker) collectionItemIndex_j = markerData[collectionItemPropertyName][markerIndex_j];
                    const k = offsetEdges2 + j;

                    if (markerIndex_j == 0) {
                        // FIXME Workaround for false edges to marker with index 0
                        markerIndex_j = markerIndex;
                        lutIndex_j = lutIndex;
                        collectionItemIndex_j = collectionItemIndex;
                    }

                    bytedata_point[4 * k + 0] = markerData[xPosName][markerIndex] * markerCoordFactor;
                    bytedata_point[4 * k + 1] = markerData[yPosName][markerIndex] * markerCoordFactor;
                    bytedata_point[4 * k + 2] = markerData[xPosName][markerIndex_j] * markerCoordFactor;
                    bytedata_point[4 * k + 3] = markerData[yPosName][markerIndex_j] * markerCoordFactor;
                    bytedata_index[k] = lutIndex + (lutIndex_j * 4096.0);
                    bytedata_opacity[k] = Math.floor(Math.max(0.0, Math.min(1.0, opacity)) * 65535.0);
                    bytedata_transform[k] = collectionItemIndex + (collectionItemIndex_j * 256);
                }

                offsetEdges2 += edges.length;
            }

            // Create WebGL objects (if this has not already been done)
            if (!(uid + "_edges" in glUtils._buffers))
                glUtils._buffers[uid + "_edges"] = glUtils._createMarkerBuffer(gl, numEdges * NUM_BYTES_PER_EDGE);
            if (!(uid + "_edges" in glUtils._vaos))
                glUtils._vaos[uid + "_edges"] = gl.createVertexArray();

            // Upload chunks of vertex data to buffer
            gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_edges"]);
            if (offset == 0) {
                // Check if buffer needs to be allocated or re-allocated
                const newBufferSize = numEdges * NUM_BYTES_PER_EDGE;
                const oldBufferSize = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
                if (newBufferSize != oldBufferSize) {
                    gl.bufferData(gl.ARRAY_BUFFER, newBufferSize, gl.STATIC_DRAW);
                }
            }

            gl.bufferSubData(gl.ARRAY_BUFFER, (POINT_OFFSET + offsetEdges * 16), bytedata_point);
            gl.bufferSubData(gl.ARRAY_BUFFER, (INDEX_OFFSET + offsetEdges * 4), bytedata_index);
            gl.bufferSubData(gl.ARRAY_BUFFER, (OPACITY_OFFSET + offsetEdges * 2), bytedata_opacity);
            gl.bufferSubData(gl.ARRAY_BUFFER, (TRANSFORM_OFFSET + offsetEdges * 2), bytedata_transform);
            gl.bindBuffer(gl.ARRAY_BUFFER, null);
            offsetEdges += chunkSizeEdges;
        }
        console.timeEnd("Generate edge data");

        // Set up VAO with vertex format for drawing thick lines via instancing
        gl.bindVertexArray(glUtils._vaos[uid + "_edges"]);
        gl.bindBuffer(gl.ARRAY_BUFFER, glUtils._buffers[uid + "_edges"]);
        gl.enableVertexAttribArray(POINT_LOCATION);
        gl.vertexAttribPointer(POINT_LOCATION, 4, gl.FLOAT, false, 0, 0);
        gl.vertexAttribDivisor(POINT_LOCATION, 1);
        gl.enableVertexAttribArray(INDEX_LOCATION);
        gl.vertexAttribIPointer(INDEX_LOCATION, 1, gl.INT, 0, INDEX_OFFSET);
        gl.vertexAttribDivisor(INDEX_LOCATION, 1);
        gl.enableVertexAttribArray(OPACITY_LOCATION);
        gl.vertexAttribPointer(OPACITY_LOCATION, 1, gl.UNSIGNED_SHORT, true, 0, OPACITY_OFFSET);
        gl.vertexAttribDivisor(OPACITY_LOCATION, 1);
        gl.enableVertexAttribArray(TRANSFORM_LOCATION);
        gl.vertexAttribPointer(TRANSFORM_LOCATION, 1, gl.UNSIGNED_SHORT, false, 0, TRANSFORM_OFFSET);
        gl.vertexAttribDivisor(TRANSFORM_LOCATION, 1);
        gl.bindVertexArray(null);
    }
    glUtils._edgeInputsCached[uid] = JSON.stringify(newInputs);

    return numEdges;
}


// TODO Fix naming of this function, since we now use it for generic markers
glUtils._updateBarcodeToLUTIndexDict = function (uid, markerData, keyName) {
    const barcodeToLUTIndex = {};
    const barcodeToKey = {};
    const numPoints = markerData[markerData.columns[0]].length;
    console.log("Key name: " + keyName);
    for (let i = 0, index = 0; i < numPoints; ++i) {
        const barcode = (keyName != null) ? markerData[keyName][i] : undefined;
        if (!(barcode in barcodeToLUTIndex)) {
            barcodeToLUTIndex[barcode] = index++;
            barcodeToKey[barcode] = barcode;
            index = index % 4096;  // Prevent index from becoming >= the maximum LUT size,
                                   // since this causes problems with pie-chart markers
        }
    }
    glUtils._barcodeToLUTIndex[uid] = barcodeToLUTIndex;
    glUtils._barcodeToKey[uid] = barcodeToKey;
    console.log("barcodeToLUTIndex, barcodeToKey", barcodeToLUTIndex, barcodeToKey);
}


glUtils._createColorLUTTexture = function(gl) {
    const randomColors = [];
    for (let i = 0; i < 4096; ++i) {
        randomColors[4 * i + 0] = Math.random() * 256.0; 
        randomColors[4 * i + 1] = Math.random() * 256.0;
        randomColors[4 * i + 2] = Math.random() * 256.0;
        randomColors[4 * i + 3] = Math.floor(Math.random() * 7) + 1;
    }

    const bytedata = new Uint8Array(randomColors);

    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4096, 1, 0, gl.RGBA,
                  gl.UNSIGNED_BYTE, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);

    return texture;
}


glUtils._updateColorLUTTexture = function(gl, uid, texture) {
    if (!(uid + "_colorLUT" in glUtils._textures)) return;

    const colors = new Array(4096 * 4);
    for (let [barcode, index] of Object.entries(glUtils._barcodeToLUTIndex[uid])) {
        const key = (barcode != "undefined" ? glUtils._barcodeToKey[uid][barcode] : "All");
        const inputs = interfaceUtils._mGenUIFuncs.getGroupInputs(uid, key);
        const hexColor = "color" in inputs ? inputs["color"] : "#ffff00";
        const shape = "shape" in inputs ? inputs["shape"] : "circle";
        const visible = "visible" in inputs ? inputs["visible"] : true;
        const hidden = "hidden" in inputs ? inputs["hidden"] : true;
        // OBS! Need to clamp this value, since indexOf() can return -1
        const shapeIndex = Math.max(0, markerUtils._symbolStrings.indexOf(shape));

        colors[4 * index + 0] = Number("0x" + hexColor.substring(1,3)); 
        colors[4 * index + 1] = Number("0x" + hexColor.substring(3,5));
        colors[4 * index + 2] = Number("0x" + hexColor.substring(5,7));
        colors[4 * index + 3] = Number(visible) * (1 - Number(hidden)) * (Number(shapeIndex) + 1);
    }

    const bytedata = new Uint8Array(colors);

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4096, 1, 0, gl.RGBA,
                  gl.UNSIGNED_BYTE, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);
}


/**
 * @summary Update the color scale LUTs for all marker datasets.
 * This function is a callback and should not normally be called directly.
 */
glUtils.updateColorLUTTextures = function() {
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        glUtils._updateColorLUTTexture(gl, uid, glUtils._textures[uid + "_colorLUT"]);
    }
}


glUtils._createTransformLUTTexture = function(gl) {
    const imageTransforms = new Array(256 * 4);  // TODO
    const bytedata = new Float32Array(imageTransforms);

    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 256, 1, 0, gl.RGBA, gl.FLOAT, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);

    return texture;
}


glUtils._updateTransformLUTTexture = function(texture) {
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    // Compute transforms that takes into account if collection mode viewing is
    // enabled for image layers
    const imageTransforms = new Array(256 * 4);
    for (let i = 0; i < tmapp["ISS_viewer"].world.getItemCount(); ++i) {
        const bounds = tmapp["ISS_viewer"].viewport.getBounds();
        const image = tmapp["ISS_viewer"].world.getItemAt(i);
        const imageWidth = image.getContentSize().x;
        const imageHeight = image.getContentSize().y;
        const imageBounds = image.getBounds();

        // Compute the scale and shift to be applied to marker positions
        imageTransforms[i * 4 + 0] = (imageBounds.width / imageWidth) / bounds.width;     // ScaleX
        imageTransforms[i * 4 + 1] = (imageBounds.height / imageHeight) / bounds.height;  // ScaleY
        imageTransforms[i * 4 + 2] = -(bounds.x - imageBounds.x) / bounds.width;          // ShiftX
        imageTransforms[i * 4 + 3] = -(bounds.y - imageBounds.y) / bounds.height;         // ShiftY
    }

    const bytedata = new Float32Array(imageTransforms);

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 256, 1, 0, gl.RGBA, gl.FLOAT, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);
}


glUtils._createColorScaleTexture = function(gl) {
    const bytedata = new Uint8Array(256 * 4);

    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA,
                  gl.UNSIGNED_BYTE, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);

    return texture;
}


glUtils._formatHex = function(color) {
    if (color.includes("rgb")) {
        const r = color.split(",")[0].replace("rgb(", "").replace(")", "");
        const g = color.split(",")[1].replace("rgb(", "").replace(")", "");
        const b = color.split(",")[2].replace("rgb(", "").replace(")", "");
        const hex = (Number(r) * 65536 + Number(g) * 256 + Number(b)).toString(16);
        color = "#" + ("0").repeat(6 - hex.length) + hex;
    }
    return color;
}


glUtils._updateColorScaleTexture = function(gl, uid, texture) {
    const colors = [];
    const colorscaleName = glUtils._colorscaleName[uid];
    console.log(colorscaleName);
    for (let i = 0; i < 256; ++i) {
        const normalized = i / 255.0;
        if (colorscaleName.includes("interpolate") &&
            !colorscaleName.includes("Rainbow")) {
            const color = d3[colorscaleName](normalized);
            const hexColor = glUtils._formatHex(color);  // D3 sometimes returns RGB strings
            colors[4 * i + 0] = Number("0x" + hexColor.substring(1,3));
            colors[4 * i + 1] = Number("0x" + hexColor.substring(3,5));
            colors[4 * i + 2] = Number("0x" + hexColor.substring(5,7));
            colors[4 * i + 3] = 255.0;
        } else {
            // Use a version of Google's Turbo colormap with brighter blue range
            // Reference: https://www.shadertoy.com/view/WtGBDw
            const r = Math.sin((normalized - 0.33) * 3.141592);
            const g = Math.sin((normalized + 0.00) * 3.141592);
            const b = Math.sin((normalized + 0.33) * 3.141592);
            const s = 1.0 - normalized;  // For purplish tone at end of the range
            colors[4 * i + 0] = Math.max(0.0, Math.min(1.0, r * (1.0 - 0.5 * b*b) + s*s)) * 255.99;
            colors[4 * i + 1] = Math.max(0.0, Math.min(1.0, g * (1.0 - r*r * b*b))) * 255.99;
            colors[4 * i + 2] = Math.max(0.0, Math.min(1.0, b * (1.0 - 0.5 * r*r))) * 255.99;
            colors[4 * i + 3] = 255.0;
        }
    }
    glUtils._colorscaleData[uid] = colors;

    const bytedata = new Uint8Array(colors);

    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA,
                  gl.UNSIGNED_BYTE, bytedata);
    gl.bindTexture(gl.TEXTURE_2D, null);
}


glUtils._updateColorbarCanvas = function() {
    const canvas = document.getElementById("colorbar_canvas");
    const ctx = canvas.getContext("2d");

    // Determine canvas height needed to show colorbars for all markersets that
    // have colormaps
    let canvasHeight = 0;
    const rowHeight = 70;  // Note: hardcoded value
    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        if (glUtils._showColorbar && glUtils._useColorFromColormap[uid])
            canvasHeight += rowHeight + 10;
    }
    canvasHeight -= 10; // No margin for last colorbar 

    // Resize and clear canvas
    ctx.canvas.height = canvasHeight;
    ctx.canvas.style.marginTop = -canvasHeight + "px";
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    if (ctx.canvas.height == -10) {
        ctx.canvas.className = "d-none";
        return;  // Nothing more to do for empty canvas
    }
    ctx.canvas.className = "viewer-layer";
    // Create colorbars for the markersets
    let yOffset = 0;
    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        if (!glUtils._useColorFromColormap[uid]) continue;

        const propertyRange = glUtils._markerScalarRange[uid];
        const propertyName = glUtils._markerScalarPropertyName[uid];
        const colorscaleData = glUtils._colorscaleData[uid];

        // Define gradient for color scale
        const gradient = ctx.createLinearGradient(5, 0, 256+5, 0);
        const numStops = 32;
        for (let i = 0; i < numStops; ++i) {
            const normalized = i / (numStops - 1);
            const index = Math.floor(normalized * 255.99);
            const r = Math.floor(colorscaleData[4 * index + 0]);
            const g = Math.floor(colorscaleData[4 * index + 1]);
            const b = Math.floor(colorscaleData[4 * index + 2]);
            gradient.addColorStop(normalized, "rgb(" + r + "," + g + "," + b + ")");
        }
        // Draw colorbar (with outline)
        ctx.fillStyle = gradient;
        ctx.fillRect(5, 48 + yOffset, 256, 16);
        ctx.strokeStyle = "#555";
        ctx.strokeRect(5, 48 + yOffset, 256, 16);

        // Convert range annotations to precision 7 and remove trailing zeros
        let propertyMin = propertyRange[0].toPrecision(7).replace(/\.([^0]+)0+$/,".$1");
        let propertyMax = propertyRange[1].toPrecision(7).replace(/\.([^0]+)0+$/,".$1");
        // Convert range annotations to scientific notation if they may overflow
        if (propertyMin.length > 9) propertyMin = propertyRange[0].toExponential(5);
        if (propertyMax.length > 9) propertyMax = propertyRange[1].toExponential(5);
        // Get marker tab name to show together with property name
        const tabName = interfaceUtils.getElementById(uid + "_marker-tab-name").textContent;
        let label = tabName.substring(0, 15) + "." + propertyName.substring(0, 15);

        // Draw annotations (with drop shadow)
        ctx.font = "16px Segoe UI";
        ctx.textAlign = "center";
        ctx.fillStyle = "#000";  // Shadow color
        ctx.fillText(label, ctx.canvas.width/2+1, 18+1 + yOffset);
        ctx.textAlign = "left";
        ctx.fillText(propertyMin, ctx.canvas.width/2-128+1, 40+1 + yOffset);
        ctx.textAlign = "right";
        ctx.fillText(propertyMax, ctx.canvas.width/2+128+1, 40+1 + yOffset);
        yOffset += rowHeight + 10;  // Move to next colorbar row
    }
}


// Creates a 2D-canvas for drawing the colorbar on top of the WebGL-canvas
glUtils._createColorbarCanvas = function() {
    const root = document.getElementById("gl_canvas").parentElement;
    const canvas = document.createElement("canvas");
    root.appendChild(canvas);

    canvas.id = "colorbar_canvas";
    canvas.className = "d-none";
    canvas.width = "266";  // Fixed width in pixels
    canvas.height = "96";  // Fixed height in pixels
    canvas.style = "position:relative; float:right; width:266px; bottom: 11px; right: 14px; " +
                   "margin-top:-96px; z-index:20; pointer-events:none";
}


// Creates WebGL canvas for drawing the markers
glUtils._createMarkerWebGLCanvas = function() {
    const canvas = document.createElement("canvas");
    canvas.id = "gl_canvas";
    canvas.width = "1"; canvas.height = "1";
    canvas.style = "position:relative; pointer-events:none; z-index: 12; width: 100%; height: 100%";
    return canvas;
}


glUtils._loadTextureFromImageURL = function(gl, src) {
    const texture = gl.createTexture();
    const image = new Image();
    image.onload = function() {
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        gl.generateMipmap(gl.TEXTURE_2D);  // Requires power-of-two size images
        gl.bindTexture(gl.TEXTURE_2D, null);
        glUtils.draw();  // Force redraw to avoid black shapes after context loss
    };
    image.src = src;
    return texture;
}


glUtils._drawColorPass = function(gl, viewportTransform, markerScaleAdjusted) {
    // Set up render pipeline
    const program = glUtils._programs[glUtils._useInstancing ? "markers_instanced" : "markers"];
    gl.useProgram(program);
    gl.enable(gl.BLEND);
    gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    // Set per-scene uniforms
    gl.uniformMatrix2fv(gl.getUniformLocation(program, "u_viewportTransform"), false, viewportTransform);
    gl.uniform2fv(gl.getUniformLocation(program, "u_canvasSize"), [gl.canvas.width, gl.canvas.height]);
    gl.uniform1f(gl.getUniformLocation(program, "u_markerScale"), markerScaleAdjusted);
    gl.uniform1f(gl.getUniformLocation(program, "u_maxPointSize"), glUtils._useInstancing ? 2048 : 256);
    gl.activeTexture(gl.TEXTURE3);
    gl.bindTexture(gl.TEXTURE_2D, glUtils._textures["transformLUT"]);
    gl.uniform1i(gl.getUniformLocation(program, "u_transformLUT"), 3);
    gl.activeTexture(gl.TEXTURE2);
    gl.bindTexture(gl.TEXTURE_2D, glUtils._textures["shapeAtlas"]);
    gl.uniform1i(gl.getUniformLocation(program, "u_shapeAtlas"), 2);

    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        if (numPoints == 0) continue;
        gl.bindVertexArray(glUtils._vaos[uid + (glUtils._useInstancing ? "_markers_instanced" : "_markers")]);

        // Set per-markerset uniforms
        gl.uniform1f(gl.getUniformLocation(program, "u_transformIndex"),
            glUtils._collectionItemIndex[uid] != null ? glUtils._collectionItemIndex[uid] : -1);
        gl.uniform1f(gl.getUniformLocation(program, "u_globalMarkerScale"), glUtils._globalMarkerScale * glUtils._markerScaleFactor[uid]);
        gl.uniform2fv(gl.getUniformLocation(program, "u_markerScalarRange"), glUtils._markerScalarRange[uid]);
        gl.uniform1f(gl.getUniformLocation(program, "u_markerOpacity"), glUtils._markerOpacity[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_markerOutline"), glUtils._markerOutline[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_useColorFromMarker"), glUtils._useColorFromMarker[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_useColorFromColormap"), glUtils._useColorFromColormap[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_usePiechartFromMarker"), glUtils._usePiechartFromMarker[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_useShapeFromMarker"), glUtils._useShapeFromMarker[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_pickedMarker"),
            glUtils._pickedMarker[0] == uid ? glUtils._pickedMarker[1] : -1);
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, glUtils._textures[uid + "_colorscale"]);
        gl.uniform1i(gl.getUniformLocation(program, "u_colorscale"), 1);
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, glUtils._textures[uid + "_colorLUT"]);
        gl.uniform1i(gl.getUniformLocation(program, "u_colorLUT"), 0);

        if (glUtils._usePiechartFromMarker[uid]) {
            // 1st pass: draw alpha for whole marker shapes
            gl.uniform1i(gl.getUniformLocation(program, "u_alphaPass"), true);
            if (glUtils._useInstancing) {
                gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, numPoints);
            } else {
                gl.drawArrays(gl.POINTS, 0, numPoints);
            }
            // 2nd pass: draw colors for individual piechart sectors
            gl.uniform1i(gl.getUniformLocation(program, "u_alphaPass"), false);
            gl.colorMask(true, true, true, false);
            if (glUtils._useInstancing) {
                gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, numPoints);
            } else {
                gl.drawArrays(gl.POINTS, 0, numPoints);
            }
            gl.colorMask(true, true, true, true);
        } else {
            if (glUtils._useInstancing) {
                gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, numPoints);
            } else {
                gl.drawArrays(gl.POINTS, 0, numPoints);
            }
        }
    }

    // Restore render pipeline state
    gl.bindVertexArray(null);
    gl.blendFunc(gl.ONE, gl.ONE);
    gl.disable(gl.BLEND);
    gl.useProgram(null);
}


glUtils._drawEdgesColorPass = function(gl, viewportTransform, markerScaleAdjusted) {
    // Set up render pipeline
    const program = glUtils._programs["edges"];
    gl.useProgram(program);
    gl.enable(gl.BLEND);
    gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    // Set per-scene uniforms
    gl.uniformMatrix2fv(gl.getUniformLocation(program, "u_viewportTransform"), false, viewportTransform);
    gl.uniform2fv(gl.getUniformLocation(program, "u_canvasSize"), [gl.canvas.width, gl.canvas.height]);
    gl.uniform1f(gl.getUniformLocation(program, "u_markerScale"), markerScaleAdjusted);
    gl.uniform1f(gl.getUniformLocation(program, "u_maxPointSize"), glUtils._useInstancing ? 2048 : 256);
    gl.activeTexture(gl.TEXTURE3);
    gl.bindTexture(gl.TEXTURE_2D, glUtils._textures["transformLUT"]);
    gl.uniform1i(gl.getUniformLocation(program, "u_transformLUT"), 3);

    for (let [uid, numEdges] of Object.entries(glUtils._numEdges)) {
        if (numEdges == 0) continue;
        gl.bindVertexArray(glUtils._vaos[uid + "_edges"]);

        // Set per-markerset uniforms
        gl.uniform1f(gl.getUniformLocation(program, "u_globalMarkerScale"), glUtils._globalMarkerScale * glUtils._markerScaleFactor[uid]);
        gl.uniform1f(gl.getUniformLocation(program, "u_markerOpacity"), glUtils._markerOpacity[uid]);
        gl.uniform1f(gl.getUniformLocation(program, "u_transformIndex"),
            glUtils._collectionItemIndex[uid] != null ? glUtils._collectionItemIndex[uid] : -1);

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, glUtils._textures[uid + "_colorLUT"]);
        gl.uniform1i(gl.getUniformLocation(program, "u_colorLUT"), 0);

        gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, numEdges);
    }

    // Restore render pipeline state
    gl.bindVertexArray(null);
    gl.blendFunc(gl.ONE, gl.ONE);
    gl.disable(gl.BLEND);
    gl.useProgram(null);
}


glUtils._drawPickingPass = function(gl, viewportTransform, markerScaleAdjusted) {
    // Set up render pipeline
    const program = glUtils._programs["picking"];
    gl.useProgram(program);

    // Set per-scene uniforms
    gl.uniformMatrix2fv(gl.getUniformLocation(program, "u_viewportTransform"), false, viewportTransform);
    gl.uniform2fv(gl.getUniformLocation(program, "u_canvasSize"), [gl.canvas.width, gl.canvas.height]);
    gl.uniform2fv(gl.getUniformLocation(program, "u_pickingLocation"), glUtils._pickingLocation);
    gl.uniform1f(gl.getUniformLocation(program, "u_markerScale"), markerScaleAdjusted);
    gl.uniform1f(gl.getUniformLocation(program, "u_maxPointSize"), glUtils._useInstancing ? 2048 : 256);
    gl.activeTexture(gl.TEXTURE3);
    gl.bindTexture(gl.TEXTURE_2D, glUtils._textures["transformLUT"]);
    gl.uniform1i(gl.getUniformLocation(program, "u_transformLUT"), 3);
    gl.activeTexture(gl.TEXTURE2);
    gl.bindTexture(gl.TEXTURE_2D, glUtils._textures["shapeAtlas"]);
    gl.uniform1i(gl.getUniformLocation(program, "u_shapeAtlas"), 2);

    glUtils._pickedMarker = [-1, -1];  // Reset to no picked marker
    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        if (numPoints == 0) continue;
        gl.bindVertexArray(glUtils._vaos[uid + "_markers"]);

        // Set per-markerset uniforms
        gl.uniform1f(gl.getUniformLocation(program, "u_transformIndex"),
            glUtils._collectionItemIndex[uid] != null ? glUtils._collectionItemIndex[uid] : -1);
        gl.uniform1f(gl.getUniformLocation(program, "u_globalMarkerScale"), glUtils._globalMarkerScale * glUtils._markerScaleFactor[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_usePiechartFromMarker"), glUtils._usePiechartFromMarker[uid]);
        gl.uniform1i(gl.getUniformLocation(program, "u_useShapeFromMarker"), glUtils._useShapeFromMarker[uid]);
        gl.uniform1f(gl.getUniformLocation(program, "u_markerOpacity"), glUtils._markerOpacity[uid]);
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, glUtils._textures[uid + "_colorLUT"]);
        gl.uniform1i(gl.getUniformLocation(program, "u_colorLUT"), 0);

        // 1st pass: clear the corner pixel
        gl.uniform1i(gl.getUniformLocation(program, "u_op"), 0);
        gl.drawArrays(gl.POINTS, 0, 1);
        // 2nd pass: draw all the markers (as single pixels)
        gl.uniform1i(gl.getUniformLocation(program, "u_op"), 1);
        gl.drawArrays(gl.POINTS, 0, numPoints);

        // Read back pixel at location (0, 0) to get the picked object
        const result = new Uint8Array(4);
        gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, result);
        //const picked = Number(result[2] + result[1] * 256 + result[0] * 65536) - 1;
        const picked = Number(result[0] + result[1] * 256 + result[2] * 65536 + result[3] * 16777216) - 1;
        if (picked >= 0)
            glUtils._pickedMarker = [uid, picked];
    }

    // Restore render pipeline state
    gl.bindVertexArray(null);
    gl.useProgram(null);
}


/**
 * @summary Do rendering to the WebGL canvas.
 * Calling this function will force an update of the rendering of markers and
 * the data used for picking (i.e. for marker selection). Only marker datasets
 * for which glUtils.loadMarkers() have been called will be rendered.
 */
glUtils.draw = function() {
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);
    const ext = gl.getExtension("EXT_disjoint_timer_query_webgl2");

    // Update per-image transforms that take into account if collection mode
    // viewing is enabled for the image layers
    glUtils._updateTransformLUTTexture(glUtils._textures["transformLUT"]);

    // The OSD viewer can be rotated, so need to also apply rotation to markers
    const orientationDegrees = tmapp["ISS_viewer"].viewport.getRotation();
    const t = orientationDegrees * (3.141592 / 180.0);
    const viewportTransform = [Math.cos(t), -Math.sin(t), Math.sin(t), Math.cos(t)];

    // Compute adjusted marker scale so that the actual marker size becomes less
    // dependant on screen resolution or window size
    let markerScaleAdjusted = glUtils._markerScale;
    if (glUtils._useMarkerScaleFix) markerScaleAdjusted *= (gl.canvas.height / 900.0);
    markerScaleAdjusted /= tmapp["ISS_viewer"].viewport.getBounds().height;  // FIXME

    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    if (glUtils._pickingEnabled) {
        glUtils._drawPickingPass(gl, viewportTransform, markerScaleAdjusted);
        glUtils._pickingEnabled = false;  // Clear flag until next click event
    }

    if (glUtils._showEdgesExperimental) {
        // Note: Edges should ideally be drawn interleaved with the markers (for
        // correct overlap, if multiple markersets have spatial connectivity
        // data) but for now we just draw them first a separate rendering pass
        glUtils._drawEdgesColorPass(gl, viewportTransform, markerScaleAdjusted);
    }

    const query = glUtils._query;
    if (glUtils._logPerformance) {
        if (query == null) {
            glUtils._query = gl.createQuery();
            gl.beginQuery(ext.TIME_ELAPSED_EXT, glUtils._query);
        }
    }

    glUtils._drawColorPass(gl, viewportTransform, markerScaleAdjusted);

    if (glUtils._logPerformance) {
        if (query == null) {
            gl.endQuery(ext.TIME_ELAPSED_EXT);
        } else {
            const available = gl.getQueryParameter(glUtils._query, gl.QUERY_RESULT_AVAILABLE);
            const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
            if (available && !disjoint) {
                const timeElapsed = gl.getQueryParameter(glUtils._query, gl.QUERY_RESULT);
                console.log("Rasterization time (GPU) (ms): ", timeElapsed / 1e6);
            }
            if (available || disjoint) {
                gl.deleteQuery(glUtils._query);
                glUtils._query = null;
            }
        }
    }
}


/**
 * @summary Do GPU-based picking for marker selection.
 * This function is a callback and should not normally be called directly. The
 * function will automatically call glUtils.draw() to update the rendering and
 * the picking.
 * @param {Object} event An object with click events from the canvas
 */
glUtils.pick = function(event) {
    if (event.quick) {
        glUtils._pickingEnabled = true;
        glUtils._pickingLocation = [event.position.x * glUtils._resolutionScaleActual,
                                    event.position.y * glUtils._resolutionScaleActual];
        glUtils.draw();  // This will update the value of glUtils._pickedMarker

        const pickedMarker = glUtils._pickedMarker;
        const hasPickedMarker = pickedMarker[1] >= 0;

        tmapp["ISS_viewer"].removeOverlay("ISS_marker_info");
        if (hasPickedMarker && glUtils._showMarkerInfo) {
            const uid = pickedMarker[0];
            const markerIndex = pickedMarker[1];
            const tabName = interfaceUtils.getElementById(uid + "_marker-tab-name").textContent;
            const markerData = dataUtils.data[uid]["_processeddata"];
            const keyName = dataUtils.data[uid]["_gb_col"];
            const groupName = (keyName != null) ? markerData[keyName][markerIndex] : undefined;
            const piechartPropertyName = dataUtils.data[uid]["_pie_col"];

            const div = document.createElement("div");
            div.id = "ISS_marker_info";
            div.width = "1px"; div.height = "1px";
            if (glUtils._usePiechartFromMarker[uid]) {
                div.innerHTML = markerUtils.makePiechartTable(uid, markerIndex, piechartPropertyName);
            } else {
                div.innerHTML = markerUtils.getMarkerTooltip(uid, markerIndex);
                console.log("Marker clicked:",tabName, groupName, "index:", markerIndex);
            }
            div.classList.add("viewer-layer", "m-0", "p-1");

            tmapp["ISS_viewer"].addOverlay({
                element: div,
                placement: "TOP_LEFT",
                location: tmapp["ISS_viewer"].viewport.viewerElementToViewportCoordinates(event.position),
                checkResize: false,
                rotationMode: OpenSeadragon.OverlayRotationMode.NO_ROTATION
            });
            interfaceUtils._mGenUIFuncs.ActivateTab(uid);
            var tr = document.querySelectorAll('[data-uid="'+uid+'"][data-key="'+groupName+'"]')[0];
            if (tr != null) {
                tr.scrollIntoView({block: "center",inline: "nearest"});
                tr.classList.remove("transition_background")
                tr.classList.add("table-primary")
                setTimeout(function(){tr.classList.add("transition_background");tr.classList.remove("table-primary");},400);
            }
        }
    }
}


/**
 * @summary Callback for resizing the WebGL canvas.
 * Calling this function will force an update of the width and height of the
 * WebGL canvas, but will not automatically call glUtils.draw() to update the
 * rendering.
 */
glUtils.resize = function() {
    const canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    const op = tmapp["object_prefix"];
    const width = tmapp[op + "_viewer"].viewport.containerSize.x;
    const height = tmapp[op + "_viewer"].viewport.containerSize.y;

    glUtils._resolutionScaleActual = glUtils._resolutionScale * window.devicePixelRatio;
    if (Math.max(width, height) * glUtils._resolutionScale >= 4096.0) {
        // A too large WebGL canvas can lead to misalignment between the WebGL
        // markers and the OSD image layers, so here the resolution scaling
        // factor is adjusted to restrict the canvas size to a safe value
        glUtils._resolutionScaleActual *= 4096.0 / (Math.max(width, height) * glUtils._resolutionScale);
    }
    gl.canvas.width = width * glUtils._resolutionScaleActual;
    gl.canvas.height = height * glUtils._resolutionScaleActual;
}


/**
 * @summary Callback for resizing the WebGL canvas.
 * Works like glUtils.resize(), but will also automatically call glUtils.draw()
 * to update the rendering.
 */
glUtils.resizeAndDraw = function() {
    glUtils.resize();
    glUtils.draw();
}


/**
 * @summary Callback for updating marker scale when changing the global marker size GUI slider.
 * This function is a callback and should not normally be called directly.
 */
glUtils.updateMarkerScale = function() {
    const globalMarkerSize = Number(document.getElementById("ISS_globalmarkersize_text").value);
    // Clamp the scale factor to avoid giant markers and slow rendering if the
    // user inputs a very large value (say 10000 or something)
    glUtils._markerScale = Math.max(0.01, Math.min(20.0, globalMarkerSize / 25.0));
}


/**
 * @summary Callback for restoring WebGL resources after WebGL context is lost
 * This function is a callback and should not normally be called directly. Loss
 * of context can happen when for example the computer goes into sleep mode.
 */
glUtils.restoreLostContext = function(event) {
    console.log("Restoring WebGL objects after context loss");
    let canvas = document.getElementById("gl_canvas");
    const gl = canvas.getContext("webgl2", glUtils._options);

    // Restore shared WebGL objects
    glUtils._programs["markers"] = glUtils._loadShaderProgram(gl, glUtils._markersVS, glUtils._markersFS);
    glUtils._programs["markers_instanced"] = glUtils._loadShaderProgram(gl, glUtils._markersVS, glUtils._markersFS, "#define USE_INSTANCING\n");
    glUtils._programs["picking"] = glUtils._loadShaderProgram(gl, glUtils._pickingVS, glUtils._pickingFS);
    glUtils._programs["edges"] = glUtils._loadShaderProgram(gl, glUtils._edgesVS, glUtils._edgesFS);
    glUtils._textures["shapeAtlas"] = glUtils._loadTextureFromImageURL(gl, glUtils._markershapes);
    glUtils._textures["transformLUT"] = glUtils._createTransformLUTTexture(gl);

    // Restore per-markers WebGL objects
    for (let [uid, numPoints] of Object.entries(glUtils._numPoints)) {
        delete glUtils._buffers[uid + "_markers"];
        delete glUtils._vaos[uid + "_markers"];
        delete glUtils._textures[uid + "_colorLUT"];
        delete glUtils._textures[uid + "_colorscale"];
        glUtils.loadMarkers(uid);
    }

    glUtils.draw();  // Make sure markers are redrawn
}


/**
 * @summary Do initialization of the WebGL canvas.
 * This will also load WebGL resources like shaders and textures, as well as set
 * up events for interaction with other parts of TissUUmaps such as the
 * OpenSeaDragon (OSD) canvas.
 */
glUtils.init = function() {
    if (glUtils._initialized) return;

    let canvas = document.getElementById("gl_canvas");
    if (!canvas) canvas = this._createMarkerWebGLCanvas();
    canvas.addEventListener("webglcontextlost", function(e) { e.preventDefault(); }, false);
    canvas.addEventListener("webglcontextrestored", glUtils.restoreLostContext, false);
    const gl = canvas.getContext("webgl2", glUtils._options);

    // Place marker canvas under the OSD canvas. Doing this also enables proper
    // compositing with the minimap and other OSD elements.
    const osd = document.getElementsByClassName("openseadragon-canvas")[0];
    osd.appendChild(canvas);

    this._programs["markers"] = this._loadShaderProgram(gl, this._markersVS, this._markersFS);
    this._programs["markers_instanced"] = this._loadShaderProgram(gl, this._markersVS, this._markersFS, "#define USE_INSTANCING\n");
    this._programs["picking"] = this._loadShaderProgram(gl, this._pickingVS, this._pickingFS);
    this._programs["edges"] = this._loadShaderProgram(gl, this._edgesVS, this._edgesFS);
    this._textures["shapeAtlas"] = this._loadTextureFromImageURL(gl, glUtils._markershapes);
    this._textures["transformLUT"] = this._createTransformLUTTexture(gl);

    this._createColorbarCanvas();  // The colorbar is drawn separately in a 2D-canvas

    glUtils.updateMarkerScale();
    document.getElementById("ISS_globalmarkersize_text").addEventListener("input", glUtils.updateMarkerScale);
    document.getElementById("ISS_globalmarkersize_text").addEventListener("input", glUtils.draw);

    tmapp["hideSVGMarkers"] = true;
    tmapp["ISS_viewer"].removeHandler('resize', glUtils.resizeAndDraw);
    tmapp["ISS_viewer"].addHandler('resize', glUtils.resizeAndDraw);
    tmapp["ISS_viewer"].removeHandler('open', glUtils.draw);
    tmapp["ISS_viewer"].addHandler('open', glUtils.draw);
    tmapp["ISS_viewer"].removeHandler('viewport-change', glUtils.draw);
    tmapp["ISS_viewer"].addHandler('viewport-change', glUtils.draw);
    tmapp["ISS_viewer"].removeHandler('canvas-click', glUtils.pick);
    tmapp["ISS_viewer"].addHandler('canvas-click', glUtils.pick);

    glUtils._initialized = true;
    glUtils.resize();  // Force initial resize to OSD canvas size
}