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 = ''; //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.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+""); } } 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(""+legTitle+""); y += lineHeight; if (subTitle) { svgLines.push(""+subTitle+""); 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(""); var prec = 1; if (freq<1) prec = 2; //var lineCount = 0; //svgLines.push(""+label+""); svgLines.push(""+label+""); //} svgLines.push(""+freq.toFixed(prec)+"%"); 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>> 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("\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(''); self.svgLines.push(''); // draw axis labels var initZoom = self.port.initZoom; // x axis self.svgLines.push(""+initZoom.minY+""); self.svgLines.push(""+initZoom.maxY+""); // y axis self.svgLines.push(""+initZoom.minX+""); self.svgLines.push(""+initZoom.maxX+""); 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("\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 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