Part of QUB Online
qubtrace.drawRastered() can draw millions of sampled data points quickly and efficiently, using the browser's 2D Canvas API. drawRastered() draws the output of raster_samples(), which is a summary of the points under each horizontal pixel: minimum, maximum, and mean +/- one std. For each pixel i in [0, w-1], it draws a gray line from minimum to maximum value, and a black line from mean-std to mean+std.
qubtrace.drawScalebar() makes horizontal and vertical scale bars, joined at the upper-right. Horizontal units are assumed to be seconds.
Class Plot presents one or more segments of data with a main (low-res) panel showing the full segment(s), and an optional high-res panel showing only data highlighted in the main panel. Data (in Float32Array format) are stored in the plot via clear(), segmentSize(), doneSegmentSize(), and set(). It can also display additional (stimulus) signals and a fit curve. The fit curve can optionally be given with a std (confidence interval) for each point; the std controls its line width. Scale bars and other options are configurable via Plot constructor options.
/* qubtrace.js -- plot large sampled datasets // requirejs-style module // requires Hammer.js for touch interactions define("qubtrace", ["hammer"], Hammer => { "use strict"; const exports = {}; function batchDelayed(action, msDelay) { let handle = null; const timeout = () => { handle = null; actSoon.active = false; action(); }; const actSoon = () => { if ( handle ) { clearTimeout(handle); } handle = setTimeout(timeout, msDelay); actSoon.active = true; } return actSoon; } function eventOffset(e, pageX, pageY) { var x, y; if ( typeof(pageX) === 'undefined' ) { x = e.clientX; } else { x = pageX - (document.body.scrollLeft + document.documentElement.scrollLeft); } if ( typeof(pageY) === 'undefined' ) { y = e.clientY; } else { y = pageY - (document.body.scrollTop + document.documentElement.scrollTop); } const target = e.target || e.srcElement, rect = target.getBoundingClientRect(); return { x: x - rect.left, y: y - rect.top }; }; function extrema(buf, start, count) { start = start || 0; count = count || buf.length - start; if ( ! count ) { return {lo: 0, hi: 0}; } let low = buf[start], high = low, i = 1; for (; i<count; ++i, ++start) { const y = buf[start]; if ( y < low ) { low = y; } if ( y > high ) { high = y; } } return {lo: low, hi: high}; } exports.pinchInterval = function pinchInterval(from, to, center, scale) { const pInFrame = (center - from) / (to - from), w = Math.min(1, (to - from)/scale); return { from: Math.max(0, center - pInFrame*w), to: Math.min(1, center + (1 - pInFrame)*w) }; } function stats(buf, start, count) { let mean = 0.0, rss = 0.0, n = 0, i = 1; var last_mean; if ( n ) { mean = buf[start]; n = 1; } for (; i<count; ++i) { const x = buf[start+i]; last_mean = mean; ++n; mean = (1.0/n)*((n-1)*last_mean + x); const dm = mean - last_mean, d = x - last_mean; rss += d*d - n*dm*dm; } return { mean: mean, rss: rss, n: n }; } function raster_stats(buf, start, n, y_res) { if ( ! n ) { return {lo: 0, hi: 0, gs_lo: 0, gs_hi: 0}; }; const ex = extrema(buf, start, n), s = stats(buf, start, n), std = Math.max(y_res/2, Math.sqrt(s.rss / n)), midpoint = (ex.hi + ex.lo) / 2, diff = Math.max(y_res, ex.hi - ex.lo) / 2, lo = midpoint - diff, hi = midpoint + diff; let gs_lo = s.mean - std, gs_hi = s.mean + std; if ( gs_lo < lo ) { gs_lo = lo; } if ( gs_hi > hi ) { gs_hi = hi; } return {lo, hi, gs_lo, gs_hi}; } exports.raster_samples = function raster_samples(buf, start, count, w, spp, y_res) { const lows = [], highs = [], gauss_lows = [], gauss_highs = [], arr = new Float32Array(buf); let i = 0; for (; i<w; ++i) { let iSam = Math.round(i*spp); if ( iSam >= count ) { break; } iSam += start; if ( iSam >= arr.length ) { break; } const jSam = Math.min(arr.length, start + Math.round((i+1)*spp)), rs = raster_stats(arr, iSam, (jSam-iSam), y_res); lows.push(rs.lo); highs.push(rs.hi); gauss_lows.push(rs.gs_lo); gauss_highs.push(rs.gs_hi); } return {lows, highs, gauss_lows, gauss_highs}; } function extrema_std(buf, start, count) { if ( ! count ) { return {lo: 0, hi: 0}; } var lo, hi; let noLow = true, i = 0; for (; i<count; ++i) { const std = buf[2*(start+i)+1]; if ( std >= 0 ) { const y = buf[2*start]; if ( noLow ) { noLow = false; lo = y - std; hi = y + std; } else { lo = Math.min(lo, y-std); hi = Math.max(hi, y+std); } } } return {lo, hi}; } function raster_stats_std(buf, start, n, y_res) { if ( ! n ) { return {lo: 0, hi: 0}; }; const ex = extrema_std(buf, start, n); if ( ex.lo === null ) { return ex; } const midpoint = (ex.hi + ex.lo) / 2; let diff = (ex.hi - ex.lo) / 2.0; if ( diff < y_res ) { diff = y_res; } const lo = midpoint - diff, hi = midpoint + diff; return {lo, hi}; } exports.raster_samples_std = function raster_samples_std(buf, start, count, w, spp, y_res) { const lows = [], highs = [], arr = new Float32Array(buf); let i = 0; for (; i<w; ++i) { let iSam = Math.round(i*spp); if ( iSam >= count ) { break; } iSam += start; if ( (2*iSam) >= arr.length ) { break; } const jSam = Math.min(arr.length/2, start + Math.round((i+1)*spp)), rs = raster_stats_std(arr, iSam, (jSam-iSam), y_res); lows.push(rs.lo); highs.push(rs.hi); } return {lows, highs}; } exports.drawRastered = function drawRastered(ctx, px0, pxw, pxh, raster, dataStyle, normStyle, spp, yLo, ypp) { ctx.save(); ctx.translate(0.5, pxh); ctx.scale(1.0, -1/ypp); // using (pixel x, real y) coords ctx.translate(0.0, -yLo); ctx.strokeStyle = dataStyle; ctx.beginPath(); let i = 0; for (i=0; i<pxw; ++i) { const lo = raster.lows[i]; if ( lo !== null ) { ctx.moveTo(px0+i, lo); ctx.lineTo(px0+i, raster.highs[i]); } } ctx.closePath(); ctx.stroke(); if ( normStyle ) { ctx.strokeStyle = normStyle; ctx.beginPath(); for (i=0; i<pxw; ++i) { ctx.moveTo(px0+i, raster.gauss_lows[i]); ctx.lineTo(px0+i, raster.gauss_highs[i]); } ctx.closePath(); ctx.stroke(); } ctx.restore(); }; exports.drawLines = function drawLines(ctx, px0, pxw, pxh, points, offset, n, lineStyle, spp, yLo, ypp, stride) { stride = stride || 1; const ppy = 1.0 / ypp, y2p = y => pxh - (y - yLo)*ppy; ctx.save(); ctx.strokeStyle = lineStyle; ctx.beginPath(); ctx.translate(px0+0.5, 0); ctx.moveTo(0, y2p(points[stride*offset])); const dx = 1/spp; let i = 0, x = 0; for (; i<n; ++i, x+=dx) { ctx.lineTo(x, y2p(points[stride*(offset+i)])); } ctx.stroke(); ctx.restore(); }; exports.drawDots = function drawDots(ctx, px0, pxw, pxh, X, Y, offset, n, dotStyle, rad, spp, yLo, ypp, selectedRad, selectedX) { const ppy = 1.0 / ypp, y2p = y => pxh - (y - yLo)*ppy, pps = 1.0 / spp, x2p = x => pps*(x-offset), ndot = X.length; ctx.save(); ctx.fillStyle = dotStyle; selectedRad = selectedRad || 1.4*rad; let i = 0; for (; i<ndot; ++i) { const x = X[i]; if ( (x >= offset) && (x < (offset+n)) ) { if ( x === selectedX ) { ctx.fillRect(x2p(x)-selectedRad, y2p(Y[i])-selectedRad, 2*selectedRad, 2*selectedRad); } else { ctx.fillRect(x2p(x)-rad, y2p(Y[i])-rad, 2*rad, 2*rad); } } } ctx.restore(); }; exports.drawLinesStd = function drawLinesStd(ctx, px0, pxw, pxh, points, offset, n, lineStyle, spp, yLo, ypp) { const segments = []; let start = null, haveStd = false, i = 0; for (; i<n; ++i) { const j = 2*(offset+i), s = points[j+1]; if ( start !== null ) { // already in interval if ( s < 0 ) { segments.push({start: start, end: i}); start = null; } } else if ( s >= 0 ) { start = i; } if ( s > 0 ) { haveStd = true; } } if ( start !== null ) { segments.push({start: start, end: n}); } if ( segments.length && (segments[0].start === 0) && (segments[0].end === n) && ! haveStd ) { return exports.drawLines(ctx, px0, pxw, pxh, points, offset, n, lineStyle, spp, yLo, ypp, 2); } const ppy = 1.0 / ypp, y2p = y => pxh - (y - yLo)*ppy; ctx.save(); ctx.fillStyle = lineStyle; ctx.beginPath(); ctx.translate(px0+0.5, 0); const dx = 1/spp; segments.forEach(seg => { let x = dx*seg.start; const start = seg.start + offset, end = seg.end + offset; let s = Math.max(2*ypp, points[2*start+1]); ctx.moveTo(x, y2p(points[2*start]-s)); let i = start; for (; i<end; ++i, x+=dx) { s = Math.max(2*ypp, points[2*i+1]); ctx.lineTo(x, y2p(points[2*i]-s)); } x -= dx; for (i=end-1; i>=start; --i, x-=dx) { s = Math.max(2*ypp, points[2*i+1]); ctx.lineTo(x, y2p(points[2*i]+s)); } ctx.fill(); }); ctx.restore(); }; exports.drawScalebar = function drawScalebar(canvas, emsize, plotW, plotH, xpp, signals, scalebarHeight, scalebarFont, scalebarBack, scalebarStyle) { const h_ = Math.round(scalebarHeight * plotH)|0, ctx = canvas.getContext("2d"), fontHeight = (.3*h_)|0; ctx.font = ''+fontHeight+'px '+scalebarFont; const metrics = ctx.measureText("-0.00e+21"), dx = exports.nearestRound(Math.min(plotW/2, Math.max(1.5*emsize, metrics.width*2/3)), xpp), px = dx/xpp, dy = signals.map(sig => exports.nearestRound(Math.min(plotH, Math.max(1.5*emsize, h_*2/3)), sig.ypp)), py = dy.map((d,i) => d/signals[i].ypp); let pyMax = 0; py.forEach(p => { pyMax = Math.max(p, pyMax); }); const h = (pyMax + 1.2*fontHeight)|0, lw = Math.max(1, Math.round((py.length ? h : h_)/20)), w = (3*lw + px + fontHeight)|0, wFull = Math.max(w, signals.length*(fontHeight+2*lw))|0; if ( (h !== canvas.height) || (wFull != canvas.width) ) { canvas.height = h; canvas.width = wFull; ctx.font = ''+fontHeight+'px '+scalebarFont; } ctx.save(); ctx.clearRect(0, 0, wFull, h) ctx.fillStyle = scalebarBack; ctx.fillRect(0, 0, wFull, h); const offX = 0.5 + Math.round(0.5*(w-(px+2*lw+fontHeight))) + Math.max(0, wFull-w), offY = 0.5 + (lw+fontHeight) + Math.round(0.5*(h - (pyMax+fontHeight+3*lw))); ctx.translate(offX, offY); ctx.lineWidth = lw; ctx.strokeStyle = scalebarStyle; ctx.fillStyle = scalebarStyle; ctx.beginPath(); ctx.moveTo(lw, lw); ctx.lineTo(lw+px, lw); ctx.stroke(); const lw2 = lw/2; py.forEach((p, i) => { const x = lw+px-i*(fontHeight+2*lw); ctx.strokeStyle = signals[i].color; ctx.beginPath(); ctx.moveTo(x-lw2, lw2); ctx.lineTo(x-lw2, lw2+p); ctx.stroke(); }); const lblX = exports.formatTime(dx), lblW = ctx.measureText(lblX).width; ctx.textBaseline = 'alphabetic'; ctx.fillText(lblX, lw, -lw2); ctx.textBaseline = 'top'; dy.forEach((d, i) => { ctx.save(); ctx.translate(px+lw-i*(fontHeight+2*lw), py[i]); ctx.rotate(-Math.PI/2); ctx.fillText(exports.formatRound(d)+signals[i].units, 0, 0); ctx.restore(); }); ctx.restore(); }; exports.nearestRound = function nearestRound(pixels, npp) { const nExact = pixels * npp, logExact = Math.log10(nExact), decade = Math.floor(logExact), logRem = logExact - decade, base = Math.pow(10, decade); if ( logRem < .1 ) { return base; } else if ( logRem < .3 ) { return 2*base; } else if ( logRem < .5 ) { return 3*base; } else if ( logRem < .8 ) { return 5*base; } else { return 10*base; } }; exports.formatRound = function formatRound(x) { if ( Math.abs(x) >= 1e-3 ) { let s = x.toFixed(2); while ( s.length > 1 ) { const c = s[s.length-1]; if ( c === '0' || c === '.' ) { s = s.substring(0, s.length-1); if ( c === '.' ) { break; } } else { break; } } return s; } else { return x.toExponential(2); } }; exports.formatTime = function formatTime(s) { if ( s >= 50 ) { // hh:mm:ss? return ''+(Math.round(s)|0)+' s'; } if ( s >= 5e-1 ) { return exports.formatRound(s)+' s'; } if ( s >= 5e-4 ) { return exports.formatRound(1e3*s)+' ms'; } if ( s >= 5e-7 ) { return exports.formatRound(1e6*s)+' \u00b5s'; } //if ( s >= 5e-10 ) { return exports.formatRound(1e9*s)+' ns'; //} }; exports.layoutSignals = function layoutSignals(w, h, emsize, Nstim) { // top: stimulus signals // default height h/3, min 3em, max h // each signal default height sh/Nstim, min 2em, max h // top at iStim*sh1 // bottom: current // default height h*2/3, min 5em, max h // -> {current: {top, height in px}, stimulus: [{top, height}, ...] const stimsH = Nstim ? Math.min(h, Math.max(1.5*emsize, h/3)) : 0, stimH = Nstim ? Math.min(h, Math.max(1.5*emsize, stimsH/Nstim)) : 0, stimOff = Nstim ? (stimH / Nstim) : 0, curH = Nstim ? Math.min(h, Math.max(2.5*emsize, h*2/3)) : h; function inset(top, height) { const margin = .05 * height; return { top: (top + margin)|0, height: (height - 2*margin)|0 }; }; const stimulus = []; let i = 0; for (; i<Nstim; ++i) { stimulus.push(inset(i*stimOff, stimH)); } return { current: inset(h - curH, curH), stimulus: stimulus }; }; function relMouseCoords(currentElement, event) { let totalOffsetX = 0, totalOffsetY = 0; do{ totalOffsetX += currentElement.offsetLeft - currentElement.scrollLeft; totalOffsetY += currentElement.offsetTop - currentElement.scrollTop; } while (currentElement = currentElement.offsetParent) return { x: event.pageX - totalOffsetX, y: event.pageY - totalOffsetY }; } function expandY(obj, h, factor) { factor = factor || 1.15; const c = (obj.hi + obj.lo)/2; obj.dy = obj.hi - obj.lo; const dy2 = factor*obj.dy/2; obj.loEx = c-dy2; obj.hiEx = c+dy2; obj.dyEx = 2*dy2; obj.ypp = obj.dyEx / h; } const PlotConfig = { backStyle: "rgb(255,255,255)", dataStyle: "rgb(100,100,100)", dataChosenStyle: "rgb(200,0,30)", normStyle: "rgb(0,0,0)", normChosenStyle: "rgb(255,0,40)", stimulusStyle: ["rgb(0,128,0)", "rgb(128,40,0)"], stimulusChosenStyle: ["rgb(0,255,0)", "rgb(255,120,0)"], lineStyle: "rgb(0,0,0)", lineChosenStyle: "rgb(255,0,40)", selStyle: "rgba(100,150,250, 0.2)", fitStyle: "rgba(255, 80, 120, 0.5)", resampledStyle: "rgba(155, 0, 111, 0.4)", resampledRad: 2, stimulusHeight: 0.25, sampling: 1, minSppRaster: 5.0, lineHeight: 1/50, // as fraction of trace height scalebarHeight: .5, scalebarFont: "sans-serif", scalebarLores: false, scalebarLoresPx: .99, scalebarLoresPy: .1, scalebarHires: true, scalebarHiresPx: .99, scalebarHiresPy: .1, scalebarBack: "rgb(254,254,254)", scalebarStyle: "rgb(0,0,0)", raster_samples: exports.raster_samples, // can replace with asm.js compiled version raster_samples_std: exports.raster_samples_std // also expected: node: from the DOM // optional: hires: DOM node for detail view // onSelect: function(offset, count) }; exports.Plot = class Plot { constructor(config) { this.node = config.node; this.hires = config.hires; this.config = Object.assign({}, PlotConfig, config); this.w = this.node.clientWidth; this.h = this.node.clientHeight; this.plotLayer = document.createElement('div'); this.plotLayer.className = 'plotLayer'; this.canvas = document.createElement('canvas'); this.canvas.width = this.w; this.canvas.height = this.h; this.emsize = this.canvas.getContext('2d').measureText('M').width; if ( window.devicePixelRatio ) { this.emsize *= window.devicePixelRatio; } this.plotLayer.appendChild(this.canvas); this.node.appendChild(this.plotLayer); this.selLayer = document.createElement('div'); this.selLayer.className = 'plotLayer'; this.selCanvas = document.createElement('canvas'); this.selCanvas.width = this.w; this.selCanvas.height = this.h; this.selLayer.appendChild(this.selCanvas); this.node.appendChild(this.selLayer); if ( this.config.scalebarLores ) { this.scaleCanvas = document.createElement('canvas'); this.scaleCanvas.className = 'scaleCanvas qubx_noselect'; this.node.appendChild(this.scaleCanvas); } if ( this.hires ) { this.hiresLayer = document.createElement('div'); this.hiresLayer.className = 'plotLayer'; this.hiresCanvas = document.createElement('canvas'); this.hiresCanvas.width = this.hires.clientWidth; this.hiresCanvas.height = this.hires.clientHeight; this.hiresLayer.appendChild(this.hiresCanvas); this.hires.appendChild(this.hiresLayer); this.fitLayer = document.createElement('div'); this.fitLayer.className = 'plotLayer'; this.fitCanvas = document.createElement('canvas'); this.fitCanvas.width = this.hires.clientWidth; this.fitCanvas.height = this.hires.clientHeight; this.fitLayer.appendChild(this.fitCanvas); this.hires.appendChild(this.fitLayer); if ( this.config.scalebarHires ) { this.hiresScaleCanvas = document.createElement('canvas'); this.hiresScaleCanvas.className = 'scaleCanvas qubx_noselect'; this.hires.appendChild(this.hiresScaleCanvas); } this.hiresCanvasTouch = new Hammer(this.fitCanvas); this.hiresCanvasTouch.get('pinch').set({ enable: true }); this.hiresCanvasTouch.on("pan", this._onPanHires.bind(this)); this.hiresCanvasTouch.on("panend", this._onPanendHires.bind(this)); this.hiresCanvasTouch.on("pinch", this._onPinchHires.bind(this)); this.hiresCanvasTouch.on("pinchend", this._onPinchendHires.bind(this)); } [this.scaleCanvas, this.hiresScaleCanvas].forEach((scale, hires) => { const propPx = hires ? 'scalebarHiresPx' : 'scalebarLoresPx', propPy = hires ? 'scalebarHiresPy' : 'scalebarLoresPy'; if ( scale ) { const parent = scale.parentNode; let down = null; const onMouseUp = evt => { document.body.removeEventListener('mouseup', onMouseUp); parent.removeEventListener('mousemove', onMouseMove); }; const onMouseMove = evt => { const rel = relMouseCoords(parent, evt), x = Math.max(0, Math.min(parent.clientWidth-5, parseInt(scale.style.left) + rel.x - down.x)), y = Math.max(0, Math.min(parent.clientHeight-5, parseInt(scale.style.top) + rel.y - down.y)); scale.style.left = ''+x+'px'; scale.style.top = ''+y+'px'; this.config[propPx] = (x < (parent.clientWidth/2)) ? (x/parent.clientWidth) : (x/(parent.clientWidth - scale.clientWidth)); this.config[propPy] = (y < (parent.clientHeight/2)) ? (y/parent.clientHeight) : (y/(parent.clientHeight - scale.clientHeight)); down = rel; }; scale.addEventListener('mousedown', evt => { evt.stopPropagation(); evt.preventDefault(); down = relMouseCoords(parent, evt); document.body.addEventListener('mouseup', onMouseUp); parent.addEventListener('mousemove', onMouseMove); }); } }); this.selFrom = this.selTo = 0; // as fractions 0..1 of total this.segments = []; this._chosen = -1; this.maxNpoint = 0; this.lo = this.hi = this.dyEx = this.ypp = null; this.loStim = this.hiStim = this.dyStim = this.yppStim = null; this._sampling = this.config.sampling; this.drawSoon = batchDelayed(this.draw.bind(this), 50); this.drawHiresSoon = batchDelayed(this.drawHires.bind(this), 50); this.drawFitSoon = batchDelayed(this.drawFit.bind(this), 50); this.reSelectSoon = batchDelayed(this.reSelect.bind(this), 50); this.reshape(); this.mouseX0 = null; this.canvasTouch = new Hammer(this.selCanvas); this.canvasTouch.on("pan", this._onPanSel.bind(this)); this.zoom(0,1); } segmentCount(n) { const nPrev = this.segments.length; if ( typeof(n) === 'undefined' ) { return nPrev; } if ( n < nPrev ) { this.segments.splice(n, nPrev-n); this.chosen(Math.min(this._chosen, n-1)); return this; } while ( n > this.segments.length ) { this.segments.push({ n: 0, seen: 0, points: [], stimuli: [], stimIdl: [], extrema: null, indVar: null, resampled: null }); } return this; } chosen(i) { if ( typeof(i) === 'undefined' ) { return this._chosen; } if ( (this._chosen !== i) && (i >= -1) && (i < this.segments.length) ) { this._chosen = i; this.drawSoon(); this.hires && this.drawHiresSoon(); } return this; } segmentSize(i, n, stimCount) { const seg = this.segments[i], nPrev = seg.n; if ( typeof(n) === 'undefined' ) { return seg.n; } let nStim = seg.stimuli.length || 1; if ( typeof(stimCount) !== 'undefined' ) { nStim = stimCount; } if ( (n !== nPrev) || (seg.stimuli.length !== nStim) ) { seg.points = new Float32Array(n); seg.fit = new Float32Array(2*n); let j = 0; for (; j<n; ++j) { seg.fit[2*j+1] = -1; // negative std::no draw } let s = 0; for (; s<nStim; ++s) { seg.stimuli[s] = new Float32Array(n); seg.stimIdl[s] = {classes: [], durations: [], amps: []}; } seg.n = n; } return this; } doneSegmentSize() { this.clear(); this.reshape(); } sampling(x) { if ( typeof(x) === 'undefined' ) { return this._sampling; } if ( x !== this._sampling ) { this._sampling = x; this.reshape(); } return this; } indVar(iSeg, indVar) { if ( typeof(indVar) === 'undefined' ) { return this.segments[iSeg].indVar; } this.segments[iSeg].indVar = indVar; } clear(iSeg) { const oneSeg = (typeof(iSeg)!=='undefined'); if ( ! oneSeg ) { this.lo = this.loStim = 1e20 this.hi = this.hiStim = -1e20; } (oneSeg ? [this.segments[iSeg]] : this.segments).forEach(seg => { seg.seen = 0; seg.extrema = null; seg.resampled = null; }); this.erase(); } setBounds(lo, hi) { this.lo = lo; this.hi = hi; expandY(this, this.h); this.yppHi = this.dyEx / this.hHi; this.segments.forEach(seg => seg.extrema && expandY(seg.extrema)); this.drawSoon(); this.hires && this.drawHiresSoon(); } setStimBounds(lo, hi) { //// TODO: separate scaling for each stim signal? this.loStim = lo; this.hiStim = hi; this.dyStim = hi - lo; this.yppStim = this.dyStim / (this.config.stimulusHeight * this.h); this.drawSoon(); this.hires && this.drawHiresSoon(); } zoom(from, to, immediate) { if ( typeof(to) === 'undefined' ) { const z = { from: this.selFrom, to: this.selTo, offset: Math.round(this.selFrom*this.maxNpoint) }; z.count = Math.round(z.to*this.maxNpoint) - z.offset + 1; return z; } from = Math.max(0, from); to = Math.min(1, to); if ( (from !== this.selFrom) || (to !== this.selTo) ) { this.selFrom = from; this.selTo = to; this.sppHi = this.zoom().count / this.w; this.xppHi = this._sampling * this.sppHi; this.drawSel(); if ( this.hires ) { if ( immediate ) { this.drawHires(); this.reSelect(); } else { this.drawHiresSoon(); this.reSelectSoon(); } } else { this.reSelect(); } } return this; } reSelect(onSelect) { onSelect = onSelect || this.config.onSelect; if ( onSelect ) { const iFrom = Math.round(this.selFrom*this.maxNpoint), iTo = this.selTo ? Math.round(this.selTo*(this.maxNpoint-1)) : (this.maxNpoint-1), segments = this.segments.map(seg => { const segOut = { selFrom: Math.min(seg.points.length-1, iFrom), selTo: Math.min(seg.points.length-1, iTo), indVar: seg.indVar, sources: [seg.points], signals: [] }; seg.stimuli.forEach((arr, i) => { segOut.sources[i+1] = arr; }); segOut.sources.forEach((src, i) => { segOut.signals[i] = src.slice && src.slice(iFrom, Math.min(iTo, src.length)); }); return segOut; }); onSelect(iFrom, iTo-iFrom+1, segments); } } set(iSeg, offset, x, count, xOffset, segExtrema) { const seg = this.segments[iSeg]; var ex; xOffset = xOffset|0; if ( x.length ) { count = count || x.length; if ( x.copyWithin ) { seg.points.set(x.subarray(xOffset, xOffset+count), offset); } else { let i = 0; for (; i<count; ++i) { seg.points[offset+i] = x[xOffset+i]; } } ex = extrema(seg.points, offset, count); ex.lo = Math.min(this.lo, ex.lo); ex.hi = Math.max(this.hi, ex.hi); } else { count = 1; seg.points[offset] = x; ex = {lo: x, hi: x}; } seg.seen = Math.max(seg.seen, offset+count-1); if ( (ex.lo !== this.lo) || (ex.hi !== this.hi) ) { this.setBounds(ex.lo, ex.hi); } else if ( ! this.drawSoon.active ) { this.draw(offset, count); } if ( segExtrema ) { if ( ! seg.extrema ) { seg.extrema = segExtrema; } else { seg.extrema.lo = Math.min(segExtrema.lo, seg.extrema.hi); seg.extrema.hi = Math.max(segExtrema.lo, seg.extrema.hi); } expandY(segExtrema, this.h); } this._drawHiresOverlap(iSeg, offset, count); this.reSelectSoon(); } setStimulus(iSeg, iStim, offset, x, count, xOffset, idl) { const seg = this.segments[iSeg], points = seg.stimuli[iStim]; var ex; xOffset = xOffset|0; if ( x.length ) { count = count || x.length; if ( x.copyWithin ) { points.set(x.subarray(xOffset, xOffset+count), offset); } else { let i = 0; for (; i<count; ++i) { points[offset+i] = x[xOffset+i]; } } ex = extrema(points, offset, count); ex.lo = Math.min(this.loStim, ex.lo); ex.hi = Math.max(this.hiStim, ex.hi); } else { count = 1; seg.points[offset] = x; ex = {lo: x, hi: x}; } if ( (ex.lo !== this.loStim) || (ex.hi !== this.hiStim) ) { this.setStimBounds(ex.lo, ex.hi); } else if ( ! this.drawSoon.active ) { this.draw(offset, count); } seg.stimIdl[iStim] = idl || {classes: [], durations: [], amps: []}; this._drawHiresOverlap(iSeg, offset, count); this.reSelectSoon(); } setFit(iSeg, offset, x, count, xOffset) { // x: just mean const seg = this.segments[iSeg], points = seg.fit; xOffset = xOffset|0; count = count || x.length; for (var i=0; i<count; ++i) { points[2*i] = x[xOffset+i]; points[2*i+1] = 0.0; } this.drawFitSoon(); } setFitStd(iSeg, offset, x, count, xOffset, stride, isVar) { // xOffset, count of pairs, alternating mean, std; pairs separated by stride indices stride = stride || 2; const seg = this.segments[iSeg], points = seg.fit; xOffset = xOffset|0; count = count || x.length/stride; if ( x.copyWithin && (stride === 2) && ! isVar ) { points.set(x.subarray(stride*xOffset, stride*(xOffset+count)), stride*xOffset); } else { let i = 0; for (; i<count; ++i) { const j = 2*(xOffset+i), k = stride*(xOffset+i); points[j] = x[k]; points[j+1] = isVar ? Math.sqrt(x[k+1]) : x[k+1]; } } this.drawFitSoon(); } _drawHiresOverlap(iSeg, offset, count) { if ( this.hires && (this._chosen < 0) || (iSeg === this._chosen) ) { const zoom = this.zoom(); if ( Math.max(offset, zoom.offset) <= Math.min(offset+count-1, zoom.offset+zoom.count-1) ) { this.drawHiresSoon(); } } } reshape() { this.w = this.plotLayer.clientWidth || 100; this.h = this.plotLayer.clientHeight || 100; if ( window.devicePixelRatio ) { this.w *= window.devicePixelRatio; this.h *= window.devicePixelRatio; } this.canvas.width = this.w; this.canvas.height = this.h; this.selCanvas.width = this.w; this.selCanvas.height = this.h; let maxNpoint = 1; this.segments.map(seg => { if ( seg.n > maxNpoint ) { maxNpoint = seg.n; } }); this.maxNpoint = maxNpoint; this.spp = maxNpoint / this.w; this.xpp = this._sampling * this.spp; this.ypp = this.dyEx / this.h; if ( this.hires ) { this.wHi = this.hiresLayer.clientWidth; this.hHi = this.hiresLayer.clientHeight; if ( window.devicePixelRatio ) { this.wHi *= window.devicePixelRatio; this.hHi *= window.devicePixelRatio; } this.hiresCanvas.width = this.wHi; this.hiresCanvas.height = this.hHi; this.fitCanvas.width = this.wHi; this.fitCanvas.height = this.hHi; this.sppHi = this.zoom().count / this.wHi; this.xppHi = this._sampling * this.sppHi; this.yppHi = this.dyEx / this.hHi; } this.drawSoon(); this.drawSel(); this.hires && this.drawHiresSoon(); } drawSel() { const ctx = this.selCanvas.getContext('2d'), zoom = this.zoom(); ctx.clearRect(0, 0, this.w, this.h); ctx.fillStyle = this.config.selStyle; ctx.fillRect(this.selFrom*this.w, 0, (this.selTo - this.selFrom)*this.w, this.h); } erase() { const toErase = [this.canvas]; if ( this.hires ) { toErase.push(this.hiresCanvas); toErase.push(this.fitCanvas); } toErase.forEach(canvas => { const ctx = canvas.getContext('2d'); ctx.fillStyle = this.config.backStyle; ctx.fillRect(0, 0, canvas.width, canvas.height); }); } draw(offset, count) { if ( typeof(offset) === 'undefined' ) { offset = 0; count = this.maxNpoint; this.erase(); } else if ( typeof(count) === 'undefined' ) { count = this.maxNpoint - offset; } const spp = this.spp, i0 = Math.max(0, Math.min(this.w-1, Math.floor(offset/spp))), i1 = Math.max(0, Math.min(this.w-1, Math.ceil((offset+count)/spp))), ctx = this.canvas.getContext('2d'), n = this.segments.length; let i = 0; for (; i<n; ++i) { if ( i !== this._chosen ) { this._draw(ctx, i0, (i1-i0+1), this.h, i, offset, count, this.config.dataStyle, this.config.normStyle, this.config.lineStyle, this.config.stimulusStyle); } } if ( (0 <= this._chosen) && (this._chosen < this.segments.length) ) { this._draw(ctx, i0, (i1-i0+1), this.h, this._chosen, offset, count, this.config.dataChosenStyle, this.config.normChosenStyle, this.config.lineChosenStyle, this.config.stimulusChosenStyle); } this.config.scalebarLores && this._drawScalebar(this.scaleCanvas, this.w, this.h, this.xpp, this.ypp, this.config.scalebarLoresPx, this.config.scalebarLoresPy); } drawHires() { const ctx = this.hiresCanvas.getContext('2d'), zoom = this.zoom(); ctx.fillStyle = this.config.backStyle; ctx.fillRect(0, 0, this.wHi, this.hHi); if ( (0 <= this._chosen) && (this._chosen < this.segments.length) ) { this._draw(ctx, 0, this.wHi, this.hHi, this._chosen, zoom.offset, zoom.count, this.config.dataStyle, this.config.normStyle, this.config.lineStyle, this.config.stimulusStyle, this.config.resampledStyle); } else { const n = this.segments.length; let i = 0; for (; i<n; ++i ) { this._draw(ctx, 0, this.wHi, this.hHi, i, zoom.offset, zoom.count, this.config.dataStyle, this.config.normStyle, this.config.lineStyle, this.config.stimulusStyle, this.config.resampledStyle); } } this.drawFitSoon(); this.config.scalebarHires && this._drawScalebar(this.hiresScaleCanvas, this.wHi, this.hHi, this.xppHi, this.yppHi, this.config.scalebarHiresPx, this.config.scalebarHiresPy); } _drawScalebar(canvas, plotW, plotH, xpp, ypp, scalePx, scalePy) { exports.drawScalebar(canvas, this.emsize, plotW, plotH, xpp, ypp, this.config.scalebarHeight, this.config.scalebarFont, this.config.scalebarBack, this.config.scalebarStyle); canvas.style.left = (scalePx < 0.5) ? ((scalePx * plotW / (window.devicePixelRatio||1))|0) : ((scalePx * (plotW-canvas.width)/(window.devicePixelRatio||1))|0); canvas.style.top = (scalePy < 0.5) ? ((scalePy * plotH / (window.devicePixelRatio||1))|0) : ((scalePy * (plotH-canvas.height/(window.devicePixelRatio||1)))|0); } drawFit() { const ctx = this.fitCanvas.getContext('2d'), zoom = this.zoom(); ctx.clearRect(0, 0, this.wHi, this.hHi); if ( (0 <= this._chosen) && (this._chosen < this.segments.length) ) { this._drawFit(ctx, 0, this.wHi, this.hHi, this._chosen, zoom.offset, zoom.count, this.config.fitStyle); } else { const n = this.segments.length; let i = 0; for (; i<n; ++i ) { this._drawFit(ctx, 0, this.wHi, this.hHi, i, zoom.offset, zoom.count, this.config.fitStyle); } } } _drawFit(ctx, px0, pxw, pxh, iSeg, offset, count, fitStyle) { let n = count; const seg = this.segments[iSeg]; if ( (seg.seen - offset) < count ) { n = seg.seen - offset; pxw = Math.round(pxw * n/count); } if ( n <= 1 ) { return; } const spp = n / pxw, extrema = seg.extrema || this; if ( spp >= this.config.minSppRaster ) { this._drawRasterStd(ctx, px0, pxw, pxh, seg.fit, offset, n, fitStyle, spp, extrema.loEx, extrema.dyEx/pxh); } else { exports.drawLinesStd(ctx, px0, pxw, pxh, seg.fit, offset, n, fitStyle, spp, extrema.loEx, extrema.dyEx/pxh); } } _draw(ctx, px0, pxw, pxh, iSeg, offset, count, dataStyle, normStyle, lineStyle, stimulusStyle, resampledStyle) { let n = count; const seg = this.segments[iSeg]; if ( (seg.seen - offset) < count ) { n = seg.seen - offset; pxw = Math.round(pxw * n/count); } if ( n <= 1 ) { return; } const spp = n / pxw, extrema = seg.extrema || this; if ( spp >= this.config.minSppRaster ) { ctx.lineWidth = 1.0; seg.stimuli.map((points, iStim) => this._drawRaster(ctx, px0, pxw, pxh*1.1*this.config.stimulusHeight, points, offset, n, stimulusStyle[iStim], null, spp, this.loStim, this.dyStim/(this.config.stimulusHeight*pxh))); this._drawRaster(ctx, px0, pxw, pxh, seg.points, offset, n, dataStyle, normStyle, spp, extrema.loEx, extrema.dyEx/pxh); } else { ctx.lineWidth = 0.5*Math.max(1, Math.round(pxh*this.config.lineHeight)|0); seg.stimuli.map((points, iStim) =>exports.drawLines(ctx, px0, pxw, pxh*1.1*this.config.stimulusHeight, points, offset, n, stimulusStyle[iStim], spp, this.loStim, this.dyStim/(this.config.stimulusHeight*pxh))); exports.drawLines(ctx, px0, pxw, pxh, seg.points, offset, n, lineStyle, spp, extrema.loEx, extrema.dyEx/pxh); } if ( resampledStyle && seg.resampled ) { exports.drawDots(ctx, px0, pxw, pxh, seg.resampled.closests, seg.resampled.means, offset, n, resampledStyle, this.config.resampledRad, spp, extrema.loEx, extrema.dyEx/pxh); } } _drawRaster(ctx, px0, pxw, pxh, points, offset, n, dataStyle, normStyle, spp, yLo, ypp) { if ( n > (points.length - offset) ) { const coverage = (points.length - offset) / n; n = points.length - offset; pxw = Math.round(pxw * coverage)|0; } const lineWidth = Math.max(1, Math.round(pxh*this.config.lineHeight)|0), raster = this.config.raster_samples(points.buffer, offset, n, pxw, spp, 2*lineWidth*ypp); exports.drawRastered(ctx, px0, pxw, pxh, raster, dataStyle, normStyle, spp, yLo, ypp); } _drawRasterStd(ctx, px0, pxw, pxh, points, offset, n, dataStyle, spp, yLo, ypp) { const raster = this.config.raster_samples_std(points.buffer, offset, n, pxw, spp, ypp); exports.drawRastered(ctx, px0, pxw, pxh, raster, dataStyle, null, spp, yLo, ypp); } _onPanSel(event) { const offset = eventOffset(event, event.center.x, event.center.y); var x0, x1; if ( event.deltaX < 0 ) { x0 = offset.x; x1 = x0 - event.deltaX; } else { x1 = offset.x; x0 = x1 - event.deltaX; } this.zoom(x0/this.w, x1/this.w); } _onPanHires(event) { if ( ! this._hiPan ) { this._hiPan = { selFrom: this.selFrom, selTo: this.selTo, pw: this.selTo - this.selFrom }; } const rq = - this._hiPan.pw * event.deltaX / this.wHi, d = Math.max(-this._hiPan.selFrom, Math.min(1-this._hiPan.selTo, rq)); this.zoom(this._hiPan.selFrom+d, this._hiPan.selTo+d, true); } _onPanendHires(event) { this._hiPan = null; } _onPinchHires(event) { if ( ! this._hiPinch && (event.offsetDirection != Hammer.DIRECTION_NONE) ) { const offset = eventOffset(event, event.center.x, event.center.y); this._hiPinch = { dir: event.offsetDirection, x: offset.x, y: offset.y, selFrom: this.selFrom, selTo: this.selTo }; } if ( this._hiPinch ) { if ( this._hiPinch.dir & (Hammer.DIRECTION_LEFT | Hammer.DIRECTION_RIGHT) ) { const center = this._hiPinch.selFrom + (this._hiPinch.selTo - this._hiPinch.selFrom)*this._hiPinch.x/this.wHi, scaled = exports.pinchInterval(this._hiPinch.selFrom, this._hiPinch.selTo, center, event.scale); this.zoom(scaled.from, scaled.to, true); } else { /// zoom in Y direction... } } } _onPinchendHires(event) { this._hiPinch = null; } }; return exports; });