fee658e200a700b6ff336bcb5d449892021e5708 max Fri May 2 10:08:38 2025 -0700 adding one more drawing stage so when plotting gene expression zero values will not interfere with non zero values diff --git src/cbPyLib/cellbrowser/cbWeb/js/maxPlot.js src/cbPyLib/cellbrowser/cbWeb/js/maxPlot.js index 942da9a..94ffd6a 100644 --- src/cbPyLib/cellbrowser/cbWeb/js/maxPlot.js +++ src/cbPyLib/cellbrowser/cbWeb/js/maxPlot.js @@ -1,3205 +1,3263 @@ 'use strict'; // maxPlot: a fast scatter plot class /*jshint globalstrict: true*/ /*jshint -W069 */ /*jshint -W104 */ /*jshint -W117 */ // TODO: // fix mouseout into body -> marquee stays function getAttr(obj, attrName, def) { var val = obj[attrName]; if (val===undefined) return def; else return val; } function cloneObj(d) { /* returns a deep copy of an object, wasteful and destroys old references */ // see http://stackoverflow.com/questions/122102/what-is-the-most-efficient-way-to-deep-clone-an-object-in-javascript return JSON.parse(JSON.stringify(d)); } function isValid(x) { /* x is not null nor undefined */ return (x!==null && x!==undefined) } function cloneArray(a) { /* returns a copy of an array */ return a.slice(); } function copyObj(src, trg) { /* object copying: copies all values from src to trg */ var key; for (key in src) { trg[key] = src[key]; // copies each property to the objCopy object } } function debug(msg) { if (window.doDebug) console.log(msg); } function MaxPlot(div, top, left, width, height, args) { // a class that draws circles onto a canvas, like a scatter plot // div is a div DOM element under which the canvas will be created // top, left: position in pixels, integers // width and height: integers, in pixels, includes the status line const HIDCOORD = 12345; // magic value for missing coordinates // In rare instances, coordinates are saved but should not be shown. This way of implementing hiding // may look hacky, but it simplifies the logic and improves performance. // export this special value so other part of the code can use it this.hiddenCoord = HIDCOORD; var self = this; // 'this' has two conflicting meanings in javascript. // I use 'self' to refer to object variables, so I can use 'this' to refer to the caller context const gTextSize = 16; // size of cluster labels const gTitleSize = 18; // size of title text const gStatusHeight = 14; // height of status bar const gSliderFromBottom = 45; // distance from buttom to top of slider div const gZoomButtonSize = 30; // size of zoom buttons const gZoomFromLeft = 10; // position of zoom buttons from left const gZoomFromBottom = 140; // position of zoom buttons from bottom const gButtonBackground = "rgb(230, 230, 230, 0.85)" // grey level of buttons const gButtonBackgroundClicked = "rgb(180, 180, 180, 0.6)"; // grey of buttons when clicked const gCloseButtonFromRight = 60; // distance of "close" button from right edge const nonFatColor = "F9F9F9"; // color used in fattening mode for all non-fat cells const nonFatColorRect = "DDDDDD"; // rectangle mode: color used in fattening mode for all non-fat cells const nonFatColorCircles = "BBBBBB"; // color used in fattening mode for all non-fat cell circles // the rest of the initialization is done at the end of this file, // because the init involves many functions that are not defined yet here this.initCanvas = function (div, top, left, width, height, args) { /* initialize a new Canvas */ div.style.top = top+"px"; div.style.left = left+"px"; div.style.position = "absolute"; div.style.display = "block"; self.div = div; self.gSampleDescription = "cell"; self.ctx = null; // the canvas context self.canvas = addCanvasToDiv(div, top, left, width, height-gStatusHeight ); self.interact = false; if (args && args.showClose===true) { self.closeButton = addChildControls(10, width-gCloseButtonFromRight); } if (args===undefined || (args["interact"]!==false)) { self.interact = true; addZoomButtons(height-gZoomFromBottom, gZoomFromLeft, self); addModeButtons(10, 10, self); addStatusLine(height-gStatusHeight, left, width, gStatusHeight); addTitleDiv(height-gTitleSize-gStatusHeight-4, 8); /* add the div used for the mouse selection/zoom rectangle to the DOM */ var selectDiv = document.createElement('div'); selectDiv.id = "mpSelectBox"; selectDiv.style.border = "1px dotted black"; selectDiv.style.position = "absolute"; selectDiv.style.display = "none"; selectDiv.style.pointerEvents = "none"; self.div.appendChild(selectDiv); // callbacks when user clicks or hovers over label or cell self.onLabelClick = null; // called on label click, args: text of label and event self.onCellClick = null; // called on cell click, args: array of cellIds and event self.onCellHover = null; // called on cell hover, arg: array of cellIds self.onNoCellHover = null; // called on hover over empty background self.onSelChange = null; // called when the selection has been changed, arg: array of cell Ids self.onLabelHover = null; // called when mouse hovers over a label self.onNoLabelHover = null; // called when mouse does not hover over a label self.onLineHover = null; // called when mouse over a trajectory line self.onRadiusAlphaChange = null; // called when user changes radius or alpha // self.onZoom100Click: called when user clicks the zoom100 button. Implemented below. self.selectBox = selectDiv; // we need this later self.setupMouse(); // connected plots self.childPlot = null; // plot that is syncing from us, see split() self.parentPlot = null; // plot that syncs to us, see split() } addProgressBars(top+Math.round(height*0.3), left+30); if (!args || args.showSliders===undefined || args.showSliders===true) addSliders(); // timer that is reset on every mouse move self.timer = null; } function isHidden(x, y) { /* special coords are used for circles that are off-screen or otherwise not visible */ return ((x===HIDCOORD && y===HIDCOORD)) // not shown (e.g. no coordinate or off-screen) } function hexToGrey(hexColors) { let greyArray = [] for (let i = 0; i < hexColors.length; i++) { // Extract red, green, and blue components let hexColor = hexColors[i]; const maxCol = 200; const addCol = 20; const red = Math.min(maxCol, addCol+parseInt(hexColor.slice(1, 3), 16)); const green = Math.min(maxCol, addCol+parseInt(hexColor.slice(3, 5), 16)); const blue = Math.min(maxCol, addCol+parseInt(hexColor.slice(5, 7), 16)); // Calculate the grayscale value using the luminosity method const gray = Math.round(0.2126 * red + 0.7152 * green + 0.0722 * blue); // Convert the grayscale value to a two-character hex string const grayHex = gray.toString(16).padStart(2, '0'); // Return the grayscale hex color const greySixHex = `${grayHex}${grayHex}${grayHex}`; greyArray.push(greySixHex); } return greyArray; } this.initPort = function(args) { /* init all viewport related state (zoom, radius, alpha) */ self.port = {}; self.port.zoomRange = {}; // object with keys minX, , maxX, minY, maxY, in data units self.port.radius = getAttr(args, "radius", null); // current radius of the circles, 0=one pixel dots // we keep a copy of the 'initial' arguments at 100% zoom self.port.initZoom = {}; self.port.initRadius = self.port.radius; // circle radius at full zoom self.port.initAlpha = getAttr(args, "alpha", 0.3); }; this.initPlot = function(args) { /* create a new scatter plot on the canvas */ if (args===undefined) args = {}; self.scalingDone = false; self.globalOpts = args; self.mode = 1; // drawing mode // everything related to circle coordinates self.coords = {}; self.coords.orig = null; // coordinates of cells in original coordinates self.coords.labels = null; // cluster label positions in pixels, array of [x,y,text] self.coords.px = null; // coordinates of cells and labels as screen pixels or (HIDCOORD,HIDCOORD) if not shown self.coords.labelBbox = null; // cluster label bounding boxes, array of [x1,x2,x2,y2] self.col = {}; self.col.pal = null; // list of six-digit hex codes self.col.arr = null; // length is coords.px/2, one byte per cell = index into self.col.pal self.selCells = new Set(); // IDs of cells that are selected (drawn in black) self.fatIdx = null; // Index of value that is in "fat mode" (=cells bigger, all other cells in light-grey) self.doDrawLabels = true; // should cluster labels be drawn? self.initPort(args); // mouse drag mode: can be "select", "move" or "zoom" self.dragMode = "select"; // for zooming and panning self.mouseDownX = null; self.mouseDownY = null; self.panCopy = null; // to detect if user just clicked on a dot self.dotClickX = null; self.dotClickY = null; // the background image for spatial mode self.background = null; self.activateMode(getAttr(args, "mode", "move")); }; this.clear = function() { clearCanvas(self.ctx, self.canvas.width, self.canvas.height); }; this.setTitle = function (text) { self.title = text; self.titleDiv.innerHTML = text; }; this.activateSliders = function () { $(self.alphaSlider).slider({ "value": 4, "min" : 1, "max" : 7, "step" : 1, "slide": onChangeAlpha }); $(self.radiusSlider).slider({ "value": 4, "min" : 1, "max" : 7, "step" : 1, "slide": onChangeRadius }); } this.setWatermark = function (text) { if (text==="" && self.watermark) { self.watermark.parentNode.removeChild(self.watermark); self.watermark = undefined; return; } if (self.watermark) self.watermark.parentNode.removeChild(self.watermark); var elem = document.createElement('div'); elem.id = "tpWatermark"; elem.style.cssText = 'pointer-events: none;position: absolute; width: 1000px; opacity: 0.5; top: 10px; left: 45px; text-align: left; vertical-align: top; color: black; font-size: 20px; font-weight:bold; font-style:oblique'; elem.textContent = text; self.div.appendChild(elem); self.watermark = elem; } // -- (private) helper functions // -- these are normal functions, not methods, they do not access "self" function gebi(idStr) { return document.getElementById(idStr); } function removeElById(idStr) { var el = gebi(idStr); if (el!==null) { el.parentNode.removeChild(el); } } function activateTooltip(selector) { /* uses bootstrap tooltip. Use noconflict in html, I had to rename BS's tooltip to avoid overwrite by jquery */ if (window.jQuery && $.fn.bsTooltip!==undefined) { var ttOpt = {"html": true, "animation": false, "delay":{"show":400, "hide":100}, container:"body"}; $(selector).bsTooltip(ttOpt); } } function guessRadius(coordCount) { /* a few rules to find a good initial radius, depending on the number of dots */ if (coordCount > 50000) return 0; else if (coordCount > 10000) return 2; else if (coordCount > 4000) return 4; else return 5; } function createSliderSpan(id, width, height, left) { /* create div with given width and height */ var div = document.createElement('span'); div.id = id; div.style.position = "relative"; div.style.width = width+"px"; div.style.height = height+"px"; div.style.left = left+"px"; return div; } function createButton(width, height, id, title, text, imgFname, paddingTop, paddingBottom, addSep, addThickSep, fontSize) { /* make a light-grey div that behaves like a button, with text and/or an image on it * Images are hard to vertically center, so padding top can be specified. * */ var div = document.createElement('div'); div.id = id; div.className = "mpButton"; div.style.backgroundColor = gButtonBackground; div.style.width = width+"px"; div.style.height = height+"px"; div.style["z-index"]="10"; div.style["text-align"]="center"; div.style["vertical-align"]="middle"; div.style["line-height"]=height+"px"; if (fontSize === undefined || fontSize=== null) { if (text!==null) if (text.length>3) div.style["font-size"]="11px"; else div.style["font-size"]="14px"; } else { div.style["font-size"]=fontSize+"px"; } div.style["font-weight"]="bold"; div.style["font-family"]="sans-serif"; if (title!==null) div.title = title; if (text!==null) div.innerHTML = text; if (imgFname!==null && imgFname!==undefined) { var img = document.createElement('img'); img.src = imgFname; if (paddingTop!==null && paddingTop!==undefined) { img.style.paddingTop = paddingTop+"px"; if (paddingBottom) img.style.paddingBottom = paddingBottom+"px"; } div.appendChild(img); } if (addSep===true) div.style["border-bottom"] = "1px solid #D7D7D7"; if (addThickSep===true) div.style["border-bottom"] = "2px solid #C7C7C7"; // make color dark grey when mouse is pressed div.addEventListener("mousedown", function() { this.style.backgroundColor = gButtonBackgroundClicked; }); div.addEventListener("mouseup", function() { this.style.backgroundColor = gButtonBackground; }); return div; } function makeCtrlContainer(top, left) { /* make a container for half-transprent ctrl buttons over the canvas */ var ctrlDiv = document.createElement('div'); ctrlDiv.id = "mpCtrls"; ctrlDiv.style.position = "absolute"; ctrlDiv.style.left = left+"px"; ctrlDiv.style.top = top+"px"; ctrlDiv.style["border-radius"]="2px"; ctrlDiv.style["cursor"]="pointer"; ctrlDiv.style["box-shadow"]="0px 2px 4px rgba(0,0,0,0.3)"; ctrlDiv.style["border-top-left-radius"]="2px"; ctrlDiv.style["border-top-right-radius"]="2px"; ctrlDiv.style["user-select"]="none"; return ctrlDiv; } function addZoomButtons(top, left, self) { /* add the plus/minus buttons to the DOM and place at position x,y on the screen */ var width = gZoomButtonSize; var height = gZoomButtonSize; var plusDiv = createButton(width, height, "mpCtrlZoomPlus", "Zoom in. Keyboard: +", "+", null, null, null, true); //plusDiv.style["border-bottom"] = "1px solid #D7D7D7"; var fullDiv = createButton(width, height, "mpCtrlZoom100", "Zoom in. Keyboard: space", "100%", null, null, null, true); //full.style["border-bottom"] = "1px solid #D7D7D7"; var minusDiv = createButton(width, height, "mpCtrlZoomMinus", "Zoom out. Keyboard: -", "-"); var ctrlDiv = makeCtrlContainer(top, left); ctrlDiv.appendChild(plusDiv); ctrlDiv.appendChild(fullDiv); ctrlDiv.appendChild(minusDiv); self.zoomDiv = ctrlDiv; self.div.appendChild(ctrlDiv); minusDiv.addEventListener('click', function() { self.zoomBy(0.75); self.drawDots(); }); fullDiv.addEventListener('click', function() { self.zoom100(); self.drawDots()}); plusDiv.addEventListener('click', function() { self.zoomBy(1.333); self.drawDots(); }); } function addTitleDiv(top, left) { var div = document.createElement('div'); div.className = "tpTitle"; div.style.top = top+"px"; div.style.left = left+"px"; div.style.fontSize = gTitleSize; div.id = 'mpTitle'; self.div.appendChild(div); self.titleDiv = div; } function onChangeAlpha(ev, ui) { console.log("alpha: "+ui.value); var sliderVal = ui.value; // 1-7 var multMap = { 7 : 0.15, 6 : 0.4, 5 : 0.8, 4 : 1.0, 3 : 1.2, 2 : 1.5, 1 : 1.8 } self.port.alphaMult = multMap[sliderVal]; console.log("alphaMult: "+self.port.alphaMult); self.calcRadius(); self.drawDots(); } function onChangeRadius(ev, ui) { //console.log("radius: "+ui.value); var sliderVal = ui.value; // 1-7 var multMap = { 1 : 1/3, 2 : 1/2, 3 : 1/1.5, 4 : 1.0, 5 : 1.5, 6 : 2.0, 7 : 3.0 } self.port.radiusMult = multMap[sliderVal]; self.calcRadius(); self.drawDots(); } function addSliders() { /* add sliders for transparency and radius */ // alpha reset slider: a label, a slider + a reset button var sliderWidth = 90; //var fromLeft = canvWidth - sliderWidth - 2*45 - 50; var alphaSlider = createSliderSpan("mpAlphaSlider", sliderWidth, 10, 35); self.alphaSlider = alphaSlider; // see activateSliders() for the jquery UI part of the code, executed later alphaSlider.style.float = "left"; //alphaSlider.style.top = "3px"; // container for label + control elements var alphaCont = document.createElement('div'); alphaCont.id = "mpAlphaCont"; //alphaCont.style.left = "150px"; // cellbrowser.css defines grid widths: 45 alphaCont.className = "sliderContainer"; alphaCont.style.top = "15px"; alphaCont.style.left = "0px"; var alphaLabel = document.createElement('div'); // contains the slider and the reset button, floats right alphaLabel.id = "alphaSliderLabel"; alphaLabel.textContent = "Transparency"; alphaLabel.className = "sliderLabel"; // reset button var undoSvg = '<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"/></svg>'; //var alphaReset = createButton(15, 15, "mpAlphaReset", "Reset transparency", undoSvg, null, null, null, false, false, 10); //alphaReset.style.float = "right"; //alphaReset.style.marginLeft = "2px"; //alphaReset.addEventListener ('click', function() { self.resetAlpha(); self.drawDots();}, false); var sliderReset = createButton(15, 15, "mpSliderReset", "Reset transparency and circle size", undoSvg, null, null, null, false, false, 10); sliderReset.style.backgroundColor = "transparent"; sliderReset.style.float = "right"; //sliderReset.style.lineHeight = "16px"; sliderReset.style.marginLeft = "10px"; sliderReset.style.top = "0px"; sliderReset.style.top = "0px"; sliderReset.style.position = "relative"; sliderReset["z-index"] = "10"; // ? why ? sliderReset.addEventListener ('click', function() { self.resetAlpha(); self.resetRadius(); self.drawDots()}, false); alphaCont.appendChild(alphaLabel); alphaCont.appendChild(alphaSlider); //alphaCont.appendChild(alphaReset); alphaCont.appendChild(sliderReset); // Radius reset slider: label, slider and reset button var radiusSlider = createSliderSpan("mpRadiusSlider", sliderWidth, 10, 35); radiusSlider.style.float = "left"; self.radiusSlider = radiusSlider; // see activateSliders() for the jquery UI part of the code, executed later // container for label + slider and reset button var radiusCont = document.createElement('span'); radiusCont.className = "sliderContainer"; radiusCont.id = "mpRadiusDiv"; radiusCont.style.left = "0px"; radiusCont.style.top = "0px"; radiusCont.appendChild(radiusSlider) var radiusLabel = document.createElement('span'); // contains the slider and the reset button, floats right radiusLabel.id = "radiusSliderLabel"; radiusLabel.textContent = "Circle Size"; radiusLabel.style.width = "110px"; radiusLabel.className = "sliderLabel"; radiusCont.appendChild(radiusLabel); radiusCont.appendChild(radiusSlider); //radiusCont.appendChild(sliderReset); var brEl = document.createElement('br'); radiusCont.appendChild(brEl); // add both to the big container div that holds all three slider elements var sliderDiv = document.createElement('span'); //sliderDiv.style.top = fromTop+"px"; //sliderDiv.style.left = fromLeft+"px"; sliderDiv.style.bottom = "28px"; sliderDiv.style.right = "200px"; sliderDiv.style.position = "absolute"; sliderDiv.style.zIndex = "10"; sliderDiv.id = "mpSliderDiv"; sliderDiv.appendChild(radiusCont); sliderDiv.appendChild(alphaCont); self.div.appendChild(sliderDiv); //self.canvasDiv.appendChild(sliderDiv); self.sliderDiv = sliderDiv; // for quickResize() } function addCloseButton(top, left) { /* add close button and sync checkbox */ var div = document.createElement('div'); div.style.cursor = "default"; div.style.left = left+"px"; div.style.top = top+"px"; div.style.display = "block"; div.style.position = "absolute"; div.style.fontSize = gTitleSize; div.style.padding = "3px"; div.style.borderRadius = "3px"; div.style.border = "1px solid #c5c5c5"; div.style.backgroundColor = "#f6f6f6"; div.style.color = "#454545"; div.id = 'mpCloseButton'; div.textContent = "Close"; self.div.appendChild(div); return div; } function addChildControls(top, left) { addCloseButton(top, left); } function appendButton(parentDiv, id, title, imgName) { /* add a div styled like a button under div */ var div = document.createElement('div'); div.title = title; div.id = id; } function addModeButtons(top, left, self) { /* add the zoom/move/select control buttons to the DOM */ var ctrlDiv = makeCtrlContainer(top, left); var bSize = gZoomButtonSize; var selectButton = createButton(bSize, bSize, "mpIconModeSelect", "Select mode. Keyboard: shift or s", null, "img/select.png", 0, 4, true, true); selectButton.addEventListener ('click', function() { self.activateMode("select")}, false); var zoomButton = createButton(bSize, bSize, "mpIconModeZoom", "Zoom-to-rectangle mode. Keyboard: Windows/Command or z", null, "img/zoom.png", 4, 4, true); zoomButton.addEventListener ('click', function() { self.activateMode("zoom")}, false); var moveButton = createButton(bSize, bSize, "mpIconModeMove", "Move mode. Keyboard: Alt or m", null, "img/move.png", 4, 4); moveButton.addEventListener('click', function() { self.activateMode("move");}, false); self.icons = {}; self.icons["move"] = moveButton; self.icons["select"] = selectButton; self.icons["zoom"] = zoomButton; //ctrlDiv.innerHTML = htmls.join(""); ctrlDiv.appendChild(moveButton); ctrlDiv.appendChild(selectButton); ctrlDiv.appendChild(zoomButton); self.div.appendChild(ctrlDiv); self.toolDiv = ctrlDiv; activateTooltip('.mpIconButton'); } function setStatus(text) { self.statusLine.innerHTML = text; } function addStatusLine(top, left, width, height) { /* add a status line div */ var div = document.createElement('div'); div.id = "mpStatus"; div.style.backgroundColor = "rgb(240, 240, 240)"; div.style.position = "absolute"; div.style.top = top+"px"; //div.style.left = left+"px"; div.style.width = width+"px"; div.style.height = height+"px"; div.style["border-left"]="1px solid #DDD"; div.style["border-right"]="1px solid #DDD"; div.style["border-top"]="1px solid #DDD"; div.style["font-size"]=(gStatusHeight-1)+"px"; div.style["cursor"]="pointer"; div.style["font-family"] = "sans-serif"; self.div.appendChild(div); self.statusLine = div; } function addProgressBars(top, left) { /* add the progress bar DIVs to the DOM */ var div = document.createElement('div'); div.id = "mpProgressBars"; div.style.top = top+"px"; div.style.left = left+"px"; div.style.position = "absolute"; var htmls = []; for (var i=0; i<3; i++) { htmls.push('<div id="mpProgressDiv'+i+'" style="display:none; height:17px; width:300px; background-color: rgba(180, 180, 180, 0.3)" style="">'); htmls.push('<div id="mpProgress'+i+'" style="background-color:#666; height:17px; width:10%"></div>'); htmls.push('<div id="mpProgressLabel'+i+'" style="color:black; line-height:17px; position:absolute; top:'+(i*17)+'px;left:100px">Loading...</div>'); htmls.push('</div>'); } div.innerHTML = htmls.join(""); self.div.appendChild(div); } function addCanvasToDiv(div, top, left, width, height) { /* add a canvas element to the body element of the current page and keep left/top/width/eight in self */ var canv = document.createElement('canvas'); self.canvasDiv = canv; canv.id = 'mpCanvas'; //canv.style.border = "1px solid #AAAAAA"; canv.style.backgroundColor = "white"; canv.style.position = "relative"; canv.style.display = "block"; canv.style.width = width+"px"; canv.style.height = height+"px"; //canv.style.top = top+"px"; //canv.style.left = left+"px"; // No scaling = one unit on screen is one pixel. Essential for speed. canv.width = width; canv.height = height; // need to keep these as ints, need them all the time self.width = width; self.height = height; self.top = top; // location of the canvas in pixels self.left = left; div.appendChild(canv); // adds the canvas to the div element self.canvas = canv; // alpha:false recommended by https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas self.ctx = self.canvas.getContext("2d", { alpha: false }); // by default, the canvas background is transparent+black // we use alpha=false, so we need to initialize the canvas with white pixels clearCanvas(self.ctx, width, height); return canv; } function scaleLabels(labels, zoomRange, borderSize, winWidth, winHeight) { /* scale cluster label position to pixel coordinates */ if (labels===undefined) return undefined; winWidth = winWidth-(2*borderSize); winHeight = winHeight-(2*borderSize); var minX = zoomRange.minX; var maxX = zoomRange.maxX; var minY = zoomRange.minY; var maxY = zoomRange.maxY; var spanX = maxX - minX; var spanY = maxY - minY; var xMult = winWidth / spanX; var yMult = winHeight / spanY; // scale the label coords var pxLabels = []; for (var i = 0; i < labels.length; i++) { var annot = labels[i]; var x = annot[0]; var y = annot[1]; var text = annot[2]; // XX ignore anything outside of current zoom range. Performance? if (isHidden(x,y) || (x < minX) || (x > maxX) || (y < minY) || (y > maxY)) { pxLabels.push(null); } else { var xPx = Math.round((x-minX)*xMult)+borderSize; var yPx = winHeight - Math.round((y-minY)*yMult)+borderSize; pxLabels.push([xPx, yPx, text]); } } return pxLabels; } function constrainVal(x, min, max) { /* if x is not in range min, max, limit to min or max */ if (x < min) return min; if (x > max) return max; return x; } function scaleLines(lines, zoomRange, winWidth, winHeight) { /* scale an array of (x1, y1, x2, y2), cutting lines at the screen edges */ var minX = zoomRange.minX; var maxX = zoomRange.maxX; var minY = zoomRange.minY; var maxY = zoomRange.maxY; var spanX = maxX - minX; var spanY = maxY - minY; var xMult = winWidth / spanX; var yMult = winHeight / spanY; // transform from data floats to screen pixel coordinates var pxLines = []; for (var lineIdx = 0; lineIdx < lines.length; lineIdx++) { var line = lines[lineIdx]; var x1 = line[0]; var y1 = line[1]; var x2 = line[2]; var y2 = line[3]; var startInvis = ((x1 < minX) || (x1 > maxX) || (y1 < minY) || (y1 > maxY)); var endInvis = ((x2 < minX) || (x2 > maxX) || (y2 < minY) || (y2 > maxY)); // line is entirely hidden if (startInvis && endInvis) continue; if (startInvis) { x1 = constrainVal(x1, minX, maxX); y1 = constrainVal(y1, minY, maxY); } if (endInvis) { x2 = constrainVal(x2, minX, maxX); y2 = constrainVal(y2, minY, maxY); } var x1Px = Math.round((x1-minX)*xMult); var y1Px = winHeight - Math.round((y1-minY)*yMult); var x2Px = Math.round((x2-minX)*xMult); var y2Px = winHeight - Math.round((y2-minY)*yMult); pxLines.push( [x1Px, y1Px, x2Px, y2Px] ); } return pxLines; } function scaleCoords(coords, borderSize, zoomRange, winWidth, winHeight, annots, aspectRatio) { /* scale list of [x (float),y (float)] to integer pixels on screen and * annots is an array with on-screen annotations in the format (x, y, * otherInfo) that is also scaled. return [array of (x (int), y (int)), * scaled annots array]. Take into account the current zoom range. * * Canvas origin is top-left, but usually plotting origin is bottom-left, * so also flip the Y axis. sets invisible coords to HIDCOORD * */ if (coords===null) return; console.time("scale"); var minX = zoomRange.minX; var maxX = zoomRange.maxX; var minY = zoomRange.minY; var maxY = zoomRange.maxY; var spanX = maxX - minX; var spanY = maxY - minY; //if (aspectRatio) { //xMult = Math.min(xMult, yMult); //yMult = Math.min(xMult, yMult); //let ratio = spanX / spanY; //spanY = spanY*0.5; //} winWidth = winWidth-(2*borderSize); winHeight = winHeight-(2*borderSize); var xMult = winWidth / spanX; var yMult = winHeight / spanY; // transform from data floats to screen pixel coordinates var pixelCoords = new Uint16Array(coords.length); for (var i = 0; i < coords.length/2; i++) { var x = coords[i*2]; var y = coords[i*2+1]; // set everything outside of current zoom range to hidden if ((x < minX) || (x > maxX) || (y < minY) || (y > maxY)) { pixelCoords[2*i] = HIDCOORD; // see isHidden() pixelCoords[2*i+1] = HIDCOORD; } else { var xPx = Math.round((x-minX)*xMult)+borderSize; // our y-axis is flipped compared to matplotlib/R, so we do winHeight - pixel value // to make sure that our plot looks like the figures in the papers var yPx = winHeight - Math.round((y-minY)*yMult)+borderSize; pixelCoords[2*i] = xPx; pixelCoords[2*i+1] = yPx; } } console.timeEnd("scale"); return pixelCoords; } function drawRect(ctx, pxCoords, coordColors, colors, radius, alpha, selCells, fatIdx) { /* draw not circles but tiny rectangles. Maybe good enough for 2pixels sizes */ debug("Drawing "+coordColors.length+" rectangles, with fillRect"); ctx.save(); ctx.globalAlpha = alpha; var dblSize = 2*radius; var count = 0; if (selCells.size!==0 || fatIdx!==null) //colors = makeAllGreyHex(colors.length); colors = hexToGrey(colors); var fatCells = []; + // first draw all the cells with value 0. Poor mans approximation of a z index. + // (Should be faster than sorting by z and drawing afterwards.) for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var valIdx = coordColors[i]; + // only plot color 0 + if (valIdx!==0) + continue; + + var col = colors[valIdx]; + if (fatIdx!==null) { + if (valIdx===fatIdx) { + // fattened cells must be overdrawn later, so just save their coords now + fatCells.push(pxX); + fatCells.push(pxY); + continue + } + } + ctx.fillStyle="#"+col; + ctx.fillRect(pxX-radius, pxY-radius, dblSize, dblSize); + count++; + } + + // then draw all the cells with value <> 0. + for (var i = 0; i < pxCoords.length/2; i++) { + var pxX = pxCoords[2*i]; + var pxY = pxCoords[2*i+1]; + if (isHidden(pxX, pxY)) + continue; + + var valIdx = coordColors[i]; + // only plot color != 0 + if (valIdx===0) + continue; + var col = colors[valIdx]; if (fatIdx!==null) { if (valIdx===fatIdx) { // fattened cells must be overdrawn later, so just save their coords now fatCells.push(pxX); fatCells.push(pxY); continue } //else //col = "DDDDDD"; //col = nonFatColorRect; } ctx.fillStyle="#"+col; ctx.fillRect(pxX-radius, pxY-radius, dblSize, dblSize); count++; } // overdraw the selection as black rectangles on top //if (fatIdx===null) { ctx.globalAlpha = 0.7; ctx.fillStyle="black"; selCells.forEach(function(cellId) { let pxX = pxCoords[2*cellId]; let pxY = pxCoords[2*cellId+1]; ctx.fillRect(pxX-radius, pxY-radius, dblSize, dblSize); count += 1; }) //} // overdraw the fattened cells as blue rectangles on top if (fatCells.length!==0) { ctx.globalAlpha = 0.7; ctx.fillStyle="blue"; for (var i = 0; i < fatCells.length/2; i++) { var pxX = fatCells[2*i]; var pxY = fatCells[2*i+1]; ctx.fillRect(pxX-radius, pxY-radius, dblSize, dblSize); count++; } } debug(count+" rectangles drawn (including selection+fattening)"); ctx.restore(); return count; } function drawCirclesStupid(ctx, pxCoords, coordColors, colors, radius, alpha, selCells, fatIdx) { /* TOO SLOW - Only used in testing/for demos. Not used anywhere else. Draw circles onto canvas with very slow functions.. */ debug("Drawing "+coordColors.length+" circles with stupid renderer"); ctx.globalAlpha = alpha; var dblSize = 2*radius; var count = 0; for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var valIdx = coordColors[i]; var col = colors[valIdx]; if (fatIdx!==null && valIdx!==fatIdx) col = nonFatColor; ctx.fillStyle="#"+col; ctx.beginPath(); ctx.arc(pxX, pxY, radius, 0, 2 * Math.PI); ctx.closePath(); ctx.fill(); count++; } return count; } function intersectRect(r1left, r1right, r1top, r1bottom, r2left, r2right, r2top, r2bottom) { /* return true if two rectangles overlap, https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection */ return !(r2left > r1right || r2right < r1left || r2top > r1bottom || r2bottom < r1top); } function drawLines(ctx, pxLines, width, height, attrs) { /* draw lines defined by array with (x1, y1, x2, y2) arrays. * color is a CSS name, so usually prefixed by # if a hexcode * width is the width in pixels. * */ ctx.save(); //ctx.globalAlpha = 1.0; ctx.strokeStyle = attrs.lineColor || "#888888"; ctx.lineWidth = attrs.lineWidth || 3; ctx.globalAlpha = attrs.lineAlpha || 0.5; //ctx.miterLimit =2; //ctx.strokeStyle = "rgba(200, 200, 200, 0.3)"; for (var i=0; i < pxLines.length; i++) { var line = pxLines[i]; var x1 = line[0]; var y1 = line[1]; var x2 = line[2]; var y2 = line[3]; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } ctx.restore(); } function drawLabelsSvg(svgLines, labelCoords, winWidth, winHeight, zoomFact) { /* given an array of [x, y, text], draw the text. returns bounding * boxes as array of [x1, y1, x2, y2] */ if (labelCoords===undefined) return undefined; for (var i=0; i < labelCoords.length; i++) { var coord = labelCoords[i]; if (coord===null) { // outside of view range, push a null to avoid messing up the order of bboxArr continue; } var x = coord[0]; var y = coord[1]; var text = coord[2]; // don't draw labels where the midpoint is off-screen if (x<0 || y<0 || x>winWidth || y>winHeight) { continue; } svgLines.push("<text font-family='sans-serif' font-size='"+(gTextSize+2)+"' fill='black' text-anchor='middle' x='"+x+"' y='"+y+"'>"+text+"</text>"); } } self.drawLegendSvg = function (legend) { /* draw a legend onto the SVG given a legend object. */ var legWidth = self.svgLabelWidth; var svgLines = self.svgLines; var rows = legend.rows; var legTitle = legend.title; var subTitle = legend.subTitle; var left = self.canvas.width; // x position where legend starts var lineHeight = gTextSize; var x = left + 11; var y = lineHeight; svgLines.push("<text font-family='sans-serif' font-size='"+gTextSize+"' fill='black' x='"+x+"' y='"+y+"'>"+legTitle+"</text>"); y += lineHeight; if (subTitle) { svgLines.push("<text font-family='sans-serif' font-size='"+gTextSize+"' fill='black' text-anchor='middle' x='"+x+"' y='"+y+"'>"+subTitle+"</text>"); y += lineHeight; } y += lineHeight; // get the sum of all rows, to calculate frequency // this code was copied from buildLegend -> refactor one day var sum = 0; for (var i = 0; i < rows.length; i++) { let count = rows[i].count; sum += count; } for (i = 0; i < rows.length; i++) { // a lot was copied from cellBrowser:buildLegend(), could use some refactoring to reduce duplication var row = rows[i]; var colorHex = row.color; // manual color if (colorHex===null) colorHex = row.defColor; // default color var label = row.label; var longLabel = row.longLabel; let count = row.count; var valueIndex = row.intKey; var freq = 100*count/sum; if (count===0) // never output categories with 0 count. continue; // this was copied from cellbrowser:buildLegend - refactor soon label = label.replace(/_/g, " ").replace(/'/g, "'").trim(); if (label==="") { label = "(empty)"; } label = label.replace("–", "-"); // draw colored rectangle first var textSize = gTextSize-3; svgLines.push("<rect width='15' height='15' fill='#"+colorHex+"' x='"+x+"' y='"+y+"'></rect>"); var prec = 1; if (freq<1) prec = 2; //var lineCount = 0; //svgLines.push("<text font-family='sans-serif' font-size='"+textSize+"' fill='black' text-anchor='start' x='"+(x+18)+"' y='"+((y-4)+lineCount*textSize)+"'>"+label+"</text>"); svgLines.push("<text font-family='sans-serif' font-size='"+textSize+"' fill='black' text-anchor='start' x='"+(x+18)+"' y='"+(y+8)+"'>"+label+"</text>"); //} svgLines.push("<text font-family='sans-serif' font-size='"+textSize+"' fill='black' text-anchor='end' x='"+(left+legWidth-3)+"' y='"+(y+15)+"'>"+freq.toFixed(prec)+"%</text>"); y+= textSize; } // cannot draw violin plots in SVG - no library for it }; function drawLabels(ctx, labelCoords, winWidth, winHeight, zoomFact, doGrey) { /* given an array of [x, y, text], draw the text. returns bounding * boxes as array of [x1, y1, x2, y2] */ if (labelCoords===undefined) return undefined; console.time("labels"); ctx.save(); ctx.font = "bold "+gTextSize+"px Sans-serif" ctx.globalAlpha = 1.0; //ctx.strokeStyle = '#EEEEEE'; if (doGrey===undefined) { ctx.strokeStyle = "rgba(200, 200, 200, 0.3)"; ctx.lineWidth = 5; ctx.miterLimit =2; } //else //ctx.strokeStyle = "rgba(20, 20, 20, 0.3)"; ctx.textBaseline = "top"; if (doGrey===undefined) { ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.shadowBlur=6; ctx.shadowColor="white"; } else ctx.fillStyle = "rgba(0,0,0,1.0)"; ctx.textAlign = "left"; var addMargin = 1; // how many pixels to extend the bbox around the text, make clicking easier var bboxArr = []; // array of click hit boxes for (var i=0; i < labelCoords.length; i++) { var coord = labelCoords[i]; if (coord===null) { // outside of view range, push a null to avoid messing up the order of bboxArr bboxArr.push( null ); continue; } var x = coord[0]; var y = coord[1]; var text = coord[2]; var textWidth = Math.round(ctx.measureText(text).width); // move x to the left, so text is centered on x x = x - Math.round(textWidth*0.5); var textX1 = x; var textY1 = y; var textX2 = Math.round(x+textWidth); var textY2 = y+gTextSize; // don't draw labels where the midpoint is off-screen if (x<0 || y<0 || x>winWidth || y>winHeight) { bboxArr.push( null ); continue; } ctx.strokeText(text,x,y); ctx.fillText(text,x,y); bboxArr.push( [textX1-addMargin, textY1-addMargin, textX2+addMargin, textY2+addMargin] ); } ctx.restore(); console.timeEnd("labels"); return bboxArr; } // function drawLabels_dom(ctx, labelCoords, isFull) { // /* given an array of [x, y, text], draw the text. returns bounding boxes as array of [x1, y1, x2, y2] */ // for (var i=0; i < labelCoords.length; i++) { // var coord = labelCoords[i]; // var x = coord[0]; // var y = coord[1]; // var text = coord[2]; // var div = document.createElement('div'); // div.id = id; // //div.style.border = "1px solid #DDDDDD"; // div.style.backgroundColor = "rgb(230, 230, 230, 0.6)"; // div.style.width = width+"px"; // div.style.height = height+"px"; // div.style["text-align"]="center"; // div.style["vertical-align"]="middle"; // div.style["line-height"]=height+"px"; // } // ctx.restore(); // return bboxArr; // } // https://stackoverflow.com/questions/5560248/programmatically-lighten-or-darken-a-hex-color-or-rgb-and-blend-colors function shadeColor(color, percent) { var f=parseInt(color,16),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=f>>16,G=f>>8&0x00FF,B=f&0x0000FF; return (0x1000000+(Math.round((t-R)*p)+R)*0x10000+(Math.round((t-G)*p)+G)*0x100+(Math.round((t-B)*p)+B)).toString(16).slice(1); } function makeCircleTemplates(radius, tileWidth, tileHeight, colors, fatIdx) { /* create an off-screen-canvas with the circle-templates that will be stamped later onto the bigger canvas * Returns the canvas. This feels very much like sprites on the AMIGA in the 1980s. * * returns an object with these attributes: * .off = off-screen canvas * .selImgIdx = index on off with circle with a black outline only, for the selection * .greyImgIdx= index on off with grey circle for the non-selected cells * .nonFatImgIdx (only when fatIdx is != null) = index on off with grey circle(?) for the non-fattened cells */ var off = document.createElement('canvas'); // not added to DOM, will be gc'ed at some point var nonFatImgIdx = 0; if (fatIdx!==null) { //colCount = 2; //colors = [colors[fatIdx], nonFatColorCircles]; nonFatImgIdx = colors.length; colors.push(nonFatColorCircles); } var colCount = colors.length; off.width = (colCount+2) * tileWidth; // "+2" because we have three additional circles at the end off.height = tileHeight; var ctxOff = off.getContext('2d'); //pre-render circles into the off-screen canvas. for (var i = 0; i < colors.length; ++i) { ctxOff.fillStyle = "#"+colors[i]; ctxOff.beginPath(); // parameters are: arc(x, y, r, 0, 2*pi) ctxOff.arc(i * tileWidth + radius + 1, radius + 1, radius, 0, 2 * Math.PI); ctxOff.closePath(); ctxOff.fill(); // only draw a pretty shaded outline for very big circles, at extremely high zoom levels if (radius > 6) { ctxOff.lineWidth=1.0; var strokeCol = "#"+shadeColor(colors[i], 0.9); //if (fatIdx!==null) //strokeCol = "#000000"; //else ctxOff.strokeStyle=strokeCol; ctxOff.beginPath(); ctxOff.arc(i * tileWidth + radius + 1, radius + 1, radius, 0, 2 * Math.PI); ctxOff.closePath(); ctxOff.stroke(); } } // pre-render a black circle outline for the selection, quality of anti-aliasing? var selImgId = colors.length; ctxOff.lineWidth=2; ctxOff.strokeStyle="black"; ctxOff.beginPath(); // args: arc(x, y, r, 0, 2*pi) ctxOff.arc((selImgId * tileWidth) + radius + 1, radius + 1, radius - 1, 0, 2 * Math.PI); ctxOff.closePath(); ctxOff.stroke(); // pre-render a grey circle for the non-selection, when something is selected, all the rest is drawn in grey let greyImgId = colors.length + 1; ctxOff.lineWidth = 1; ctxOff.fillStyle = "#b2b2b2"; ctxOff.beginPath(); ctxOff.arc((greyImgId * tileWidth) + radius + 1, radius + 1, radius, 0, 2 * Math.PI); ctxOff.closePath(); ctxOff.fill(); let ret = {}; ret.off = off; ret.selImgIdx = selImgId; ret.greyImgIdx = greyImgId; ret.nonFatImgIdx = nonFatImgIdx; return ret; } //function blitTwo(ctx, off, pxCoords, coordColors, tileWidth, tileHeight, radius, fatIdx, selImgId) { /* blit only the fatIdx circle in color, and all the rest in grey. Also draw selection circles. */ //var count = 0; //for (let i = 0; i < pxCoords.length/2; i++) { //var pxX = pxCoords[2*i]; //var pxY = pxCoords[2*i+1]; //if (isHidden(pxX, pxY)) //continue; //var col = coordColors[i]; //if (col===fatIdx) //col = 0; //else //col = 1; //count++; //ctx.drawImage(off, col * tileWidth, 0, tileWidth, tileHeight, pxX - radius - 1, pxY - radius - 1, tileWidth, tileHeight); // //if (radius>=5) //ctx.drawImage(off, selImgId * tileWidth, 0, tileWidth, tileHeight, pxX - radius -1, pxY - radius-1, tileWidth, tileHeight); //} //return count; //} function blitAll(ctx, off, pxCoords, coordColors, tileWidth, tileHeight, radius, selCells, greyIdx, fatIdx, colors) { /* blit the circles onto the main canvas, using all colors */ var count = 0; var hasSelection = false; if (selCells.size!==0) hasSelection = true; var col = 0; + // first draw all the cells of the color 0 for (let i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; // when a selection is active, draw everything in grey. This only works because the selection is overdrawn afterwards // (The selection must be overdrawn later, because otherwise circles shine through the selection) col = coordColors[i]; + count++; + // only draw the cells of entry 0 of the palette first. + // This is a poor approximation of a z-index, but a real z-index would take way too long. + // So we're just drawing twice. + if (col!==0) + continue + if (fatIdx===null) { + if (hasSelection) + col = greyIdx; + } + else if (!hasSelection && !(fatIdx!=null && fatIdx===col)) + col = greyIdx; + + ctx.drawImage(off, col * tileWidth, 0, tileWidth, tileHeight, pxX - radius - 1, pxY - radius - 1, tileWidth, tileHeight); + } + + // then draw all the cells with the colors != 0 + for (let i = 0; i < pxCoords.length/2; i++) { + var pxX = pxCoords[2*i]; + var pxY = pxCoords[2*i+1]; + if (isHidden(pxX, pxY)) + continue; + + col = coordColors[i]; + if (col===0) + continue if (fatIdx===null) { if (hasSelection) col = greyIdx; } else if (!hasSelection && !(fatIdx!=null && fatIdx===col)) col = greyIdx; - count++; ctx.drawImage(off, col * tileWidth, 0, tileWidth, tileHeight, pxX - radius - 1, pxY - radius - 1, tileWidth, tileHeight); } if (fatIdx!==null) { // do not fatten //radius = radius * 2; //let templates = makeCircleTemplates(radius, tileWidth, tileHeight, colors, fatIdx); //let off = templates.off; radius *= 1.5; for (let i = 0; i < pxCoords.length/2; i++) { col = coordColors[i]; if (fatIdx!==col) continue; var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; count++; ctx.drawImage(off, col * tileWidth, 0, tileWidth, tileHeight, pxX - radius - 1, pxY - radius - 1, tileWidth, tileHeight); } } return count; } function copyColorsOnly(coordColors, fatIdx, nonFatImgIdx) /* copy numbers in coordColors array to a new array of the same size and keep only fatIdx, set, all others to nonFatImgIdx */ { let newArr = new Array(coordColors.length); for (let i=0; i<coordColors.length; i++) { let colIdx = coordColors[i]; if (colIdx===fatIdx) newArr[i] = fatIdx; else newArr[i] = nonFatImgIdx; } return newArr; } function drawCirclesDrawImage(ctx, pxCoords, coordColors, colors, radius, alpha, selCells, fatIdx) { /* predraw and copy circles into canvas. pxCoords are the centers. */ // almost copied from by https://stackoverflow.com/questions/13916066/speed-up-the-drawing-of-many-points-on-a-html5-canvas-element // around 2x faster than drawing full circles by using an off-screen canvas debug("Drawing "+coordColors.length+" coords with drawImg renderer, radius="+radius); var diam = Math.round(2 * radius); var tileWidth = diam + 2; // must add one pixel on each side, space for antialising var tileHeight = tileWidth; // otherwise circles look cut off let templates = makeCircleTemplates(radius, tileWidth, tileHeight, colors, fatIdx); let off = templates.off; ctx.save(); if (alpha!==undefined) ctx.globalAlpha = alpha; let count = 0; let origCoordColors = null; if (fatIdx!==null) { origCoordColors = coordColors; coordColors = copyColorsOnly(origCoordColors, fatIdx, templates.nonFatImgIdx); } count = blitAll(ctx, off, pxCoords, coordColors, tileWidth, tileHeight, radius, selCells, templates.greyImgIdx, fatIdx, colors); if (origCoordColors) coordColors = origCoordColors; // overdraw the selection on top: as circles with black outlines ctx.globalAlpha = 0.7; var selImgIdx = templates.selImgIdx; // second-to last template is the black outline, see makeCircleTemplates() selCells.forEach(function(cellId) { let pxX = pxCoords[2*cellId]; let pxY = pxCoords[2*cellId+1]; if (isHidden(pxX, pxY)) return; // make sure that old leftover overlapping black circles don't shine through and redraw the circle // slow, but not sure what else I can do... let col = coordColors[cellId]; ctx.drawImage(off, col * tileWidth, 0, tileWidth, tileHeight, pxX - radius -1, pxY - radius-1, tileWidth, tileHeight); // and draw the black outline ctx.drawImage(off, selImgIdx * tileWidth, 0, tileWidth, tileHeight, pxX - radius -1, pxY - radius-1, tileWidth, tileHeight); }); ctx.restore(); return count; } function hexToInt(colors) { /* convert a list of hex values to ints */ var intList = []; for (var i = 0; i < colors.length; i++) { var colHex = colors[i]; var colInt = parseInt(colHex, 16); if (colInt===undefined) { alert("Illegal color value, not a six-digit hex code: "+colHex); intList.push(0); } else intList.push(colInt); } return intList; } function makeAllGreyHex(num) { /* return a list of num grey hex strings */ var hexList = []; for (var i = 0; i < num; i++) { hexList.push("b2b2b2"); } return hexList; } function drawRectBuffer(ctx, width, height, pxCoords, colorArr, colors, alpha, selCells) { /* Draw little rectangles with size 3 using a memory buffer*/ var canvasData = ctx.getImageData(0, 0, width, height); var cData = canvasData.data; var rgbColors = null; if (selCells.length===0) rgbColors = hexToInt(colors); else rgbColors = makeAllGrey(colors.length); var invAlpha = 1.0 - alpha; // alpha-blend pixels into array for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var p = 4 * (pxY*width+pxX); // pointer to red value of pixel at x,y var oldR = cData[p]; var oldG = cData[p+1]; var oldB = cData[p+2]; var newRgb = rgbColors[colorArr[i]]; var newR = (newRgb >>> 16) & 0xff; var newG = (newRgb >>> 8) & 0xff; var newB = (newRgb) & 0xff; var mixR = ~~(oldR * invAlpha + newR * alpha); var mixG = ~~(oldG * invAlpha + newG * alpha); var mixB = ~~(oldB * invAlpha + newB * alpha); cData[p] = mixR; cData[p+1] = mixG; cData[p+2] = mixB; cData[p+3] = 255; // no transparency... ever? } } function drawBackground(ctx, back) { /* draw the background image onto the canvas ctx */ if (!back) return; console.time("image"); //var ctxWidth = ctx.canvas.width; // size of the canvas on the screen in pixels //var ctxHeight = ctx.canvas.height; //var clipWidth = backuwidth; //var clipHeight = back.height; // arguments are: (imgObject, x/y coord on image for clipping, width / height of clipped image, where to place the image, width/height of image) //var a = getSafeRect(back.image.width, back.image.height, back.clipX, back.clipY, back.image.width, back.image.height, 0, 0, ctxWidth, ctxHeight); //console.log("drawing fixed coords", a.sx, a.sy, a.sw, a.sh, a.dx, a.dy, a.dw, a.dh); //self.ctx.drawImage(back.image, a.sx, a.sy, a.sw, a.sh, a.dx, a.dy, a.dw, a.dh); //self.ctx.drawImage(back.image, a.sx, a.sy, back.width, back.height, a.dx, a.dy, a.dw, a.dh); console.log("drawImage sx, sy, sw, sh, dx, dy, dw, dh", back.sx, back.sy, back.sw, back.sh, back.dx,back.dy, back.dw, back.dh); //self.ctx.drawImage(back.image, back.sx, back.sy, back.width, back.height, 0, 0, ctxWidth, ctxHeight); self.ctx.drawImage(back.image, back.sx, back.sy, back.sw, back.sh, back.dx, back.dy, back.dw, back.dh); console.timeEnd("image"); } function drawPixels(ctx, width, height, pxCoords, coordColors, colors, alpha, selCells, fatIdx) { /* draw single pixels into a pixel buffer and copy the buffer into a canvas */ // by default the canvas has black pixels // so not doing: var canvasData = ctx.createImageData(width, height); // XX is this really faster than manually zero'ing the array? var canvasData = ctx.getImageData(0, 0, width, height); var cData = canvasData.data; var rgbColors = null; if (selCells.size===0) rgbColors = hexToInt(colors); else rgbColors = hexToInt(makeAllGreyHex(colors.length)); // selection is active, all cells are grey, except the selection var invAlpha = 1.0 - alpha; var count = 0; // alpha-blend pixels into array for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var p = 4 * (pxY*width+pxX); // pointer to red value of pixel at x,y var valIdx = coordColors[i]; if (fatIdx!==null) { // fattening mode: fat cluster is black, all the rest is blue let grey; if (valIdx!==fatIdx) { grey = 0xDD; cData[p] = grey; cData[p+1] = grey; cData[p+2] = grey; } else { // stop all transparency, just overdraw fat blue here cData[p] = 0; cData[p+1] = 0; cData[p+2] = 255; } cData[p+3] = 255; // no transparency... ever? } else { // normal colors var oldR = cData[p]; var oldG = cData[p+1]; var oldB = cData[p+2]; var newRgb = rgbColors[valIdx]; var newR = (newRgb >>> 16) & 0xff; var newG = (newRgb >>> 8) & 0xff; var newB = (newRgb) & 0xff; var mixR = ~~(oldR * invAlpha + newR * alpha); var mixG = ~~(oldG * invAlpha + newG * alpha); var mixB = ~~(oldB * invAlpha + newB * alpha); cData[p] = mixR; cData[p+1] = mixG; cData[p+2] = mixB; cData[p+3] = 255; // no transparency... ever? } count++; } // overdraw the selection as black pixels if (fatIdx===null) { selCells.forEach(function(cellId) { let pxX = pxCoords[2*cellId]; let pxY = pxCoords[2*cellId+1]; if (isHidden(pxX, pxY)) return; let p = 4 * (pxY*width+pxX); // pointer to red value of pixel at x,y cData[p] = 0; cData[p+1] = 0; cData[p+2] = 0; }) } self.ctx.putImageData(canvasData, 0, 0); return count; } function findRange(coords) { /* find range of pairs-array and return obj with attributes minX/maxX/minY/maxY */ var minX = 9999999; var maxX = -9999999; var minY = 9999999; var maxY = -9999999; for (var i = 0; i < coords.length/2; i++) { var x = coords[i*2]; var y = coords[i*2+1]; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); } var obj = {}; obj.minX = minX; obj.maxX = maxX; obj.minY = minY; obj.maxY = maxY; return obj; // not needed, but more explicit } function clearCanvas(ctx, width, height) { /* clear with a white background */ // jsperf says this is fastest on Chrome, and still OK-ish in FF //console.time("clear"); ctx.save(); ctx.globalAlpha = 1.0; ctx.fillStyle = "rgb(255,255,255)"; ctx.fillRect(0, 0, width, height); ctx.restore(); //console.timeEnd("clear"); } // -- object methods (=access the self object) this.onZoom100Click = function(ev) { self.zoom100(); self.drawDots(); }; this.setBackground = function(img) { /* */ if (self.background===undefined || self.background===null) self.background = {}; self.background.image = img; self.scaleBackground(self.background, self.port.initZoom, self.port.zoomRange); if (self.childPlot) self.childPlot.setBackground(img); }; function getSafeRect(width, height, sx, sy, sw, sh, dx, dy, dw, dh) { if( sw < 0 ) { sx += sw; sw = Math.abs( sw ); } if( sh < 0 ) { sy += sh; sh = Math.abs( sh ); } if( dw < 0 ) { dx += dw; dw = Math.abs( dw ); } if( dh < 0 ) { dy += dh; dh = Math.abs( dh ); } const x1 = Math.max( sx, 0 ); const x2 = Math.min( sx + sw, width ); const y1 = Math.max( sy, 0 ); const y2 = Math.min( sy + sh, height ); const w_ratio = dw / sw; const h_ratio = dh / sh; } this.scaleBackground = function(background, dataRange, zoomRange) { /* determine the (x,y,width,height) in pixels of the current rectangle of the background bitmap on the canvas */ if (!background) return; var width = background.image.width; // source image width var height = background.image.height; // source image height var ctxWidth = self.canvas.width; var ctxHeight = self.canvas.height; var coordHeight = dataRange.maxY-dataRange.minY; var coordWidth = dataRange.maxX-dataRange.minX; var scaleX = width / coordWidth; // this is px/dataUnit to convert background image pixels to canvas pixels var scaleY = height / coordHeight; var sx1 = zoomRange.minX * scaleX; // sx = x position on source image var sx2 = zoomRange.maxX * scaleX; var sy1 = (coordHeight - zoomRange.maxY) * scaleY; // Y-positions must be subtracted from coordHeight - y axis is flipped! var sy2 = (coordHeight - zoomRange.minY) * scaleY; // Our y-axis is always flipped! var sw = sx2 - sx1; // size of slice of background image that is currently shown, in source pixels var sh = sy2 - sy1; // lame: since I wasn't able to figure out how to transform negative sx, sy to corrected dx, dy, coords - safari doesn't // understand negative sx/sy - I simply use the scaleData function // somehow https://gist.github.com/Kaiido/ca9c837382d89b9d0061e96181d1d862 didn't work for me //var coords = [0.0, 0.0, dataRange.maxX, dataRange.maxY]; //var newCoords = scaleCoords(coords, 0, zoomRange, ctxWidth, ctxHeight, []) var dx = 0; var dy = 0; var dw = ctxWidth; var dh = ctxHeight; //if (sx1 < 0) { //sx1 = 0; //sw = width; //dx = newCoords[0]; //dw = newCoords[2] - dx; //} //if (sy1 < 0) { //sy1 = 0; //sh = height; //dy = newCoords[1]; //dh = newCoords[3] - dy; //} background.sx = sx1; background.sy = sy1; background.sw = sw; background.sh = sh; background.dx = dx; background.dy = dy; background.dw = dw; background.dh = dh; } this.scaleData = function() { /* scale coords and labels to current zoom range, write results to pxCoords and pxLabels */ if (!self.coords) // window resize can call this before coordinates are loaded. return; var borderMargin = self.port.radius; self.calcRadius(); let w = self.canvas.width; let h = self.canvas.height; self.coords.px = scaleCoords(self.coords.orig, borderMargin, self.port.zoomRange, w, h, self.coords.aspectRatio); if (self.coords.lines) self.coords.pxLines = scaleLines(self.coords.lines, self.port.zoomRange, self.canvas.width, self.canvas.height); self.scaleBackground(self.background, self.port.initZoom, self.port.zoomRange); self.scalingDone = true; } this.readyToDraw = function() { return (self.scalingDone) } this.setTopLeft = function(top, left) { /* set top and left position in pixels of the canvas */ self.top = top; self.left = left; // keep an integer version of these numbers self.div.style.top = top+"px"; self.div.style.left = left+"px"; self.setSize(self.width, self.height, false); // resize the various buttons } this.quickResize = function(width, height) { /* resize the canvas and move the status line, don't rescale or draw */ self.div.style.width = width+"px"; self.div.style.height = height+"px"; // if in split screen mode, pass on the message to the second window if (self.childPlot) { width = width/2; //self.childPlot.left = self.left+width; //self.childPlot.canvas.style.left = self.childPlot.left+"px"; self.childPlot.setPos(null, self.left+width); self.childPlot.setSize(width, height, true); } if (self.closeButton) { self.closeButton.style.left = width - gCloseButtonFromRight; } // css and actual canvas sizes: these must be identical, otherwise canvas gets super slow self.canvas.style.width = width+"px"; self.width = width; self.height = height; //let canvHeight = height - gStatusHeight; let canvHeight = height - gStatusHeight; self.canvas.height = canvHeight; self.canvas.width = width; self.canvas.style.height = canvHeight+"px"; self.zoomDiv.style.top = (height-gZoomFromBottom)+"px"; self.zoomDiv.style.left = (gZoomFromLeft)+"px"; // move status line, dataset name and radius/transparency sliders var statusDiv = self.statusLine; statusDiv.style.top = (height-gStatusHeight)+"px"; statusDiv.style.width = width+"px"; self.titleDiv.style.top = (height-gStatusHeight-gTitleSize)+"px"; if (self.sliderDiv) self.sliderDiv.style.top = (height-gStatusHeight-gSliderFromBottom)+"px"; } this.setPos = function(top, left) { /* position canvas. Does not affect child */ if (top) { self.top = top; self.div.style.top = top+"px"; } if (left) { self.left = left; self.div.style.left = left+"px"; } } this.setSize = function(width, height, doRedraw) { /* resize canvas on the page re-scale the data and re-draw, unless doRedraw is false */ if (width===null) width = self.div.getBoundingClientRect().width; self.quickResize(width, height); if (self.coords) self.scaleData(); if (doRedraw===undefined || doRedraw===true) self.drawDots(); }; this.setCoords = function(coords, clusterLabels, coordInfo, opts) { /* specify new coordinates of circles to draw, an array of (x,y) coordinates */ /* Scale data to current screen dimensions */ /* clusterLabels is optional: array of [x, y, labelString]*/ /* minX, maxX, etc are optional * opts are optional arguments like radius, alpha etc, see initPlot/args */ self.scalingDone = false; if (coords.length === 0) alert("cbDraw-setCoords called with no coordinates"); var minX = coordInfo.minX; var maxX = coordInfo.maxX; var minY = coordInfo.minY; var maxY = coordInfo.maxY; var useRaw = coordInfo.useRaw; var coordOpts = cloneObj(self.globalOpts); copyObj(opts, coordOpts); var oldRadius = self.port.initRadius; var oldAlpha = self.port.initAlpha; var oldLabels = self.coords.pxLabels; self.port = {}; self.initPort(coordOpts); if (oldRadius) self.port.initRadius = oldRadius; if (oldAlpha) self.port.initAlpha = oldAlpha; self.coords = {}; self.coords.origAll = undefined; var newZr = {}; if (minX===undefined || maxX===undefined || minY===undefined || maxY===undefined) newZr = findRange(coords); else { newZr = {minX:minX, maxX:maxX, minY:minY, maxY:maxY}; } // switch off any moving of the spots to the minimum position if (useRaw) { newZr.minX = 0.0; newZr.minY = 0.0; } // we need the maximal min-max ranges of the original coordinates for later copyObj(newZr, self.port.initZoom); // the current min-max ranges of the values are the maximal values, so copy them = zoom 100% copyObj(newZr, self.port.zoomRange); self.coords.orig = coords; self.coords.coordInfo = coordInfo; // we need to find out the label of the coords self.coords.labels = clusterLabels; if (coordInfo.aspectRatio) self.coords.aspectRatio = coordInfo.aspectRatio; var count = 0; for (var i = 0; i < coords.length/2; i++) { var cellX = coords[i*2]; var cellY = coords[i*2+1]; if (!(isHidden(cellX, cellY))) count++; } setStatus(count+ " visible " + self.gSampleDescription+"s loaded"); if (opts.lines) self._setLines(opts["lines"], opts); self.scaleData(); }; this.setLabelCoords = function(labelCoords) { /* set the label coords and return true if there were any labels before */ var hadLabelsBefore = (self.coords.labels && self.coords.labels.length > 0); self.coords.labels = labelCoords; return hadLabelsBefore; }; this.setColorArr = function(colorArr) { /* set the color array, one array with one index per coordinate */ self.col.arr = colorArr; }; this.setColors = function(colors) { /* set the colors, one for each value of a in setColorArr(a). colors is an * array of six-digit hex strings. Not #-prefixed! */ self.col.pal = colors; }; this.calcRadius = function() { /* calculate the radius from current zoom factor and set radius, alpha and zoomFact in self.port */ // make the circles a bit smaller than expected var zr = self.port.zoomRange; var iz = self.port.initZoom; var initAlpha = self.port.initAlpha; var initSpan = iz.maxX-iz.minX; var currentSpan = zr.maxX-zr.minX; var zoomFact = initSpan/currentSpan; // both radius and alpha can be change by a 'multiplier' var alphaMult = self.port.alphaMult || 1.0; var radiusMult = self.port.radiusMult || 1.0; var baseRadius = self.port.initRadius; if (baseRadius===0) baseRadius = 0.7; var radius = Math.floor(baseRadius * Math.sqrt(zoomFact) * radiusMult); // the higher the zoom factor, the higher the alpha value var zoomFrac = Math.min(1.0, zoomFact/100.0); // zoom as fraction, max is 1.0 var alpha = initAlpha + 3.0*zoomFrac*(1.0 - initAlpha); alpha = Math.min(0.8, alpha)*alphaMult; debug("Zoom factor: ", zoomFact, ", Radius: "+radius+", alpha: "+alpha); if (self.onRadiusAlphaChange) self.onRadiusAlphaChange(radiusMult, alphaMult); self.port.zoomFact = zoomFact; self.port.alpha = alpha; self.port.alphaMult = alphaMult; self.port.radius = radius; self.port.radiusMult = radiusMult; } this.drawSvg = function(alpha, radius, coords, colArr, pal) { self.svgLines = []; var plotHeight = self.canvas.height; var plotWidth = self.canvas.width; var width = plotWidth+self.svgLabelWidth; var height = 1500; // enough space for 100 lines in the legend self.svgLines.push("<svg xmlns='http://www.w3.org/2000/svg' height='"+height+"' width='"+width+"'>\n"); drawCirclesSvg(self.svgLines, coords, colArr, pal, radius, alpha, self.selCells); if (self.doDrawLabels===true && self.coords.labels!==null && self.coords.labels!==undefined) drawLabelsSvg(self.svgLines, self.coords.pxLabels, plotWidth, plotHeight, self.port.zoomFact); // axis lines self.svgLines.push('<line x1="2" y1="2" x2="2" y2="'+plotHeight+'" stroke="black" stroke-width="2"/>'); self.svgLines.push('<line x1="2" y1="2" x2="'+plotWidth+'" y2="2" stroke="black" stroke-width="2"/>'); // draw axis labels var initZoom = self.port.initZoom; // x axis self.svgLines.push("<text font-family='sans-serif' font-size='"+(gTextSize)+"' fill='black' text-anchor='left' x='"+10+"' y='"+(gTextSize+20)+"'>"+initZoom.minY+"</text>"); self.svgLines.push("<text font-family='sans-serif' font-size='"+(gTextSize)+"' fill='black' text-anchor='left' x='"+10+"' y='"+(plotHeight-gTextSize-2)+"'>"+initZoom.maxY+"</text>"); // y axis self.svgLines.push("<text font-family='sans-serif' font-size='"+(gTextSize)+"' fill='black' text-anchor='left' x='"+10+"' y='"+(gTextSize+3)+"'>"+initZoom.minX+"</text>"); self.svgLines.push("<text font-family='sans-serif' font-size='"+(gTextSize)+"' fill='black' text-anchor='left' x='"+(plotWidth-40)+"' y='"+(gTextSize+3)+"'>"+initZoom.maxX+"</text>"); return; } this.drawDots = function(doSvg) { /* draw coordinates to canvas with current colors */ console.time("draw"); self.clear(); var radius = self.port.radius; var alpha = self.port.alpha; var zoomFact = self.port.zoomFact; var coords = self.coords.px; var pal = self.col.pal; var colArr = self.col.arr; var count = 0; if (alpha===undefined) alert("internal error: alpha is not defined"); if (coords===null) alert("internal error: cannot draw if coordinates are not set yet"); if (colArr && colArr.length !== (coords.length>>1)) alert("internal error: cbDraw.drawDots - colorArr is not 1/2 of coords array. Got "+pal.length+" color values but coordinates for "+(coords.length/2)+" cells."); if (doSvg!==undefined) { self.drawSvg(alpha, radius, coords, colArr, pal); return; } drawBackground(self.ctx, self.background) // if the labels are not shown, fattening should not be active if (self.fatIdx && !self.doDrawLabels) self.fatIdx = null; if (radius===0) { count = drawPixels(self.ctx, self.canvas.width, self.canvas.height, coords, colArr, pal, alpha, self.selCells, self.fatIdx); } else if (radius===1 || radius===2) { count = drawRect(self.ctx, coords, colArr, pal, radius, alpha, self.selCells, self.fatIdx); } else { switch (self.mode) { case 0: count = drawCirclesStupid (self.ctx, coords, colArr, pal, radius, alpha, self.selCells, self.fatIdx); break; case 1: count = drawCirclesDrawImage(self.ctx, coords, colArr, pal, radius, alpha, self.selCells, self.fatIdx); break; case 2: break; } } self.count = count; console.timeEnd("draw"); if (self.coords.pxLines) { console.time("draw lines"); drawLines(self.ctx, self.coords.pxLines, self.canvas.width, self.canvas.height, self.coords.lineAttrs); console.timeEnd("draw lines"); } if ((self.doDrawLabels===true && self.coords.labels!==null && self.coords.labels!==undefined) || self.coords.coordInfo.annots!==undefined) { self.drawLabels(); } if (self.childPlot) self.childPlot.drawDots(); }; this.getSvgText = function() { /* close and return the accumulated svg lines and clear the svg line buffer */ var svgLines = self.svgLines; svgLines.push("</svg>\n"); self.svgLines = null; return svgLines; } this.drawLabels = function() { /* draw only the labels */ self.coords.pxLabels = scaleLabels( self.coords.labels, self.port.zoomRange, self.port.radius, self.canvas.width, self.canvas.height ); self.coords.labelBbox = drawLabels( self.ctx, self.coords.pxLabels, self.canvas.width, self.canvas.height, self.port.zoomFact ); // draw annotations - look like labels, but cannot be clicked self.coords.pxAnnots = scaleLabels( self.coords.coordInfo.annots, self.port.zoomRange, self.port.radius, self.canvas.width, self.canvas.height ); drawLabels(self.ctx, self.coords.pxAnnots, self.canvas.width, self.canvas.height, self.port.zoomFact, true); }; this.cellsAtPixel = function(x, y) { /* return the Ids of all cells at a particular pixel */ var res = []; var pxCoords = self.coords.px; for (var i = 0; i < pxCoords.length/2; i++) { var cellX = pxCoords[i*2]; var cellY = pxCoords[i*2+1]; if (cellX===x || cellY===y) res.push(i); } return res; }; this.cellsInRect = function(x1, y1, x2, y2) { /* return the Ids of all cells within certain pixel boundaries */ var res = []; var pxCoords = self.coords.px; for (var i = 0; i < pxCoords.length/2; i++) { var cellX = pxCoords[i*2]; var cellY = pxCoords[i*2+1]; if ((cellX >= x1) && (cellX <= x2) && (cellY >= y1) && (cellY <= y2)) res.push(i); } return res; }; this.resetAlpha = function() { self.port.alphaMult = 1.0; $('#mpAlphaSlider').slider({value:4}); self.calcRadius(); }; this.resetRadius = function() { self.port.radiusMult = 1.0; $('#mpRadiusSlider').slider({value:4}); self.calcRadius(); }; this.setRadiusAlpha = function(radius, alpha) { self.port.radiusMult = radius; self.port.alphaMult = alpha; self.calcRadius(); }; this.zoom100 = function() { /* zoom to 100% and redraw */ copyObj(self.port.initZoom, self.port.zoomRange); self.resetAlpha(); self.resetRadius(); self.scaleData(); }; this.zoomToTest = function(x1, y1, x2, y2) { self.port.zoomRange = {"minX":x1, "minY":y1, "maxX":x2, "maxY":y2}; self.scaleData(); } this.zoomTo = function(x1, y1, x2, y2) { /* zoom to rectangle defined by two pixel points */ // make sure that x1<x2 and y1<y2 - can happen if mouse movement was upwards debug("Zooming to pixels: ", x1, y1, x2, y2); var pxMinX = Math.min(x1, x2); var pxMaxX = Math.max(x1, x2); var pxMinY = Math.min(y1, y2); var pxMaxY = Math.max(y1, y2); var zoomRange = self.port.zoomRange; // window size in data coordinates var spanX = zoomRange.maxX - zoomRange.minX; var spanY = zoomRange.maxY - zoomRange.minY; // multiplier to convert from pixels to data coordinates var xMult = spanX / self.canvas.width; // multiplier dataRange/pixel var yMult = spanY / self.canvas.height; var oldMinX = zoomRange.minX; var oldMinY = zoomRange.minY; zoomRange.minX = oldMinX + (pxMinX * xMult); zoomRange.minY = oldMinY + (pxMinY * yMult); zoomRange.maxX = oldMinX + (pxMaxX * xMult); zoomRange.maxY = oldMinY + (pxMaxY * yMult); self.port.zoomRange = zoomRange; debug("Marquee zoom window: "+JSON.stringify(self.port.zoomRange)); self.scaleData(); }; this.zoomBy = function(zoomFact, xPx, yPx) { /* zoom centered around xPx,yPx by a given factor. Returns new zoom range. * zoomFact = 1.2 means zoom +20% * zoomFact = 0.8 means zoom -20% * */ var zr = self.port.zoomRange; var iz = self.port.initZoom; var xRange = Math.abs(zr.maxX-zr.minX); var yRange = Math.abs(zr.maxY-zr.minY); var minWeightX = 0.5; // how zooming should be distributed between min/max var minWeightY = 0.5; if (xPx!==undefined) { minWeightX = (xPx/self.width); minWeightY = (yPx/self.canvas.height); } var scale = (1.0-zoomFact); var newRange = {}; newRange.minX = zr.minX - (xRange*scale*minWeightX); newRange.maxX = zr.maxX + (xRange*scale*(1-minWeightX)); // inversed, because we flip the Y axis (flipY) newRange.minY = zr.minY - (yRange*scale*(1-minWeightY)); newRange.maxY = zr.maxY + (yRange*scale*(minWeightY)); // extreme zoom factors don't make sense, at some point we reach // the limit of the floating point numbers var newZoom = ((iz.maxX-iz.minX)/(newRange.maxX-newRange.minX)); if (newZoom < 0.01 || newZoom > 1500) return zr; debug("x min max "+zr.minX+" "+zr.maxX); debug("y min max "+zr.minY+" "+zr.maxY); self.port.zoomRange = newRange; self.scaleData(); // a special case for connected plots that are not sharing our pixel coordinates if (self.childPlot && self.coords!==self.childPlot.coords) { self.childPlot.zoomBy(zoomFact, xPx, yPx); } return newRange; }; this.movePerc = function(xDiffFrac, yDiffFrac) { /* move a certain percentage of current view. xDiff/yDiff are floats, e.g. 0.1 is 10% up */ var zr = self.port.zoomRange; var xRange = Math.abs(zr.maxX-zr.minX); var yRange = Math.abs(zr.maxY-zr.minY); var xDiffAbs = xRange*xDiffFrac; var yDiffAbs = yRange*yDiffFrac; var newRange = {}; newRange.minX = zr.minX + xDiffAbs; newRange.maxX = zr.maxX + xDiffAbs; newRange.minY = zr.minY + yDiffAbs; newRange.maxY = zr.maxY + yDiffAbs; copyObj(newRange, self.port.zoomRange); self.scaleData(); }; this.panStart = function() { /* called when starting a panning sequence, makes a snapshop of the current image */ self.panCopy = document.createElement('canvas'); // not added to DOM, will be gc'ed self.panCopy.width = self.canvas.width; self.panCopy.height = self.canvas.height; var destCtx = self.panCopy.getContext("2d", { alpha: false }); destCtx.drawImage(self.canvas, 0, 0); } this.panBy = function(xDiff, yDiff) { /* pan current image by x/y pixels */ debug('panning by '+xDiff+' '+yDiff); //var srcCtx = self.panCopy.getContext("2d", { alpha: false }); clearCanvas(self.ctx, self.canvas.width, self.canvas.height); self.ctx.drawImage(self.panCopy, -xDiff, -yDiff); // keep these for panEnd self.panDiffX = xDiff; self.panDiffY = yDiff; } this.panEnd = function() { /* end a sequence of panBy calls, called when the mouse is released */ self.moveBy(self.panDiffX, -self.panDiffY); // -1 because of flipY self.panCopy = null; self.panDiffX = null; self.panDiffY = null; } // BEGIN SELECTION METHODS (could be an object?) this.selectClear = function(skipNotify) { /* clear selection */ self.selCells.clear(); setStatus(""); if (self.onSelChange!==null && skipNotify!==true) self.onSelChange(self.selCells); }; this.selectSet = function(cellIds) { /* set selection to an array of integer cellIds */ self.selCells.clear(); //self.selCells.push(...cellIds); // "extend" = array spread syntax, https://davidwalsh.name/spread-operator let selCells = self.selCells; for (let i=0; i<cellIds.length; i++) selCells.add(cellIds[i]); self._selUpdate(); }; this.selectAdd = function(cellIdx) { /* add a single cell to the selection. If it already exists, remove it. */ console.time("selectAdd"); if (self.selCells.has(cellIdx)) self.selCells.delete(cellIdx); else self.selCells.add(cellIdx); console.time("selectAdd"); self._selUpdate(); }; this.selectAll = function(cellIdx) { /* add all cells to selection */ var selCells = self.selCells; var pxCoords = self.coords.px; for (var i = 0, I = pxCoords.length / 2; i < I; i++) { selCells.add(i); } self.selCells = selCells; self._selUpdate(); }; this.selectVisible = function() { /* add all visible cells to selection */ var selCells = self.selCells; var pxCoords = self.coords.px; for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; selCells.add(i); } self.selCells = selCells; self._selUpdate(); } this.selectByColor = function(colIdx) { /* add all cells with a given color to the selection */ var colArr = self.col.arr; var selCells = self.selCells; var cnt = 0; for (var i = 0; i < colArr.length; i++) { if (colArr[i]===colIdx) { selCells.add(i); cnt++; } } self.selCells = selCells; debug(cnt + " cells appended to selection, by color"); self._selUpdate(); }; this.unselectByColor = function(colIdx) { /* remove all cells with a given color from the selection */ var colArr = self.col.arr; var selCells = self.selCells; var cnt = 0; for (var i = 0; i < colArr.length; i++) { if (colArr[i]===colIdx) { selCells.delete(i); cnt++; } } self.selCells = selCells; debug(cnt + " cells removed from selection, by color"); self._selUpdate(); }; this.selectInRect = function(x1, y1, x2, y2) { /* find all cells within a rectangle and add them to the selection. */ var minX = Math.min(x1, x2); var maxX = Math.max(x1, x2); var minY = Math.min(y1, y2); var maxY = Math.max(y1, y2); console.time("select"); var pxCoords = self.coords.px; for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; if ((minX <= pxX) && (pxX <= maxX) && (minY <= pxY) && (pxY <= maxY)) { self.selCells.add(i); } } console.timeEnd("select"); self._selUpdate(); }; this.hasSelected = function() { return (self.selCells.size!==0) } this.hasAllSelected = function() { return (self.selCells.length===self.getCount()); } this.getSelection = function() { /* return selected cells as a list of ints */ var cellIds = []; self.selCells.forEach(function(x) {cellIds.push(x)}); return cellIds; }; this.selectInvert = function() { /* invert selection */ var selCells = self.selCells; var cellCount = self.getCount(); for (let i = 0; i < cellCount; i++) { if (selCells.has(i)) { selCells.delete(i); } else { selCells.add(i); } } self.selCells = selCells; self._selUpdate(); }; this.selectOnlyShow = function() { /* the opposite of selectHide() = remove all coords that are not selected */ var selCells = self.selCells; if (selCells.size===0) return; if (self.coords.origAll===undefined) self.coords.origAll = cloneArray(self.coords.orig); var coords = self.coords.orig; for (var i = 0; i < coords.length/2; i++) { if (!selCells.has(i)) { coords[2*i] = HIDCOORD; coords[2*i+1] = HIDCOORD; } } self.scaleData(); self._selUpdate(); } this.selectHide = function() { /* remove all coords that are selected */ if (self.coords.origAll===undefined) self.coords.origAll = cloneArray(self.coords.orig); var selCells = self.selCells; var coords = self.coords.orig; for (var i = 0; i < coords.length/2; i++) { if (selCells.has(i)) { coords[2*i] = HIDCOORD; coords[2*i+1] = HIDCOORD; } } self.scaleData(); self.selectSet([]); self._selUpdate(); } this.unhideAll = function() { /* undo the hide operation */ if (self.coords.origAll!==undefined) { self.coords.orig = self.coords.origAll; self.coords.origAll = undefined; } self.scaleData(); } this.getCount = function() { /* return maximum number of cells in dataset, may include hidden cells, see isHidden() */ return self.coords.orig.length / 2; }; this.getVisibleCount = function() { /* return number of cells that are visible */ let count = 0; let coords = self.coords.orig; for (var i = 0; i < coords.length/2; i++) { if (!isHidden(coords[2*i], coords[2*i+1])) count++; } return count; }; // END SELECTION METHODS (could be an object?) this._selUpdate = function() { /* called after the selection has been updated, calls the onSelChange callback */ setStatus(self.selCells.size + " " + self.gSampleDescription + "s selected"); if (self.onSelChange!==null) self.onSelChange(self.selCells); } this.moveBy = function(xDiff, yDiff) { /* update the pxCoords by a certain x/y distance and redraw */ // convert pixel range to data scale range var zr = self.port.zoomRange; var xDiffData = xDiff * ((zr.maxX - zr.minX) / self.canvas.width); var yDiffData = yDiff * ((zr.maxY - zr.minY) / self.canvas.height); // move zoom range zr.minX = zr.minX + xDiffData; zr.maxX = zr.maxX + xDiffData; zr.minY = zr.minY + yDiffData; zr.maxY = zr.maxY + yDiffData; self.scaleData(); // a special case for connected plots that are not sharing our pixel coordinates if (self.childPlot && self.coords!==self.childPlot.coords) { self.childPlot.moveBy(xDiff, yDiff); } }; this.labelAt = function(x, y) { /* return the index and the text of the label at position x,y or null if nothing there */ //console.time("labelCheck"); var clusterLabels = self.coords.labels; if (clusterLabels===null || clusterLabels===undefined) return null; var labelCoords = self.coords.labels; var boxes = self.coords.labelBbox; if (boxes==null) // no cluster labels return null; if (labelCoords.length!==clusterLabels.length) alert("internal error maxPLot.js: coordinates of labels are different from clusterLabels"); for (var i=0; i < labelCoords.length; i++) { var box = boxes[i]; if (box===null) // = outside of the screen continue; var x1 = box[0]; var y1 = box[1]; var x2 = box[2]; var y2 = box[3]; if ((x >= x1) && (x <= x2) && (y >= y1) && (y <= y2)) { //console.timeEnd("labelCheck"); var labelText = clusterLabels[i][2]; return [labelText, i]; } } //console.timeEnd("labelCheck"); return null; }; this.lineAt = function(x, y) { /* check if there is a line at x,y and return its label if so or null if not */ var pxLines = self.coords.pxLines; for (var i=0; i < pxLines.length; i++) { var line = pxLines[i]; var x1 = line[0]; var y1 = line[1]; var x2 = line[2]; var y2 = line[3]; if (pDistance(x, y, x1, y1, x2, y2) <= 2) { var lineLabel = self.coords.lineLabels[i]; return lineLabel; } } return null; }; this.cellsAt = function(x, y) { /* check which cell's bounding boxes contain (x, y), return a list of the cell IDs, sorted by distance */ //console.time("cellSearch"); var pxCoords = self.coords.px; if (pxCoords===null) return null; var possIds = []; var radius = self.port.radius; for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var x1 = pxX - radius; var y1 = pxY - radius; var x2 = pxX + radius; var y2 = pxY + radius; if ((x >= x1) && (x <= x2) && (y >= y1) && (y <= y2)) { var dist = Math.sqrt(Math.pow(x-pxX, 2) + Math.pow(y-pxY, 2)); possIds.push([dist, i]); } } //console.timeEnd("cellSearch"); if (possIds.length===0) return null; else { possIds.sort(function(a,b) { return a[0]-b[0]} ); // sort by distance // strip the distance information var ret = []; for (let i=0; i < possIds.length; i++) { ret.push(possIds[i][1]); } return ret; } }; this.resetMarquee = function() { /* make the marquee disappear and reset its internal status */ if (!self.interact) return; self.mouseDownX = null; self.mouseDownY = null; self.lastPanX = null; self.lastPanY = null; self.selectBox.style.display = "none"; self.selectBox.style.width = 0; self.selectBox.style.height = 0; }; this.drawMarquee = function(x1, y1, x2, y2, forceAspect) { /* draw the selection or zooming marquee using the DIVs created by setupMouse */ var selectWidth = Math.abs(x1 - x2); var selectHeight = 0; if (forceAspect) { var aspectRatio = self.width / self.canvas.height; selectHeight = selectWidth/aspectRatio; } else selectHeight = Math.abs(y1 - y2); var minX = Math.min(x1, x2); var minY = Math.min(y1, y2); var div = self.selectBox; div.style.left = (minX-self.left)+"px"; div.style.top = (minY-self.top)+"px"; div.style.width = selectWidth+"px"; div.style.height = selectHeight+"px"; div.style.display = "block"; }; this.activatePlot = function() { /* draw black border around plot, remove black border from all connected plots, call onActive */ if (self.parentPlot===null) return false; // only need to do something if we're not already the active plot self.canvas.style["border"] = "2px solid black"; self.parentPlot.canvas.style["border"] = "2px solid white"; // flip the parent/child relationship self.childPlot = self.parentPlot; self.childPlot.parentPlot = self; self.parentPlot = null; self.childPlot.childPlot = null; // hide/show the tool and zoom buttons self.childPlot.zoomDiv.style.display = "none"; self.childPlot.toolDiv.style.display = "none"; self.zoomDiv.style.display = "block"; self.toolDiv.style.display = "block"; // notify the UI self.onActiveChange(self); return true; } this.isSplit = function() { /* return true if this renderer has either a parent or a child plot = is in split screen mode */ return (isValid(self.parentPlot) || isValid(self.childPlot)) } // https://stackoverflow.com/questions/73187456/canvas-determine-if-point-is-on-line //function distancePointFromLine(x0, y0, x1, y1, x2, y2) { //return Math.abs((x2 - x1) * (y1 - y0) - (x1 - x0) * (y2 - y1)) / Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) //} function pDistance(x, y, x1, y1, x2, y2) { /* distance of point from line segment, copied from https://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment */ var A = x - x1; var B = y - y1; var C = x2 - x1; var D = y2 - y1; var dot = A * C + B * D; var len_sq = C * C + D * D; var param = -1; if (len_sq != 0) //in case of 0 length line param = dot / len_sq; var xx, yy; if (param < 0) { xx = x1; yy = y1; } else if (param > 1) { xx = x2; yy = y2; } else { xx = x1 + param * C; yy = y1 + param * D; } var dx = x - xx; var dy = y - yy; return Math.sqrt(dx * dx + dy * dy); } this.onMouseMove = function(ev) { /* called when the mouse is moved over the Canvas */ // set a timer so we can get "hover" functionality without too much CPU if (self.timer!==null) clearTimeout(self.timer); self.timer = setTimeout(self.onNoMouseMove, 130); // save mouse pos for onNoMouseMove timer handler self.lastMouseX = ev.clientX; self.lastMouseY = ev.clientY; // label hit check requires canvas coordinates x/y var clientX = ev.clientX; var clientY = ev.clientY; var canvasTop = self.top; var canvasLeft = self.left; var xCanvas = clientX - canvasLeft; var yCanvas = clientY - canvasTop; // is there just white space under the mouse, do nothing, // from https://stackoverflow.com/questions/15325283/how-to-detect-if-a-mouse-pointer-hits-a-line-already-drawn-on-an-html-5-canvas //var imageData = self.ctx.getImageData(0, 0, self.width, self.height); //var inputData = imageData.data; //var pData = (~~xCanvas + (~~yCanvas * self.width)) * 4; //if (!inputData[pData + 3]) { //console.log("just white space under mouse"); //return; //} // when the cursor is over a label, change it to a hand, but only when there is no marquee if (self.coords.labelBbox!==null && self.mouseDownX === null) { var labelInfo = self.labelAt(xCanvas, yCanvas); if (labelInfo===null) { self.canvas.style.cursor = self.canvasCursor; self.onNoLabelHover(ev); } else { self.canvas.style.cursor = 'pointer'; // not 'hand' anymore ! and not 'grab' yet! if (self.onLabelHover!==null) self.onLabelHover(labelInfo[0], labelInfo[1], ev); } } // when the cursor is over a line, trigger callback if (self.onLineHover && self.coords.lineLabels) { var lineLabel = self.lineAt(xCanvas, yCanvas); self.onLineHover(lineLabel, ev); } if (self.mouseDownX!==null) { // we're panning if (((ev.altKey || self.dragMode==="move")) && self.panCopy!==null) { var xDiff = self.mouseDownX - clientX; var yDiff = self.mouseDownY - clientY; self.panBy(xDiff, yDiff); } else { // zooming or selecting var forceAspect = false; var anyKey = (ev.metaKey || ev.altKey || ev.shiftKey); if ((self.dragMode==="zoom" && !anyKey) || ev.metaKey ) forceAspect = true; self.drawMarquee(self.mouseDownX, self.mouseDownY, clientX, clientY, forceAspect); } } }; this.onNoMouseMove = function() { /* called after some time has elapsed and the mouse has not been moved */ if (self.coords.px===null) return; var x = self.lastMouseX - self.left; // need canvas, not screen coordinates var y = self.lastMouseY - self.top; var cellIds = self.cellsAt(x, y); // only call onNoCellHover if callback exists and there is nothing selected if (cellIds===null && self.onNoCellHover!==null && self.selCells===null) self.onNoCellHover(); else if (self.onCellHover!==null) self.onCellHover(cellIds); }; this.onMouseDown = function(ev) { /* user clicks onto canvas */ if (self.activatePlot()) return; // ignore the first click into the plot, if it was the activating click debug("background mouse down"); var clientX = ev.clientX; var clientY = ev.clientY; if ((ev.altKey || self.dragMode==="move") && !ev.shiftKey && !ev.metaKey) { debug("alt key or move mode: starting panning"); self.panStart(); } self.mouseDownX = clientX; self.mouseDownY = clientY; }; this.onMouseUp = function(ev) { debug("background mouse up"); // these are screen coordinates var clientX = ev.clientX; var clientY = ev.clientY; var mouseDidNotMove = (self.mouseDownX === clientX && self.mouseDownY === clientY); if (self.panCopy!==null && !mouseDidNotMove) { debug("ending panning operation"); self.panEnd(); self.mouseDownX = null; self.mouseDownY = null; self.drawDots(); return; } else { // abort panning self.panCopy = null; } if (self.mouseDownX === null && self.lastPanX === null) { // user started the click outside of the canvas: do nothing debug("first click must have been outside of canvas"); return; } // the subsequent operations require canvas coordinates x/y var canvasTop = self.top; var canvasLeft = self.left; var x1 = self.mouseDownX - canvasLeft; var y1 = self.mouseDownY - canvasTop; var x2 = clientX - canvasLeft; var y2 = clientY - canvasTop; // user did not move the mouse, so this is a click if (mouseDidNotMove) { // recognize a double click -> zoom if (self.lastClick!==undefined && x2===self.lastClick[0] && y2===self.lastClick[1]) { self.zoomBy(1.33); self.lastClick = [-1,-1]; } else { self.lastClick = [x2, y2]; } var labelInfo = self.labelAt(x2, y2); if (labelInfo!==null && self.doDrawLabels) self.onLabelClick(labelInfo[0], labelInfo[1], ev); else { var clickedCellIds = self.cellsAt(x2, y2); // click on a cell -> update selection and redraw if (clickedCellIds!==null && self.onCellClick!==null) { self.selectClear(true); for (var i = 0; i < clickedCellIds.length; i++) { self.selCells.add(clickedCellIds[i]); } self.drawDots(); self.onCellClick(clickedCellIds, ev); } else { // user clicked onto background: // reset selection and redraw debug("not moved at all: reset "+clientX+" "+self.mouseDownX+" "+self.mouseDownY+" "+clientY); self.selectClear(); self.drawDots(); } self.lastPanX = null; self.lastPanY = null; } self.mouseDownX = null; self.mouseDownY = null; return; } //debug("moved: reset "+x+" "+mouseDownX+" "+mouseDownY+" "+y); // it wasn't a click, so it was a drag var anyKey = (ev.metaKey || ev.altKey || ev.shiftKey); // zooming if ((self.dragMode==="zoom" && !anyKey) || ev.metaKey ) { // get current coords of the marquee in canvas pixels var div = self.selectBox; let zoomX1 = parseInt(div.style.left.replace("px","")); let zoomY1 = parseInt(div.style.top.replace("px","")); let zoomX2 = zoomX1+parseInt(div.style.width.replace("px","")); let zoomY2 = zoomY1+parseInt(div.style.height.replace("px","")); zoomY1 = self.canvas.height - zoomY1; zoomY2 = self.canvas.height - zoomY2; self.zoomTo(zoomX1, zoomY1, zoomX2, zoomY2); // switch back to the mode before zoom was clicked if (self.prevMode) { self.activateMode(self.prevMode); self.prevMode = null; } } // marquee select else if ((self.dragMode==="select" && !anyKey) || ev.shiftKey ) { if (! ev.shiftKey) self.selectClear(true); self.selectInRect(x1, y1, x2, y2); } else { debug("Internal error: no mode?"); } self.resetMarquee(); self.drawDots(); }; function drawCirclesSvg(svgLines, pxCoords, coordColors, colors, radius, alpha, selCells) { /* add SVG text to the array svgLines */ debug("Drawing "+coordColors.length+" circles with SVG renderer"); var count = 0; for (var i = 0; i < pxCoords.length/2; i++) { var pxX = pxCoords[2*i]; var pxY = pxCoords[2*i+1]; if (isHidden(pxX, pxY)) continue; var col = colors[coordColors[i]]; svgLines.push("<circle cx='"+pxX+"' cy='"+pxY+"' r='"+radius+"' fill-opacity='"+alpha+"' fill='#"+col+"' />"); count++; } return count; } this.onWheel = function(ev) { /* called when the user moves the mouse wheel */ if (self.parentPlot!==null) return; debug(ev); var normWheel = normalizeWheel(ev); debug(normWheel); var pxX = ev.clientX - self.left; var pxY = ev.clientY - self.top; var spinFact = 0.1; if (ev.ctrlKey) // = OSX pinch and zoom gesture (and no other OS/mouse combination?) spinFact = 0.08; // is too fast, so slow it down a little var zoomFact = 1-(spinFact*normWheel.spinY); debug("Wheel Zoom by "+zoomFact); self.zoomBy(zoomFact, pxX, pxY); self.drawDots(); ev.preventDefault(); ev.stopPropagation(); }; this.setupMouse = function() { // setup the mouse callbacks self.canvas.addEventListener('mousedown', self.onMouseDown); self.canvas.addEventListener("mousemove", self.onMouseMove); self.canvas.addEventListener("mouseup", self.onMouseUp); // when the user moves the mouse, the mouse is often NOT on the canvas, // but on the marquee box, so connect this one, too. self.selectBox.addEventListener("mouseup", self.onMouseUp); self.canvas.addEventListener("wheel", self.onWheel); }; this.setShowLabels = function(trueOrFalse) { /* this is separate from setLabelField, so you can switch it off and on quickly */ self.doDrawLabels = trueOrFalse; } this.setLabelField = function(fieldName) { /* this is only to keep track of what the current label field is. Switches off label drawing if fieldName is null */ self.activeLabelField = fieldName; self.setShowLabels( fieldName!==null ) }; this.getLabelField = function(fieldName) { return self.activeLabelField; }; this.getLabels = function() { /* get current labels */ var ret = []; var labels = self.coords.labels; for (var i = 0; i<labels.length; i++) ret.push(labels[i][2]); return ret; } this.setLabels = function(newLabels) { /* set new label text */ if (newLabels.length!==self.coords.labels.length) { debug("maxPlot:setLabels error: new labels have wrong length."); return; } for (var i = 0; i<newLabels.length; i++) self.coords.labels[i][2] = newLabels[i]; self.coords.pxLabels = scaleLabels(self.coords.labels, self.port.zoomRange, self.port.radius, self.canvas.width, self.canvas.height); if (self.coords.annots) { let pxAnnots = scaleLabels(self.coords.annots, self.port.zoomRange, self.port.radius, self.canvas.width, self.canvas.height); for (let pxa of pxAnnots) self.coords.labels.push(pxa); } // a special case for connected plots that are not sharing our pixel coordinates if (self.childPlot && self.coords!==self.childPlot.coords) { self.childPlot.setLabels(newLabels); } }; this._setLines = function(lines, attrs) { /* set the line attributes */ if (lines===undefined) return; self.coords.lines = lines; if (!attrs) self.coords.lineAttrs = {}; else self.coords.lineAttrs = attrs; // save the labels elsewhere. Labels are optional. if (lines[0].length > 4) { var lineLabels = [] for (var i=0; i<lines.length; i++) { lineLabels.push(lines[i][4]); } self.coords.lineLabels = lineLabels; } } this.activateMode = function(modeName) { /* switch to one of the mouse drag modes: zoom, select or move */ if (modeName==="zoom") self.prevMode = self.dragMode; else self.prevMode = null; self.dragMode=modeName; var cursor = null; if (modeName==="move") cursor = 'all-scroll'; else if (modeName==="zoom") cursor = "zoom-in" else if (modeName=="select") cursor = 'crosshair'; //else //cursor= 'default'; self.canvas.style.cursor = cursor; self.canvasCursor = cursor; self.resetMarquee(); if (self.interact) { self.icons["move"].style.backgroundColor = gButtonBackground; self.icons["zoom"].style.backgroundColor = gButtonBackground; self.icons["select"].style.backgroundColor = gButtonBackground; self.icons[modeName].style.backgroundColor = gButtonBackgroundClicked; } if (self.childPlot) self.childPlot.activateMode(modeName); } this.randomDots = function(n, radius, mode) { /* draw x random dots with x random colors*/ function randomArray(ArrType, length, max) { /* make Array and fill it with random numbers up to max */ var arr = new ArrType(length); for (var i = 0; i<length; i++) { arr[i] = Math.round(Math.random() * max); } return arr; } if (mode!==undefined) self.mode = mode; self.port.radius = radius; self.setCoords(randomArray(Uint16Array, 2*n, 65535)); self.setColors(["FF0000", "00FF00", "0000FF", "CC00CC", "008800"]); self.setColorArr(randomArray(Uint8Array, n, 4)); console.time("draw"); self.drawDots(); console.timeEnd("draw"); return self; }; this.split = function() { /* reduce width of renderer, create new renderer and place both side-by-side. * They initially share the .coords but setCoords() can break that relationship. * */ var canvHeight = self.canvas.height; var canvLeft = self.left; var newWidth = self.width/2; var newTop = self.top; var newLeft = self.left+newWidth; var newHeight = canvHeight+gStatusHeight; var newDiv = document.createElement('div'); newDiv.id = "mpPlot2"; document.body.appendChild(newDiv); var opts = cloneObj(self.globalOpts); opts.showClose = true; var plot2 = new MaxPlot(newDiv, newTop, newLeft, newWidth, newHeight, {"showClose" : true, "showSliders" : false}); plot2.statusLine.style.display = "none"; plot2.port = self.port; plot2.selCells = self.selCells; plot2.coords = self.coords; plot2.col = {}; plot2.col.pal = self.col.pal; plot2.col.arr = self.col.arr; if (self.background) plot2.setBackground (self.background.image); self.setSize(newWidth, newHeight, false); // will call scaleData(), but not redraw. plot2.onLabelClick = self.onLabelClick; plot2.onCellClick = self.onCellClick; plot2.onCellHover = self.onCellHover; plot2.onNoCellHover = self.onNoCellHover; plot2.onSelChange = self.onSelChange; plot2.onLabelHover = self.onLabelHover; plot2.onNoLabelHover = self.onNoLabelHover; plot2.onActiveChange = self.onActiveChange; plot2.drawDots(); self.childPlot = plot2; plot2.parentPlot = self; // add a thick border and hide the menus in the child self.canvas.style["border"] = "2px solid black"; self.childPlot.zoomDiv.style.display = "none"; self.childPlot.toolDiv.style.display = "none"; return plot2; }; this.unsplit = function() { /* remove the connected non-active renderer */ //var canvWidth = window.innerWidth - canvLeft - legendBarWidth; var otherRend = self.childPlot; self.childPlot = undefined; if (!otherRend) { otherRend = self.parentPlot; self.parentPlot = undefined; } self.setSize(self.width*2, self.height, false); otherRend.div.remove(); self.canvas.style["border"] = "none"; return; } this.getWidth = function() { /* return total size of renderer, including any split child renderers */ if (self.childPlot) return self.width + self.childPlot.width; else return self.width; } this.calcMedian = function(coords, values, names, numNames) { /* given an array of coordinates (x at even positions, y at odd positions) and an array of names for each of them * return the median for each name as an object name -> array of [x, y, count] * if names is undefined, will use numNames and convert to two decimals * */ var calc = {}; for (var i = 0, I = values.length; i < I; i++) { var label = null; if (names) { label = names[values[i]]; } else { label = numNames[i].toFixed(2); } if (calc[label] === undefined) { calc[label] = [[], [], 0]; // all X, all Y, count } var x = coords[i * 2]; var y = coords[i * 2 + 1]; if (isHidden(x, y)) continue; calc[label][0].push(x); calc[label][1].push(y); calc[label][2] += 1; } return calc; } // object constructor code self.initCanvas(div, top, left, width, height, args); self.initPlot(args); }