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;
});