'use strict'; var Registry = require('../../registry'); var Axes = require('../../plots/cartesian/axes'); var Fx = require('../../components/fx'); var createPlot2D = require('../../../stackgl_modules').gl_plot2d; var createSpikes = require('../../../stackgl_modules').gl_spikes2d; var createSelectBox = require('../../../stackgl_modules').gl_select_box; var getContext = require('webgl-context'); var createOptions = require('./convert'); var createCamera = require('./camera'); var showNoWebGlMsg = require('../../lib/show_no_webgl_msg'); var axisConstraints = require('../cartesian/constraints'); var enforceAxisConstraints = axisConstraints.enforce; var cleanAxisConstraints = axisConstraints.clean; var doAutoRange = require('../cartesian/autorange').doAutoRange; var dragHelpers = require('../../components/dragelement/helpers'); var drawMode = dragHelpers.drawMode; var selectMode = dragHelpers.selectMode; var AXES = ['xaxis', 'yaxis']; var STATIC_CANVAS, STATIC_CONTEXT; var SUBPLOT_PATTERN = require('../cartesian/constants').SUBPLOT_PATTERN; function Scene2D(options, fullLayout) { this.container = options.container; this.graphDiv = options.graphDiv; this.pixelRatio = options.plotGlPixelRatio || window.devicePixelRatio; this.id = options.id; this.staticPlot = !!options.staticPlot; this.scrollZoom = this.graphDiv._context._scrollZoom.cartesian; this.fullData = null; this.updateRefs(fullLayout); this.makeFramework(); if(this.stopped) return; // update options this.glplotOptions = createOptions(this); this.glplotOptions.merge(fullLayout); // create the plot this.glplot = createPlot2D(this.glplotOptions); // create camera this.camera = createCamera(this); // trace set this.traces = {}; // create axes spikes this.spikes = createSpikes(this.glplot); this.selectBox = createSelectBox(this.glplot, { innerFill: false, outerFill: true }); // last button state this.lastButtonState = 0; // last pick result this.pickResult = null; // is the mouse over the plot? // it's OK if this says true when it's not, so long as // when we get a mouseout we set it to false before handling this.isMouseOver = true; // flag to stop render loop this.stopped = false; // redraw the plot this.redraw = this.draw.bind(this); this.redraw(); } module.exports = Scene2D; var proto = Scene2D.prototype; proto.makeFramework = function() { // create canvas and gl context if(this.staticPlot) { if(!STATIC_CONTEXT) { STATIC_CANVAS = document.createElement('canvas'); STATIC_CONTEXT = getContext({ canvas: STATIC_CANVAS, preserveDrawingBuffer: false, premultipliedAlpha: true, antialias: true }); if(!STATIC_CONTEXT) { throw new Error('Error creating static canvas/context for image server'); } } this.canvas = STATIC_CANVAS; this.gl = STATIC_CONTEXT; } else { var liveCanvas = this.container.querySelector('.gl-canvas-focus'); var gl = getContext({ canvas: liveCanvas, preserveDrawingBuffer: true, premultipliedAlpha: true }); if(!gl) { showNoWebGlMsg(this); this.stopped = true; return; } this.canvas = liveCanvas; this.gl = gl; } // position the canvas var canvas = this.canvas; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.position = 'absolute'; canvas.style.top = '0px'; canvas.style.left = '0px'; canvas.style['pointer-events'] = 'none'; this.updateSize(canvas); // create SVG container for hover text var svgContainer = this.svgContainer = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg'); svgContainer.style.position = 'absolute'; svgContainer.style.top = svgContainer.style.left = '0px'; svgContainer.style.width = svgContainer.style.height = '100%'; svgContainer.style['z-index'] = 20; svgContainer.style['pointer-events'] = 'none'; // create div to catch the mouse event var mouseContainer = this.mouseContainer = document.createElement('div'); mouseContainer.style.position = 'absolute'; mouseContainer.style['pointer-events'] = 'auto'; this.pickCanvas = this.container.querySelector('.gl-canvas-pick'); // append canvas, hover svg and mouse div to container var container = this.container; container.appendChild(svgContainer); container.appendChild(mouseContainer); var self = this; mouseContainer.addEventListener('mouseout', function() { self.isMouseOver = false; self.unhover(); }); mouseContainer.addEventListener('mouseover', function() { self.isMouseOver = true; }); }; proto.toImage = function(format) { if(!format) format = 'png'; this.stopped = true; if(this.staticPlot) this.container.appendChild(STATIC_CANVAS); // update canvas size this.updateSize(this.canvas); // grab context and yank out pixels var gl = this.glplot.gl; var w = gl.drawingBufferWidth; var h = gl.drawingBufferHeight; // force redraw gl.clearColor(1, 1, 1, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); this.glplot.setDirty(); this.glplot.draw(); gl.bindFramebuffer(gl.FRAMEBUFFER, null); var pixels = new Uint8Array(w * h * 4); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); // flip pixels for(var j = 0, k = h - 1; j < k; ++j, --k) { for(var i = 0; i < w; ++i) { for(var l = 0; l < 4; ++l) { var tmp = pixels[4 * (w * j + i) + l]; pixels[4 * (w * j + i) + l] = pixels[4 * (w * k + i) + l]; pixels[4 * (w * k + i) + l] = tmp; } } } var canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; var context = canvas.getContext('2d', {willReadFrequently: true}); var imageData = context.createImageData(w, h); imageData.data.set(pixels); context.putImageData(imageData, 0, 0); var dataURL; switch(format) { case 'jpeg': dataURL = canvas.toDataURL('image/jpeg'); break; case 'webp': dataURL = canvas.toDataURL('image/webp'); break; default: dataURL = canvas.toDataURL('image/png'); } if(this.staticPlot) this.container.removeChild(STATIC_CANVAS); return dataURL; }; proto.updateSize = function(canvas) { if(!canvas) canvas = this.canvas; var pixelRatio = this.pixelRatio; var fullLayout = this.fullLayout; var width = fullLayout.width; var height = fullLayout.height; var pixelWidth = Math.ceil(pixelRatio * width) |0; var pixelHeight = Math.ceil(pixelRatio * height) |0; // check for resize if(canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } return canvas; }; proto.computeTickMarks = function() { this.xaxis.setScale(); this.yaxis.setScale(); var nextTicks = [ Axes.calcTicks(this.xaxis), Axes.calcTicks(this.yaxis) ]; for(var j = 0; j < 2; ++j) { for(var i = 0; i < nextTicks[j].length; ++i) { // coercing tick value (may not be a string) to a string nextTicks[j][i].text = nextTicks[j][i].text + ''; } } return nextTicks; }; function compareTicks(a, b) { for(var i = 0; i < 2; ++i) { var aticks = a[i]; var bticks = b[i]; if(aticks.length !== bticks.length) return true; for(var j = 0; j < aticks.length; ++j) { if(aticks[j].x !== bticks[j].x) return true; } } return false; } proto.updateRefs = function(newFullLayout) { this.fullLayout = newFullLayout; var spmatch = this.id.match(SUBPLOT_PATTERN); var xaxisName = 'xaxis' + spmatch[1]; var yaxisName = 'yaxis' + spmatch[2]; this.xaxis = this.fullLayout[xaxisName]; this.yaxis = this.fullLayout[yaxisName]; }; proto.relayoutCallback = function() { var graphDiv = this.graphDiv; var xaxis = this.xaxis; var yaxis = this.yaxis; var layout = graphDiv.layout; // make a meaningful value to be passed on to possible 'plotly_relayout' subscriber(s) var update = {}; var xrange = update[xaxis._name + '.range'] = xaxis.range.slice(); var yrange = update[yaxis._name + '.range'] = yaxis.range.slice(); update[xaxis._name + '.autorange'] = xaxis.autorange; update[yaxis._name + '.autorange'] = yaxis.autorange; Registry.call('_storeDirectGUIEdit', graphDiv.layout, graphDiv._fullLayout._preGUI, update); // update the input layout var xaIn = layout[xaxis._name]; xaIn.range = xrange; xaIn.autorange = xaxis.autorange; var yaIn = layout[yaxis._name]; yaIn.range = yrange; yaIn.autorange = yaxis.autorange; // lastInputTime helps determine which one is the latest input (if async) update.lastInputTime = this.camera.lastInputTime; graphDiv.emit('plotly_relayout', update); }; proto.cameraChanged = function() { var camera = this.camera; this.glplot.setDataBox(this.calcDataBox()); var nextTicks = this.computeTickMarks(); var curTicks = this.glplotOptions.ticks; if(compareTicks(nextTicks, curTicks)) { this.glplotOptions.ticks = nextTicks; this.glplotOptions.dataBox = camera.dataBox; this.glplot.update(this.glplotOptions); this.handleAnnotations(); } }; proto.handleAnnotations = function() { var gd = this.graphDiv; var annotations = this.fullLayout.annotations; for(var i = 0; i < annotations.length; i++) { var ann = annotations[i]; if(ann.xref === this.xaxis._id && ann.yref === this.yaxis._id) { Registry.getComponentMethod('annotations', 'drawOne')(gd, i); } } }; proto.destroy = function() { if(!this.glplot) return; var traces = this.traces; if(traces) { Object.keys(traces).map(function(key) { traces[key].dispose(); delete traces[key]; }); } this.glplot.dispose(); this.container.removeChild(this.svgContainer); this.container.removeChild(this.mouseContainer); this.fullData = null; this.glplot = null; this.stopped = true; this.camera.mouseListener.enabled = false; this.mouseContainer.removeEventListener('wheel', this.camera.wheelListener); this.camera = null; }; proto.plot = function(fullData, calcData, fullLayout) { var glplot = this.glplot; this.updateRefs(fullLayout); this.xaxis.clearCalc(); this.yaxis.clearCalc(); this.updateTraces(fullData, calcData); this.updateFx(fullLayout.dragmode); var width = fullLayout.width; var height = fullLayout.height; this.updateSize(this.canvas); var options = this.glplotOptions; options.merge(fullLayout); options.screenBox = [0, 0, width, height]; var mockGraphDiv = {_fullLayout: { _axisConstraintGroups: fullLayout._axisConstraintGroups, xaxis: this.xaxis, yaxis: this.yaxis, _size: fullLayout._size }}; cleanAxisConstraints(mockGraphDiv, this.xaxis); cleanAxisConstraints(mockGraphDiv, this.yaxis); var size = fullLayout._size; var domainX = this.xaxis.domain; var domainY = this.yaxis.domain; options.viewBox = [ size.l + domainX[0] * size.w, size.b + domainY[0] * size.h, (width - size.r) - (1 - domainX[1]) * size.w, (height - size.t) - (1 - domainY[1]) * size.h ]; this.mouseContainer.style.width = size.w * (domainX[1] - domainX[0]) + 'px'; this.mouseContainer.style.height = size.h * (domainY[1] - domainY[0]) + 'px'; this.mouseContainer.height = size.h * (domainY[1] - domainY[0]); this.mouseContainer.style.left = size.l + domainX[0] * size.w + 'px'; this.mouseContainer.style.top = size.t + (1 - domainY[1]) * size.h + 'px'; var ax, i; for(i = 0; i < 2; ++i) { ax = this[AXES[i]]; ax._length = options.viewBox[i + 2] - options.viewBox[i]; doAutoRange(this.graphDiv, ax); ax.setScale(); } enforceAxisConstraints(mockGraphDiv); options.ticks = this.computeTickMarks(); options.dataBox = this.calcDataBox(); options.merge(fullLayout); glplot.update(options); // force redraw so that promise is returned when rendering is completed this.glplot.draw(); }; proto.calcDataBox = function() { var xaxis = this.xaxis; var yaxis = this.yaxis; var xrange = xaxis.range; var yrange = yaxis.range; var xr2l = xaxis.r2l; var yr2l = yaxis.r2l; return [xr2l(xrange[0]), yr2l(yrange[0]), xr2l(xrange[1]), yr2l(yrange[1])]; }; proto.setRanges = function(dataBox) { var xaxis = this.xaxis; var yaxis = this.yaxis; var xl2r = xaxis.l2r; var yl2r = yaxis.l2r; xaxis.range = [xl2r(dataBox[0]), xl2r(dataBox[2])]; yaxis.range = [yl2r(dataBox[1]), yl2r(dataBox[3])]; }; proto.updateTraces = function(fullData, calcData) { var traceIds = Object.keys(this.traces); var i, j, fullTrace; this.fullData = fullData; // remove empty traces traceIdLoop: for(i = 0; i < traceIds.length; i++) { var oldUid = traceIds[i]; var oldTrace = this.traces[oldUid]; for(j = 0; j < fullData.length; j++) { fullTrace = fullData[j]; if(fullTrace.uid === oldUid && fullTrace.type === oldTrace.type) { continue traceIdLoop; } } oldTrace.dispose(); delete this.traces[oldUid]; } // update / create trace objects for(i = 0; i < fullData.length; i++) { fullTrace = fullData[i]; var calcTrace = calcData[i]; var traceObj = this.traces[fullTrace.uid]; if(traceObj) traceObj.update(fullTrace, calcTrace); else { traceObj = fullTrace._module.plot(this, fullTrace, calcTrace); this.traces[fullTrace.uid] = traceObj; } } // order object per traces this.glplot.objects.sort(function(a, b) { return a._trace.index - b._trace.index; }); }; proto.updateFx = function(dragmode) { // switch to svg interactions in lasso/select mode & shape drawing if(selectMode(dragmode) || drawMode(dragmode)) { this.pickCanvas.style['pointer-events'] = 'none'; this.mouseContainer.style['pointer-events'] = 'none'; } else { this.pickCanvas.style['pointer-events'] = 'auto'; this.mouseContainer.style['pointer-events'] = 'auto'; } // set proper cursor if(dragmode === 'pan') { this.mouseContainer.style.cursor = 'move'; } else if(dragmode === 'zoom') { this.mouseContainer.style.cursor = 'crosshair'; } else { this.mouseContainer.style.cursor = null; } }; proto.emitPointAction = function(nextSelection, eventType) { var uid = nextSelection.trace.uid; var ptNumber = nextSelection.pointIndex; var trace; for(var i = 0; i < this.fullData.length; i++) { if(this.fullData[i].uid === uid) { trace = this.fullData[i]; } } var pointData = { x: nextSelection.traceCoord[0], y: nextSelection.traceCoord[1], curveNumber: trace.index, pointNumber: ptNumber, data: trace._input, fullData: this.fullData, xaxis: this.xaxis, yaxis: this.yaxis }; Fx.appendArrayPointValue(pointData, trace, ptNumber); this.graphDiv.emit(eventType, {points: [pointData]}); }; proto.draw = function() { if(this.stopped) return; requestAnimationFrame(this.redraw); var glplot = this.glplot; var camera = this.camera; var mouseListener = camera.mouseListener; var mouseUp = this.lastButtonState === 1 && mouseListener.buttons === 0; var fullLayout = this.fullLayout; this.lastButtonState = mouseListener.buttons; this.cameraChanged(); var x = mouseListener.x * glplot.pixelRatio; var y = this.canvas.height - glplot.pixelRatio * mouseListener.y; var result; if(camera.boxEnabled && fullLayout.dragmode === 'zoom') { this.selectBox.enabled = true; var selectBox = this.selectBox.selectBox = [ Math.min(camera.boxStart[0], camera.boxEnd[0]), Math.min(camera.boxStart[1], camera.boxEnd[1]), Math.max(camera.boxStart[0], camera.boxEnd[0]), Math.max(camera.boxStart[1], camera.boxEnd[1]) ]; // 1D zoom for(var i = 0; i < 2; i++) { if(camera.boxStart[i] === camera.boxEnd[i]) { selectBox[i] = glplot.dataBox[i]; selectBox[i + 2] = glplot.dataBox[i + 2]; } } glplot.setDirty(); } else if(!camera.panning && this.isMouseOver) { this.selectBox.enabled = false; var size = fullLayout._size; var domainX = this.xaxis.domain; var domainY = this.yaxis.domain; result = glplot.pick( (x / glplot.pixelRatio) + size.l + domainX[0] * size.w, (y / glplot.pixelRatio) - (size.t + (1 - domainY[1]) * size.h) ); var nextSelection = result && result.object._trace.handlePick(result); if(nextSelection && mouseUp) { this.emitPointAction(nextSelection, 'plotly_click'); } if(result && result.object._trace.hoverinfo !== 'skip' && fullLayout.hovermode) { if(nextSelection && ( !this.lastPickResult || this.lastPickResult.traceUid !== nextSelection.trace.uid || this.lastPickResult.dataCoord[0] !== nextSelection.dataCoord[0] || this.lastPickResult.dataCoord[1] !== nextSelection.dataCoord[1]) ) { var selection = nextSelection; this.lastPickResult = { traceUid: nextSelection.trace ? nextSelection.trace.uid : null, dataCoord: nextSelection.dataCoord.slice() }; this.spikes.update({ center: result.dataCoord }); selection.screenCoord = [ ((glplot.viewBox[2] - glplot.viewBox[0]) * (result.dataCoord[0] - glplot.dataBox[0]) / (glplot.dataBox[2] - glplot.dataBox[0]) + glplot.viewBox[0]) / glplot.pixelRatio, (this.canvas.height - (glplot.viewBox[3] - glplot.viewBox[1]) * (result.dataCoord[1] - glplot.dataBox[1]) / (glplot.dataBox[3] - glplot.dataBox[1]) - glplot.viewBox[1]) / glplot.pixelRatio ]; // this needs to happen before the next block that deletes traceCoord data // also it's important to copy, otherwise data is lost by the time event data is read this.emitPointAction(nextSelection, 'plotly_hover'); var trace = this.fullData[selection.trace.index] || {}; var ptNumber = selection.pointIndex; var hoverinfo = Fx.castHoverinfo(trace, fullLayout, ptNumber); if(hoverinfo && hoverinfo !== 'all') { var parts = hoverinfo.split('+'); if(parts.indexOf('x') === -1) selection.traceCoord[0] = undefined; if(parts.indexOf('y') === -1) selection.traceCoord[1] = undefined; if(parts.indexOf('z') === -1) selection.traceCoord[2] = undefined; if(parts.indexOf('text') === -1) selection.textLabel = undefined; if(parts.indexOf('name') === -1) selection.name = undefined; } Fx.loneHover({ x: selection.screenCoord[0], y: selection.screenCoord[1], xLabel: this.hoverFormatter('xaxis', selection.traceCoord[0]), yLabel: this.hoverFormatter('yaxis', selection.traceCoord[1]), zLabel: selection.traceCoord[2], text: selection.textLabel, name: selection.name, color: Fx.castHoverOption(trace, ptNumber, 'bgcolor') || selection.color, borderColor: Fx.castHoverOption(trace, ptNumber, 'bordercolor'), fontFamily: Fx.castHoverOption(trace, ptNumber, 'font.family'), fontSize: Fx.castHoverOption(trace, ptNumber, 'font.size'), fontColor: Fx.castHoverOption(trace, ptNumber, 'font.color'), nameLength: Fx.castHoverOption(trace, ptNumber, 'namelength'), textAlign: Fx.castHoverOption(trace, ptNumber, 'align') }, { container: this.svgContainer, gd: this.graphDiv }); } } } // Remove hover effects if we're not over a point OR // if we're zooming or panning (in which case result is not set) if(!result) { this.unhover(); } glplot.draw(); }; proto.unhover = function() { if(this.lastPickResult) { this.spikes.update({}); this.lastPickResult = null; this.graphDiv.emit('plotly_unhover'); Fx.loneUnhover(this.svgContainer); } }; proto.hoverFormatter = function(axisName, val) { if(val === undefined) return undefined; var axis = this[axisName]; return Axes.tickText(axis, axis.c2l(val), 'hover').text; };