diff --git a/backend/common/models/block.js b/backend/common/models/block.js index 2b9fe12e1..2830ccbdc 100644 --- a/backend/common/models/block.js +++ b/backend/common/models/block.js @@ -474,10 +474,10 @@ Block.blockNamespace = async function(blockIds) { * @param blockId block * @param nBins number of bins to partition the block's features into */ - Block.blockFeaturesCounts = function(blockId, nBins, options, res, cb) { + Block.blockFeaturesCounts = function(blockId, interval, nBins, options, res, cb) { let db = this.dataSource.connector; let cursor = - blockFeatures.blockFeaturesCounts(db, blockId, nBins); + blockFeatures.blockFeaturesCounts(db, blockId, interval, nBins); cursor.toArray() .then(function(featureCounts) { cb(null, featureCounts); @@ -633,6 +633,7 @@ Block.blockNamespace = async function(blockIds) { Block.remoteMethod('blockFeaturesCounts', { accepts: [ {arg: 'block', type: 'string', required: true}, + {arg: 'interval', type: 'array', required: false}, {arg: 'nBins', type: 'number', required: false}, {arg: "options", type: "object", http: "optionsFromRequest"}, {arg: 'res', type: 'object', 'http': {source: 'res'}}, diff --git a/backend/common/utilities/block-features.js b/backend/common/utilities/block-features.js index 865781b22..2032dc674 100644 --- a/backend/common/utilities/block-features.js +++ b/backend/common/utilities/block-features.js @@ -49,35 +49,118 @@ exports.blockFeaturesCount = function(db, blockIds) { /*----------------------------------------------------------------------------*/ +/** Calculate the bin size for even-sized bins to span the given interval. + * The bin size is rounded to be a multiple of a power of 10, only the first 1-2 + * digits are non-zero. + * Used in @see binBoundaries(). + * @return lengthRounded + */ +function binEvenLengthRound(interval, nBins) { + let lengthRounded; + if (interval && (interval.length === 2) && (nBins > 0)) { + /* if (interval[1] < interval[0]) + interval = interval.sort(); */ + let intervalLength = interval[1] - interval[0], + binLength = intervalLength / nBins, + digits = Math.floor(Math.log10(binLength)), + eN1 = Math.exp(digits * Math.log(10)), + mantissa = binLength / eN1, + /** choose 1 2 or 5 as the first digit of the bin size. */ + m1 = mantissa > 5 ? 5 : (mantissa > 2 ? 2 : 1); + lengthRounded = Math.round(m1 * eN1); + + console.log('binEvenLengthRound', interval, nBins, intervalLength, binLength, digits, eN1, mantissa, m1, lengthRounded); + } + return lengthRounded; +}; +/** Generate an array of even-sized bins to span the given interval. + * Used for mongo aggregation pipeline : $bucket : boundaries. + */ +function binBoundaries(interval, lengthRounded) { + let b; + if (lengthRounded) { + let + start = interval[0], + intervalLength = interval[1] - interval[0], + direction = Math.sign(intervalLength), + forward = (direction > 0) ? + function (a,b) {return a < b; } + : function (a,b) {return a > b; }; + + let location = Math.floor(start / lengthRounded) * lengthRounded; + b = [location]; + do { + location += lengthRounded; + b.push(location); + } + while (forward(location, interval[1])); + console.log('binBoundaries', direction, b.length, location, b[0], b[b.length-1]); + } + return b; +}; + + + /** Count features of the given block in bins. * * @param blockCollection dataSource collection * @param blockId id of data block + * @param interval if given then use $bucket with boundaries in this range, + * otherwise use $bucketAuto. * @param nBins number of bins to group block's features into * * @return cursor : binned feature counts * { "_id" : { "min" : 4000000, "max" : 160000000 }, "count" : 22 } * { "_id" : { "min" : 160000000, "max" : 400000000 }, "count" : 21 } */ -exports.blockFeaturesCounts = function(db, blockId, nBins = 10) { +exports.blockFeaturesCounts = function(db, blockId, interval, nBins = 10) { // initial draft based on blockFeaturesCount() let featureCollection = db.collection("Feature"); + /** The requirement (so far) is for even-size boundaries on even numbers, + * e.g. 1Mb bins, with each bin boundary a multiple of 1e6. + * + * $bucketAuto doesn't require the boundaries to be defined, but there may not + * be a way to get it to use even-sized boundaries which are multiples of 1eN. + * By default it defines bins which have even numbers of features, i.e. the + * bin length will vary. If the parameter 'granularity' is given, the bin + * boundaries are multiples of 1e4 at the start and 1e7 near the end (in a + * dataset [0, 800M]; the bin length increases in an exponential progression. + * + * So $bucket is used instead, and the boundaries are given explicitly. + * This requires interval; if it is not passed, $bucketAuto is used, without granularity. + */ + const useBucketAuto = ! (interval && interval.length === 2); if (trace_block) - console.log('blockFeaturesCount', blockId, nBins); + console.log('blockFeaturesCounts', blockId, interval, nBins); let ObjectId = ObjectID; - + let lengthRounded, boundaries; + if (! useBucketAuto) { + lengthRounded = binEvenLengthRound(interval, nBins), + boundaries = binBoundaries(interval, lengthRounded); + } + let matchBlock = [ {$match : {blockId : ObjectId(blockId)}}, - { $bucketAuto: { groupBy: {$arrayElemAt : ['$value', 0]}, buckets: Number(nBins), granularity : 'R5'} } + useBucketAuto ? + { $bucketAuto : { groupBy: {$arrayElemAt : ['$value', 0]}, buckets: Number(nBins)} } // , granularity : 'R5' + : { $bucket : + { + groupBy: {$arrayElemAt : ['$value', 0]}, boundaries, + output: { + count: { $sum: 1 }, + idWidth : {$addToSet : lengthRounded } + } + } + } ], pipeline = matchBlock; if (trace_block) - console.log('blockFeaturesCount', pipeline); + console.log('blockFeaturesCounts', pipeline); if (trace_block > 1) console.dir(pipeline, { depth: null }); @@ -114,10 +197,18 @@ exports.blockFeatureLimits = function(db, blockId) { console.log('blockFeatureLimits', blockId); let ObjectId = ObjectID; + /** unwind the values array of Features, and group by blockId, with the min & + * max values within the Block. + * value may be [from, to, anyValue], so use slice to extract just [from, to], + * and $match $ne null to ignore to === undefined. + * The version prior to adding this comment assumed value was just [from, to] (optional to); + * so we can revert to that code if we separate additional values from the [from,to] location range. + */ let group = [ - {$project : {_id : 1, name: 1, blockId : 1, value : 1 }}, + {$project : {_id : 1, name: 1, blockId : 1, value : {$slice : ['$value', 2]} }}, {$unwind : '$value'}, + {$match: { $or: [ { value: { $ne: null } } ] } }, {$group : { _id : '$blockId' , featureCount : { $sum: 1 }, diff --git a/backend/common/utilities/paths-aggr.js b/backend/common/utilities/paths-aggr.js index c9b7e8431..68c051b87 100644 --- a/backend/common/utilities/paths-aggr.js +++ b/backend/common/utilities/paths-aggr.js @@ -311,7 +311,7 @@ function keyValue (k,v) { let r = {}; r[k] = v; return r; }; function valueBound(intervals, b, l) { let r = keyValue( l ? '$lte' : '$gte', - [ keyValue('$arrayElemAt', ['$value', l ? -1 : 0]), + [ keyValue('$arrayElemAt', ['$value', l ? 1 : 0]), +intervals.axes[b].domain[l]] ); return r; diff --git a/doc/notes/d3_devel.md b/doc/notes/d3_devel.md new file mode 100644 index 000000000..6cd0f8137 --- /dev/null +++ b/doc/notes/d3_devel.md @@ -0,0 +1,27 @@ +# debugging techniques for d3 + +## Web Inspector : Console, Elements tab, Ember Inspector + +- nodes(), node(), data(), .__data__ + +- click in Elements tab to select an element, then in console can inspect the element details with + $0, $0.__data__ + +- d3 stores .data(), .datum() on element.__data__, so $0.__data__ shows the d3 datum + +- in console, selection .node() shows the element as it appears in the Elements panel; +right-click on this -> view in Elements panel + +- when single-stepping through d3 attribute changes, note that in a transition the change won't take place until later, so commenting out the .transition().duration() line makes it easy to observe element changes while stepping through code + +- Elements panel highlights attribute changes for about 1 second, so have this displayed and continue (eg. to the next breakpoint) + +- use ember inspector to get component handle, from there get element and datum + +- d3 selection in console : can be used to inspect the app at any time - no need to breakpoint the app + +- Chrome and Firefox Web Inspector are similar, each has at least 1 thing which the other doesn't do or isn't as easy to use + +- more : +https://developers.google.com/web/tools/chrome-devtools/console/utilities +http://anti-code.com/devtools-cheatsheet/ diff --git a/frontend/app/components/axis-2d.js b/frontend/app/components/axis-2d.js index 43acb14c0..6e71d25b4 100644 --- a/frontend/app/components/axis-2d.js +++ b/frontend/app/components/axis-2d.js @@ -6,6 +6,8 @@ import AxisEvents from '../utils/draw/axis-events'; /* global d3 */ +const dLog = console.debug; + const axisTransitionTime = 750; @@ -35,6 +37,10 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { console.log('axis1d', axis1d, axesPCount); return axis1d; }), + /** @return the blocks of this axis. + * Result type is [] of (stack.js) Block. + * For the Ember data Object blocks, @see dataBlocks(). + */ blocks : Ember.computed('axis', function() { let axis = this.get('axis'); return axis && axis.blocks; @@ -69,6 +75,17 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { return dataBlocks; }), + /** @return blocks which are viewedChartable, and whose axis is this axis. + */ + viewedChartable : Ember.computed('blockService.viewedChartable.[]', 'axisID', + function () { + let + id = this.get('axisID'), + viewedChartable = this.get('blockService.viewedChartable') + .filter((b) => { let axis = b.get('axis'); return axis && axis.axisName === id; }); + console.log('viewedChartable', id, viewedChartable); + return viewedChartable; + }), /*--------------------------------------------------------------------------*/ @@ -153,12 +170,6 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { remove: function(){ this.remove(); console.log("components/axis-2d remove()"); - }, - /** - * @param componentName e.g. 'axis-tracks' - */ - contentWidth : function (componentName, axisID, width) { - this.contentWidth(componentName, axisID, width); } }, @@ -177,7 +188,53 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { && (currentWidth = use_data[0]); return currentWidth; }, - childWidths : {}, + /** width of sub-components within this axis-2d. Indexed by componentName. + * For each sub-component, [min, max] : the minimum required width, and the + * maximum useful width, i.e. the maximum width that the component can fill + * with content. + * Measured nominally in pixels, but space may be allocated proportional to the width allocated to this axis-2d. + */ + childWidths : undefined, + /** Allocate the available width among the children listed in .childWidths + * @return [horizontal start offset, width] for each child. + * The key of the result is the same as the input .childWidths + */ + allocatedWidths : Ember.computed('childWidths.@each.1', 'childWidths.chart.1', function () { + let allocatedWidths, + childWidths = this.get('childWidths'), + groupNames = Object.keys(childWidths), + requested = + groupNames.reduce((result, groupName) => { + let cw = childWidths[groupName]; + result[0] += cw[0]; // min + result[1] += cw[1]; // max + }, [0, 0]); + /** Calculate the spare width after each child is assigned its requested + * minimum width, and apportion the spare width among them. + * If spare < 0 then each child will get < min, but not <0. + */ + let + startWidth = this.get('startWidth'), + available = (this.get('axisUse') && this.rectWidth()) || startWidth || 120, + /** spare and share may be -ve */ + spare = available - requested[0], + share = 0; + if (groupNames.length > 0) { + share = spare / groupNames.length; + } + /** horizontal offset to the start (left) of the child. */ + let offset = 0; + allocatedWidths = groupNames.map((groupName) => { + let w = childWidths[groupName][0] + share; + if (w < 0) + w = 0; + let allocated = [offset, w]; + offset += w; + return allocated; + }); + dLog('allocatedWidths', allocatedWidths, childWidths); + return allocatedWidths; + }), contentWidth : function (componentName, axisID, width) { let childWidths = this.get('childWidths'), @@ -200,6 +257,16 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { Ember.run.later(call_setWidth); }, + init() { + this._super(...arguments); + this.set('childWidths', Ember.Object.create()); + }, + + /*--------------------------------------------------------------------------*/ + + resizeEffect : Ember.computed.alias('drawMap.resizeEffect'), + + /*--------------------------------------------------------------------------*/ didInsertElement() { let oa = this.get('data'), diff --git a/frontend/app/components/axis-chart.js b/frontend/app/components/axis-chart.js index d0ce9392a..782658399 100644 --- a/frontend/app/components/axis-chart.js +++ b/frontend/app/components/axis-chart.js @@ -1,595 +1,30 @@ import Ember from 'ember'; const { inject: { service } } = Ember; -import { getAttrOrCP } from '../utils/ember-devel'; -import { configureHorizTickHover } from '../utils/hover'; -import { eltWidthResizable, noShiftKeyfilter } from '../utils/domElements'; -import { noDomain } from '../utils/draw/axis'; -import { stacks } from '../utils/stacks'; // just for oa.z and .y, don't commit this. - import InAxis from './in-axis'; - -const className = "chart", classNameSub = "chartRow"; +import { className, AxisCharts, setupFrame, setupChart, drawChart, Chart1, blockData, parsedData } from '../utils/draw/chart1'; +import { DataConfig, dataConfigs } from '../utils/data-types'; /*----------------------------------------------------------------------------*/ const dLog = console.debug; -/*----------------------------------------------------------------------------*/ -/* Copied from draw-map.js */ - /** - * @return true if a is in the closed interval range[] - * @param a value - * @param range array of 2 values - limits of range. - */ - function inRange(a, range) - { - return range[0] <= a && a <= range[1]; - } - -function featureLocation(oa, axisID, d) -{ - let feature = oa.z[axisID][d]; - if (feature === undefined) - { - console.log("axis-chart featureY_", axisID, oa.z[axisID], "does not contain feature", d); - return undefined; - } - else - return feature.location; -} - -/*----------------------------------------------------------------------------*/ -/* based on axis-1d.js: hoverTextFn() and setupHover() */ - -/** eg: "ChrA_283:A:283" */ -function hoverTextFn (feature, block) { - let - value = getAttrOrCP(feature, 'value'), - valueText = value && (value.length ? ('' + value[0] + ' - ' + value[1]) : value), - - blockR = block.block, - featureName = getAttrOrCP(feature, 'name'), - /** common with dataConfig.datum2Description */ - description = value && JSON.stringify(value), - - text = [featureName, valueText, description] - .filter(function (x) { return x; }) - .join(" : "); - return text; -}; - -function configureChartHover(feature) -{ - let block = this.parentElement.__data__; - return configureHorizTickHover.apply(this, [feature, block, hoverTextFn]); -}; - - -/** Add a .hasChart class to the which contains this chart. - * Currently this is used to hide the so that hover events are - * accessible on the chart bars, because the is above the chart. - * Later can use e.g. axis-accordion to separate these horizontally; - * for axis-chart the foreignObject is not used. - * - * @param g parent of the chart. this is the with clip-path axis-clip. - */ -function addParentClass(g) { - let axisUse=g.node().parentElement.parentElement, - us=d3.select(axisUse); - us.classed('hasChart', true); - console.log(us.node()); -}; -/*----------------------------------------------------------------------------*/ - -let oa = stacks.oa, - axisID0; - - /** @param name is a feature or gene name */ - function name2Location(name) - { - /** @param ak1 axis name, (exists in axisIDs[]) - * @param d1 feature name, i.e. ak1:d1 - */ - let ak1 = axisID0, d1 = name; - return featureLocation(oa, ak1, d1); - } - - /** Used for both blockData and parsedData. */ - function datum2Location(d) { return name2Location(d.name); } - function datum2Value(d) { return d.value; } - let parsedData = { - dataTypeName : 'parsedData', - datum2Location, - datum2Value : datum2Value, - datum2Description : function(d) { return d.description; } - - }, - blockData = { - dataTypeName : 'blockData', - datum2Location, - datum2Value : function(d) { return d.value[0]; }, - datum2Description : function(d) { return JSON.stringify(d.value); } - }; - /*----------------------------------------------------------------------------*/ /* global d3 */ -/** Display data which has a numeric value for each y axis position (feature). - * Shown as a line curve or bar chart, with the y axis of the graph as the baseline. - * - * @param block a block returned by viewedChartable() - * @param chart data (field name is className); may be either : - * result of parseTextData() : array of {name : , value : , description : } - * or chartBlock passed in : .features - * @param axis axisComponent; parent axis-2d component - * @param axisID axisID - * @param data oa - * @param width resizedWidth - *---------------- - * data attributes created locally, not passed in : - * @param chart1 - */ -export default InAxis.extend({ - blockService: service('data/block'), - - className : className, +export default Ember.Component.extend({ didRender() { - console.log("components/axis-chart didRender()"); - }, - - blockFeatures : Ember.computed('block', 'block.features.[]', 'axis.axis1d.domainChanged', function () { - if (this.get('block.isChartable')) - this.drawBlockFeatures0(); - }), - featuresCounts : Ember.computed('block', 'block.featuresCounts.[]', 'axis.axis1d.domainChanged', function () { - this.drawBlockFeaturesCounts(); - return this.get('block.featuresCounts'); - }), - - drawBlockFeatures0 : function() { - let features = this.get('block.features'); - let domain = this.get('axis.axis1d.domainChanged'); - console.log('blockFeatures', features.length, domain); - if (features.length) // - should also handle drawing when .length changes to 0 - { - if (features[0] === undefined) - dLog('drawBlockFeatures0', features.length, domain); - else - this.drawBlockFeatures(features); - } - }, - drawBlockFeaturesCounts : function() { - let featuresCounts = this.get('block.featuresCounts'); - let domain = this.get('axis.axis1d.domainChanged'); - if (featuresCounts) { - console.log('drawBlockFeaturesCounts', featuresCounts.length, domain, this.get('block.id')); - - /** example element of array f : */ - const dataExample = - { - "_id": { - "min": 100, - "max": 160 - }, - "count": 109 - }; - let f = featuresCounts.toArray(), - /** the min, max will be passed also - first need to factor out part of axis-chart for featuresCounts. */ - fa = f; // .map(function (f0) { return f0.count;}); - console.log('drawBlockFeaturesCounts', f); - let - featureCountData = { - dataTypeName : 'featureCountData', - datum2Location : function datum2Location(d) { return d._id.min; }, // todo : use .max - datum2Value : function(d) { return d.count; }, - datum2Description : function(d) { return JSON.stringify(d._id); } - }; - // pass alternate dataConfig to layoutAndDrawChart(), defining alternate functions for {datum2Value, datum2Location } - this.layoutAndDrawChart(fa, featureCountData); - } - }, - drawBlockFeatures : function(features) { - let f = features.toArray(), - fa = f.map(function (f0) { return f0._internalModel.__data;}); - - let axisID = this.get("axis.axisID"), - za = oa.z[axisID]; - if (Object.keys(za).length == 2) { - dLog('drawBlockFeatures()', axisID, za, fa); - fa.forEach((f) => za[f.name] = f.value); - } - - this.layoutAndDrawChart(fa); - }, - - redraw : function(axisID, t) { - let data = this.get(className), - layoutAndDrawChart = this.get('layoutAndDrawChart'); - if (data) { - console.log("redraw", this, (data === undefined) || data.length, axisID, t); - if (data) - layoutAndDrawChart.apply(this, [data]); - } - else { // use block.features when not using data parsed from table. - this.drawBlockFeatures0(); - } + this.draw(); }, + draw() { - /** Convert input text to an array. - * @param tableText text string, TSV, rows separated by \n and/or \r. - * First row may contain a header with column names, indicated by leading #. - * Column names "name", "value" and "description" indicate the columns containing those values, - * otherwise the default columns are 0, 1, 2 respectively. - * Other columns are appended to the description value of the row. - */ - parseTextData(tableText) - { - /* can replace most of this function with d3.tsv; - * It currently implements the feature that header is optional; to replicate that can use - * dsv.parse() when header is input and dsv.parseRows() otherwise. - */ - let values = []; - let rows = tableText.split(/[\n\r]+/); - let colIdx = {name : 0, value : 1, description : 2}; - for (let i=0; i - // g.axis-outer#id - let - axisComponent = this.get("axis"), - axisID = axisComponent.axisID, - gAxis = d3.select("g.axis-outer#id" + axisID + "> g.axis-use"), - /** relative to the transform of parent g.axis-outer */ - bbox = gAxis.node().getBBox(), - yrange = [bbox.y, bbox.height]; - axisID0 = axisID; - if (bbox.x < 0) - { - console.log("x < 0", bbox); - bbox.x = 0; - } - let - barWidth = 10, - /** isBlockData is not used if dataConfig is defined. this can be moved out to the caller. */ - isBlockData = chart.length && (chart[0].description === undefined), - valueName = chart.valueName || "Values", - oa = this.get('data'), - // axisID = gAxis.node().parentElement.__data__, - yAxis = oa.y[axisID], // this.get('y') - yAxisDomain = yAxis.domain(), yDomain; - if (noDomain(yAxisDomain) && chart.length) { - yAxisDomain = [chart[0]._id.min, chart[chart.length-1]._id.max]; - yAxis.domain(yAxisDomain); - yDomain = yAxisDomain; - } - else - yDomain = [yAxis.invert(yrange[0]), yAxis.invert(yrange[1])]; - - if (! dataConfig) - dataConfig = isBlockData ? blockData : parsedData; - - let - pxSize = (yDomain[1] - yDomain[0]) / bbox.height, - withinZoomRegion = function(d) { - return inRange(dataConfig.datum2Location(d), yDomain); - }, - data = chart.filter(withinZoomRegion); - let resizedWidth = this.get('width'); - console.log(resizedWidth, bbox, yDomain, pxSize, data.length, (data.length == 0) || dataConfig.datum2Location(data[0])); - if (resizedWidth) - bbox.width = resizedWidth; - - /* axis - * x .value - * y .name Location - */ - /** 1-dimensional chart, within an axis. */ - function Chart1(parentG, options) - { - this.parentG = parentG; - this.options = options; - } - Chart1.prototype.barsLine = true; - Chart1.prototype.draw = function (data) - { - // based on https://bl.ocks.org/mbostock/3885304, axes x & y swapped. - let - options = this.options, - parentG = this.parentG, - margin = {top: 10, right: 20, bottom: 40, left: 20}, - // pp=parentG.node().parentElement, - parentW = options.bbox.width, // +pp.attr("width") - parentH = options.bbox.height, // +pp.attr("height") - width = parentW - margin.left - margin.right, - height = parentH - margin.top - margin.bottom; - let - xRange = [0, width], - yRange = [height, 0], - y = d3.scaleBand().rangeRound(yRange).padding(0.1), - x = d3.scaleLinear().rangeRound(xRange); - // datum2LocationScaled() uses me.x rather than the value in the closure in which it was created. - this.x = x; - // Used by bars() - could be moved there, along with datum2LocationScaled(). - this.y = y; - // line() does not use y; it creates yLine and uses yRange, to set its range. - this.yRange = yRange; - console.log("Chart1", parentW, parentH, xRange, yRange, options.dataTypeName); - - let me = this; - /* these can be renamed datum2{abscissa,ordinate}{,Scaled}() */ - /* apply y after scale applied by datum2Location */ - function datum2LocationScaled(d) { return me.y(options.datum2Location(d)); } - function datum2ValueScaled(d) { return me.x(options.datum2Value(d)); } - this.datum2LocationScaled = datum2LocationScaled; - this.datum2ValueScaled = datum2ValueScaled; - - let gs = parentG - .selectAll("g > g") - .data([1]), - gsa = gs - .enter() - .append("g") // maybe drop this g, move margin calc to gp - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"), - g = gsa.merge(gs); - this.g = g; - - /** first draft showed all data; subsequently adding : - * + select region from y domain - * - place data in tree for fast subset by region - * + alternate view : line - * + transition between views, zoom, stack - */ - y.domain(data.map(options.datum2Location)); - x.domain([0, d3.max(data, options.datum2Value)]); - - let axisXa = - gsa.append("g") - // - handle .exit() for these 2 also - .attr("class", "axis axis--x"); - axisXa.merge(gs.selectAll("g > g.axis--x")) - .attr("transform", "translate(0," + height + ")") - .call(d3.axisBottom(x)); - - let axisYa = - gsa.append("g") - .attr("class", "axis axis--y"); - axisYa.merge(gs.selectAll("g > g.axis--y")) - .call(d3.axisLeft(y)) // .tickFormat(".2f") ? - .append("text") - .attr("transform", "rotate(-90)") - .attr("y", 6) - .attr("dy", "0.71em") - .attr("text-anchor", "end") - .text(valueName); - - this.drawContent(data); - this.currentData = data; - }; - Chart1.prototype.bars = function (data) - { - let - options = this.options, - g = this.g; - let - rs = g - // .select("g." + className + " > g") - .selectAll("rect." + options.barClassName) - .data(data), - re = rs.enter(), rx = rs.exit(); - let ra = re - .append("rect"); - ra - .attr("class", options.barClassName) - .each(configureChartHover); - ra - .merge(rs) - .transition().duration(1500) - .attr("x", 0) - .attr("y", this.datum2LocationScaled) - .attr("height", this.y.bandwidth()) - .attr("width", this.datum2ValueScaled); - rx.remove(); - console.log(gAxis.node(), rs.nodes(), re.nodes()); - }; - Chart1.prototype.line = function (data) - { - // based on https://bl.ocks.org/mbostock/3883245 - if (! this.yLine) - this.yLine = d3.scaleLinear() - .rangeRound(this.yRange); - let y = this.yLine, options = this.options; - - function datum2LocationScaled(d) { return y(options.datum2Location(d)); } - - let line = d3.line() - .x(this.datum2ValueScaled) - .y(datum2LocationScaled); - - y.domain(d3.extent(data, this.options.datum2Location)); - console.log("line x domain", this.x.domain(), this.x.range()); - let - g = this.g, - ps = g - .selectAll("g > path." + options.barClassName) - .data([1]); - ps - .enter() - .append("path") - .attr("class", options.barClassName + " line") - .datum(data) - .attr("d", line) - .merge(ps) - .datum(data) - .transition().duration(1500) - .attr("d", line); - // data length is constant 1, so .remove() is not needed - ps.exit().remove(); - }; - /** Alternate between bar chart and line chart */ - Chart1.prototype.toggleBarsLine = function () - { - console.log("toggleBarsLine", this); - d3.event.stopPropagation(); - this.barsLine = ! this.barsLine; - this.chartTypeToggle - .classed("pushed", this.barsLine); - this.g.selectAll("g > *").remove(); - this.drawContent(this.currentData); - }; - Chart1.prototype.drawContent = function(data) - { - let chartDraw = this.barsLine ? this.bars : this.line; - chartDraw.apply(this, [data]); - }; - - /** datum is value in hash : {value : , description: } and with optional attribute description. */ - - /** parent; contains a clipPath, g > rect, text.resizer. */ - let gps = gAxis - .selectAll("g." + className) - .data([axisID]), - gp = gps - .enter() - .insert("g", ":first-child") - .attr('class', className); - if (false) { // not completed. Can base resized() on axis-2d.js - let text = gp - .append("text") - .attr('class', 'resizer') - .html("⇹") - .attr("x", bbox.width-10); - if (gp.size() > 0) - eltWidthResizable("g.axis-use > g." + className + " > text.resizer", resized); - } - /** datum is axisID, so id and clip-path could be functions. */ - let axisClipId = "axis-clip-" + axisID; - let gpa = - gp // define the clipPath - .append("clipPath") // define a clip path - .attr("id", axisClipId) // give the clipPath an ID - .append("rect"), // shape it as a rect - gprm = - gpa.merge(gps.selectAll("g > clipPath > rect")) - .attr("x", bbox.x) - .attr("y", bbox.y) - .attr("width", bbox.width) - .attr("height", bbox.height) - ; - gp.append("g") - .attr("clip-path", "url(#" + axisClipId + ")"); // clip with the rectangle - - let g = - gps.merge(gp).selectAll("g." + className+ " > g"); - - /* It is possible to recreate Chart1 for each call, but that leads to - * complexity in ensuring that the instance rendered by toggleBarsLineClosure() is - * the same one whose options.bbox.width is updated from axis-chart.width. - */ - let chart1 = this.get("chart1"); - if (chart1) - { - chart1.options.bbox.width = bbox.width; - } - else - { - chart1 = new Chart1(g, - { - bbox : bbox, - barClassName : classNameSub, - dataTypeName : dataConfig.dataTypeName, - datum2Location : dataConfig.datum2Location, - datum2Value : dataConfig.datum2Value, - datum2Description : dataConfig.datum2Description - }); - this.set("chart1", chart1); - addParentClass(g); - } - let b = chart1; // b for barChart - - function toggleBarsLineClosure(e) - { - b.toggleBarsLine(); - } - - /** currently placed at g.chart, could be inside g.chart>g (clip-path=). */ - let chartTypeToggle = gp - .append("circle") - .attr("class", "radio toggle chartType") - .attr("r", 6) - .on("click", toggleBarsLineClosure); - chartTypeToggle.merge(gps.selectAll("g > circle")) - .attr("cx", bbox.x + bbox.width / 2) /* was o[p], but g.axis-outer translation does x offset of stack. */ - .attr("cy", bbox.height - 10) - .classed("pushed", b.barsLine); - b.chartTypeToggle = chartTypeToggle; - - b.draw(data); - }, - - pasteProcess: function(textPlain) { - console.log("components/axis-chart pasteProcess", textPlain.length); - - let - parseTextData = this.get('parseTextData'), - layoutAndDrawChart = this.get('layoutAndDrawChart'); - - let chart = parseTextData(textPlain); - this.set(className, chart); // used by axisStackChanged() : redraw() : layoutAndDrawChart() - let forTable = chart; - chart.valueName = "values"; // add user config - // ; draw chart. - layoutAndDrawChart.apply(this, [chart]); - - this.set('data.chart', forTable); - }, }); diff --git a/frontend/app/components/axis-charts.js b/frontend/app/components/axis-charts.js new file mode 100644 index 000000000..1108581b0 --- /dev/null +++ b/frontend/app/components/axis-charts.js @@ -0,0 +1,349 @@ +import Ember from 'ember'; +const { inject: { service } } = Ember; + +import InAxis from './in-axis'; +import { className, AxisCharts, Chart1 } from '../utils/draw/chart1'; +import { DataConfig, dataConfigs, blockData, parsedData } from '../utils/data-types'; + +/*----------------------------------------------------------------------------*/ + +const dLog = console.debug; + + +/*----------------------------------------------------------------------------*/ + + +/* global d3 */ + +/** Display data which has a numeric value for each y axis position (feature). + * Shown as a line curve or bar chart, with the y axis of the graph as the baseline. + * + * @param block a block returned by viewedChartable() + * @param chart data (field name is className); may be either : + * result of parseTextData() : array of {name : , value : , description : } + * or chartBlock passed in : .features + * @param axis axisComponent; parent axis-2d component - + * @param gContainer - + * @param axisID axisID - local value ? + * @param data oa - + * @param width resizedWidth + *---------------- + * data attributes created locally, not passed in : + * @param charts map of Chart1, indexed by typeName + * @param blocksData map of features, indexed by typeName, blockId, + */ +export default InAxis.extend({ + blockService: service('data/block'), + + className : className, + + /** blocks-view sets blocksData[blockId]. */ + blocksData : undefined, + /** {dataTypeName : Chart1, ... } */ + charts : undefined, + + didReceiveAttrs() { + this._super(...arguments); + + this.createAttributes(); + }, + + createAttributes() { + /** these objects persist for the life of the object. */ + if (! this.get('blocksData')) { + this.set('blocksData', Ember.Object.create()); + this.set('charts', Ember.Object.create()); + // axisID isn't planned to change for this component + this.set('axisCharts', new AxisCharts(this.get('axisID'))); + } + }, + + didRender() { + console.log("components/axis-chart didRender()", this.get('axisID')); + + let axisID = this.get('axisID'), + axisCharts = this.get('axisCharts'), + gAxis = axisCharts && axisCharts.selectParentContainer(axisID); + if (! gAxis.empty()) + this.draw(); + else + Ember.run.later(() => this.draw(), 500); + }, + willDestroyElement() { + console.log("components/axis-chart willDestroyElement()"); + this.undraw(); + + this._super(...arguments); + }, + + + axisID : Ember.computed.alias('axis.axisID'), + + gAxis : Ember.computed('axisID', function () { + let axisID = this.get('axisID'); + let axisCharts = this.get('axisCharts'); + let gAxis = axisCharts.selectParentContainer(axisID); + return gAxis; + }), + + yAxesScales : Ember.computed('data', function () { + let oa = this.get('data'); + return oa.y; + }), + yAxisScale : Ember.computed('axisID', 'yAxesScales', function () { + let yAxesScales = this.get('yAxesScales'), + axisID = this.get('axisID'), + yAxis = yAxesScales[axisID]; + dLog('yAxisScale', axisID, yAxis && yAxis.domain()); + return yAxis; + }), + + + chartTypes : Ember.computed('blocksData.@each', function () { + let blocksData = this.get('blocksData'), + chartTypes = Object.keys(blocksData); + dLog('chartTypes', chartTypes); + return chartTypes; + }), + chartsArray : Ember.computed('chartTypes.[]', function () { + /* The result is roughly equivalent to Object.values(this.get('charts')), + * but for any chartType which doesn't have an element in .charts, add + * it. */ + let + chartTypes = this.get('chartTypes'), + charts = chartTypes.map((typeName) => { + let chart = this.charts[typeName]; + if (! chart) { + let + dataConfig = dataConfigs[typeName], + parentG = this.get('axisCharts.dom.g'); // this.get('gAxis'), + chart = this.charts[typeName] = new Chart1(parentG, dataConfig); + let axisCharts = this.get('axisCharts'); + chart.overlap(axisCharts); + } + return chart; + }); + return charts; + }), + + + + /** Retrieve charts handles from the DOM. + * This could be used as verification - the result should be the same as + * this.get('chartsArray'). + */ + chartHandlesFromDom () { + /** this value is currently the g.axis-outer, which is 2 + * levels out from the g.axis-use, so this is a misnomer - + * will change either the name or the value. + * The result is the same because there is just 1 g.chart inside 'g.axis-outer > g.axis-all > g.axis-use'. + */ + let axisUse = this.get('axis.axisUse'), + g = axisUse.selectAll('g.chart > g[clip-path] > g'), + charts = g.data(); + return charts; + }, + + resizeEffectHere : Ember.computed('resizeEffect', function () { + dLog('resizeEffectHere in axis-charts', this.get('axisID')); + }), + zoomedDomainEffect : Ember.computed('zoomedDomain', function () { + dLog('zoomedDomainEffect in axis-charts', this.get('axisID')); + this.drawContent(); + }), + + draw() { + // probably this function can be factored out as AxisCharts:draw() + let axisCharts = this.get('axisCharts'), + charts = this.get('charts'), + allocatedWidth = this.get('allocatedWidth'); + axisCharts.setupFrame( + this.get('axisID'), + charts, allocatedWidth); + + let + chartTypes = this.get('chartTypes'), + // equiv : charts && Object.keys(charts).length, + nCharts = chartTypes && chartTypes.length; + if (nCharts) + allocatedWidth /= nCharts; + chartTypes.forEach((typeName) => { + // this function could be factored out as axis-chart:draw() + let + chart = charts[typeName]; + /*if (! chart.ranges)*/ { + let + blocksData = this.get('blocksData'), + data = blocksData.get(typeName), + dataConfig = chart.dataConfig; + let blocks = this.get('blocks'); + + chart.setupChart( + this.get('axisID'), axisCharts, data, blocks, + dataConfig, this.get('yAxisScale'), allocatedWidth); + + chart.drawChart(axisCharts, data); + } + }); + + /** drawAxes() uses the x scale updated in drawChart() -> prepareScales(), called above. */ + const showChartAxes = true; + if (showChartAxes) + axisCharts.drawAxes(charts); + + // place controls after the ChartLine-s group, so that the toggle is above the bars and can be accessed. + axisCharts.controls(); + + }, + + undraw() { + this.get('axisCharts').frameRemove(); + }, + + drawContent() { + let charts = this.get('chartsArray'); + /* y axis has been updated, so redrawing the content will update y positions. */ + if (charts) + charts.forEach((chart) => chart.drawContent()); + }, + + /** Called via in-axis:{zoomed or resized}() -> redrawDebounced() -> redrawOnce() + * Redraw the chart content. + * In the case of resize, the chart frame will need to be resized (not yet + * done; the setup functions are designed to be called again - in that case + * they will update sizes rather than add new elements). + */ + redraw : function(axisID, t) { + this.drawContent(); + }, + /** for use with @see pasteProcess() */ + redraw_from_paste : function(axisID, t) { + let data = this.get(className), + layoutAndDrawChart = this.get('layoutAndDrawChart'); + if (data) { + console.log("redraw", this, (data === undefined) || data.length, axisID, t); + if (data) + layoutAndDrawChart.apply(this, [data, undefined]); + } + }, + + /*--------------------------------------------------------------------------*/ + /* These 2 functions provide a way to paste an array of data to be plotted in the chart. + * This was used in early prototype; an alternative is to use upload tab to + * add a data block - maybe this more direct and lightweight approach could + * have some use. + */ + + /** Convert input text to an array. + * Used to process text from clip-board, by @see pasteProcess(). + * @param tableText text string, TSV, rows separated by \n and/or \r. + * First row may contain a header with column names, indicated by leading #. + * Column names "name", "value" and "description" indicate the columns containing those values, + * otherwise the default columns are 0, 1, 2 respectively. + * Other columns are appended to the description value of the row. + */ + parseTextData(tableText) + { + /* can replace most of this function with d3.tsv; + * It currently implements the feature that header is optional; to replicate that can use + * dsv.parse() when header is input and dsv.parseRows() otherwise. + */ + let values = []; + let rows = tableText.split(/[\n\r]+/); + let colIdx = {name : 0, value : 1, description : 2}; + for (let i=0; i interval[1]) { let swap = interval[0]; interval[0] = interval[1]; @@ -616,6 +636,7 @@ export default InAxis.extend({ width = 40 + blockIds.length * 2 * trackWidth + 20 + 50; console.log('layoutWidth', blockIds, width); + this.get('childWidths').set(this.get('className'), [width, width]); return width; }), showTrackBlocks: Ember.computed( diff --git a/frontend/app/components/draw-map.js b/frontend/app/components/draw-map.js index 77cb4aa41..e7418dd02 100644 --- a/frontend/app/components/draw-map.js +++ b/frontend/app/components/draw-map.js @@ -341,7 +341,8 @@ export default Ember.Component.extend(Ember.Evented, { console.log("create", axisID, axis, "in", axes2d); } else - axis.set('extended', enabled); // was axis2DEnabled + Ember.run.later( + () => axis.set('extended', enabled)); // was axis2DEnabled console.log("enableAxis2D in components/draw-map", axisID, enabled, axis); console.log("splitAxes", this.get('splitAxes')); console.log("axes2d", this.get('axes2d')); @@ -1210,11 +1211,13 @@ export default Ember.Component.extend(Ember.Evented, { receivedBlock2.apply(me, [dBlock]); - if (! sBlock) { + if (! sBlock || ! dBlock.get('view')) { /** sBlock may already be associated with dBlock */ let view = dBlock.get('view'); sBlock = view || new Block(dBlock); - oa.stacks.blocks[d] = sBlock; + // if view, then this is already set. + if (oa.stacks.blocks[d] !== sBlock) + oa.stacks.blocks[d] = sBlock; if (! view) { /* this .set() was getting assertion fail (https://github.com/emberjs/ember.js/issues/13948), * hence the catch and trace; this has been resolved by not displaying .view in .hbs @@ -1951,6 +1954,9 @@ export default Ember.Component.extend(Ember.Evented, { if (options_param && ! this.get('urlOptions') && (options = parseOptions(options_param))) { + /* splitAxes1 is now enabled by default. */ + if (! options.splitAxes1) + options.splitAxes1 = true; this.set('urlOptions', options); this.get('blockService').injectParsedOptions(options); // alpha enables new features which are not yet robust. @@ -5443,6 +5449,7 @@ export default Ember.Component.extend(Ember.Evented, { { // removeBlockByName() is already done above + // this can be factored with : deleteButtonS.on('click', ... ) let stack = axis && axis.stack; // axes[axisName] is deleted by removeStacked1() let stackID = Stack.removeStacked(axisName); @@ -5537,6 +5544,7 @@ export default Ember.Component.extend(Ember.Evented, { .on('click', function (buttonElt /*, i, g*/) { console.log("delete", axisName, this); // this overlaps with the latter part of blockIsUnviewed() + // and can be factored with that. let axis = oa.axes[axisName], stack = axis && axis.stack; // axes[axisName] is deleted by removeStacked1() let stackID = Stack.removeStacked(axisName); @@ -5544,6 +5552,7 @@ export default Ember.Component.extend(Ember.Evented, { let sBlock = oa.stacks.blocks[axisName]; console.log('sBlock.axis', sBlock.axis); sBlock.setAxis(undefined); + removeBrushExtent(axisName); removeAxisMaybeStack(axisName, stackID, stack); me.send('mapsToViewDelete', axisName); // filter axisName out of selectedFeatures and selectedAxes @@ -5713,6 +5722,13 @@ export default Ember.Component.extend(Ember.Evented, { .attr("viewBox", oa.vc.viewBox.bind(oa.vc)) .attr('height', graphDim.h /*"auto"*/); + /* axes removed via manage-view : removeBlock don't remove any brush of + * that axis from brushedRegions, so check for that here. + * This check could be done when services/data/blocks:viewed.[] updates. + */ + Object.keys(oa.brushedRegions).forEach( + (refBlockId) => { if (! oa.axes[refBlockId]) removeBrushExtent(refBlockId); } + ); /** As in zoom(), note the brushedDomains before the scale change, for * updating the brush selection position. * This could be passed to axisBrushShowSelection() and used in place of @@ -5769,9 +5785,13 @@ export default Ember.Component.extend(Ember.Evented, { DropTarget.prototype.showResize(); } Ember.run.later( function () { - /* probably better to do .trigger() within .later(); it works either way. */ + /* This does .trigger() within .later(), which seems marginally better than vice versa; it works either way. (Planning to replace event:resize soon). */ if (widthChanged || heightChanged) - me.trigger('resized', widthChanged, heightChanged, useTransition); + try { + me.trigger('resized', widthChanged, heightChanged, useTransition); + } catch (exc) { + console.log('showResize', 'resized', me, me.resized, widthChanged, heightChanged, useTransition, graphDim, brushedDomains, exc.stack || exc); + } showSynteny(oa.syntenyBlocks, undefined); }); }; @@ -6077,6 +6097,14 @@ export default Ember.Component.extend(Ember.Evented, { return changed; }, + /** draw-map sends resized event to listening sub-components using trigger(). + * It does not listen to this event, or need to, but defining resized() may fix : + * 't[m] is not a function, at Object.applyStr (ember-utils.js:524)' + */ + resized : function(prevSize, currentSize) { + console.log("resized in components/draw-map", this, prevSize, currentSize); + }, + resize : function() { console.log("resize", this, arguments); /** when called via .observes(), 'this' is draw-map object. When called diff --git a/frontend/app/components/draw/axis-1d.js b/frontend/app/components/draw/axis-1d.js index 0eaf7ac42..02a6b8c60 100644 --- a/frontend/app/components/draw/axis-1d.js +++ b/frontend/app/components/draw/axis-1d.js @@ -84,6 +84,7 @@ function FeatureTicks(axis, axisApi, axis1d) /** Draw horizontal ticks on the axes, at feature locations. * * @param axis block (Ember object), result of stacks-view:axesP + * If the axis has multiple (data) blocks, this is the reference block. */ FeatureTicks.prototype.showTickLocations = function (featuresOfBlockLookup, setupHover, groupName, blockFilter) { @@ -304,8 +305,12 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, AxisPosition, { let axisName = this.get('axis.id'), axisS = Stacked.getAxis(axisName); - if (axisS && ! axisS.axis1d) { - axisS.axis1d = this; + if (axisS) { + if (axisS.axis1d === this && this.isDestroying) + axisS.axis1d = undefined; + else if (! axisS.axis1d && ! this.isDestroying) { + axisS.axis1d = this; + } } return axisS; }), @@ -725,8 +730,13 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, AxisPosition, { dLog('axis-1d didInsertElement', this, this.get('listen') !== undefined); }, willDestroyElement() { - dLog('willDestroyElement'); + dLog('willDestroyElement', this.get('axis.id')); this.removeTicks(); + let axisS = this.get('axisS'); + if (axisS) { + if (axisS.axis1d === this) + delete axisS.axis1d; + } this._super(...arguments); }, removeTicks() { diff --git a/frontend/app/components/draw/block-adj.js b/frontend/app/components/draw/block-adj.js index 592fb33f0..a3bdea7b9 100644 --- a/frontend/app/components/draw/block-adj.js +++ b/frontend/app/components/draw/block-adj.js @@ -3,12 +3,11 @@ import Ember from 'ember'; const { inject: { service } } = Ember; import { throttle } from '@ember/runloop'; -import PathData from './path-data'; - import AxisEvents from '../../utils/draw/axis-events'; import { stacks, Stacked } from '../../utils/stacks'; import { selectAxis, blockAdjKeyFn, blockAdjEltId, featureEltIdPrefix, featureNameClass, foregroundSelector, selectBlockAdj } from '../../utils/draw/stacksAxes'; import { targetNPaths, pathsFilter } from '../../utils/draw/paths-filter'; +import { pathsResultTypes, pathsApiResultType, flowNames, resultBlockIds, pathsOfFeature, locationPairKeyFn } from '../../utils/paths-api'; /* global d3 */ @@ -99,8 +98,10 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { */ drawCurrent : function(prType) { let - /** e.g. 'pathsResult' or 'pathsAliasesResult' */ - pathsResult = this.get('blockAdj.' + prType.fieldName), + /** e.g. 'pathsResult' or 'pathsAliasesResult' + * Use the filtered form of the API result. + */ + pathsResult = this.get('blockAdj.' + prType.fieldName + 'Filtered'), fnName = prType.fieldName + 'Length', length = pathsResult && pathsResult.length; dLog(fnName, this, length); @@ -290,7 +291,7 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { draw (pathsResultType, featurePaths) { if (featurePaths.length === 0) return; - pathsResultType.typeCheck(featurePaths[0]); + pathsResultType.typeCheck(featurePaths[0], true); let store = this.get('store'); /** blockAdjId is also contained in the result featurePaths @@ -512,207 +513,3 @@ export default Ember.Component.extend(Ember.Evented, AxisEvents, { }); - -if (false) { - /** Example of param paths passed to draw() above. */ - const examplePaths = -[{"_id":{"name":"myMarkerC"}, - "alignment":[ - {"blockId":"5c75d4f8792ccb326827daa2","repeats":{ - "_id":{"name":"myMarkerC","blockId":"5c75d4f8792ccb326827daa2"}, - "features":[{"_id":"5c75d4f8792ccb326827daa6","name":"myMarkerC","value":[3.1,3.1],"blockId":"5c75d4f8792ccb326827daa2","parentId":null}],"count":1}}, - {"blockId":"5c75d4f8792ccb326827daa1","repeats":{ - "_id":{"name":"myMarkerC","blockId":"5c75d4f8792ccb326827daa1"}, - "features":[{"_id":"5c75d4f8792ccb326827daa5","name":"myMarkerC","value":[0,0],"blockId":"5c75d4f8792ccb326827daa1","parentId":null}],"count":1}}]}]; -} - - -/*----------------------------------------------------------------------------*/ - -function featureEltId(featureBlock) -{ - let id = featurePathKeyFn(featureBlock); - id = featureNameClass(id); - return id; -} - -function featurePathKeyFn (featureBlock) -{ return featureBlock._id.name; } - -/** Given the grouped data for a feature, from the pathsDirect() result, - * generate the cross-product feature.alignment[0].repeats X feature.alignment[1].repeats. - * The result is an array of pairs of features; each pair defines a path and is of type PathData. - * for each pair an element of pairs[] : - * pair.feature0 is in block pair.block0 - * pair.feature1 is in block pair.block1 - * (for the case of pathsResultTypes.direct) : - * pair.block0 === feature.alignment[0].blockId - * pair.block1 === feature.alignment[1].blockId - * i.e. the path goes from the first block in the request params to the 2nd block - * @param pathsResultType e.g. pathsResultTypes.{Direct,Aliases} - * @param feature 1 element of the result array passed to draw() - * @return [PathData, ...] - */ -function pathsOfFeature(store, pathsResultType, owner) { - const PathData = owner.factoryFor('component:draw/path-data'); - return function (feature) { - let blocksFeatures = - [0, 1].map(function (blockIndex) { return pathsResultType.blocksFeatures(feature, blockIndex); }), - blocks = resultBlockIds(pathsResultType, feature), - pairs = - blocksFeatures[0].reduce(function (result, f0) { - let result1 = blocksFeatures[1].reduce(function (result, f1) { - let pair = - pathCreate(store, f0, f1, blocks[0], blocks[1]); - result.push(pair); - return result; - }, result); - return result1; - }, []); - return pairs; - }; -} - -const trace_pc = 1; - -function pathCreate(store, feature0, feature1, block0, block1) { - let - /** not used - same as feature{0,1}.blockId. */ - block0r = store.peekRecord('block', block0), - block1r = store.peekRecord('block', block1); - if (true) { - let properties = { - feature0, - feature1/*, - block0r, - block1r*/ - }, - pair = - PathData.create({ renderer : {} }); - pair.setProperties(properties); - if (trace_pc > 2) - dLog('PathData.create()', PathData, pair); - return pair; - } - else { - let - modelName = 'draw/path-data', - idText = locationPairKeyFn({ feature0, feature1}), - r = store.peekRecord(modelName, idText); - if (r) - dLog('pathCreate', feature0, feature1, block0, block1, r._internalModel.__attributes, r._internalModel.__data); - else if (false) - { - let data = { - type : modelName, - id : idText, - relationships : { - feature0 : { data: { type: "feature", "id": feature0 } }, - feature1 : { data: { type: "feature", "id": feature1 } } /*, - block0 : { data: { type: "block", "id": block0r } }, - block1 : { data: { type: "block", "id": block1r } }*/ - }/*, - attributes : { - 'block-id0' : block0, - 'block-id1' : block1 - }*/ - }; - r = store.push({data}); - if (trace_pc) - dLog('pathCreate', r, r.get('id'), r._internalModel, r._internalModel.__data, store, data); - } - else { - let inputProperties = { - feature0, - feature1/*, - block0r, - block1r*/ - }; - r = store.createRecord(modelName, inputProperties); - } - return r; - } -} - - -function locationPairKeyFn(locationPair) -{ - return locationPair.feature0.id + '_' + locationPair.feature1.id; -} - -/*----------------------------------------------------------------------------*/ - -const pathsApiFields = ['featureAObj', 'featureBObj']; -/** This type is created by paths-progressive.js : requestAliases() : receivedData() */ -const pathsApiResultType = { - // fieldName may be pathsResult or pathsAliasesResult - typeCheck : function(resultElt) { if (! resultElt.featureAObj) { - dLog('pathsApiResultType : typeCheck', resultElt); } }, - pathBlock : function (resultElt, blockIndex) { return resultElt[pathsApiFields[blockIndex]].blockId; }, - /** direct.blocksFeatures() returns an array of features, so match that. See - * similar commment in alias.blocksFeatures. */ - blocksFeatures : function (resultElt, blockIndex) { return [ resultElt[pathsApiFields[blockIndex]] ]; }, - featureEltId : - function (resultElt) - { - let id = pathsApiResultType.featurePathKeyFn(resultElt); - id = featureNameClass(id); - return id; - }, - featurePathKeyFn : function (resultElt) { return resultElt.featureA + '_' + resultElt.featureB; } - -}; - -/** This is provision for using the API result type as data type; not used currently because - * the various forms of result data are converted to path-data. - * These are the result types from : - * Block/paths -> apiLookupAliases() -> task.paths() - * Blocks/pathsViaStream -> pathsAggr.pathsDirect() - * getPathsAliasesViaStream() / getPathsAliasesProgressive() -> Blocks/pathsAliasesProgressive -> dbLookupAliases() -> pathsAggr.pathsAliases() - */ -const pathsResultTypes = { - direct : { - fieldName : 'pathsResult', - typeCheck : function(resultElt) { if (! resultElt._id) { - dLog('direct : typeCheck', resultElt); } }, - pathBlock : function (resultElt, blockIndex) { return resultElt.alignment[blockIndex].blockId; }, - blocksFeatures : function (resultElt, blockIndex) { return resultElt.alignment[blockIndex].repeats.features; }, - featureEltId : featureEltId, - featurePathKeyFn : featurePathKeyFn - }, - - alias : - { - fieldName : 'pathsAliasesResult', - typeCheck : function(resultElt) { if (! resultElt.aliased_features) { - dLog('alias : typeCheck', resultElt); } }, - pathBlock : function (resultElt, blockIndex) { return resultElt.aliased_features[blockIndex].blockId; }, - /** There is currently only 1 element in .aliased_features[blockIndex], but - * pathsOfFeature() handles an array an produces a cross-product, so return - * this 1 element as an array. */ - blocksFeatures : function (resultElt, blockIndex) { return [resultElt.aliased_features[blockIndex]]; }, - featureEltId : - function (resultElt) - { - let id = pathsResultTypes.alias.featurePathKeyFn(resultElt); - id = featureNameClass(id); - return id; - }, - featurePathKeyFn : function (resultElt) { - return resultElt.aliased_features.map(function (f) { return f.name; } ).join('_'); - } - } -}, -/** This matches the index values of services/data/flows-collate.js : flows */ -flowNames = Object.keys(pathsResultTypes); -// add .flowName to each of pathsResultTypes, which could later require non-const declaration. -flowNames.forEach(function (flowName) { pathsResultTypes[flowName].flowName = flowName; } ); - -/** - * @return array[2] of blockId, equivalent to blockAdjId - */ -function resultBlockIds(pathsResultType, featurePath) { - let blockIds = - [0, 1].map(function (blockIndex) { return pathsResultType.pathBlock(featurePath, blockIndex); }); - return blockIds; -} diff --git a/frontend/app/components/draw/block-view.js b/frontend/app/components/draw/block-view.js new file mode 100644 index 000000000..a03f48394 --- /dev/null +++ b/frontend/app/components/draw/block-view.js @@ -0,0 +1,86 @@ +import Ember from 'ember'; +const { inject: { service } } = Ember; + +import { ensureBlockFeatures } from '../../utils/feature-lookup'; + +/*----------------------------------------------------------------------------*/ + +const dLog = console.debug; + +/*----------------------------------------------------------------------------*/ + + +/** + * @param block a block returned by viewedChartable() + * @param axis axisComponent; parent axis-2d component + * @param axisID axisID + * @param featuresByBlock + * @param blocksData map to receive feature data + */ +export default Ember.Component.extend({ + blockService: service('data/block'), + + setBlockFeaturesData(dataTypeName, featuresData){ + let blocksData = this.get('blocksData'), + typeData = blocksData[dataTypeName] || (blocksData[dataTypeName] = {}), + blockId = this.get('block.id'); + typeData[blockId] = featuresData; + }, + + blockFeatures : Ember.computed('block', 'block.features.[]', 'axis.axis1d.domainChanged', function () { + if (this.get('block.isChartable')) { + let features = this.get('block.features'); + let domain = this.get('axis.axis1d.domainChanged'); + console.log('blockFeatures', features.length, domain); + if (features.length) // - should also handle drawing when .length changes to 0 + { + if (features.hasOwnProperty('promise')) + features = features.toArray(); + if (features[0] === undefined) + dLog('blockFeatures', features.length, domain); + else { + let + // f = features.toArray(), + featuresA = features.map(function (f0) { return f0._internalModel.__data;}); + this.ensureBlockFeatures(featuresA); + this.setBlockFeaturesData('blockData', featuresA); + } + } + } + }), + + featuresCounts : Ember.computed('block', 'block.featuresCounts.[]', 'axis.axis1d.domainChanged', function () { + let featuresCounts = this.get('block.featuresCounts'); + if (featuresCounts && featuresCounts.length) { + /** recognise the data format : $bucketAuto ._id contains .min and .max, whereas $bucket ._id is a single value. + * @see featureCountAutoDataExample, featureCountDataExample + */ + let id = featuresCounts[0]._id, + /** id.min may be 0 */ + dataTypeName = (id.min !== undefined) ? 'featureCountAutoData' : 'featureCountData'; + this.setBlockFeaturesData(dataTypeName, featuresCounts); + } + + return featuresCounts; + }), + +// - results -> blocksData + +/** add features to featuresByBlock (oa.z) + * @param features array of just the feature attributes, without the relation to the parent block. + */ + ensureBlockFeatures : function(features) { + let + axisID = this.get("axisID"); + + ensureBlockFeatures(this.get('block.id'), features); + + // this.layoutAndDrawChart(fa, 'blockData'); + } + + + /*--------------------------------------------------------------------------*/ + + +}); + diff --git a/frontend/app/components/draw/stacks-view.js b/frontend/app/components/draw/stacks-view.js index 6207f96c0..06b046ecd 100644 --- a/frontend/app/components/draw/stacks-view.js +++ b/frontend/app/components/draw/stacks-view.js @@ -11,9 +11,35 @@ export default Ember.Component.extend({ block: service('data/block'), previous : {}, - stacksCount : Ember.computed('block.stacksCount', 'block.viewed', 'axes2d.[]', function () { + /** Extract from viewedBlocksByReferenceAndScope(), the viewed blocks, mapped by the id of their reference block. + */ + axesBlocks : Ember.computed('block.viewedBlocksByReferenceAndScope.@each', function () { + let mapByDataset = this.get('block.viewedBlocksByReferenceAndScope'); + let mapByReferenceBlock = {}; + if (mapByDataset) + for (var [referenceName, mapByScope] of mapByDataset) { + for (var [scope, blocks] of mapByScope) { + mapByReferenceBlock[blocks[0].id] = blocks; + if (true /*trace*/ ) + dLog('axesBlocks', referenceName, scope, blocks.mapBy('_internalModel.__data')); + } + } + dLog('axesBlocks', mapByDataset, mapByReferenceBlock); + return mapByReferenceBlock; + }), + + axesP : Ember.computed('axesBlocks.@each', function () { + let axesBlocks = this.get('axesBlocks'), + axisIDs = Object.keys(axesBlocks), + axesP = axisIDs.map((axisID) => axesBlocks[axisID][0]); + dLog('axesP2', axesP, axisIDs); + return axesP; + }), + + stacksCount : Ember.computed('block.stacksCount', 'block.viewed', 'axes2d.[]', 'axesP.length', function () { let count; let previous = this.get('previous.stacks'); + let axesP = this.get('axesP'); count = this.get('block.stacksCount'); dLog('stacks', count, stacks); dLog(stacks, stacks.axesPCount, 'stacksCount', stacks.stacksCount); @@ -28,7 +54,7 @@ export default Ember.Component.extend({ }), /** @return blocks which correspond to axes, i.e. are not child blocks. */ - axesP : Ember.computed('block.viewed', function () { + axesP_unused : Ember.computed('block.viewed', function () { let blockService = this.get('block'); let viewedBlocks = blockService.get('viewed'); // dLog('viewedBlocks', viewedBlocks); diff --git a/frontend/app/components/in-axis.js b/frontend/app/components/in-axis.js index d5628f5ca..17d070065 100644 --- a/frontend/app/components/in-axis.js +++ b/frontend/app/components/in-axis.js @@ -4,12 +4,15 @@ import { eltWidthResizable, noShiftKeyfilter } from '../utils/domElements'; /* global d3 */ +const dLog = console.debug; + export default Ember.Component.extend({ className : undefined, didInsertElement : function() { this._super(...arguments); + /* grandparent component - listen for resize and zoom events. * possibly these events will move from axis-2d to axis-accordion. * This event handling will move to in-axis, since it is shared by all children of axis-2d/axis-accordion. @@ -36,11 +39,13 @@ export default Ember.Component.extend({ /** @param [axisID, t] */ redrawOnce(axisID_t) { console.log("redrawOnce", axisID_t); - // - redraw if axisID matches this axis - // possibly use transition t for redraw - let redraw = this.get('redraw'); - if (redraw) - redraw.apply(this, axisID_t); + if (! this.isDestroying) { + // - redraw if axisID matches this axis + // possibly use transition t for redraw + let redraw = this.get('redraw'); + if (redraw) + redraw.apply(this, axisID_t); + } }, redrawDebounced(axisID_t) { Ember.run.debounce(this, this.redrawOnce, axisID_t, 1000); @@ -48,6 +53,16 @@ export default Ember.Component.extend({ /*--------------------------------------------------------------------------*/ + /** axis is the axis-2d; this is passed into axis-tracks and axis-charts; + * can be passed into subComponents also. */ + axis1d : Ember.computed.alias('axis.axis1d'), + /** y scale of axis has changed */ + scaleChanged : Ember.computed.alias('axis1d.scaleChanged'), + domainChanged : Ember.computed.alias('axis1d.domainChanged'), + zoomedDomain : Ember.computed.alias('axis1d.zoomedDomain'), + + /*--------------------------------------------------------------------------*/ + getRanges(margin) { // initial version supports only 1 split axis; next identify axis by axisID (and possibly stack id) @@ -122,12 +137,13 @@ export default Ember.Component.extend({ .attr("clip-path", "url(#axis-clip)"); // clip the rectangle let + allocatedWidth = this.get('allocatedWidth'), margin = ranges.margin; let g = gps.merge(gp).selectAll("g." + className+ " > g"); g - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + .attr("transform", "translate(" + (allocatedWidth[0] + margin.left) + "," + margin.top + ")"); return g; }, @@ -148,6 +164,15 @@ export default Ember.Component.extend({ /*--------------------------------------------------------------------------*/ + allocatedWidth : Ember.computed('allocatedWidths', function () { + let + allocatedWidths = this.get('allocatedWidths'), + allocatedWidth = this.get('allocatedWidths.' + this.get('className')); + dLog('allocatedWidth', allocatedWidth, allocatedWidths); + if (! allocatedWidth) + allocatedWidth = [12, 113]; + return allocatedWidth; + }), width : undefined, resized : function(prevSize, currentSize) { diff --git a/frontend/app/components/panel/manage-features.js b/frontend/app/components/panel/manage-features.js index 80de9df05..abb251a2d 100644 --- a/frontend/app/components/panel/manage-features.js +++ b/frontend/app/components/panel/manage-features.js @@ -1,12 +1,33 @@ +import Ember from 'ember'; +const { inject: { service } } = Ember; + import ManageBase from './manage-base' + export default ManageBase.extend({ + block: service('data/block'), + filterOptions: { 'all': {'formal': 'All', 'icon': 'plus'}, 'block': {'formal': 'Block', 'icon': 'globe'}, 'intersect': {'formal': 'Intersect', 'icon': 'random'} }, filter: 'all', + + /** From the .Chromosome field of selectedFeatures, lookup the block. + */ + blockIdFromName(datasetIdAndScope) { + let blocksByReferenceAndScope = this.get('block.blocksByReferenceAndScope'), + [datasetId, scope]=datasetIdAndScope.split(':'), + /** In a GM there can be multiple blocks with the same scope - at this point + * there is no way to distinguish them. This will be resolved when + * selectedFeatures is be converted to a Set of features, from which .block + * can be accessed. + */ + block = blocksByReferenceAndScope.get(datasetId).get(scope)[0]; + return block && block.get('id'); + }, + data: Ember.computed('selectedBlock', 'selectedFeatures', 'filter', function() { let selectedBlock = this.get('selectedBlock') let selectedFeatures = this.get('selectedFeatures') @@ -14,18 +35,23 @@ export default ManageBase.extend({ // perform filtering according to selectedChr let filtered = selectedFeatures //all if (filter == 'block' && selectedBlock) { - filtered = selectedFeatures.filter(function(feature) { - return feature.Block === selectedBlock.id - }) + filtered = selectedFeatures.filter((feature) => { + /** was : feature.Block, which is not defined, so lookup from .Chromosome */ + let featureBlock = this.blockIdFromName(feature.Chromosome); + return featureBlock === selectedBlock.id; + }); } else if (filter == 'intersect') { //split by block let blocks = {} - selectedFeatures.forEach(function(feature) { - if (!blocks[feature.Block]) { - blocks[feature.Block] = {} + selectedFeatures.forEach((feature) => { + /** was : feature.Block */ + let featureBlock = this.blockIdFromName(feature.Chromosome); + if (!blocks[featureBlock]) { + blocks[featureBlock] = {}; } - let block = blocks[feature.Block] + let block = blocks[featureBlock]; + // block[] could be a WeakSet. block[feature.Feature] = true }) filtered = selectedFeatures.filter(function(feature) { diff --git a/frontend/app/components/panel/paths-table.js b/frontend/app/components/panel/paths-table.js new file mode 100644 index 000000000..167206fb0 --- /dev/null +++ b/frontend/app/components/panel/paths-table.js @@ -0,0 +1,741 @@ +import Ember from 'ember'; +const { inject: { service } } = Ember; + +/* global d3 */ +/* global Handsontable */ + + +import PathData from '../draw/path-data'; +import { pathsResultTypes, pathsApiResultType, pathsResultTypeFor, featureGetFn, featureGetBlock } from '../../utils/paths-api'; +import { eltClassName } from '../../utils/domElements'; + + +const dLog = console.debug; +const trace = 0; +/** for trace */ +const fileName = 'panel/paths-table.js'; + +const columnFields = ['block', 'feature', 'position', 'positionEnd']; +const columnFieldsCap = columnFields.map((s) => s.capitalize()); + +var capitalize = (string) => { + return string[0].toUpperCase() + string.slice(1); +}; + +/** Concat the given arrays. + * If either array is empty then return the other array (possibly Array.concat() + * does this optimisation already). + * Used in tableData() - it is expected in this use case that one or both of + * tableData{,Aliases} will commonly be empty. + */ +function concatOpt(a, b) { + let c = (a.length && b.length) ? a.concat(b) + : (b.length ? b : a); + return c; +} + + +/** .moveToFront() is used in highlightFeature() + * table-brushed.js:highlightFeature() will also install (and override) this. + */ +if (! d3.selection.prototype.moveToFront) { + d3.selection.prototype.moveToFront = function() { + return this.each(function(){ + this.parentNode.appendChild(this); + }); + }; +} + +/** Switch to select either HandsOnTable or ember-contextual-table. */ +const useHandsOnTable = true; +/** id of element which will hold HandsOnTable. */ +const hoTableId = 'paths-table-ho'; + + +/** + * Arguments passed to template : + * @param selectedFeatures catenation of features within all brushed regions + * @param selectedBlock block selected via axis brush, or via click in dataset explorer or view panel, or adding the block. + * @param visible true when the tab containing the paths-table is selected. + * value is : mapview .get('layout.right.tab') === 'paths', + * This enables paths-table to provide continuous display of pathsCount in the tab, + * even while the table is not displayed. + * The paths-table component could be split into a calculation component and a display component. + * The calculation is parameterised by user selections and actions so a service doesn't seem suited. + * + * @desc inputs from template + * @param blockColumn true means show the block name:scope in a column instead of as a row. + * + * @desc attributes + * @param loading true indicates the requestAllPaths() API request is in progress. + */ +export default Ember.Component.extend({ + flowsService: service('data/flows-collate'), + pathsPro : service('data/paths-progressive'), + axisBrush: service('data/axis-brush'), + + useHandsOnTable : useHandsOnTable, + /** true enables display of the 'block' column for each end of the path. */ + blockColumn : true, + /** true enables checkboxes to enable the following in the GUI */ + devControls : false, + /** true enables display of the brushedDomains in the table. */ + showDomains : false, + /** true enables display in the table of paths counts pre & post filter. */ + showCounts : false, + /** true filters out paths which do not have >=1 end in a brush. */ + onlyBrushedAxes : true, + + classNames: ['paths-table', 'right-panel-paths'], + + didInsertElement() { + this._super(...arguments); + + if (! useHandsOnTable) { + this.$(".contextual-data-table").colResizable({ + liveDrag:true, + draggingClass:"dragging" + }); + } + + /** trigger the initial display of tableData.length (pathsCount) in tab. + * Another approach would be to yield this from the template, and in + * mapview.hbs ember-wormhole the count into span.badge (in button-tab paths + * Paths). + */ + Ember.run.later(() => this.get('tableData.length'), 2000); + }, + + willDestroyElement() { + this.sendUpdatePathsCount(''); + this.destroyHoTable(); + + this._super(...arguments); + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (trace) + dLog('didReceiveAttrs', this.get('block.id'), this); + }, + + didRender() { + if (trace) + dLog(fileName + " : didRender()"); + this.manageHoTable(); + }, + + /** Create & destroy the HandsOnTable as indicated by .visible (layout.right.tab === 'paths') + * The sibling panel manage-features also has a HandsOnTable, and there seems + * to be a clash if they coexist. + + * The paths-table component exists permanently so that it can provide + * tableData.length for display in the tab via updatePathsCount(). + */ + manageHoTable : function() { + if (useHandsOnTable) { + let + visible = this.get('visible'), + table = this.get('table'); + + dLog('manageHoTable', visible, table, this); + /* If the paths-table component was split into a calculation component and + * a display component, then these 2 parts would go into the + * didInsertElement() and willDestroyElement() of the display component. + */ + if (visible && ! table) { + /* Using run.later() here prevents an overlap in time between the + * HandsOnTable-s of Features and Paths tables, when switching from + * Features to Paths. + */ + Ember.run.later(() => this.set('table', this.createHoTable(this.get('tableData')))); + } + if (! visible && table) { + this.destroyHoTable(); + } + } + }.observes('visible'), + + destroyHoTable() { + let table = this.get('table'); + dLog('destroyHoTable', table); + table.destroy(); + this.set('table', null); + }, + + + /*--------------------------------------------------------------------------*/ + + actions: { + + selectionChanged: function(selA) { + dLog("selectionChanged in components/panel/paths-table", selA); + for (let i=0; i this.send('updatePathsCount', pathsCount)); + }, + + + /*--------------------------------------------------------------------------*/ + + /** Reduce the selectedFeatures value to a structure grouped by block. + * + * The form of the input selectedFeatures is an array of : + * e.g. : {Chromosome: "myMap:1A.1", Feature: "myMarkerA", Position: "12.3"} + */ + selectedFeaturesByBlock : Ember.computed( + 'selectedFeatures.[]', + function () { + + let selectedFeatures = this.get('selectedFeatures'); + /** map selectedFeatures to a hash by block (dataset name and scope). + * selectedFeatures should be e.g. a WeakSet by feature id, not name. + */ + let selectedFeaturesByBlock = selectedFeatures ? + selectedFeatures.reduce(function(result, feature) { + if (feature.Chromosome) { + if (! result[feature.Chromosome]) + result[feature.Chromosome] = {}; + result[feature.Chromosome][feature.Feature] = feature; + } + return result; + }, {}) : {}; + return selectedFeaturesByBlock; + }), + + + /*--------------------------------------------------------------------------*/ + + /** + * also in utils/paths-filter.js @see pathInDomain() + */ + filterPaths(pathsResultField) { + + let selectedBlock = this.get('selectedBlock'); + let selectedFeaturesByBlock = this.get('selectedFeaturesByBlock'); + let axisBrush = this.get('axisBrush'); + /** true means show the block name:scope in a column instead of as a row. */ + let blockColumn = this.get('blockColumn'); + /** true means if the position is an interval, also show the end of the interval. */ + let showInterval = this.get('showInterval'); + let devControls = this.get('devControls'); + /** true means show the brushed domains in the table. This may be omitted, although it + * seems important to know if the axis was brushed and what the region + * was. */ + let showDomains = this.get('showDomains'); + /** true means show counts of paths before and after filtering in the table. */ + let showCounts = this.get('showCounts'); + let onlyBrushedAxes = this.get('onlyBrushedAxes'); + + + let + blockAdjs = this.get('flowsService.blockAdjs'), + tableData = blockAdjs.reduce(function (result, blockAdj) { + // components/panel/manage-settings.js:14: return feature.Block === selectedBlock.id + if (! selectedBlock || blockAdj.adjacentTo(selectedBlock)) { + /** Prepare a row with a pair of values to add to the table */ + function setEndpoint(row, i, block, feature, position) { + if (block) + row['block' + i] = block; + row['feature' + i] = feature; + if (showInterval && position.length) { + row['position' + i] = position[0]; + row['positionEnd' + i] = position[1]; + } + else + row['position' + i] = position; + return row; + }; + let blocks = blockAdj.get('blocks'), + blocksById = blocks.reduce((r, b) => { r[b.get('id')] = b; return r; }, {}); + let blockIndex = blockAdj.get('blockIndex'); + if (devControls && ! blockColumn) { + /** push the names of the 2 adjacent blocks, as an interleaved title row in the table. + * This may be a useful feature for some users, but for now is only + * enabled as a development check. + */ + let + namesFlat = blocks.reduce( + (rowHash, b, i) => + setEndpoint(rowHash, i, undefined, b.get('datasetId.id'), b.get('scope')), {}); + // blank row to separate from previous blockAdj + result.push({'feature0': '_'}); + result.push(namesFlat); + } + if (showDomains) { + /** Add to the table the brushed domain of the 2 blocks. */ + let brushedDomains = blocks.reduce((rowHash, b, i) => { + let ab = axisBrush.brushOfBlock(b), + brushedDomain = ab && ab.get('brushedDomain'); + if (brushedDomain) { + setEndpoint(rowHash, i, undefined, undefined, brushedDomain); + } + return rowHash; + }, {}); + result.push(brushedDomains); + } + + let resultElts = blockAdj.get(pathsResultField); + if (resultElts) { + if (resultElts.length) + if (showCounts) + result.push(setEndpoint({}, 0, undefined, 'Paths loaded', resultElts.length)); + let outCount = 0; + resultElts.forEach((resultElt) => { + /** for each end of the path, if the endpoint is in a brushed block, + * filter out if the endpoint is not in the (brushed) + * selectedFeatures. + */ + + let + pathsResultTypeName = pathsResultField.replace(/Filtered$/, ''), + pathsResultType = pathsResultTypeFor(pathsResultTypeName, resultElt); + + + /** accumulate names for table, not used if out is true. */ + let path = {}; + /** Check one endpoint of the given path resultElt + * @param resultElt one row (path) of the paths result + * @param i 0 or 1 to select either endpoint of the path resultElt + * @param path array to accumulate path endpoint details in text form for table output + * @return { axisBrushed : axis is brushed, + * out : axis is brushed and path endpoint is not in the brush (i.e. not in selectedFeatures) + * } + */ + function filterOut(resultElt, i, path) { + let + blockId = pathsResultType.pathBlock(resultElt, i), + /** each end may have multiple features - should generate the + * cross-product as is done in block-adj.c : pathsOfFeature() + */ + features = pathsResultType.blocksFeatures(resultElt, i), + feature = features[0], + featureGet = featureGetFn(feature), + block = featureGetBlock(feature, blocksById), + /** brushes are identified by the referenceBlock (axisName). */ + chrName = block.get('brushName'), + selectedFeaturesOfBlock = selectedFeaturesByBlock[chrName], + featureName = featureGet('name'), + /** if the axis is brushed but there are no features in this block + * within the brush, then selectedFeaturesOfBlock will be + * undefined (empty). If ! onlyBrushedAxes and the axis is not + * brushed then no filter is applied on this endpoint. + */ + isBrushed = !!axisBrush.brushOfBlock(block), + /** out means path end is excluded from a brush, i.e. there is a + * brush on the axis of the path end, and the path end is not in it. + * (the requirement was changed after commit a6e884c, and + * onlyBrushedAxes was added). + */ + out = selectedFeaturesOfBlock ? + /* endpoint feature is not in selectedFeaturesOfBlock */ + ! selectedFeaturesOfBlock[featureName] + /* end axis is brushed, yet feature is not selected, so it is out. */ + : isBrushed; + { + let value = featureGet('value'); + let i = blockIndex.get(block); + path['block' + i] = block.get('datasetNameAndScope'); + if (value.length) { + path['position' + i] = value[0]; + path['positionEnd' + i] = value[1]; + } + else + path['position' + i] = '' + value; + path['feature' + i] = featureName; + } + return {axisBrushed : isBrushed, out}; + } + /** Considering the axes of each end of the path : + * . one brushed : show paths with an end in the brush + * . both brushed : show paths with ends in both brushes + * . neither brushed : don't show + * + * Evaluate both because they populate path, and path is in if + * either end is in a brush, but out if both ends are not in a brush + * on its axis. */ + let ends = [0, 1].map((i) => filterOut(resultElt, i, path)), + out = (ends[0].out || ends[1].out) || (onlyBrushedAxes ? (! ends[0].axisBrushed && ! ends[1].axisBrushed) : false); + if (! out) { + result.push(path); + outCount++; + } + }); + if (showCounts) + result.push(setEndpoint({}, 0, undefined, 'Filtered Count', outCount)); + } + } + return result; + }, []); + + dLog('filterPaths', tableData.length, blockAdjs); + return tableData; + }, + + /** Map the paths (direct and by-alias) for each block-adj bounding + * selectedBlock into an array formatted for display in the table (either + * ember-contextual-table or HandsOnTable). + */ + tableData : Ember.computed( + 'pathsResultFiltered.[]', + 'pathsAliasesResultFiltered.[]', + 'selectedBlock', + 'selectedFeaturesByBlock.@each', + 'blockColumn', + 'showInterval', + 'showDomains', + 'showCounts', + 'onlyBrushedAxes', + function () { + let tableData = this.filterPaths('pathsResultFiltered'); + if (tableData.length < 20) + dLog('tableData', tableData); + let tableDataAliases = this.filterPaths('pathsAliasesResultFiltered'); + if (tableDataAliases.length < 20) + dLog('tableDataAliases', tableDataAliases); + let data = concatOpt(tableData, tableDataAliases); + this.sendUpdatePathsCount(data.length); + return data; + }), + + /** From columnFields, select those columns which are enabled. + * The blockColumn flag enables the 'block' column. + */ + activeFields : Ember.computed('blockColumn', 'showInterval', function () { + let activeFields = columnFields.slice(this.get('blockColumn') ? 0 : 1); + if (! this.get('showInterval')) + activeFields.pop(); // pop off : positionEnd + return activeFields; + }), + /** Generate the names of values in a row. */ + rowValues : Ember.computed('activeFields', function () { + let activeFields = this.get('activeFields'); + let rowValues = + [0, 1].reduce(function (result, end) { + activeFields.reduce(function (result, fieldName) { + result.push(fieldName + end); + return result; + }, result); + return result; + }, []); + return rowValues; + }), + /** Generate the column headings row. + */ + headerRow : Ember.computed('rowValues', function () { + let headerRow = this.get('rowValues').map((fieldName) => capitalize(fieldName)); + dLog('headerRow', headerRow); + return headerRow; + }), + /** Map from tableData to the data form used by the csv export / download. + */ + csvExportData : Ember.computed('tableData', 'blockColumn', function () { + let activeFields = this.get('activeFields'); + /** column header text does not contain punctuation, but wrapping with + * quotes may help clarify to Xl that it is text. + */ + let headerRow = this.get('headerRow').map((name) => '"' + name + '"'); + + let data = this.get('tableData').map((d, i) => { + let da = + [0, 1].reduce(function (result, end) { + activeFields.reduce(function (result, fieldName) { + // if undefined, push '' for a blank cell. + let v = d[fieldName + end], + outVal = (v === undefined) ? '' : ((typeof v !== "string") ? v: format(fieldName, v)); + /** Format some table cell values : wrap text values with double-quotes; + * If the position field is an interval it will present here as a + * string containing the start&end locations of the interval, + * separated by a comma - change the comma to a semi-colon. + * + * With the addition of showInterval, intervals won't be expressed as + * strings containing comma, so most of this will be not needed, can + * be reduced to just the quoting. + * + * @param fieldName one of columnFields[] + * @param v is type "string" and is not undefined. + */ + function format (fieldName, v) { + let + /** wrap text fields with double-quotes, and also position with comma - i.e. intervals 'from,to'. */ + quote = (fieldName === 'block') || (fieldName === 'feature') || (v.indexOf(',') > -1), + /** If position value is an interval, it will appear at this point as 'number,number'. + * What to use for interval separator ? There is a convention of using + * '-' for intervals in genome data, but that may cause problems in a + * csv - Excel may evaluate it as minus. In a tsv ',' would be ok. + * For now use ':'. */ + v1 = v.replace(/,/, ':'), + outVal = quote ? '"' + v1 + '"' : v1; + return outVal; + } + result.push(outVal); + return result; + }, result); + return result; + }, []); + if (i === 0) + dLog('csvExportData', d, da); + return da; + }); + data.unshift(headerRow); + return data; + }), + + /** Convert each element of the array tableData from name:value hash/object to + * an array, with 1 element per column. + * Added for hoTable, but not required as HO also accepts a name:value hash, + * as an alternative to an array per row. + */ + tableDataArray : Ember.computed('tableData.[]', function () { + let activeFields = this.get('activeFields'); + /** this could be used in csvExportData() - it also uses an array for each row. */ + let data = this.get('tableData').map(function (d, i) { + let da = + [0, 1].reduce(function (result, end) { + activeFields.reduce(function (result, fieldName) { + // if undefined, push '' for a blank cell. + let v = d[fieldName + end], + outVal = v ? v : ''; + result.push(outVal); + return result; + }, result); + return result; + }, []); + + if (trace && (i === 0)) + dLog('tableDataArray', d, da); + return da; + }); + return data; + }), + + + /*--------------------------------------------------------------------------*/ + + /** Send API request for the block-adj-s displayed in the table, with + * fullDensity=true, i.e. not limited by GUI path densityFactor / nSamples / + * nFeatures setings. + */ + requestAllPaths() { + dLog('requestAllPaths', this); + + let selectedBlock = this.get('selectedBlock'); + let loadingPromises = []; + + let pathsPro = this.get('pathsPro'); + pathsPro.set('fullDensity', true); + + let + blockAdjs = this.get('flowsService.blockAdjs'); + blockAdjs.forEach(function (blockAdj) { + if (! selectedBlock || blockAdj.adjacentTo(selectedBlock)) { + let p = blockAdj.call_taskGetPaths(); + loadingPromises.push(p); + } + }); + pathsPro.set('fullDensity', false); + + /* set .loading true while API requests are in progress. */ + if (loadingPromises.length) { + let all = Ember.RSVP.allSettled(loadingPromises); + this.set('loading', true); + all.then(() => this.set('loading', false)); + } + + }, + /*--------------------------------------------------------------------------*/ + + /** + * @param data array of [block, feature, position, block, feature, position]. + * see columnFields, activeFields. + */ + showData : function(data) + { + if (trace && data.length < 20) + dLog("showData", data); + let table = this.get('table'); + if (table) + { + table.loadData(data); + } + }, + + /** for HO table configuration. */ + columns : Ember.computed('rowValues', function () { + let columns = this.get('rowValues').map((name) => { + let options = { data : name}; + if (name.match(/^position/)) { + options.type = 'numeric'; + options.numericFormat = { + pattern: '0,0.*' + }; + } + else + options.type = 'text'; + return options; + }); + return columns; + }), + + /** for HO table configuration. */ + colTypes : { + block : { width : 100, header : 'Block'}, + feature : { width : 135, header : 'Feature'}, + position : { width : 60, header : 'Position'}, + positionEnd : { width : 60, header : 'Position End'} + }, + /** for HO table configuration. + * + * colTypeNames and colHeaders could also be used for generating + * filterableColumn / sortableColumn in the template, although for the current + * number of columns it probably would not reduce the code size. + */ + colTypeNames : Ember.computed('rowValues', function () { + let colTypeNames = this.get('rowValues').map((name) => { + return name.replace(/[01]$/, ''); + }); + return colTypeNames; + }), + /** for HO table configuration. */ + colHeaders : Ember.computed('colTypeNames', function () { + let colTypes = this.get('colTypes'), + colHeaders = this.get('colTypeNames').map((name) => colTypes[name].header); + return colHeaders; + }), + /** for HO table configuration. + * HO column settings (columns, colHeaders, colWidths) could be combined into + * a single CP, but the widths will change depending on window resize, + * independent of columns changing. + */ + colWidths : Ember.computed('colTypeNames', function () { + let colTypes = this.get('colTypes'), + colWidths = this.get('colTypeNames').map((name) => colTypes[name].width); + return colWidths; + }), + + createHoTable: function(data) { + /** copied from table-brushed.js */ + var that = this; + dLog("createHoTable", this); + + let tableDiv = Ember.$('#' + hoTableId)[0]; + + + dLog("tableDiv", tableDiv); + var table = new Handsontable(tableDiv, { + data: data || [['', '', '']], + minRows: 1, + rowHeaders: true, + columns: this.get('columns'), + colHeaders: this.get('colHeaders'), + headerTooltips: true, + colWidths: this.get('colWidths'), + height: 600, + manualRowResize: true, + manualColumnResize: true, + manualRowMove: true, + // manualColumnMove: true, + copyPaste: { + /** increase the limit on copy/paste. default is 1000 rows. */ + rowsLimit: 10000 + }, + // disable editing, refn: https://stackoverflow.com/a/40801002 + readOnly: true, // make table cells read-only + contextMenu: false, // disable context menu to change things + comments: false, // prevent editing of comments + + sortIndicator: true, + multiColumnSorting: true, + /* in the latest non-commercial version 6.2.2, multiColumnSorting is + * present but didn't work with 'multiColumnSorting: true'; + * it is fine for all other features used. + * The following line enables the app to work with versions after 6.2.2, + * refn : https://handsontable.com/blog/articles/2019/3/handsontable-drops-open-source-for-a-non-commercial-license + * This app is used for academic research. + */ + licenseKey: 'non-commercial-and-evaluation' + }); + + let $ = Ember.$; + $('#' + hoTableId).on('mouseleave', function(e) { + that.highlightFeature(); + }).on("mouseover", function(e) { + if (e.target.tagName == "TD") { + var tr = e.target.parentNode; + /** determine which half of the table the hovered cell is in, the left or right, + * and identify the feature cell in this row, in that half of the table. */ + let row = Array.from(e.target.parentElement.childNodes), + colIndex = row.indexOf(e.target), + featureIndex = (colIndex > (1 + 2)) * (1 + 2) + (1 + 1), + featureNode = tr.childNodes[featureIndex]; + if (featureNode) { + var feature_name = $(featureNode).text(); + if (feature_name && feature_name.length && (feature_name.indexOf(" ") == -1)) { + that.highlightFeature(feature_name); + return; + } + } + } + // that.highlightFeature(); + }); + return table; + }, + + + onDataChange: function () { + let data = this.get('tableData'), + me = this, + table = this.get('table'); + if (table) + { + if (trace) + dLog(fileName, "onDataChange", table, data.length); + // me.send('showData', data); + Ember.run.throttle(() => table.updateSettings({data:data}), 500); + } + }.observes('tableData'), + + onColumnsChange : function () { + let table = this.get('table'); + if (table) { + let colSettings = { + columns: this.get('columns'), + colHeaders: this.get('colHeaders'), + colWidths: this.get('colWidths') + }; + table.updateSettings(colSettings); + } + }.observes('columns', 'colHeaders', 'colWidths'), + + highlightFeature: function(feature) { + d3.selectAll("g.axis-outer > circle") + .attr("r", 2) + .style("fill", "red") + .style("stroke", "red"); + if (feature) { + /** see also handleFeatureCircleMouseOver(). */ + d3.selectAll("g.axis-outer > circle." + eltClassName(feature)) + .attr("r", 5) + .style("fill", "yellow") + .style("stroke", "black") + .moveToFront(); + } + } + +}); diff --git a/frontend/app/components/table-brushed.js b/frontend/app/components/table-brushed.js index 6de6fccab..3b4c57d97 100644 --- a/frontend/app/components/table-brushed.js +++ b/frontend/app/components/table-brushed.js @@ -3,7 +3,8 @@ import { eltClassName } from '../utils/domElements'; // import Handsontable from 'handsontable'; -/* global d3 Handsontable */ +/* global d3 */ +/* global Handsontable */ const trace = 0; const dLog = console.debug; @@ -36,6 +37,20 @@ export default Ember.Component.extend({ dLog("components/table-brushed.js: didInsertElement"); }, + /** Destroy the HandsOnTable so that it does not clash with the HandsOnTable + * created by paths-table. + */ + willDestroyElement() { + let table = this.get('table'); + if (table) { + dLog('willDestroyElement', table); + table.destroy(); + this.set('table', undefined); + } + + this._super(...arguments); + }, + didRender() { dLog("components/table-brushed.js: didRender"); @@ -83,12 +98,18 @@ export default Ember.Component.extend({ manualColumnResize: true, manualRowMove: true, // manualColumnMove: true, + copyPaste: { + /** increase the limit on copy/paste. default is 1000 rows. */ + rowsLimit: 10000 + }, contextMenu: true, sortIndicator: true, columnSorting: { column: 2, sortOrder: true - } + }, + // see comment in paths-table.js + licenseKey: 'non-commercial-and-evaluation' }); that.set('table', table); $("#table-brushed").on('mouseleave', function(e) { @@ -134,7 +155,7 @@ export default Ember.Component.extend({ .style("stroke", "red"); if (feature) { /** see also handleFeatureCircleMouseOver(). */ - d3.selectAll("g.axis-outer > circle." + eltClassName(eltClassName(feature))) + d3.selectAll("g.axis-outer > circle." + eltClassName(feature)) .attr("r", 5) .style("fill", "yellow") .style("stroke", "black") diff --git a/frontend/app/controllers/mapview.js b/frontend/app/controllers/mapview.js index 543b98d01..a4334104a 100644 --- a/frontend/app/controllers/mapview.js +++ b/frontend/app/controllers/mapview.js @@ -37,13 +37,26 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { this.set(`layout.${side}.visible`, !visibility); }, setTab: function(side, tab) { - // dLog("setTab", side, tab); + dLog("setTab", side, tab, this.get('layout')); this.set(`layout.${side}.tab`, tab); }, updateSelectedFeatures: function(features) { // dLog("updateselectedFeatures in mapview", features.length); this.set('selectedFeatures', features); - this.send('setTab', 'right', 'selection'); + /** results of a selection impact on the selection (selectedFeatures) tab + * and the paths tab, so if neither of these is currently shown, show the + * selection tab. + */ + let rightTab = this.get('layout.right.tab'); + /* this feature made sense when (selected) features table was new, but now + * there is also paths table so it is not clear which tab to switch to, + * and now the the table sizes (ie. counts of brushed features / paths) + * are shown in their respective tabs, which serves to draw attention to + * the newly available information, so this setTab() is not required. + */ + if (false) + if ((rightTab !== 'selection') && (rightTab !== 'paths')) + this.send('setTab', 'right', 'selection'); }, /** goto-feature-list is given features by the user and finds them in * blocks; this is that result in a hash, indexed by block id, with value @@ -53,6 +66,12 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { // dLog("updateFeaturesInBlocks in mapview", featuresInBlocks); this.set('featuresInBlocks', featuresInBlocks); }, + /** from paths-table */ + updatePathsCount: function(pathsCount) { + dLog("updatePathsCount in mapview", pathsCount); + this.set('pathsTableSummary.count', pathsCount); + }, + /** Change the state of the named block to viewed. * If this block has a parent block, also add the parent. @@ -131,11 +150,6 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { t.apply(this, [id]); } }, - blockFromId : function(blockId) { - let store = this.get('store'), - block = store.peekRecord('block', blockId); - return block; - }, selectBlock: function(block) { dLog('SELECT BLOCK mapview', block.get('name'), block.get('mapName'), block.id, block); @@ -179,7 +193,7 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { } }, - layout: { + layout: Ember.Object.create({ 'left': { 'visible': true, 'tab': 'view' @@ -188,7 +202,7 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { 'visible': true, 'tab': 'selection' } - }, + }), controls : Ember.Object.create({ view : { } }), @@ -196,6 +210,8 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { mapsToView: [], selectedFeatures: [], + /** counts of selected paths, from paths-table; shown in tab. */ + pathsTableSummary : {}, scaffolds: undefined, @@ -227,6 +243,13 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { blockIds : readOnly('model.viewedBlocks.blockIds'), + blockFromId : function(blockId) { + let store = this.get('store'), + block = store.peekRecord('block', blockId); + return block; + }, + + /** Used by the template to indicate when & whether any data is loaded for the graph. */ hasData: Ember.computed( @@ -259,7 +282,11 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { * Also adding a similar request to updateModal (refreshDatasets) so by this * time that result should have been received. */ - this.ensureFeatureLimits(id); + let block = this.blockFromId(id); + if (block) + block.ensureFeatureLimits(); + else + this.get('block').ensureFeatureLimits(id); /** Before progressive loading this would load the data (features) of the block. */ const progressiveLoading = true; @@ -269,7 +296,19 @@ export default Ember.Controller.extend(Ember.Evented, ViewedBlocks, { dLog("block", id, block); // block.set('isViewed', true); } - } + }, + /** Provide a class for the div which wraps the right panel. + * + * The class indicates which of the tabs in the right panel is currently + * selected/displayed. paths-table css uses this to display:none the + * components div; the remainder of the template is disabled via {{#if + * (compare layout.right.tab '===' 'paths')}} which wraps the whole component. + */ + rightPanelClass : Ember.computed('layout.right.tab', function () { + let tab = this.get('layout.right.tab'); + dLog('rightPanelClass', tab); + return 'right-panel-' + tab; + }), }); diff --git a/frontend/app/mixins/axis-position.js b/frontend/app/mixins/axis-position.js index 9e44ac435..fd4ca9ec9 100644 --- a/frontend/app/mixins/axis-position.js +++ b/frontend/app/mixins/axis-position.js @@ -30,12 +30,12 @@ export default Mixin.create({ currentPosition : undefined, lastDrawn : undefined, - init() { + init_1 : function() { let store = this.get('store'); this.set('currentPosition', store.createRecord('vline-position')); this.set('lastDrawn', store.createRecord('vline-position')); this._super(...arguments); - }, + }.on('init'), /* updateDomain() and setDomain() moved here from utils/stacks.js diff --git a/frontend/app/models/block-adj.js b/frontend/app/models/block-adj.js index 9a1411811..dad841098 100644 --- a/frontend/app/models/block-adj.js +++ b/frontend/app/models/block-adj.js @@ -8,6 +8,9 @@ import { task, timeout } from 'ember-concurrency'; const { inject: { service } } = Ember; import { stacks, Stacked } from '../utils/stacks'; +import { pathsResultTypeFor, featureGetFn, featureGetBlock } from '../utils/paths-api'; +import { inRangeEither } from '../utils/draw/zoomPanCalcs'; + const dLog = console.debug; const trace_blockAdj = 0; @@ -16,6 +19,7 @@ export default DS.Model.extend(Ember.Evented, { pathsPro : service('data/paths-progressive'), flowsService: service('data/flows-collate'), + blocksService : service('data/block'), /** id is blockAdjId[0] + '_' + blockAdjId[1], as per. serializers/block-adj.js : extractId() @@ -49,15 +53,33 @@ export default DS.Model.extend(Ember.Evented, { }, /*--------------------------------------------------------------------------*/ - /* CFs based on axes could be moved to a component, e.g. draw/ stacks-view or block-adj */ + /** @return true if this block is adjacent to the given block-adj + */ + adjacentTo (block) { + let blocks = this.get('blocks'), + found = blocks.indexOf(block) > -1; + if (! found) { + /** also check referenceBlocks because clicking on axis selects the reference block */ + let referenceBlocks = this.get('referenceBlocks'); + found = referenceBlocks.indexOf(block) > -1; + dLog('adjacentTo', found, referenceBlocks, block); + } + return found; + }, + /*--------------------------------------------------------------------------*/ + /* CFs based on axes could be moved to a component, e.g. draw/ stacks-view or block-adj */ + + /** @return an array of block-s, for blockId-s in blockAdjId + */ blocks : Ember.computed('blockAdjId', function () { let blockAdjId = this.get('blockAdjId'), blocks = blockAdjId.map((blockId) => { return this.peekBlock(blockId); } ); return blocks; }), + referenceBlocks : Ember.computed.mapBy('blocks', 'referenceBlock'), /** Stacked Blocks - should be able to retire this. */ sBlocks : Ember.computed('blockAdjId', function () { let @@ -82,6 +104,19 @@ export default DS.Model.extend(Ember.Evented, { axes1d = axes.mapBy('axis1d'); return axes1d; }), + + /** Return the zoomedDomain for each block of the block-adj, if they are + * zoomed, undefined otherwise. + * + * This is useful because filtering can be skipped if ! zoomed, and the API + * request also can omit the range filter if ! zoomed, so this might be used + * also by intervalParams() (paths-progressive.js). + * + * Similar to following @see axesDomains() + * @desc but that function determines the referenceBlock's domain if the block is not zoomed. + */ + zoomedDomains : Ember.computed.mapBy('axes1d', 'zoomedDomain'), + /** Return the domains (i.e. zoom scope) of the 2 axes of this block-adj. * These are equivalent : * * this.get('axes').mapBy('domain') @@ -148,6 +183,7 @@ export default DS.Model.extend(Ember.Evented, { /** interim measure to include pathsDensityParams in comparison; planning to * restructure using CP. */ pathsDensityParams = this.get('pathsPro.pathsDensityParams'), + fullDensity = this.get('pathsPro.fullDensity'), /** this can now be simplified to use axesDomains(). */ intervalsAxes = this.get('axisDimensions'), domainsDiffer = function () @@ -178,7 +214,7 @@ export default DS.Model.extend(Ember.Evented, { dLog('domainChange', intervals, intervalsAxes, domainChanges, change); return change; }, - domainChange = ! intervals || domainsDiffer(); + domainChange = fullDensity || ! intervals || domainsDiffer(); return domainChange; }), @@ -238,6 +274,71 @@ export default DS.Model.extend(Ember.Evented, { result.alias = p; return result; }), + /** @return a Map from a block of the block-adj to its position in blockAdjId. */ + blockIndex : Ember.computed('blocks', function () { + let blocks = this.get('blocks'), + blockIndex = blocks.reduce(function (index, block, i) { + index.set(block, i); + return index; + }, new Map()); + return blockIndex; + }), + filterPathsResult(pathsResultTypeName) { + let paths, pathsFiltered; + let blocksById = this.get('blocksService.blocksById'); + if ((paths = this.get(pathsResultTypeName))) { + let pathsResultType = pathsResultTypeFor(pathsResultTypeName, paths && paths[0]); + let + blockIndex = this.get('blockIndex'), + /** blocks[] is parallel to zoomedDomains[]. */ + blocks = this.get('blocks'), + /** just a check, will use block.get('zoomedDomain') in filter. */ + zoomedDomains = this.get('zoomedDomains'); + + function filterOut(resultElt, i) { + /** based on filterPaths: filterOut() in paths-table */ + let + blockId = pathsResultType.pathBlock(resultElt, i), + /** each end may have multiple features. + * Here any features outside of the zoomedDomain of the feature's + * block's axis are filtered out (or will be in a later version), and later the cross-product is + * generated from the remainder. If all features of one end are + * filtered out then the resultElt is filtered out. + */ + features = pathsResultType.blocksFeatures(resultElt, i), + /** currently only handles paths with 1 feature at each end; i.e. it + * will filter out the whole resultElt if feature[0] is out of range. + * To filter out just some features of a resultElt, will need to copy the resultElt and .features[]. + */ + feature = features[0], + featureGet = featureGetFn(feature), + block = featureGetBlock(feature, blocksById), + index = blockIndex.get(block), + zoomedDomain = zoomedDomains[index], + value = featureGet('value'), + out = zoomedDomain && ! inRangeEither(value, zoomedDomain); + return out; + } + + pathsFiltered = paths.filter(function (resultElt, i) { + let include; + let out = filterOut(resultElt, 0) || filterOut(resultElt, 1); + return ! out; + }); + } + return pathsFiltered; + }, + pathsResultFiltered : Ember.computed('blocks', 'pathsResult.[]', function () { + let + pathsFiltered = this.filterPathsResult('pathsResult'); + return pathsFiltered; + }), + pathsAliasesResultFiltered : Ember.computed('pathsAliasesResult.[]', function () { + let + pathsFiltered = this.filterPathsResult('pathsAliasesResult'); + return pathsFiltered; + }), + /** * Depending on zoomCounter is just a stand-in for depending on the domain of each block, * which is part of changing the axes (Stacked) to Ember components, and the dependent keys can be e.g. block0.axis.domain. diff --git a/frontend/app/models/block.js b/frontend/app/models/block.js index a0abd087f..8be6e96b3 100644 --- a/frontend/app/models/block.js +++ b/frontend/app/models/block.js @@ -6,19 +6,28 @@ const { inject: { service } } = Ember; import { A } from '@ember/array'; import { and } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; import { intervalMerge } from '../utils/interval-calcs'; +/*----------------------------------------------------------------------------*/ + const trace_block = 0; const dLog = console.debug; - const moduleName = 'models/block'; +/*----------------------------------------------------------------------------*/ + +/** trace the (array) value or just the length depending on trace level. */ +function valueOrLength(value) { return (trace_block > 1) ? value : value.length; } + +/*----------------------------------------------------------------------------*/ export default DS.Model.extend({ pathsP : service('data/paths-progressive'), // for getBlockFeaturesInterval() blockService : service('data/block'), + auth: service('auth'), datasetId: DS.belongsTo('dataset'), annotations: DS.hasMany('annotation', { async: false }), @@ -147,6 +156,117 @@ export default DS.Model.extend({ return isChartable; }), + /*--------------------------------------------------------------------------*/ + + /*--------------------------------------------------------------------------*/ + + /** these 3 functions ensureFeatureLimits(), taskGetLimits(), getLimits() (and + * also valueOrLength()) are copied from services/data/block.js; + * although the API is the same, this use case is for a loaded block, and the + * services/data/ case is for all blocks or a blockId (which may not be + * loaded). + * This can be rationalised when re-organising the model construction. + */ + + /** get featureLimits if not already received. After upload the block won't have + * .featureLimits until requested + */ + ensureFeatureLimits() { + let limits = this.get('featureLimits'); + /** Reference blocks don't have .featureLimits so don't request it. + * block.get('isData') depends on featureCount, which won't be present for + * newly uploaded blocks. Only references have .range (atm). + */ + let isData = ! this.get('range'); + if (! limits && isData) { + let blocksLimitsTasks = this.get('taskGetLimits').perform(); + } + }, + + /** Call getLimits() in a task - yield the block limits result. + */ + taskGetLimits: task(function * () { + let blockLimits = yield this.getLimits(); + if (trace_block) + dLog('taskGetLimits', this, valueOrLength(blockLimits)); + blockLimits.forEach((bfc) => { + if (bfc._id !== this.get('id')) + dLog('taskGetLimits', bfc._id); + else { + dLog('taskGetLimits', bfc, this); + this.set('featureLimits', [bfc.min, bfc.max]); + if (! this.get('featureCount')) + this.set('featureCount', bfc.featureCount); + } + }); + + return blockLimits; + }).drop(), + + getLimits: function () { + let blockId = this.get('id'); + dLog("block getLimits", blockId); + + let blockP = + this.get('auth').getBlockFeatureLimits(blockId, /*options*/{}); + + return blockP; + }, + + + /*--------------------------------------------------------------------------*/ + + /** generate a text name for the block, to be displayed - it should be + * user-readable and uniquely identify the block. + */ + datasetNameAndScope : Ember.computed('datasetId.id', 'scope', function () { + /** This is currently the name format which is used in + * selectedFeatures.Chromosome + * In paths-table.js @see blockDatasetNameAndScope() + */ + let name = (this.get('datasetId.meta.shortName') || this.get('datasetId.id')) + ':' + this.get('scope'); + return name; + }), + + /** for the given block, generate the name format which is used in + * selectedFeatures.Chromosome + * Used by e.g. paths-table to access selectedFeatures - need to match the + * block identification which is used by brushHelper() when it generates + * selectedFeatures. + * + * block.get('datasetNameAndScope') may be the same value; it can + * use shortName, and its purpose is display, whereas + * selectedFeatures.Chromosome is for identifying the block (and + * could be changed to blockId). + */ + brushName : Ember.computed('name', 'datasetId', 'referenceBlock', function() { + /** This calculation replicates the value used by brushHelper(), which draws + * on axisName2MapChr(), makeMapChrName(), copyChrData(). + * That can be replaced by simply this function, which will then be the + * source of the value .Chromosome in selectedFeatures. + */ + let brushName; + /** brushHelper() uses blockR.get('datasetId.meta.shortName') where blockR is the data block, + * and axisName2MapChr(p) where p is the axisName (referenceBlock). + */ + let shortName = this.get('datasetId.meta.shortName'); + /** brushes are identified by the referenceBlock (axisName). */ + let block = this.get('referenceBlock') || this; + if (block) { + let + /** e.g. "IWGSC" */ + blockName = shortName || block.get('datasetId.name'), + /** e.g. "1B" */ + scope = block.get('name'); + brushName = blockName + ':' + scope; + } + + return brushName; + }), + + + /*--------------------------------------------------------------------------*/ + /** If the dataset of this block has a parent, return the name of that parent (reference dataset). * @return the reference dataset name or undefined if none */ @@ -230,6 +350,8 @@ export default DS.Model.extend({ dLog('block axis', this.get('id'), this.get('view'), 'no view.axis for block or referenceBlock', referenceBlock); }), + zoomedDomain : Ember.computed.alias('axis.axis1d.zoomedDomain'), + /*--------------------------------------------------------------------------*/ /** When block is added to an axis, request features, scoped by the axis diff --git a/frontend/app/routes/mapview.js b/frontend/app/routes/mapview.js index 20d320c96..1c8b09158 100644 --- a/frontend/app/routes/mapview.js +++ b/frontend/app/routes/mapview.js @@ -75,7 +75,12 @@ let config = { this.controllerFor(this.fullRouteName).setViewedOnly(params.mapsToView, true); let blockService = this.get('block'); - let blocksLimitsTask = blockService.getBlocksLimits(undefined); + let blocksLimitsTask = this.get('blocksLimitsTask'); + dLog('blocksLimitsTask', blocksLimitsTask); + if (! blocksLimitsTask || ! blocksLimitsTask.get('isRunning')) { + blocksLimitsTask = blockService.getBlocksLimits(undefined); + this.set('blocksLimitsTask', blocksLimitsTask); + } let allInitially = params.parsedOptions && params.parsedOptions.allInitially; let getBlocks = blockService.get('getBlocks' + (allInitially ? '' : 'Summary')); let viewedBlocksTasks = (params.mapsToView && params.mapsToView.length) ? diff --git a/frontend/app/services/auth.js b/frontend/app/services/auth.js index 2cc508ae6..f66a97923 100644 --- a/frontend/app/services/auth.js +++ b/frontend/app/services/auth.js @@ -228,10 +228,10 @@ export default Service.extend({ return this._ajax('Blocks/pathsByReference', 'GET', {blockA : blockA, blockB : blockB, reference, max_distance, options : options}, true); }, - getBlockFeaturesCounts(block, nBins, options) { + getBlockFeaturesCounts(block, interval, nBins, options) { if (trace_paths) - dLog('services/auth getBlockFeaturesCounts', block, nBins, options); - return this._ajax('Blocks/blockFeaturesCounts', 'GET', {block, nBins, options}, true); + dLog('services/auth getBlockFeaturesCounts', block, interval, nBins, options); + return this._ajax('Blocks/blockFeaturesCounts', 'GET', {block, interval, nBins, options}, true); }, getBlockFeaturesCount(blocks, options) { diff --git a/frontend/app/services/data/axis-brush.js b/frontend/app/services/data/axis-brush.js index 526c5de9e..9e5a4a965 100644 --- a/frontend/app/services/data/axis-brush.js +++ b/frontend/app/services/data/axis-brush.js @@ -34,8 +34,8 @@ export default Service.extend(Ember.Evented, { dLog('brushedAxes', this); const fnName = 'brushedAxes'; let records = this.get('all'); - // if (trace_axisBrush > 1) - records.map(function (a) { console.log(fnName, a, a.get('brushedDomain')); } ); + if (trace_axisBrush > 1) + records.map(function (a) { dLog(fnName, a, a.get('brushedDomain')); } ); if (trace_axisBrush) dLog('viewed Blocks', records); let blocks = records.filter(function (ab) { @@ -45,6 +45,27 @@ export default Service.extend(Ember.Evented, { dLog(fnName, blocks); return blocks; - }) + }), + brushesByBlock : Ember.computed('brushedAxes.[]', function () { + let brushesByBlock = this.get('brushedAxes').reduce(function (result, ab) { + result[ab.get('block.id')] = ab; + return result; }, {} ); + if (trace_axisBrush) + dLog('brushesByBlock', brushesByBlock); + return brushesByBlock; + }), + /** Lookup brushedAxes for the given block, or its referenceBlock. + */ + brushOfBlock(block) { + let brushesByBlock = this.get('brushesByBlock'), + brush = brushesByBlock[block.get('id')]; + let referenceBlock; + if (! brush && (referenceBlock = block.get('referenceBlock'))) { + brush = brushesByBlock[referenceBlock.get('id')]; + } + if (trace_axisBrush > 1) + dLog('brushOfBlock', brush, brushesByBlock, referenceBlock); + return brush; + } }); diff --git a/frontend/app/services/data/block.js b/frontend/app/services/data/block.js index 178d375b2..5bae0f16b 100644 --- a/frontend/app/services/data/block.js +++ b/frontend/app/services/data/block.js @@ -28,7 +28,35 @@ function log_Map(label, map) { console.log(label, block_text(key), blocks && blocks.map(block_text)); }); } +/** log a Map string -> Map (string -> [block]) + */ +function log_Map_Map(label, map) { + map.forEach(function (value, key) { + console.log(label, 'key'); + log_Map('', value); + }); +} +/** Filter the values of the given map. If the filter result is undefined, omit + * the value from the result map. + * This is a combination of map and filter. + * @param map a Map + * @return a Map, or undefined if the result Map would be empty. + */ +function filterMap(map, mapFilterFn) { + /* factored out of viewedBlocksByReferenceAndScope(); similar to : + * https://stackoverflow.com/questions/48707227/how-to-filter-a-javascript-map/53065133 */ + let result; + for (let [key, value] of map) { + let newValue = mapFilterFn(value); + if (newValue) { + if (! result) + result = new Map(); + result.set(key, newValue); + } + } + return result; +} /*----------------------------------------------------------------------------*/ @@ -231,10 +259,35 @@ export default Service.extend(Ember.Evented, { }, /** For trace in Web Inspector console. * Usage e.g. this.get('blocksReferences').map(this.blocksReferencesText); + * @param io {id : blockId, obj : blockObject } */ blocksReferencesText(io) { let b=io.obj; return [io.id, b.view && b.view.longName(), b.mapName]; }, + /** Collate the ranges or feature limits of the references of the given blockIds. + * This is used for constructing boundaries in + * backend/common/utilities/block-features.js : blockFeaturesCounts(). + * @return a hash object mapping from blockId to reference limits [from, to]. + */ + blocksReferencesLimits(blockIds) { + function blocksReferencesLimit(io) { + let b=io.obj; return b.get('range') || b.get('featureLimits'); }; + let + blocksReferences = this.blockIdsReferences(blockIds, true), + result = blocksReferences.reduce(function (result, io) { + if (! result[io.id]) { + result[io.id] = blocksReferencesLimit(io); + } + return result; + }, {}); + + if (trace_block > 1) + dLog('blocksReferencesLimits', blockIds, result, + blocksReferences.map(this.blocksReferencesText) ); + return result; + }, + + /** Set .isViewed for each of the blocksToView[].obj * @param blocksToView form is the result of this.blocksReferences() */ @@ -258,6 +311,48 @@ export default Service.extend(Ember.Evented, { */ this.trigger('receivedBlock', blocksToView); }, + + /** controls() and pathsDensityParams() are copied from paths-progressive.js + * They can be moved into a service control-params, which will be backed by + * query params in the URL. + */ + controls : Ember.computed(function () { + let oa = stacks.oa, + /** This occurs after mapview.js: controls : Ember.Object.create({ view : { } }), + * and draw-map : draw() setup of oa.drawOptions. + * This can be replaced with a controls service. + */ + controls = oa.drawOptions.controls; + dLog('controls', controls); + return controls; + }), + /** This does have a dependency on the parameter values. */ + pathsDensityParams : Ember.computed.alias('controls.view.pathsDensityParams'), + /** + * @param blockId later will use this to lookup axis yRange + */ + nBins(blockId) { + let nBins; + /** based on part of intervalParams(intervals) */ + let vcParams = this.get('pathsDensityParams'); + if (vcParams.nSamples) { + nBins = vcParams.nSamples; + } + if (vcParams.densityFactor) { + /** from paths-aggr.js : blockFeaturesInterval() + */ + let pixelspacing = 5; + let range = 600; // - lookup axis yRange from block; + let nBins = vcParams.densityFactor * range / pixelspacing; + } + if (vcParams.nFeatures) { + if (nBins > vcParams.nFeatures) + nBins = vcParams.nFeatures; + } + dLog('nBins', nBins, vcParams); + return nBins; + }, + getSummary: function (blockIds) { // console.log("block getSummary", id); let blockP = @@ -265,20 +360,22 @@ export default Service.extend(Ember.Evented, { if (this.get('parsedOptions.featuresCounts')) { - /** This will probably become user-configurable */ - const nBins = 100; /** As yet these result promises are not returned, not needed. */ let blockPs = blockIds.map( (blockId) => { + /** densityFactor requires axis yRange, so for that case this will (in future) lookup axis from blockId. */ + const nBins = this.nBins(blockId); let taskId = blockId + '_' + nBins; let summaryTask = this.get('summaryTask'); let p = summaryTask[taskId]; if (! p) { getCounts.apply(this); function getCounts() { + let intervals = this.blocksReferencesLimits([blockId]), + interval = intervals[blockId]; p = summaryTask[taskId] = - this.get('auth').getBlockFeaturesCounts(blockId, nBins, /*options*/{}); + this.get('auth').getBlockFeaturesCounts(blockId, interval, nBins, /*options*/{}); /* this could be structured as a task within models/block.js * A task would have .drop() to avoid concurrent request, but * actually want to bar any subsequent request for the same taskId, @@ -424,19 +521,10 @@ export default Service.extend(Ember.Evented, { */ let blocks = blockId ? - store.peekRecord('block', blockId) + [ store.peekRecord('block', blockId) ] : store.peekAll('block'); blocks.forEach((block) => { - let limits = block.get('featureLimits'); - /** Reference blocks don't have .featureLimits so don't request it. - * block.get('isData') depends on featureCount, which won't be present for - * newly uploaded blocks. Only references have .range (atm). - */ - let isData = ! block.get('range'); - if (! limits && isData) { - /** getBlocksLimits() takes a single blockId as param. */ - let blocksLimitsTasks = this.getBlocksLimits(block.get('id')); - } + block.ensureFeatureLimits(); }); } }, @@ -487,6 +575,15 @@ export default Service.extend(Ember.Evented, { console.log('blockValues', records); return records; }), + /** Can be used in place of peekBlock(). + * @return array which maps from blockId to block + */ + blocksById: Ember.computed( + 'blockValues.[]', + function() { + let blocksById = this.get('blockValues').reduce((r, b) => { r[b.get('id')] = b; return r; }, {}); + return blocksById; + }), selected: Ember.computed( 'blockValues.@each.isSelected', function() { @@ -547,7 +644,7 @@ export default Service.extend(Ember.Evented, { * These are suited to be rendered by axis-chart. */ viewedChartable: Ember.computed( - 'viewed.[]', + 'viewed.@each.{featuresCounts,isChartable}', function() { let records = this.get('viewed') @@ -595,6 +692,117 @@ export default Service.extend(Ember.Evented, { return map; }), + /** collate the blocks by the parent block they refer to, and by scope. + * + * Uses datasetsByParent(); this is more direct than blocksByReference() + * which uses Block.referenceBlock(), which does peekAll on blocks and filters + * on datasetId and scope. + * + * @return a Map, indexed by dataset name, each value being a further map + * indexed by block scope, and the value of that being an array of blocks, + * with the [0] position of the array reserved for the reference block. + * + * @desc + * Blocks without a parent / reference, will be mapped via their datasetId, + * and will be placed in [0] of the blocks array for their scope. + */ + blocksByReferenceAndScope : Ember.computed( + 'blockValues.[]', + function() { + const fnName = 'blocksByReferenceAndScope'; + let map = this.get('blockValues') + .reduce( + (map, block) => { + // related : manage-explorer : datasetsToBlocksByScope() but that applies a filter. + let id = block.id, + scope = block.get('scope'), + /** If block is a promise, block.get('datasetId.parent') will be a + * promise - non-null regardless of whether the dataset has a + * .parent, whereas .get of .parent.name will return undefined iff + * there is no parent. + */ + parentName = block.get('datasetId.parent.name'); + + function blocksOfDatasetAndScope(datasetId, scope) { + let mapByScope = map.get(datasetId); + if (! mapByScope) { + mapByScope = new Map(); + map.set(datasetId, mapByScope); + } + let blocks = mapByScope.get(scope); + if (! blocks) + mapByScope.set(scope, blocks = [undefined]); // [0] is reference block + return blocks; + } + + if (parentName) { + let blocks = blocksOfDatasetAndScope(parentName, scope); + blocks.push(block); + } else { + let datasetName = block.get('datasetId.name'); + let blocks = blocksOfDatasetAndScope(datasetName, scope); + if (blocks[0]) { + // console.log(fnName, '() >1 reference blocks for scope', scope, blocks, block, map); + /* Reference chromosome assemblies, i.e. physical maps, define a + * unique (reference) block for each scope, whereas Genetic Maps + * are their own reference, and each block in a GM is both a + * data block and a reference block, and a GM may define + * multiple blocks with the same scope. + * As a provisional measure, if there is a name clash (multiple + * blocks with no parent and the same scope), append the + * datasetName name to the scope to make it unique. + */ + blocks = blocksOfDatasetAndScope(datasetName, scope + '_' + datasetName); + } + blocks[0] = block; + } + return map; + }, + new Map()); + + if (trace_block > 1) + log_Map_Map(fnName, map); + return map; + }), + + /** filter blocksByReferenceAndScope() for viewed blocks, + * @return Map, which may be empty + */ + viewedBlocksByReferenceAndScope : Ember.computed('blocksByReferenceAndScope', 'viewed.[]', function () { + const fnName = 'viewedBlocksByReferenceAndScope'; + let viewed = this.get('viewed'); + let map = this.get('blocksByReferenceAndScope'), + resultMap = filterMap(map, referencesMapFilter); + function referencesMapFilter(mapByScope) { + let resultMap = filterMap(mapByScope, scopesMapFilter); + if (trace_block > 2) + dLog('referencesMapFilter', mapByScope, resultMap); + return resultMap; + }; + function scopesMapFilter(blocks) { + // axis : blocks : [0] is included if any blocks[*] are viewed, ... + // && .isLoaded ? + let blocksOut = blocks.filter((b, i) => ((i===0) || b.get('isViewed'))); + // expect that blocksOut.length != 0 here. + /* If a data block's dataset's parent does not have a reference block for + * the corresponding scope, then blocks[0] will be undefined, which is + * filtered out here. + * Some options for conveying the error to the user : perhaps display in + * the dataset explorer with an error status for the data block, e.g. the + * add button for the data block could be insensitive. + */ + if ((blocksOut.length === 1) && (! blocks[0] || ! blocks[0].get('isViewed'))) + blocksOut = undefined; + if (trace_block > 3) + dLog('scopesMapFilter', blocks, blocksOut); + return blocksOut; + }; + + if (trace_block && resultMap) + log_Map_Map(fnName, resultMap); + return resultMap; + }), + /** Search for the named features, and return also their blocks and datasets. * @return features (store object references) diff --git a/frontend/app/services/data/dataset.js b/frontend/app/services/data/dataset.js index af168d3bf..7a19da81c 100644 --- a/frontend/app/services/data/dataset.js +++ b/frontend/app/services/data/dataset.js @@ -4,6 +4,8 @@ import { task } from 'ember-concurrency'; const { inject: { service } } = Ember; +const dLog = console.debug; + // based on ./block.js export default Service.extend(Ember.Evented, { @@ -80,5 +82,54 @@ export default Service.extend(Ember.Evented, { console.log('values', records); return records; }) + , + /** Map the given array of objects by the result of the keyFn. + * @return Map of key values to objects. + */ + objectsMap : function (objects, keyFn) { + let + objectsMap = objects.reduce( + (map, o) => { + let block = keyFn(o), + axis = block.get('axis'), + stack = axis && axis.getStack(); + if (stack) { + let axes = map.get(stack); + if (! axes) + map.set(stack, axes = []); + axes.push(axis); + } + return map; }, + new Map() + ); + return objectsMap; + }, + /** Collate all loaded datasets by their parent name. + * Those without a parent name are mapped by the value null. + * This does not check if the named parent exists - use datasetsByName() for that. + * + * This will recalculate if the user uploads an additional dataset, which will + * not be frequent, so it is efficient to calculate this in advance. + * We could also reload when other users or system admins add datasets to the + * database, but again that is not expected to be frequent, and it is + * likely that this user will not need them in this session. + */ + datasetsByParent : Ember.computed('values.[]', function () { + let values = this.get('values'), + map = this.objectsMap(values, (d) => d.get('parent')); + dLog('datasetsByParent', map); + return map; + }), + datasetsByName : Ember.computed('values.[]', function () { + /** currently dataset.name is used as DB ID, so name lookup of datasets can + * also be done via peekRecord('dataset', name). + */ + let values = this.get('values'), + map = this.objectsMap(values, (d) => d.get('name')); + dLog('datasetsByName', map); + return map; + }), + + }); diff --git a/frontend/app/services/data/paths-progressive.js b/frontend/app/services/data/paths-progressive.js index 55a594a86..57470e9a2 100644 --- a/frontend/app/services/data/paths-progressive.js +++ b/frontend/app/services/data/paths-progressive.js @@ -179,16 +179,24 @@ export default Service.extend({ i.domain = undefined; } ); - let vcParams = this.get('pathsDensityParams'); - if (vcParams.nSamples) { - params.nSamples = vcParams.nSamples; + if (this.get('fullDensity')) { + params.nSamples = 1e6; + page.densityFactor = 1e6; + page.thresholdFactor = 1e6; + params.nFeatures = 1e6; } - if (vcParams.densityFactor) { - page.densityFactor = vcParams.densityFactor; - page.thresholdFactor = vcParams.densityFactor; // retire the name .thresholdFactor - } - if (vcParams.nFeatures) { - params.nFeatures = vcParams.nFeatures; + else { + let vcParams = this.get('pathsDensityParams'); + if (vcParams.nSamples) { + params.nSamples = vcParams.nSamples; + } + if (vcParams.densityFactor) { + page.densityFactor = vcParams.densityFactor; + page.thresholdFactor = vcParams.densityFactor; // retire the name .thresholdFactor + } + if (vcParams.nFeatures) { + params.nFeatures = vcParams.nFeatures; + } } return params; diff --git a/frontend/app/styles/app.scss b/frontend/app/styles/app.scss index 905b208a4..dc013e15f 100644 --- a/frontend/app/styles/app.scss +++ b/frontend/app/styles/app.scss @@ -195,6 +195,11 @@ div#logo { { overflow-x: scroll; } + +table.contextual-data-table td { + padding-right: 1em; +} + /* -------------------------------------------------------------------------- */ svg { @@ -245,17 +250,20 @@ g.axis-use > rect.chartRow */ rect.chartRow { - fill: aqua; opacity : 0.5; stroke: blue; } +/* .featureCountData colour is heatmap **/ +.blockData rect.chartRow { + fill: aqua; +} /* ---------------------------------- */ path.chartRow.line { fill : none; - stroke : magenta; + /* stroke : magenta; */ stroke-linejoin : round; stroke-linecap : round; stroke-width : 1.5; @@ -1313,6 +1321,17 @@ ul.feature-found { body > div.ember-popover { padding: 3px 5px; } +/* -------------------------------------------------------------------------- */ + +/* -------------------------------------------------------------------------- */ + +#right-panel-content > .right-panel-paths { + display : none; +} +#right-panel-content.right-panel-paths > .right-panel-paths { + display : block; +} + /* -------------------------------------------------------------------------- */ /* JSON editor, in RHS panel */ @@ -1345,4 +1364,38 @@ div.metaeditor-panel div.jsoneditor-treepath { div.metaeditor-panel div.jsoneditor { border: 1px solid #d3d3d3 } -/* -------------------------------------------------------------------------- */ \ No newline at end of file +/* -------------------------------------------------------------------------- */ + +.paths-table .optionControls { + display: flex; + margin-bottom: 1em; +} +.paths-table .optionControls > div { + flex-grow: 1; +} + +.paths-table div.panel-body > div.btn-group > div { + display: flex; +} + +.paths-table div.panel-body > div.btn-group > div > * { + margin: auto; +} + +.paths-table div.panel-body > div.btn-group > div > button > a { + color : inherit; +} + +.paths-table td.contextual-cell.numeric { + /* Align a numeric value. + * CSS does not currently offer a way to align numeric content by decimal point + * where the fractional part may vary in length, or there may be no decimal point, + * as proposed here : https://www.w3.org/Style/CSS/Tracker/issues/38 + * td align="char" char="." is not supported. (https://www.w3schools.com/tags/att_td_char.asp) + * An alternative is to split the integer and fractional part into 2 columns, + * e.g. https://stackoverflow.com/a/5937346, https://stackoverflow.com/a/29924653 */ + text-align: right; + padding-right: 1em !important; +} + +/* -------------------------------------------------------------------------- */ diff --git a/frontend/app/templates/components/axis-2d.hbs b/frontend/app/templates/components/axis-2d.hbs index 76ed85951..67e408e91 100644 --- a/frontend/app/templates/components/axis-2d.hbs +++ b/frontend/app/templates/components/axis-2d.hbs @@ -35,11 +35,9 @@
axis-2d :{{this}}, {{axisID}}, {{targetEltId}}, {{subComponents.length}} :
subComponents : - {{#each blockService.viewedChartable as |chartBlock|}} - {{axis-chart data=data axis=this axisID=axisID block=chartBlock}} - {{/each}} + {{axis-charts data=data axis=this axisID=axisID childWidths=childWidths allocatedWidths=allocatedWidths blocks=viewedChartable resizeEffect=resizeEffect}} {{log 'blockService' blockService 'viewedChartable' blockService.viewedChartable}} - {{axis-tracks axis=this axisID=axisID trackBlocksR=dataBlocks}} + {{axis-tracks axis=this axisID=axisID childWidths=childWidths allocatedWidths=allocatedWidths trackBlocksR=dataBlocks resizeEffect=resizeEffect}} {{#each subComponents as |subComponent|}} {{subComponent}} {{/each}} diff --git a/frontend/app/templates/components/axis-chart.hbs b/frontend/app/templates/components/axis-chart.hbs deleted file mode 100644 index bff59611d..000000000 --- a/frontend/app/templates/components/axis-chart.hbs +++ /dev/null @@ -1,14 +0,0 @@ -{{#if block}} - {{log 'blockFeatures' blockFeatures.length}} - {{blockFeatures.length}} - featuresCounts {{featuresCounts.length}} -{{else}} -
- {{content-editable - value=selection - placeholder="Paste Chart Here" - class="chart pasteData" - type="text"}} - -
-{{/if}} diff --git a/frontend/app/templates/components/axis-charts.hbs b/frontend/app/templates/components/axis-charts.hbs new file mode 100644 index 000000000..07c959a20 --- /dev/null +++ b/frontend/app/templates/components/axis-charts.hbs @@ -0,0 +1,20 @@ +{{#if blocks}} + {{#each blocks as |chartBlock|}} + {{draw/block-view axis=axis axisID=axisID block=chartBlock blocksData=blocksData}} + {{/each}} + {{#each chartsArray as |chart|}} + {{axis-chart axis=axis axisID=axisID blocks=blocks blocksData=blocksData axisCharts=this chart=chart }} + {{/each}} + {{ resizeEffectHere }} + {{ zoomedDomainEffect }} + +{{else}} +
+ {{content-editable + value=selection + placeholder="Paste Chart Here" + class="chart pasteData" + type="text"}} + +
+{{/if}} diff --git a/frontend/app/templates/components/draw/block-view.hbs b/frontend/app/templates/components/draw/block-view.hbs new file mode 100644 index 000000000..c6afb5a7a --- /dev/null +++ b/frontend/app/templates/components/draw/block-view.hbs @@ -0,0 +1,3 @@ +{{log 'blockFeatures' blockFeatures.length}} +{{blockFeatures.length}} +featuresCounts {{featuresCounts.length}} diff --git a/frontend/app/templates/components/panel/paths-table.hbs b/frontend/app/templates/components/panel/paths-table.hbs new file mode 100644 index 000000000..9ee549cb7 --- /dev/null +++ b/frontend/app/templates/components/panel/paths-table.hbs @@ -0,0 +1,64 @@ +{{#if visible}} + +{{#elem/panel-container state="primary"}} + {{#elem/panel-heading icon="filter"}} + Actions + {{/elem/panel-heading}} +
+
+ + {{!-- based on elem/button-submit.hbs, could be factored --}} +
+ + +
+
+
+ +
+
+{{/elem/panel-container}} + +
+
Selected block : {{selectedBlock.datasetNameAndScope}}
+ +
{{input type="checkbox" name="blockColumn" checked=blockColumn }} Show block columns
+
{{input type="checkbox" name="showInterval" checked=showInterval }} Show Interval
+ {{#if devControls}} +
{{input type="checkbox" name="showDomains" checked=showDomains }} Show Brushed Regions
+
{{input type="checkbox" name="showCounts" checked=showCounts }} Show Counts
+
{{input type="checkbox" name="onlyBrushedAxes" checked=onlyBrushedAxes }} Filter out unbrushed paths
+ {{/if}} +
+ +{{#if useHandsOnTable}} +
+ +{{else}} + + {{#data-filterer data=tableData as |df|}} + {{#data-sorter data=df.data as |ds|}} + {{#data-table data=ds.data + classNames=tableClassNames as |t|}} + {{#if blockColumn}} + {{t.filterableColumn propertyName='block0' name='Block' sortinformationupdated=(action ds.onsortfieldupdated) filterinformationupdated=(action df.onfilterfieldupdated) }} + {{/if}} + {{t.sortableColumn propertyName='feature0' name='From Feature' sortinformationupdated=(action ds.onsortfieldupdated)}} + {{t.sortableColumn propertyName='position0' name='Position' sortinformationupdated=(action ds.onsortfieldupdated) class="numeric" }} + {{#if showInterval}} + {{t.sortableColumn propertyName='positionEnd0' name='Position End' sortinformationupdated=(action ds.onsortfieldupdated) class="numeric" }} + {{/if}} + {{#if blockColumn}} + {{t.filterableColumn propertyName='block1' name='Block' sortinformationupdated=(action ds.onsortfieldupdated) filterinformationupdated=(action df.onfilterfieldupdated) }} + {{/if}} + {{t.sortableColumn propertyName='feature1' name='To Feature' sortinformationupdated=(action ds.onsortfieldupdated)}} + {{t.sortableColumn propertyName='position1' name='Position' sortinformationupdated=(action ds.onsortfieldupdated) class="numeric" }} + {{#if showInterval}} + {{t.sortableColumn propertyName='positionEnd1' name='Position End' sortinformationupdated=(action ds.onsortfieldupdated) class="numeric" }} + {{/if}} + {{/data-table}} + {{/data-sorter}} + {{/data-filterer}} +{{/if}} + +{{/if}} {{!-- visible --}} diff --git a/frontend/app/templates/mapview.hbs b/frontend/app/templates/mapview.hbs index 7576c6955..f504acaee 100644 --- a/frontend/app/templates/mapview.hbs +++ b/frontend/app/templates/mapview.hbs @@ -66,23 +66,31 @@ {{#if layout.right.visible}}
-
+
{{#if (compare layout.right.tab '===' 'selection')}} {{panel/manage-features selectedFeatures=selectedFeatures @@ -124,6 +132,13 @@ showScaffoldMarkers=showScaffoldMarkers}} {{/if}} + + {{panel/paths-table + visible=(compare layout.right.tab '===' 'paths') + selectedFeatures=selectedFeatures + selectedBlock=selectedBlock + updatePathsCount=(action 'updatePathsCount')}} +
{{else}} diff --git a/frontend/app/utils/data-types.js b/frontend/app/utils/data-types.js new file mode 100644 index 000000000..d61ccfeed --- /dev/null +++ b/frontend/app/utils/data-types.js @@ -0,0 +1,222 @@ +import { getAttrOrCP } from './ember-devel'; + + +/*----------------------------------------------------------------------------*/ +/** Feature values + +The API relies on Features having a .value which is a location or an interval, +represented as [location] or [from, to]. +The plan is to support other values; using JSON in the database and API enables +variant value types to be added in a way which is opaque to the API. +For example, the effects probability data has been added as [location, undefined, [probabilities, ... ]]. +The API relies on .value[0] and optionally .value[1], and ignores other elements of .values[]. +We might choose a different format as we include more data types in the scope, +e.g. we could split non-location values out to a separate field, +so it is desirable to access feature values through an access layer which abstracts away from the storage structure used. +The DataConfig class defined here provides such an abstraction. +It was added to support the axis-charts, but can be progressively used in other modules. +This can be integrated into models/feature.js + + */ + +/*----------------------------------------------------------------------------*/ + +const dLog = console.debug; + +/*----------------------------------------------------------------------------*/ + +class DataConfig { + /* + dataTypeName; + datum2Location; + datum2Value; + datum2Description; + */ + constructor (properties) { + if (properties) + Object.assign(this, properties); + } +}; + +/*----------------------------------------------------------------------------*/ + + +/** @param name is a feature or gene name */ +function name2Location(name, blockId) +{ + /** @param ak1 axis name, (exists in axisIDs[]) + * @param d1 feature name, i.e. ak1:d1 + */ + return featureLocation(blockId, name); +} + +/** Used for both blockData and parsedData. */ +function datum2LocationWithBlock(d, blockId) { return name2Location(d.name, blockId); } +function datum2Value(d) { return d.value; } +let parsedData = { + dataTypeName : 'parsedData', + // datum2LocationWithBlock assigned later, + datum2Value : datum2Value, + datum2Description : function(d) { return d.description; } +}, +blockData = { + dataTypeName : 'blockData', + // datum2LocationWithBlock assigned later, + /** The effects data is placed in .value[2] (the interval is in value[0..1]). + * Use the first effects value by default, but later will combine other values. + */ + datum2Value : function(d) { let v = d.value[2]; if (v.length) v = v[0]; return v; }, + datum2Description : function(d) { return JSON.stringify(d.value); } +}; + +/** Determine the appropriate DataConfig for the given data. + */ +function blockDataConfig(chart) { + let + isBlockData = chart.length && (chart[0].description === undefined); + + let dataConfigProperties = isBlockData ? blockData : parsedData; + return dataConfigProperties; +} + + +/*----------------------------------------------------------------------------*/ + + +/** example element of array f : + * result of $bucketAuto - it defines _id.{min,max} + */ +const featureCountAutoDataExample = + { + "_id": { + "min": 100, + "max": 160 + }, + "count": 109 + }; + +const featureCountAutoDataProperties = { + dataTypeName : 'featureCountAutoData', + datum2Location : function datum2Location(d) { return [d._id.min, d._id.max]; }, + datum2Value : function(d) { return d.count; }, + /** datum2Description() is not used; possibly intended for the same + * purpose as hoverTextFn(), so they could be assimilated. */ + datum2Description : function(d) { return JSON.stringify(d._id); }, + hoverTextFn : function (d, block) { + let valueText = '[' + d._id.min + ',' + d._id.max + '] : ' + d.count, + blockName = block.view && block.view.longName(); + return valueText + '\n' + blockName; + }, + valueIsArea : true +}; + +/** example of result of $bucket - it defines _id as a single value (the lower boundary of the bin). +*/ +const featureCountDataExample = + {_id: 77, count: 3}; + +const featureCountDataProperties = Object.assign( + {}, featureCountAutoDataProperties, { + dataTypeName : 'featureCountData', + datum2Location : function datum2Location(d) { return [d._id, d._id + d.idWidth[0]]; }, + hoverTextFn : function (d, block) { + let valueText = '' + d._id + ' +' + d.idWidth[0] + ' : ' + d.count, + blockName = block.view && block.view.longName(); + return valueText + '\n' + blockName; + } + } +); +/** $bucket returns _id equal to the lower bound of the bin / bucket, and does + * not return the upper bound, since the caller provides the list of boundaries + * (see backend/common/utilities/block-features.js : boundaries()). + * The result is sparse - if a bin count is 0 then the bin is omitted from the + * result. So blockFeaturesCounts() adds idWidth : lengthRounded to each bin in + * the result; this value is constant for all bins, because boundaries() + * generates constant-sized bins. + * That is used above in featureCountDataProperties : datum2Location() : it + * generates the upper bound by adding idWidth to the lower bound. + * The form of idWidth is an array with a single value, because it is added + * using an output accumulator in the $bucket : groupBy. + */ + + +const dataConfigs = + [featureCountAutoDataProperties, featureCountDataProperties, blockData, parsedData] + .reduce((result, properties) => { result[properties.dataTypeName] = new DataConfig(properties); return result; }, {} ); + + + +/*----------------------------------------------------------------------------*/ +/* Copied from draw-map.js */ + +import { stacks } from './stacks'; // just for oa.z and .y; this will be replaced. +let blockFeatures = stacks.oa.z; + +function featureLocation(blockId, d) +{ + let feature = blockFeatures[blockId][d]; + if (feature === undefined) + { + console.log("axis-chart featureY_", blockId, blockFeatures[blockId], "does not contain feature", d); + } + let location = feature && feature.location; + return location; +} + +/** If the given value is an interval, convert it to a single value by calculating the middle of the interval. + * @param location is a single value or an array [from, to] + * @return the middle of an interval + */ +function middle(location) { + let result = location.length ? + location.reduce((sum, val) => sum + val, 0) / location.length + : location; + return result; +} + +/** @return a function to map a chart datum to a y value or interval. + */ +function scaleMaybeInterval(datum2Location, yScale) { + /* In both uses in this file, the result is passed to middle(), so an argument + * could be added to scaleMaybeInterval() to indicate the result should be a + * single value (using mid-point if datum location is an interval). + */ + + function datum2LocationScaled(d) { + /** location may be an interval [from, to] or a single value. */ + let l = datum2Location(d); + return l.length ? l.map((li) => yScale(li)) : yScale(l); }; + return datum2LocationScaled; +} + +/*----------------------------------------------------------------------------*/ +/* based on axis-1d.js: hoverTextFn() and setupHover() */ + +/** eg: "ChrA_283:A:283" */ +function hoverTextFn (feature, block) { + let + value = getAttrOrCP(feature, 'value'), + /** undefined values are filtered out below. */ + valueText = value && (value.length ? ('' + value[0] + ' - ' + value[1]) : value), + + /** block.view is the Stacks Block. */ + blockName = block.view && block.view.longName(), + featureName = getAttrOrCP(feature, 'name'), + /** common with dataConfig.datum2Description */ + description = value && JSON.stringify(value), + + text = [featureName, valueText, description, blockName] + .filter(function (x) { return x; }) + .join(" : "); + return text; +}; + + + + + +/*----------------------------------------------------------------------------*/ + + + +export { featureCountAutoDataProperties, featureCountDataProperties, dataConfigs, DataConfig, blockDataConfig, blockData, parsedData, hoverTextFn, middle, scaleMaybeInterval, datum2LocationWithBlock }; diff --git a/frontend/app/utils/domElements.js b/frontend/app/utils/domElements.js index 85409349f..6392059cc 100644 --- a/frontend/app/utils/domElements.js +++ b/frontend/app/utils/domElements.js @@ -273,7 +273,12 @@ function eltClassName(f) * elsewhere, but handle it here by converting f to a string. */ let fString = (typeof(f) == 'string') ? f : '' + f, - fPrefixed = CSS.escape(f); // cssHexEncode(fString.replace(/^([\d])/, "_$1")); + /** d3.selectAll() is not matching the result of CSS.escape() on marker names + * starting with a digit. Prefixing with _ first works. Then CSS.escape() can + * handle any following punctuation. + */ + fPrefixNumber = fString.replace(/^([\d])/, "_$1"), + fPrefixed = CSS.escape(fPrefixNumber); // cssHexEncode(); return fPrefixed; } diff --git a/frontend/app/utils/draw/chart1.js b/frontend/app/utils/draw/chart1.js new file mode 100644 index 000000000..d8f696596 --- /dev/null +++ b/frontend/app/utils/draw/chart1.js @@ -0,0 +1,1010 @@ +import Ember from 'ember'; + +import { getAttrOrCP } from '../ember-devel'; +import { configureHorizTickHover } from '../hover'; +import { eltWidthResizable, noShiftKeyfilter } from '../domElements'; +import { logSelectionNodes } from '../log-selection'; +import { noDomain } from '../draw/axis'; +import { stacks } from '../stacks'; // just for oa.z and .y, don't commit this. +import { inRangeEither } from './zoomPanCalcs'; +import { featureCountDataProperties, dataConfigs, DataConfig, blockDataConfig, hoverTextFn, middle, scaleMaybeInterval, datum2LocationWithBlock } from '../data-types'; + + +const className = "chart", classNameSub = "chartRow"; +/** Enables per-chart axes; X axes will be often useful; Y axis might be used if + * zooming into different regions of an axis. */ +const showChartAxes = true; +const useLocalY = false; + +/* global d3 */ + +/*----------------------------------------------------------------------------*/ + +/** Introductory notes + +The axis charts code is organised into : +. components/axis-charts.js provides the data feed +. utils/draw/chart1.js renders the data using d3 +. utils/data-types.js accesses values from the data +It is not essential that they be divided this way, e.g. the Ember and JS classes could be combined, but it seems to provide a good module size, and plays to the strengths of each framework, using Ember as the integration / dynamics layer and d3 as the purely mechanistic rendering layer. + +The Ember component connects to other modules, delivering data flows to the rendering functions; e.g. connections include events such as zoom & resize which trigger render updates. +Using JS classes enables 4 small and closely related classes to be in a single file, which was useful when the early prototype code was being split into the number of pieces required by the update structure, and moved between levels as the design took shape. + + */ + +/*----------------------------------------------------------------------------*/ + +const dLog = console.debug; + +/*----------------------------------------------------------------------------*/ + +/** Example of the structure of the DOM elements generated. The .dom.* selections which address them are indicated in comments. + + + + + + + + + + + 0.1 + + + + + + + + + + + + 0.0005 + + + + + + + +*/ + +/*----------------------------------------------------------------------------*/ + +/** Add a .hasChart class to the which contains this chart. + * Currently this is used to hide the so that hover events are + * accessible on the chart bars, because the is above the chart. + * Later can use e.g. axis-accordion to separate these horizontally; + * for axis-chart the foreignObject is not used. + * + * @param g parent of the chart. this is the with clip-path #axis-chart-clip. + */ +function addParentClass(g) { + let axisUse=g.node().parentElement.parentElement, + us=d3.select(axisUse); + us.classed('hasChart', true); + console.log('addParentClass', us.node()); +}; +/*----------------------------------------------------------------------------*/ + + +/* axis + * x .value + * y .name Location + */ +/** 1-dimensional chart, within an axis. */ +function Chart1(parentG, dataConfig) +{ + this.parentG = parentG; + this.dataConfig = dataConfig; + this.chartLines = {}; + /* yAxis is imported, x & yLine are calculated locally. + * y is used for drawing - it refers to yAxis or yLine. + * yLine would be used for .line() if yBand / scaleBand was used for .bars(). + */ + this.scales = { /* yAxis, x, y, yLine */ }; +} +Chart1.prototype.barsLine = true; + + +class ChartLine { + constructor(dataConfig, scales) { + this.dataConfig = dataConfig; + /* the scales have the same .range() as the parent Chart1, but the .domain() varies. */ + this.scales = scales; // Object.assign({}, scales); + } +} + +/*----------------------------------------------------------------------------*/ + +class AxisCharts { + /* + ranges; // .bbox, .height + dom; // .gs, .gsa, + */ + + constructor(axisID) { + this.axisID = axisID; + this.ranges = { }; + this.dom = { }; + } +}; + +AxisCharts.prototype.setup = function(axisID) { + this.selectParentContainer(axisID); + this.getBBox(); +}; + +/** + * @param allocatedWidth [horizontal start offset, width] + */ +AxisCharts.prototype.setupFrame = function(axisID, charts, allocatedWidth) +{ + let axisCharts = this; + axisCharts.setup(axisID); + + let resizedWidth = allocatedWidth[1]; + axisCharts.getRanges3(resizedWidth); + + // axisCharts.size(this.get('yAxisScale'), /*axisID, gAxis,*/ chartData, resizedWidth); // - split size/width and data/draw + + axisCharts.commonFrame(/*axisID, gAxis*/); + + // equivalent to addParentClass(); + axisCharts.dom.gAxis.classed('hasChart', true); + + axisCharts.frame(axisCharts.ranges.bbox, charts, allocatedWidth); + + axisCharts.getRanges2(); +}; + + +AxisCharts.prototype.selectParentContainer = function (axisID) +{ + this.axisID = axisID; + // to support multiple split axes, identify axis by axisID (and possibly stack id) + // + // g.axis-outer#id + let gAxis = d3.select("g.axis-outer#id" + axisID + "> g.axis-use"); + if (! gAxis.node()) { /* probably because axisShowExtend() has not added the g.axis-use yet - will sort out the dependencies ... . */ + dLog('layoutAndDrawChart', d3.select("g.axis-outer#id" + axisID).node(), 'no g.axis-use', this, axisID); + } + this.dom.gAxis = gAxis; + return gAxis; +}; + +AxisCharts.prototype.getBBox = function () +{ + let + gAxis = this.dom.gAxis, + gAxisElt = gAxis.node(); + /* If the selection is empty, nothing will be drawn. */ + if (gAxisElt) { + let + /** relative to the transform of parent g.axis-outer */ + bbox = gAxisElt.getBBox(), + yrange = [bbox.y, bbox.height]; + if (bbox.x < 0) + { + console.log("x < 0", bbox); + bbox.x = 0; + } + this.ranges.bbox = bbox; + this.ranges.yrange = yrange; + } +}; + +AxisCharts.prototype.getRanges3 = function (resizedWidth) { + if (resizedWidth) { + let + {bbox} = this.ranges; + if (bbox) + bbox.width = resizedWidth; + dLog('resizedWidth', resizedWidth, bbox); + } +}; + +AxisCharts.prototype.getRanges2 = function () +{ + // based on https://bl.ocks.org/mbostock/3885304, axes x & y swapped. + let + // parentG = this.parentG, + bbox = this.ranges && this.ranges.bbox; + if (bbox) { + let + margin = showChartAxes ? + {top: 10, right: 20, bottom: 40, left: 20} : + {top: 0, right: 0, bottom: 0, left: 0}, + // pp=parentG.node().parentElement, + parentW = bbox.width, // +pp.attr("width") + parentH = bbox.height, // +pp.attr("height") + width = parentW - margin.left - margin.right, + height = parentH - margin.top - margin.bottom; + this.ranges.drawSize = {width, height}; + dLog('getRanges2', parentW, parentH, this.ranges.drawSize); + } +}; + +AxisCharts.prototype.drawAxes = function (charts) { + let + dom = this.dom, + append = dom.gca; + /** the axes were originally within the gs,gsa of .group(), but are now within the g[clip-path]; + * append here selects the into which the axes will be inserted. + */ + append.each(Chart1.prototype.drawAxes); +}; + +AxisCharts.prototype.commonFrame = function() +{ + let axisID = this.axisID, + gAxis = this.dom.gAxis, + bbox = this.ranges.bbox; + + /** datum is value in hash : {value : , description: } and with optional attribute description. */ + + dLog('commonFrame', gAxis.node()); + /** parent selection ; contains a clipPath, g clip-path, text.resizer. */ + let gps = gAxis + .selectAll("g." + className) + .data([axisID]), + gpa = gps + .enter() + .insert("g", ":first-child") + .attr('class', className); + if (false) { // not completed. Can base resized() on axis-2d.js + let text = gpa + .append("text") + .attr('class', 'resizer') + .html("⇹") + .attr("x", bbox.width-10); + if (gpa.size() > 0) + eltWidthResizable("g.axis-use > g." + className + " > text.resizer", resized); + } + this.dom.gps = gps; + this.dom.gpa = gpa; + this.dom.gp = gpa.merge(gps); +}; + +AxisCharts.prototype.frameRemove = function() { + let gp = this.dom && this.dom.gp; + gp && gp.remove(); +}; + +AxisCharts.prototype.frame = function(bbox, charts, allocatedWidth) +{ + let + gps = this.dom.gps, + gpa = this.dom.gpa; + + /** datum is axisID, so id and clip-path can be functions. + * e.g. this.dom.gp.data() is [axisID] + */ + function axisClipId(axisID) { return "axis-chart-clip-" + axisID; } + /** gpa is the .enter().append() (insert) of g.chart, and gPa is the result of + * .append()-ing clipPath to that selection. */ + let gPa = + gpa // define the clipPath + .append("clipPath") // define a clip path + .attr("id", axisClipId) // give the clipPath an ID + .append("rect"), // shape it as a rect + gprm = + gPa.merge(gps.selectAll("g > clipPath > rect")) + .attr("x", bbox.x) + .attr("y", bbox.y) + .attr("width", bbox.width) + .attr("height", bbox.height) + ; + let [startOffset, width] = allocatedWidth; + let gca = + gpa.append("g") + .attr("clip-path", (d) => "url(#" + axisClipId(d) + ")") // clip with the rectangle + .selectAll("g[clip-path]") + .data(Object.values(charts)) + .enter() + .append("g") + .attr('class', (d) => d.dataConfig.dataTypeName) + .attr("transform", (d, i) => "translate(" + (startOffset + (i * 30)) + ", 0)") + ; + /* to handle removal of chart types, split the above to get a handle for remove : + gcs = gpa ... data(...), + gca = gcs.enter() ...; + gcs.exit().remove(); + */ + + let g = + this.dom.gp.selectAll("g." + className+ " > g"); + if (! gpa.empty() ) { + addParentClass(g); + } + /* .gc is ​ + * .g (assigned later) is all g.chart-line, i.e. all chart-lines of all Chart1-s of this axis + * .gca contains a g for each chartType / dataTypeName, i.e. per Chart1. + */ + this.dom.gc = g; + this.dom.gca = gca; +}; + + +AxisCharts.prototype.controls = function controls() +{ + let + bbox = this.ranges.bbox, + append = this.dom.gca, + select = this.dom.gc; + + function toggleBarsLineClosure(chart /*, i, g*/) + { + chart.toggleBarsLine(); + } + + /** currently placed at g.chart, could be inside g.chart>g (clip-path=). */ + let chartTypeToggle = append + .append("circle") + .attr("class", "radio toggle chartType") + .attr("r", 6) + .on("click", toggleBarsLineClosure); + chartTypeToggle.merge(select.selectAll("g > circle")) + .attr("cx", bbox.x + bbox.width / 2) /* was o[p], but g.axis-outer translation does x offset of stack. */ + .attr("cy", bbox.height - 10) + .classed("pushed", (chart1) => { return chart1.barsLine; }); + chartTypeToggle.each(function(chart) { chart.chartTypeToggle = d3.select(this); } ); +}; + +/*----------------------------------------------------------------------------*/ + +/* + class AxisChart { + bbox; + data; + gp; + gps; + }; + */ + +/*----------------------------------------------------------------------------*/ + +/** For use in web inspector console, e.g. + * d3.selectAll('g.chart > g[clip-path] > g').each((d) => d.logSelectionNodes2()) + * or from Ember inspector /axis-chart : $E.chart.logSelectionNodes2() + */ +Chart1.prototype.logSelectionNodes2 = function(dom) +{ + if (! dom) + dom = this.dom; + Object.keys(dom).forEach((k) => {dLog(k); logSelectionNodes(dom[k]); }); +}; + + +/** For use in web inspector console, e.g. + * d3.selectAll('g.chart > g[clip-path] > g').each((d) => d.logScales()); + */ +Chart1.prototype.logScales = function() { + let scales = this.scales; + Object.keys(scales).forEach((k) => {var s = scales[k]; console.log(k, s.domain(), s.range()); }); +}; + + +Chart1.prototype.overlap = function(axisCharts) { + let chart1 = this; + // Plan is for AxisCharts to own .ranges, but for now there is some overlap. + if (! chart1.ranges) { + chart1.ranges = axisCharts.ranges; + chart1.dom = axisCharts.dom; + } +}; +Chart1.prototype.setupChart = function(axisID, axisCharts, chartData, blocks, dataConfig, yAxisScale, resizedWidth) +{ + this.scales.yAxis = yAxisScale; + + //---------------------------------------------------------------------------- + + /* ChartLine:setup() makes a copy of Chart1's .dataConfig, so augment it + * before then (called in createLine()). + */ + /* pasteProcess() may set .valueName, possibly provided by GUI; + * i.e. Object.values(chartData).mapBy('valueName').filter(n => n) + * and that can be passed to addedDefaults(). + */ + dataConfig.addedDefaults(); + + //---------------------------------------------------------------------------- + + let + blocksById = blocks.reduce( + (result, block) => { result[block.get('id')] = block; return result; }, []), + blockIds = Object.keys(chartData); + blockIds.forEach((blockId) => { + let block = blocksById[blockId]; + this.createLine(blockId, block); + }); + this.group(this.dom.gca, 'chart-line'); + + //---------------------------------------------------------------------------- + + this.getRanges(this.ranges, chartData); + + return this; +}; + +Chart1.prototype.drawChart = function(axisCharts, chartData) +{ + /** possibly don't (yet) have chartData for each of blocks[], + * i.e. blocksById may be a subset of blocks.mapBy('id'). + */ + let blockIds = Object.keys(chartData); + blockIds.forEach((blockId) => { + this.data(blockId, chartData[blockId]); + }); + + this.prepareScales(chartData, this.ranges.drawSize); + blockIds.forEach((blockId) => { + this.chartLines[blockId].scaledConfig(); } ); + + this.drawContent(); +}; + +Chart1.prototype.getRanges = function (ranges, chartData) { + let + {yrange } = ranges, + {yAxis } = this.scales, + // if needed, use the first data array to calculate domain + chart = Object.values(chartData), + // axisID = gAxis.node().parentElement.__data__, + + yAxisDomain = yAxis.domain(), yDomain; + if (chart) + chart = chart[0]; + if (noDomain(yAxisDomain) && chart.length) { + // this assumes featureCountData; don't expect to need this. + yAxisDomain = [chart[0]._id.min, chart[chart.length-1]._id.max]; + yAxis.domain(yAxisDomain); + yDomain = yAxisDomain; + } + else + yDomain = [yAxis.invert(yrange[0]), yAxis.invert(yrange[1])]; + + if (ranges && ranges.bbox) + ranges.pxSize = (yDomain[1] - yDomain[0]) / ranges.bbox.height; +}; + + + + +Chart1.prototype.prepareScales = function (data, drawSize) +{ + /** The chart is perpendicular to the usual presentation. + * The names x & y (in {x,y}Range and yLine) match the orientation on the + * screen, rather than the role of abscissa / ordinate; the data maps + * .name (location) -> .value, which is named y -> x. + */ + let + dataConfig = this.dataConfig, + scales = this.scales, + width = drawSize.width, + height = drawSize.height, + xRange = [0, width], + yRange = [0, height], + // yRange is used as range of yLine by this.scaleLinear(). + /* scaleBand would suit a data set with evenly spaced or ordinal / nominal y values. + * yBand = d3.scaleBand().rangeRound(yRange).padding(0.1), + */ + y = useLocalY ? this.scaleLinear(yRange, data) : scales.yAxis; + scales.xWidth = + d3.scaleLinear().rangeRound(xRange); + if (dataConfig.barAsHeatmap) { + scales.xColour = d3.scaleOrdinal().range(d3.schemeCategory20); + } + // datum2LocationScaled() uses me.scales.x rather than the value in the closure in which it was created. + scales.x = dataConfig.barAsHeatmap ? scales.xColour : scales.xWidth; + + // Used by bars() - could be moved there, along with datum2LocationScaled(). + scales.y = y; + console.log("Chart1", xRange, yRange, dataConfig.dataTypeName); + + let + valueWidthFn = dataConfig.rectWidth.bind(dataConfig, /*scaled*/false, /*gIsData*/true), + valueCombinedDomain = this.domain(valueWidthFn, data); + scales.xWidth.domain(valueCombinedDomain); + if (scales.xColour) + scales.xColour.domain(valueCombinedDomain); + +}; +Chart1.prototype.drawContent = function () +{ + Object.keys(this.chartLines).forEach((blockId) => { + let chartLine = this.chartLines[blockId]; + chartLine.drawContent(this.barsLine); + }); +}; + +Chart1.prototype.group = function (parentG, groupClassName) { + /** parentG is g.{{dataTypeName}}, within : g.axis-use > g.chart > g[clip-path] > g.{{dataTypeName}}. + * add g.(groupClassName); + * within g.axis-use there is also a sibling g.axis-html. */ + /* on subsequent calls (didRender() is called whenever params change), + * parentG is empty, and hence g is empty. + * so this is likely to need a change to handle addition/removal of chartlines after first render. + */ + let // data = parentG.data(), + gs = parentG + .selectAll("g > g." + groupClassName) + .data((chart) => Object.values(chart.chartLines)), + gsa = gs + .enter() + .append("g") // maybe drop this g, move margin calc to gp + // if drawing internal chart axes then move them inside the clip rect + // .attr("transform", "translate(" + margin.left + "," + margin.top + ")"), + .attr("class", (chartLine) => groupClassName) + .attr('id', (chartLine) => groupClassName + '-' + chartLine.block.id) + // .data((chartLine) => chartLine.currentData) + , + // parentG.selectAll("g > g." + groupClassName); // + g = gsa.merge(gs); + gs.exit().remove(); + dLog('group', this, parentG.node(), parentG, g.node()); + this.dom.gs = gs; + this.dom.gsa = gsa; + // set ChartLine .g; used by ChartLine.{lines,bars}. + gsa.each(function(chartLine, i) { chartLine.g = d3.select(this) ; } ); + return g; +}; + +Chart1.prototype.drawAxes = function (chart, i, g) { + + /** first draft showed all data; subsequently adding : + * + select region from y domain + * - place data in tree for fast subset by region + * + alternate view : line + * + transition between views, zoom, stack + */ + // scaleBand() domain is a list of all y values. + // yBand.domain(data.map(dataConfig.datum2Location)); + + let + chart1 = this.__data__, + {height} = chart1.ranges.drawSize, + {x, y} = chart1.scales, + dom = chart1.dom, + gs = dom.gc, + /** selection into which to append the axes. */ + gsa = d3.select(this), + dataConfig = chart1.dataConfig; + + let axisXa = + gsa.append("g") + // - handle .exit() for these 2 also + .attr("class", "axis axis--x"); + axisXa.merge(gs.selectAll("g > g.axis--x")) + .attr("transform", "translate(0," + height + ")"); + if (! dataConfig.barAsHeatmap) + axisXa + .call(d3.axisBottom(x)); + + if (useLocalY) { + let axisYa = + gsa.append("g") + .attr("class", "axis axis--y"); + axisYa.merge(gs.selectAll("g > g.axis--y")) + .call(d3.axisLeft(y)) // .tickFormat(".2f") ? + // can option this out if !valueName + .append("text") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", "0.71em") + .attr("text-anchor", "end") + .text(dataConfig.valueName); + } +}; +/** + * @param block hover + * used in Chart1:bars() for hover text. passed to hoverTextFn() for .longName() + */ + +Chart1.prototype.createLine = function (blockId, block) +{ + let chartLine = this.chartLines[blockId]; + if (! chartLine) + chartLine = this.chartLines[blockId] = new ChartLine(this.dataConfig, this.scales); + if (block) { + chartLine.block = block; + chartLine.setup(blockId); + // .setup() will copy dataConfig if need for custom config. + } +}; +Chart1.prototype.data = function (blockId, data) +{ + let chartLine = this.chartLines[blockId]; + + function m(d) { return middle(chartLine.dataConfig.datum2Location(d)); } + data = data.sort((a,b) => m(a) - m(b)); + data = chartLine.filterToZoom(data); +}; +/** Draw a single ChartLine of this chart. + * To draw all ChartLine of this chart, @see Chart1:drawContent() + */ +Chart1.prototype.drawLine = function (blockId, block, data) +{ + let chartLine = this.chartLines[blockId]; + chartLine.drawContent(this.barsLine); +}; + +Chart1.prototype.scaleLinear = function (yRange, data) +{ + // based on https://bl.ocks.org/mbostock/3883245 + if (! this.scales.yLine) + this.scales.yLine = d3.scaleLinear(); + let y = this.scales.yLine; + y.rangeRound(yRange); + let + d2l = this.dataConfig.datum2Location || Object.values(this.chartLines)[0].dataConfig.datum2Location; + let combinedDomain = this.domain(d2l, data); + y.domain(combinedDomain); + console.log('scaleLinear domain', combinedDomain); + return y; +}; +/** Combine the domains of each of the component ChartLine-s. + * @param valueFn e.g. datum2Location or datum2Value + */ +Chart1.prototype.domain = function (valueFn, blocksData) +{ + let blockIds = Object.keys(blocksData), + domains = blockIds.map((blockId) => { + let + data = blocksData[blockId], + chartLine = this.chartLines[blockId]; + return chartLine.domain(valueFn, data); + }); + /** Union the domains. */ + let domain = domains + .reduce((acc, val) => acc.concat(val), []); + return domain; +}; +/** Alternate between bar chart and line chart */ +Chart1.prototype.toggleBarsLine = function () +{ + console.log("toggleBarsLine", this); + d3.event.stopPropagation(); + this.barsLine = ! this.barsLine; + this.chartTypeToggle + .classed("pushed", this.barsLine); + Object.keys(this.chartLines).forEach((blockId) => { + let chartLine = this.chartLines[blockId]; + chartLine.g.selectAll("g > *").remove(); + chartLine.drawContent(this.barsLine); + }); +}; + +/*----------------------------------------------------------------------------*/ + +ChartLine.prototype.setup = function(blockId) { + /* Some types (blockData, parsedData) require a block to lookup the feature + * name for location. They are denoted by .datum2Location not being in their + * pre-defined config. + */ + if (! this.dataConfig.datum2Location) { + // copy dataConfig to give a custom value to this ChartLine. + let d = new DataConfig(this.dataConfig); + d.datum2Location = + (d) => datum2LocationWithBlock(d, blockId); + this.dataConfig = d; + } +}; +/** Filter given data according to this.scales.yAxis.domain() + * and set .currentData + */ +ChartLine.prototype.filterToZoom = function(chart) { + let + {yAxis} = this.scales, + yDomain = yAxis.domain(), + withinZoomRegion = (d) => { + return inRangeEither(this.dataConfig.datum2Location(d), yDomain); + }, + data = chart.filter(withinZoomRegion); + this.currentData = data; + + dLog(yDomain, data.length, (data.length == 0) || this.dataConfig.datum2Location(data[0])); + return data; +}; + +/** Enables use of the scales which are set up in .prepareScales(). + */ +ChartLine.prototype.scaledConfig = function () +{ + let + dataConfig = this.dataConfig, + scales = this.scales; + + /* these can be renamed datum2{abscissa,ordinate}{,Scaled}() */ + /* apply y after scale applied by datum2Location */ + let datum2LocationScaled = scaleMaybeInterval(dataConfig.datum2Location, scales.y); + /** related @see rectWidth(). */ + function datum2ValueScaled(d) { return scales.x(dataConfig.datum2Value(d)); } + dataConfig.datum2LocationScaled = datum2LocationScaled; + dataConfig.datum2ValueScaled = datum2ValueScaled; +}; + +ChartLine.prototype.blockColour = function () +{ + let blockS = this.block && this.block.get('view'), + /* For axes without a reference, i.e. GMs, there is a single data block with colour===undefined. */ + colour = (blockS && blockS.axisTitleColour()) || 'red'; + return colour; +}; + +ChartLine.prototype.bars = function (data) +{ + let + dataConfig = this.dataConfig, + block = this.block, + g = this.g; + if (dataConfig.barAsHeatmap) + this.scales.x = this.scales.xColour; + let + rs = g + // .select("g." + className + " > g") + .selectAll("rect." + dataConfig.barClassName) + .data(data, dataConfig.keyFn.bind(dataConfig)), + re = rs.enter(), rx = rs.exit(); + let ra = re + .append("rect"); + ra + .attr("class", dataConfig.barClassName) + .attr("fill", (d) => this.blockColour()) + /** parent datum is currently 1, but could be this.block; + * this.parentElement.parentElement.__data__ has the axis id (not the blockId), + */ + .each(function (d) { configureHorizTickHover.apply(this, [d, block, dataConfig.hoverTextFn]); }); + let r = + ra + .merge(rs) + .transition().duration(1500) + .attr("x", 0) + .attr("y", (d) => { let li = dataConfig.datum2LocationScaled(d); return li.length ? li[0] : li; }) + // yBand.bandwidth() + .attr("height", dataConfig.rectHeight.bind(dataConfig, /*scaled*/true, /*gIsData*/false)) // equiv : (d, i, g) => dataConfig.rectHeight(true, false, d, i, g); + ; + let barWidth = dataConfig.rectWidth.bind(dataConfig, /*scaled*/true, /*gIsData*/false); + r + .attr("width", dataConfig.barAsHeatmap ? 20 : barWidth); + if (dataConfig.barAsHeatmap) + ra + .attr('fill', barWidth); + rx.remove(); + dLog(rs.nodes(), re.nodes()); +}; + +/** A single horizontal line for each data point. + * Position is similar to the rectangle which would be drawn by bars(): + * X limits are the same as the rectangle limits (left, right) + * and Y position is at the middle of the equivalent rectangle. + */ +ChartLine.prototype.linebars = function (data) +{ + let + dataConfig = this.dataConfig, + block = this.block, + scales = this.scales, + g = this.g; + let + rs = g + // .select("g." + className + " > g") + .selectAll("path." + dataConfig.barClassName) + .data(data, dataConfig.keyFn.bind(dataConfig)), + re = rs.enter(), rx = rs.exit(); + let datum2LocationScaled = scaleMaybeInterval(dataConfig.datum2Location, scales.y); + let line = d3.line(); + + function horizLine(d, i, g) { + let barWidth = dataConfig.rectWidth(/*scaled*/true, /*gIsData*/false, d, i, g); + let y = middle(datum2LocationScaled(d)), + l = line([ + [0, y], + [barWidth, y]]); + return [l]; + } + + let ra = re + .append("path"); + ra + .attr("class", dataConfig.barClassName) + // same comment re parent datum as for bars() + .each(function (d) { configureHorizTickHover.apply(this, [d, block, dataConfig.hoverTextFn]); }); + ra + .merge(rs) + .transition().duration(1500) + .attr('d', horizLine) + .attr("stroke", (d) => this.blockColour()) + ; + + rx.remove(); + console.log(rs.nodes(), re.nodes()); +}; + +/** Calculate the domain of some function of the data, which may be the data value or location. + * This can be used in independent of axis-chart, and can be factored out + * for more general use, along with Chart1:domain(). + * @param valueFn e.g. datum2Location or datum2Value + * In axis-chart, value is x, location is y. + */ +ChartLine.prototype.domain = function (valueFn, data) +{ + let + /** location may be an interval, so flatten the result. + * Later Array.flat() can be used. + * Value is not an interval (yet), so that .reduce is a no-op). + */ + yFlat = data + .map(valueFn) + .reduce((acc, val) => acc.concat(val), []); + let domain = d3.extent(yFlat); + console.log('ChartLine domain', domain, yFlat); + return domain; +}; + +ChartLine.prototype.line = function (data) +{ + let y = this.scales.y, dataConfig = this.dataConfig; + this.scales.x = this.scales.xWidth; + + let datum2LocationScaled = scaleMaybeInterval(dataConfig.datum2Location, y); + let line = d3.line() + .x(dataConfig.rectWidth.bind(dataConfig, /*scaled*/true, /*gIsData*/false)) + .y((d) => middle(datum2LocationScaled(d))); + + console.log("line x domain", this.scales.x.domain(), this.scales.x.range()); + + let + g = this.g, + ps = g + .selectAll("g > path." + dataConfig.barClassName) + .data([this]); + ps + .enter() + .append("path") + .attr("class", dataConfig.barClassName + " line") + .attr("stroke", (d) => this.blockColour()) + .datum(data) + .attr("d", line) + .merge(ps) + .datum(data) + .transition().duration(1500) + .attr("d", line); + // data length is constant 1, so .remove() is not needed + ps.exit().remove(); +}; + + + +/** Draw, using .currentData, which is set by calling .filterToZoom(). + * @param barsLine if true, draw .bars, otherwise .line. + */ +ChartLine.prototype.drawContent = function(barsLine) +{ + let + /** could pick up data from this.g.data(). */ + data = this.currentData; + if (data) { + /** The Effects probabilities data is keyed by a location which is a SNP, so linebars() is a sensible representaion. + * This uses the data shape to recognise Effects data; this is provisional + * - we can probably lookup the tag 'EffectsPlus' in dataset tags (refn resources/tools/dev/effects2Dataset.pl). + * The effects data takes the form of an array of 5 probabilities, in the 3rd element of feature.value. + */ + let isEffectsData = data.length && data[0].name && data[0].value && (data[0].value.length === 3) && (data[0].value[2].length === 6); + let bars = isEffectsData ? this.linebars : this.bars; + let chartDraw = barsLine ? bars : this.line; + chartDraw.apply(this, [data]); + } +}; + + + + +/*----------------------------------------------------------------------------*/ + +DataConfig.prototype.keyFn = function (d, i, g) { + let key = this.datum2Location(d, i, g); + /** looking at d3 bindKey() : it appends the result of the key function to + * "$", which works fine for both a single-value location and an interval; + * e.g. the interval [54.7, 64.8] -> keyValue "$54.7,64.8". Including the + * upper bound of the interval may improvide the identifying power of the key + * in some cases. If there was a problem with this, an interval can be + * reduced to a single value, i.e. + if (key.length) + key = key[0]; */ + return key; +}; + +/** Calculate the height of rectangle to be used for this data point + * @param this is DataConfig, not DOM element. + * @param scaled true means apply scale (x) to the result + * @param gIsData true means g is __data__, otherwise it is DOM element, and has .__data__ attribute. + * gIsData will be true when called from d3.max(), and false for d3 attr functions. + */ +DataConfig.prototype.rectWidth = function (scaled, gIsData, d, i, g) +{ + Ember.assert('rectWidth arguments.length === 5', arguments.length === 5); + /** The scale is linear, so it is OK to scale before dividing by rectHeight. + * Otherwise could use datum2Value() here and apply this.x() before the return. + */ + let d2v = (scaled ? this.datum2ValueScaled : this.datum2Value), + width = d2v(d); + if (this.valueIsArea) { + let h; + // Factor the width consistently by h, don't sometimes use scaled (i.e. pass scaled==false). + width /= (h = this.rectHeight(false, gIsData, d, i, g)); + // dLog('rectWidth', h, width, gIsData); + } + return width; +}; +/** Calculate the height of rectangle to be used for this data point + * @param this is DataConfig, not DOM element. + * @param scaled true means apply scale (y) to the result + * @param gIsData true means g is __data__, otherwise it is DOM element, and has .__data__ attribute. + * gIsData will be true when called from d3.max(), and false for d3 attr functions. + */ +DataConfig.prototype.rectHeight = function (scaled, gIsData, d, i, g) +{ + Ember.assert('rectHeight arguments.length === 5', arguments.length === 5); + let height, + d2l = (scaled ? this.datum2LocationScaled : this.datum2Location), + location; + /* if location is an interval, calculate height from it. + * Otherwise, use adjacent points to indicate height. + */ + if ((location = d2l(d)).length) { + height = Math.abs(location[1] - location[0]); + } + else { + /* the boundary between 2 adjacent points is midway between them. + * So sum that half-distance for the previous and next points. + * the y range distance from the previous point to the next point. + * If this point is either end, simply double the other half-distance. + */ + if (! g.length) + /* constant value OK - don't expect to be called if g.length is 0. + * this.scales.y.range() / 10; + */ + height = 10; + else { + let r = []; + function gData(i) { let gi = g[i]; return gIsData ? gi : gi.__data__; }; + if (i > 0) + r.push(gData(i)); + r.push(d); + if (i < g.length-1) + r.push(gData(i+1)); + let y = + r.map(d2l); + height = Math.abs(y[y.length-1] - y[0]) * 2 / (y.length-1); + dLog('rectHeight', gIsData, d, i, /*g,*/ r, y, height); + if (! height) + height = 1; + } + } + return height; +}; + +DataConfig.prototype.addedDefaults = function(valueName) { + if (! this.hoverTextFn) + this.hoverTextFn = hoverTextFn; + if (this.valueIsArea === undefined) + this.valueIsArea = false; + + if (! this.barClassName) + this.barClassName = classNameSub; + if (! this.valueName) + this.valueName = valueName || "Values"; +}; + +/*----------------------------------------------------------------------------*/ + + +/* layoutAndDrawChart() has been split into class methods of AxisCharts and Chart1, + * and replaced with a proxy which calls them, and can next be re-distributed into axis-chart. */ +export { AxisCharts, /*AxisChart,*/ className, Chart1 }; diff --git a/frontend/app/utils/draw/collate-paths.js b/frontend/app/utils/draw/collate-paths.js index d1612b567..7fd48b2f2 100644 --- a/frontend/app/utils/draw/collate-paths.js +++ b/frontend/app/utils/draw/collate-paths.js @@ -749,8 +749,19 @@ function addPathsToCollation(blockA, blockB, paths) * featureIndex[], as current data structure is based on feature (feature) * names - that will change probably. */ let - /** the order of p.featureA, p.featureB matches the alias order. */ - aliasDirection = p.featureAObj.blockId === blockA, + getBlockId = p.featureAObj.get ? + ((o) => o.get('blockId.id')) + : ((o) => o.blockId), + aBlockId = getBlockId(p.featureAObj), + /** the order of p.featureA, p.featureB matches the alias order. */ + aliasDirection = aBlockId === blockA; + if (! aliasDirection && (aBlockId !== blockB)) { + dLog('aliasDirection no match', aliasDirection, + aBlockId, getBlockId(p.featureBObj), + blockA, blockB, p); + } + + let aliasFeatures = [p.featureA, p.featureB], /** the order of featureA and featureB matches the order of blockA and blockB, * i.e. featureA is in blockA, and featureB is in blockB diff --git a/frontend/app/utils/draw/zoomPanCalcs.js b/frontend/app/utils/draw/zoomPanCalcs.js index d43abf67a..92afe159b 100644 --- a/frontend/app/utils/draw/zoomPanCalcs.js +++ b/frontend/app/utils/draw/zoomPanCalcs.js @@ -30,6 +30,15 @@ function inRange(a, range) */ return (range[0] <= a) == (a <= range[1]); } +/** Same as inRange / subInterval, using the appropriate function for the type of value a. + * @param a may be a single value or an interval [from, to] + * @param range an interval [from, to] + */ +function inRangeEither(a, range) +{ + return a.length ? subInterval(a, range) : inRange(a, range); +} + /** Test if an array of values, which can be a pair defining an interval, is * contained within another interval. @@ -271,4 +280,4 @@ function wheelNewDomain(axis, axisApi, inFilter) { /*----------------------------------------------------------------------------*/ -export { wheelNewDomain }; +export { inRangeEither, wheelNewDomain }; diff --git a/frontend/app/utils/feature-lookup.js b/frontend/app/utils/feature-lookup.js index 559281826..47dd935ae 100644 --- a/frontend/app/utils/feature-lookup.js +++ b/frontend/app/utils/feature-lookup.js @@ -169,4 +169,24 @@ function storeFeature(oa, flowsService, feature, f, axisID) { /*----------------------------------------------------------------------------*/ -export { featureChrs, name2Map, chrMap, objectSet, mapsOfFeature, storeFeature }; +import { stacks } from './stacks'; +/** @param features contains the data attributes of the features. + */ +function ensureBlockFeatures(blockId, features) { + // may also need ensureFeatureIndex(). + let + /** oa.z could be passed in as a parameter, but this will be replaced anyway. */ + oa = stacks.oa, + za = oa.z[blockId]; + /* if za has not been populated with features, it will have just .dataset + * and .scope, i.e. .length === 2 */ + if (Object.keys(za).length == 2) { + dLog('ensureBlockFeatures()', blockId, za, features); + // add features to za. + features.forEach((f) => za[f.name] = f.value); + } +} + +/*----------------------------------------------------------------------------*/ + +export { featureChrs, name2Map, chrMap, objectSet, mapsOfFeature, storeFeature, ensureBlockFeatures }; diff --git a/frontend/app/utils/hover.js b/frontend/app/utils/hover.js index 6afc2567f..129253ed8 100644 --- a/frontend/app/utils/hover.js +++ b/frontend/app/utils/hover.js @@ -1,3 +1,6 @@ + +/* global Ember */ + /*------------------------------------------------------------------------*/ /* copied from draw-map.js - will import when that is split */ /* also @see configurejQueryTooltip() */ diff --git a/frontend/app/utils/paths-api.js b/frontend/app/utils/paths-api.js new file mode 100644 index 000000000..018da3460 --- /dev/null +++ b/frontend/app/utils/paths-api.js @@ -0,0 +1,251 @@ + +import PathData from '../components/draw/path-data'; +import { featureNameClass } from './draw/stacksAxes'; + +/*----------------------------------------------------------------------------*/ + +const dLog = console.debug; + +/*----------------------------------------------------------------------------*/ + + +if (false) { + /** Example of param paths passed to draw() above. */ + const examplePaths = +[{"_id":{"name":"myMarkerC"}, + "alignment":[ + {"blockId":"5c75d4f8792ccb326827daa2","repeats":{ + "_id":{"name":"myMarkerC","blockId":"5c75d4f8792ccb326827daa2"}, + "features":[{"_id":"5c75d4f8792ccb326827daa6","name":"myMarkerC","value":[3.1,3.1],"blockId":"5c75d4f8792ccb326827daa2","parentId":null}],"count":1}}, + {"blockId":"5c75d4f8792ccb326827daa1","repeats":{ + "_id":{"name":"myMarkerC","blockId":"5c75d4f8792ccb326827daa1"}, + "features":[{"_id":"5c75d4f8792ccb326827daa5","name":"myMarkerC","value":[0,0],"blockId":"5c75d4f8792ccb326827daa1","parentId":null}],"count":1}}]}]; +} + + +/*----------------------------------------------------------------------------*/ + +function featureEltId(featureBlock) +{ + let id = featurePathKeyFn(featureBlock); + id = featureNameClass(id); + return id; +} + +function featurePathKeyFn (featureBlock) +{ return featureBlock._id.name; } + +/** Given the grouped data for a feature, from the pathsDirect() result, + * generate the cross-product feature.alignment[0].repeats X feature.alignment[1].repeats. + * The result is an array of pairs of features; each pair defines a path and is of type PathData. + * for each pair an element of pairs[] : + * pair.feature0 is in block pair.block0 + * pair.feature1 is in block pair.block1 + * (for the case of pathsResultTypes.direct) : + * pair.block0 === feature.alignment[0].blockId + * pair.block1 === feature.alignment[1].blockId + * i.e. the path goes from the first block in the request params to the 2nd block + * @param pathsResultType e.g. pathsResultTypes.{Direct,Aliases} + * @param feature 1 element of the result array passed to draw() + * @return [PathData, ...] + */ +function pathsOfFeature(store, pathsResultType, owner) { + const PathData = owner.factoryFor('component:draw/path-data'); + return function (feature) { + let blocksFeatures = + [0, 1].map(function (blockIndex) { return pathsResultType.blocksFeatures(feature, blockIndex); }), + blocks = resultBlockIds(pathsResultType, feature), + pairs = + blocksFeatures[0].reduce(function (result, f0) { + let result1 = blocksFeatures[1].reduce(function (result, f1) { + let pair = + pathCreate(store, f0, f1, blocks[0], blocks[1]); + result.push(pair); + return result; + }, result); + return result1; + }, []); + return pairs; + }; +} + +const trace_pc = 1; + +function pathCreate(store, feature0, feature1, block0, block1) { + let + /** not used - same as feature{0,1}.blockId. */ + block0r = store.peekRecord('block', block0), + block1r = store.peekRecord('block', block1); + if (true) { + let properties = { + feature0, + feature1/*, + block0r, + block1r*/ + }, + pair = + PathData.create({ renderer : {} }); + pair.setProperties(properties); + if (trace_pc > 2) + dLog('PathData.create()', PathData, pair); + return pair; + } + else { + let + modelName = 'draw/path-data', + idText = locationPairKeyFn({ feature0, feature1}), + r = store.peekRecord(modelName, idText); + if (r) + dLog('pathCreate', feature0, feature1, block0, block1, r._internalModel.__attributes, r._internalModel.__data); + else if (false) + { + let data = { + type : modelName, + id : idText, + relationships : { + feature0 : { data: { type: "feature", "id": feature0 } }, + feature1 : { data: { type: "feature", "id": feature1 } } /*, + block0 : { data: { type: "block", "id": block0r } }, + block1 : { data: { type: "block", "id": block1r } }*/ + }/*, + attributes : { + 'block-id0' : block0, + 'block-id1' : block1 + }*/ + }; + r = store.push({data}); + if (trace_pc) + dLog('pathCreate', r, r.get('id'), r._internalModel, r._internalModel.__data, store, data); + } + else { + let inputProperties = { + feature0, + feature1/*, + block0r, + block1r*/ + }; + r = store.createRecord(modelName, inputProperties); + } + return r; + } +} + + +function locationPairKeyFn(locationPair) +{ + return locationPair.feature0.id + '_' + locationPair.feature1.id; +} + +/*----------------------------------------------------------------------------*/ + +const pathsApiFields = ['featureAObj', 'featureBObj']; +/** This type is created by paths-progressive.js : requestAliases() : receivedData() */ +const pathsApiResultType = { + // fieldName may be pathsResult or pathsAliasesResult + typeCheck : function(resultElt, trace) { let ok = !! resultElt.featureAObj; if (! ok && trace) { + dLog('pathsApiResultType : typeCheck', resultElt); }; return ok; }, + pathBlock : function (resultElt, blockIndex) { return resultElt[pathsApiFields[blockIndex]].blockId; }, + /** direct.blocksFeatures() returns an array of features, so match that. See + * similar commment in alias.blocksFeatures. */ + blocksFeatures : function (resultElt, blockIndex) { return [ resultElt[pathsApiFields[blockIndex]] ]; }, + featureEltId : + function (resultElt) + { + let id = pathsApiResultType.featurePathKeyFn(resultElt); + id = featureNameClass(id); + return id; + }, + featurePathKeyFn : function (resultElt) { return resultElt.featureA + '_' + resultElt.featureB; } + +}; + +/** This is provision for using the API result type as data type; not used currently because + * the various forms of result data are converted to path-data. + * These are the result types from : + * Block/paths -> apiLookupAliases() -> task.paths() + * Blocks/pathsViaStream -> pathsAggr.pathsDirect() + * getPathsAliasesViaStream() / getPathsAliasesProgressive() -> Blocks/pathsAliasesProgressive -> dbLookupAliases() -> pathsAggr.pathsAliases() + */ +const pathsResultTypes = { + direct : { + fieldName : 'pathsResult', + typeCheck : function(resultElt, trace) { let ok = !! resultElt._id; if (! ok && trace) { + dLog('direct : typeCheck', resultElt); }; return ok; }, + pathBlock : function (resultElt, blockIndex) { return resultElt.alignment[blockIndex].blockId; }, + blocksFeatures : function (resultElt, blockIndex) { return resultElt.alignment[blockIndex].repeats.features; }, + featureEltId : featureEltId, + featurePathKeyFn : featurePathKeyFn + }, + + alias : + { + fieldName : 'pathsAliasesResult', + typeCheck : function(resultElt, trace) { let ok = !! resultElt.aliased_features; if (! ok && trace) { + dLog('alias : typeCheck', resultElt); }; return ok; }, + pathBlock : function (resultElt, blockIndex) { return resultElt.aliased_features[blockIndex].blockId; }, + /** There is currently only 1 element in .aliased_features[blockIndex], but + * pathsOfFeature() handles an array an produces a cross-product, so return + * this 1 element as an array. */ + blocksFeatures : function (resultElt, blockIndex) { return [resultElt.aliased_features[blockIndex]]; }, + featureEltId : + function (resultElt) + { + let id = pathsResultTypes.alias.featurePathKeyFn(resultElt); + id = featureNameClass(id); + return id; + }, + featurePathKeyFn : function (resultElt) { + return resultElt.aliased_features.map(function (f) { return f.name; } ).join('_'); + } + } +}, +/** This matches the index values of services/data/flows-collate.js : flows */ +flowNames = Object.keys(pathsResultTypes); +// add .flowName to each of pathsResultTypes, which could later require non-const declaration. +flowNames.forEach(function (flowName) { pathsResultTypes[flowName].flowName = flowName; } ); + +pathsResultTypes.pathsApiResult = pathsApiResultType; + +/** Lookup the pathsResultType, given pathsResultField and optional resultElt. + * + * When matching each candidate pathsResultType (prt) : + * prt.fieldName is checked if it is defined. + * prt.typeCheck() is checked if resultElt is given. + */ +function pathsResultTypeFor(pathsResultField, resultElt) { + let pathsResultType = + Object.values(pathsResultTypes).find( + (prt) => (! prt.fieldName || (prt.fieldName === pathsResultField)) && (!resultElt || prt.typeCheck(resultElt, false))); + return pathsResultType; +} + + +/** + * @return array[2] of blockId, equivalent to blockAdjId + */ +function resultBlockIds(pathsResultType, featurePath) { + let blockIds = + [0, 1].map(function (blockIndex) { return pathsResultType.pathBlock(featurePath, blockIndex); }); + return blockIds; +} + +/** Return an accessor function suited to the object type of feature. + */ +function featureGetFn(feature) { + let fn = + feature.get ? (field) => feature.get(field) : (field) => feature[field]; + return fn; +} + +/** Used to get the block of a feature in pathsApiResultType (aliases). + */ +function featureGetBlock(feature, blocksById) { + let + block1 = featureGetFn(feature)('blockId'), + block2 = block1.content || block1, + block = block2.get ? block2 : blocksById[block2]; + return block; +} + + +export { pathsResultTypes, pathsApiResultType, flowNames, pathsResultTypeFor, resultBlockIds, pathsOfFeature, locationPairKeyFn, featureGetFn, featureGetBlock }; diff --git a/frontend/app/utils/stacks.js b/frontend/app/utils/stacks.js index 4edf8016e..6abe9519e 100644 --- a/frontend/app/utils/stacks.js +++ b/frontend/app/utils/stacks.js @@ -302,6 +302,15 @@ Stacked.prototype.getAxis = function() Stacked.axis1dAdd = function (axisName, axis1dComponent) { axes1d[axisName] = axis1dComponent; }; +Stacked.prototype.getAxis1d = function () { + let axis1d = this.axis1d || (this.axis1d = axes1d[this.axisName]); + if (axis1d && (axis1d.isDestroying || axis1d.isDestroying)) { + dLog('getAxis1d() isDestroying', axis1d, this); + axis1d = this.axis1d = undefined; + delete axes1d[this.axisName]; + } + return axis1d; +} function positionToString(p) { return (p === undefined) ? "" @@ -637,7 +646,8 @@ Stacked.prototype.getDomain = function () * Also this.domain above is not recalculated after additional features are received, * whereas blocksDomain has the necessary dependency. */ - let blocksDomain = this.axis1d && this.axis1d.get('blocksDomain'); + let axis1d = this.getAxis1d(); + let blocksDomain = axis1d && axis1d.get('blocksDomain'); if (blocksDomain && blocksDomain.length) { dLog('getDomain()', this.axisName, domain, blocksDomain); domain = d3.extent(domain.concat(blocksDomain)); @@ -2104,12 +2114,13 @@ Stacked.prototype.axisDimensions = function () /** y scale of this axis */ y = this.getY(), domain = this.y.domain(), - axis1d = this.axis1d, - dim = { domain, range : this.yRange(), zoomed : axis1d.zoomed}; + axis1d = this.getAxis1d(), + zoomed = axis1d && axis1d.zoomed, + dim = { domain, range : this.yRange(), zoomed}; let currentPosition = axis1d && axis1d.get('currentPosition'); if (! isEqual(domain, currentPosition.yDomain)) - dLog('axisDimensions', domain, currentPosition.yDomain, axis1d.zoomed, currentPosition); + dLog('axisDimensions', domain, currentPosition.yDomain, zoomed, currentPosition); return dim; }; /** Set the domain of the current position to the given domain @@ -2126,18 +2137,27 @@ Stacked.prototype.setDomain = function (domain) */ Stacked.prototype.setZoomed = function (zoomed) { - let axis1d = this.axis1d; + let axis1d = this.getAxis1d(); // later .zoomed may move into axis1d.currentPosition // if (! axisPosition) // dLog('setZoomed', this, 'zoomed', axis1d.zoomed, '->', zoomed, axis1d); - axis1d.setZoomed(zoomed); + if (axis1d) + axis1d.setZoomed(zoomed); }; Stacked.prototype.unviewBlocks = function () { - this.blocks.forEach((sBlock) => { - if (sBlock.block) - sBlock.block.set('isViewed', false); + /** Ember data objects. */ + let blocks = this.blocks.mapBy('block') + .filter((b) => b); + Ember.run.later(() => { + blocks.forEach((block) => { + // undefined .block-s are filtered out above + block.setProperties({ + 'view': undefined, + 'isViewed': false + }); + }); }); }; diff --git a/frontend/bower.json b/frontend/bower.json index 9a25103f4..1d71d9425 100644 --- a/frontend/bower.json +++ b/frontend/bower.json @@ -12,7 +12,7 @@ "d3-tip" : "VACLab/d3-tip", "d3": "^4.10.2", "animation-frame" : "^0.2.5", - "handsontable": "^2.0.0" + "handsontable": "^7" }, "devDependencies": { "bootstrap": "3.3.7" diff --git a/frontend/ember-cli-build.js b/frontend/ember-cli-build.js index 2a6c0942b..365824429 100644 --- a/frontend/ember-cli-build.js +++ b/frontend/ember-cli-build.js @@ -58,6 +58,7 @@ module.exports = function(defaults) { app.import('vendor/js/divgrid/divgrid.js'); app.import('node_modules/popper.js/dist/umd/popper.js'); app.import('node_modules/tooltip.js/dist/umd/tooltip.js'); + app.import('node_modules/colresizable/colResizable-1.6.min.js'); app.import('bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff', { destDir: 'fonts' diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64993e345..8c478d4f7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2963,7 +2963,6 @@ "version": "0.1.11", "resolved": "https://registry.npmjs.org/babel-plugin-debug-macros/-/babel-plugin-debug-macros-0.1.11.tgz", "integrity": "sha512-hZw5qNNGAR02Y+yBUrtsnJHh8OXavkayPRqKGAXnIm4t5rWVpj3ArwsC7TWdpZsBguQvHAeyTxZ7s23yY60HHg==", - "dev": true, "requires": { "semver": "^5.3.0" } @@ -3855,7 +3854,6 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/broccoli-babel-transpiler/-/broccoli-babel-transpiler-6.1.4.tgz", "integrity": "sha512-h63g7iOBWdxj0GuZw8kNsyaD1T9weKsY3I+gp3rOefozbHwUesJ43vzLy0jj3t/rbiP2czcJAlyHS48EcRil8Q==", - "dev": true, "requires": { "babel-core": "^6.14.0", "broccoli-funnel": "^1.0.0", @@ -5566,6 +5564,14 @@ "text-hex": "1.0.x" } }, + "colresizable": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/colresizable/-/colresizable-1.6.0.tgz", + "integrity": "sha1-NfmpOTuT58d3xw1xH6Q7hZUhnx8=", + "requires": { + "jquery": "1 - 2" + } + }, "combine-source-map": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", @@ -6557,15 +6563,6 @@ "ember-cli-babel": "^6.6.0" } }, - "ember-array-contains-helper": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ember-array-contains-helper/-/ember-array-contains-helper-1.3.2.tgz", - "integrity": "sha1-2n+Q1gM/3tujuUIitomNY02DNqM=", - "requires": { - "ember-cli-babel": "^6.1.0", - "ember-runtime-enumerable-includes-polyfill": "^2.0.0" - } - }, "ember-array-helper": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ember-array-helper/-/ember-array-helper-3.0.0.tgz", @@ -8937,13 +8934,54 @@ } }, "ember-contextual-table": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/ember-contextual-table/-/ember-contextual-table-1.10.0.tgz", - "integrity": "sha1-/1SBOygWmVGL2Q4+Z2LsUtvcRmw=", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ember-contextual-table/-/ember-contextual-table-2.0.2.tgz", + "integrity": "sha1-eUUq6+NAMgIrog/ZbagRiTqzD30=", "requires": { - "ember-array-contains-helper": "1.3.2", - "ember-cli-babel": "^6.3.0", - "ember-cli-htmlbars": "^2.0.1" + "ember-cli-babel": "6.6.0", + "ember-cli-htmlbars": "2.0.3" + }, + "dependencies": { + "amd-name-resolver": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/amd-name-resolver/-/amd-name-resolver-0.0.6.tgz", + "integrity": "sha1-0+S6Lfyqsdggwb6d6UfGeCjP5ZU=", + "requires": { + "ensure-posix-path": "^1.0.1" + } + }, + "babel-plugin-ember-modules-api-polyfill": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-ember-modules-api-polyfill/-/babel-plugin-ember-modules-api-polyfill-1.6.0.tgz", + "integrity": "sha512-HIOU4QBiselFqEvx6QaKrS/zxnfRQygQyA8wGdVUd42zO26G0jUqbEr1IE/NkTAbP4zsF0sY/ZLtVpjYiVB3VQ==", + "requires": { + "ember-rfc176-data": "^0.2.0" + } + }, + "ember-cli-babel": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/ember-cli-babel/-/ember-cli-babel-6.6.0.tgz", + "integrity": "sha1-qDYrxEhBv9+Js4nzGX8QTXulJto=", + "requires": { + "amd-name-resolver": "0.0.6", + "babel-plugin-debug-macros": "^0.1.11", + "babel-plugin-ember-modules-api-polyfill": "^1.4.1", + "babel-plugin-transform-es2015-modules-amd": "^6.24.0", + "babel-polyfill": "^6.16.0", + "babel-preset-env": "^1.5.1", + "broccoli-babel-transpiler": "^6.0.0", + "broccoli-debug": "^0.6.2", + "broccoli-funnel": "^1.0.0", + "broccoli-source": "^1.1.0", + "clone": "^2.0.0", + "ember-cli-version-checker": "^2.0.0" + } + }, + "ember-rfc176-data": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/ember-rfc176-data/-/ember-rfc176-data-0.2.7.tgz", + "integrity": "sha512-pJE2w+sI22UDsYmudI4nCp3WcImpUzXwe9qHfpOcEu3yM/HD1nGpDRt6kZD0KUnDmqkLeik/nYyzEwN/NU6xxA==" + } } }, "ember-cookies": { @@ -8956,6 +8994,14 @@ "ember-getowner-polyfill": "^1.1.0 || ^2.0.0" } }, + "ember-csv": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ember-csv/-/ember-csv-1.0.2.tgz", + "integrity": "sha512-BMf1pmkoclLIR2+iPrvCwCgV7uaGrpA+yJCVlDc996FFIrGjFDlwDeg/Gh5hRoIRN9XHpbCBN7wVziOIIecHIg==", + "requires": { + "ember-cli-babel": "^6.6.0" + } + }, "ember-data": { "version": "2.18.2", "resolved": "https://registry.npmjs.org/ember-data/-/ember-data-2.18.2.tgz", @@ -10524,6 +10570,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/ember-runtime-enumerable-includes-polyfill/-/ember-runtime-enumerable-includes-polyfill-2.1.0.tgz", "integrity": "sha512-au18iI8VbEDYn3jLFZzETnKN5ciPgCUxMRucEP3jkq7qZ6sE0FVKpWMPY/h9tTND3VOBJt6fgPpEBJoJVCUudg==", + "dev": true, "requires": { "ember-cli-babel": "^6.9.0", "ember-cli-version-checker": "^2.1.0" @@ -15618,6 +15665,11 @@ "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=", "dev": true }, + "jquery": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz", + "integrity": "sha1-LInWiJterFIqfuoywUUhVZxsvwI=" + }, "js-base64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", @@ -17538,37 +17590,37 @@ "dependencies": { "abbrev": { "version": "1.0.7", - "resolved": false, + "resolved": "", "integrity": "sha1-W2A1su6dT7XPhZ8Iqb6BsghJGEM=", "dev": true }, "ansi-regex": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-xQYbbg74qBd15Q9dZhUb9r83EQc=", "dev": true }, "ansicolors": { "version": "0.3.2", - "resolved": false, + "resolved": "", "integrity": "sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk=", "dev": true }, "ansistyles": { "version": "0.1.3", - "resolved": false, + "resolved": "", "integrity": "sha1-XeYEFb2gcbs3EnhUyGT0GyMlRTk=", "dev": true }, "aproba": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-xKwsxb7PuLCZ3n758CeQ59Mtme8=", "dev": true }, "archy": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", "dev": true }, @@ -17584,13 +17636,13 @@ }, "chownr": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", "dev": true }, "cmd-shim": { "version": "2.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-RRKjc9I5FnmuxRrR1HM1Wem4XUo=", "dev": true, "requires": { @@ -17600,7 +17652,7 @@ "dependencies": { "graceful-fs": { "version": "3.0.8", - "resolved": false, + "resolved": "", "integrity": "sha1-zoE+cl+oL35hR9UcmlymgnBVHCI=", "dev": true } @@ -17608,7 +17660,7 @@ }, "columnify": { "version": "1.5.4", - "resolved": false, + "resolved": "", "integrity": "sha1-Rzfd8ce2mop8NAVweC6UfuyOeLs=", "dev": true, "requires": { @@ -17618,7 +17670,7 @@ "dependencies": { "wcwidth": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-AtBZ/3qPx0Hg9rXaHmmytA2uym8=", "dev": true, "requires": { @@ -17627,7 +17679,7 @@ "dependencies": { "defaults": { "version": "1.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", "dev": true, "requires": { @@ -17636,7 +17688,7 @@ "dependencies": { "clone": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-Jgt6meux7f4kdTgXX3gyQ8sZ0Uk=", "dev": true } @@ -17648,7 +17700,7 @@ }, "config-chain": { "version": "1.1.9", - "resolved": false, + "resolved": "", "integrity": "sha1-Oax9TcqE+q2SYSTFTP8lpTqov24=", "dev": true, "requires": { @@ -17658,7 +17710,7 @@ "dependencies": { "proto-list": { "version": "1.2.4", - "resolved": false, + "resolved": "", "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true } @@ -17672,19 +17724,19 @@ }, "debuglog": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", "dev": true }, "editor": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-YMf4e9YrzGqJT6jM1q+3gjok90I=", "dev": true }, "fs-vacuum": { "version": "1.2.7", - "resolved": false, + "resolved": "", "integrity": "sha1-deUB+dKIm6L+n+Evk2ul2tUMo1o=", "dev": true, "requires": { @@ -17695,7 +17747,7 @@ }, "fstream": { "version": "1.0.8", - "resolved": false, + "resolved": "", "integrity": "sha1-fo16c6uzZH7zbkuKFcqAHboD0Dg=", "dev": true, "requires": { @@ -17720,7 +17772,7 @@ }, "glob": { "version": "6.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-XwLNiVh85YsVSuCFXeAqLmOYb8o=", "dev": true, "requires": { @@ -17733,7 +17785,7 @@ "dependencies": { "minimatch": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-UjYVelHk8ATBd/s8Un/33Xjw74M=", "dev": true, "requires": { @@ -17742,7 +17794,7 @@ "dependencies": { "brace-expansion": { "version": "1.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-8hRF0EiLZY4nce/YcO/1HfKfBO8=", "dev": true, "requires": { @@ -17752,13 +17804,13 @@ "dependencies": { "balanced-match": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-qRzdHr7xqGZZ5w/03vAWJfwtZ1Y=", "dev": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true } @@ -17768,7 +17820,7 @@ }, "path-is-absolute": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-Jj2tpmqz8vsQv3+dJN2PPlcO+RI=", "dev": true } @@ -17776,13 +17828,13 @@ }, "graceful-fs": { "version": "4.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-/iI5t1dJcuZ+QfgIgj+b+kqZHjc=", "dev": true }, "has-unicode": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-o82Wwwe6QdVZxaLuQIwSoRxMLsM=", "dev": true }, @@ -17794,19 +17846,19 @@ }, "iferr": { "version": "0.1.5", - "resolved": false, + "resolved": "", "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", "dev": true }, "imurmurhash": { "version": "0.1.4", - "resolved": false, + "resolved": "", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, "inflight": { "version": "1.0.4", - "resolved": false, + "resolved": "", "integrity": "sha1-bLtFIevVHODsCpNr/XZX736bFyo=", "dev": true, "requires": { @@ -17816,19 +17868,19 @@ }, "inherits": { "version": "2.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", "dev": true }, "ini": { "version": "1.3.4", - "resolved": false, + "resolved": "", "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4=", "dev": true }, "init-package-json": { "version": "1.9.1", - "resolved": false, + "resolved": "", "integrity": "sha1-oo4FtbrrM2PNRz32jTDTqAUjoxw=", "dev": true, "requires": { @@ -17857,7 +17909,7 @@ }, "promzard": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", "dev": true, "requires": { @@ -17874,19 +17926,19 @@ }, "lockfile": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-nTU+z+P1TRULtX+J1RdGk1o5xPU=", "dev": true }, "lodash._baseindexof": { "version": "3.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw=", "dev": true }, "lodash._baseuniq": { "version": "3.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-ISP6DbLWnCjVvrHB821hUip0AjQ=", "dev": true, "requires": { @@ -17897,19 +17949,19 @@ }, "lodash._bindcallback": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-5THCdkTPi1epnhftlbNcdIeJOS4=", "dev": true }, "lodash._cacheindexof": { "version": "3.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI=", "dev": true }, "lodash._createcache": { "version": "3.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-VtagZAF2JeeevKa4AY4XRAvc8JM=", "dev": true, "requires": { @@ -17918,13 +17970,13 @@ }, "lodash._getnative": { "version": "3.9.1", - "resolved": false, + "resolved": "", "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", "dev": true }, "lodash.clonedeep": { "version": "3.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-oKHkDYKl6on/WxR7hETtY9koJ9s=", "dev": true, "requires": { @@ -17934,7 +17986,7 @@ "dependencies": { "lodash._baseclone": { "version": "3.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-MDUZv2OT/n5C802LYw73eU41Qrc=", "dev": true, "requires": { @@ -17948,19 +18000,19 @@ "dependencies": { "lodash._arraycopy": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-due3wfH7klRzdIeKVi7Qaj5Q9uE=", "dev": true }, "lodash._arrayeach": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-urFWsqkNPxu9XGU0AzSeXlkz754=", "dev": true }, "lodash._baseassign": { "version": "3.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", "dev": true, "requires": { @@ -17970,7 +18022,7 @@ "dependencies": { "lodash._basecopy": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", "dev": true } @@ -17978,7 +18030,7 @@ }, "lodash._basefor": { "version": "3.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-Okzs5bcDHq54pEHFQWuQh47u5aE=", "dev": true } @@ -17988,19 +18040,19 @@ }, "lodash.isarguments": { "version": "3.0.4", - "resolved": false, + "resolved": "", "integrity": "sha1-67uITEjSc2akTqb+5X7XtaMqgeA=", "dev": true }, "lodash.isarray": { "version": "3.0.4", - "resolved": false, + "resolved": "", "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", "dev": true }, "lodash.keys": { "version": "3.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", "dev": true, "requires": { @@ -18011,13 +18063,13 @@ }, "lodash.restparam": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=", "dev": true }, "lodash.union": { "version": "3.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-pKMGb8Fdan+BUczpvf5j3Of1vP8=", "dev": true, "requires": { @@ -18028,7 +18080,7 @@ "dependencies": { "lodash._baseflatten": { "version": "3.1.4", - "resolved": false, + "resolved": "", "integrity": "sha1-B3D/gBMa9uNPO1EXlqe6UhTmX/c=", "dev": true, "requires": { @@ -18040,7 +18092,7 @@ }, "lodash.uniq": { "version": "3.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-FGw28l510ZUBukAuiLoUk39jzYs=", "dev": true, "requires": { @@ -18053,7 +18105,7 @@ "dependencies": { "lodash._basecallback": { "version": "3.3.1", - "resolved": false, + "resolved": "", "integrity": "sha1-t7K7Q9whYEJKIczybFfkQ3cqjic=", "dev": true, "requires": { @@ -18065,7 +18117,7 @@ "dependencies": { "lodash._baseisequal": { "version": "3.0.7", - "resolved": false, + "resolved": "", "integrity": "sha1-2AJfdjOdKTQnZ9zIh85cuVpbUfE=", "dev": true, "requires": { @@ -18076,7 +18128,7 @@ "dependencies": { "lodash.istypedarray": { "version": "3.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-k5exE8FfQk8yCvBsqlnMSV4gk84=", "dev": true } @@ -18084,7 +18136,7 @@ }, "lodash.pairs": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-u+CNV4bu6qCaFckevw3LfSvjJqk=", "dev": true, "requires": { @@ -18095,7 +18147,7 @@ }, "lodash._isiterateecall": { "version": "3.0.9", - "resolved": false, + "resolved": "", "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", "dev": true } @@ -18103,7 +18155,7 @@ }, "lodash.without": { "version": "3.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-1pYUs1EuUilLarq3gufKllOM6BY=", "dev": true, "requires": { @@ -18113,7 +18165,7 @@ "dependencies": { "lodash._basedifference": { "version": "3.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-8sIEKWwqeOArOJCBtu3KyTPPYpw=", "dev": true, "requires": { @@ -18126,7 +18178,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, + "resolved": "", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "requires": { @@ -18135,7 +18187,7 @@ "dependencies": { "minimist": { "version": "0.0.8", - "resolved": false, + "resolved": "", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true } @@ -18143,7 +18195,7 @@ }, "node-gyp": { "version": "3.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9d1WmXClCEZMw8Fdfp6NLehjjdU=", "dev": true, "requires": { @@ -18165,7 +18217,7 @@ "dependencies": { "glob": { "version": "4.5.3", - "resolved": false, + "resolved": "", "integrity": "sha1-xstz0yJsHv7wTePFbQEvAzd+4V8=", "dev": true, "requires": { @@ -18177,7 +18229,7 @@ "dependencies": { "minimatch": { "version": "2.0.10", - "resolved": false, + "resolved": "", "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", "dev": true, "requires": { @@ -18186,7 +18238,7 @@ "dependencies": { "brace-expansion": { "version": "1.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-8hRF0EiLZY4nce/YcO/1HfKfBO8=", "dev": true, "requires": { @@ -18196,13 +18248,13 @@ "dependencies": { "balanced-match": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-qRzdHr7xqGZZ5w/03vAWJfwtZ1Y=", "dev": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true } @@ -18220,7 +18272,7 @@ }, "minimatch": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-4N0hILSeG3JM6NcUxSCCKpQ4V20=", "dev": true, "requires": { @@ -18230,13 +18282,13 @@ "dependencies": { "lru-cache": { "version": "2.7.3", - "resolved": false, + "resolved": "", "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=", "dev": true }, "sigmund": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=", "dev": true } @@ -18244,7 +18296,7 @@ }, "npmlog": { "version": "1.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-KOe+YZYJtT960d0wChDWTXFiaLY=", "dev": true, "requires": { @@ -18255,13 +18307,13 @@ "dependencies": { "ansi": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-dLLx8YfIVTx/lQFby3YAn7Q9OOA=", "dev": true }, "are-we-there-yet": { "version": "1.0.4", - "resolved": false, + "resolved": "", "integrity": "sha1-Un/jife8upCAYQa5kkTqoH6Ib4U=", "dev": true, "requires": { @@ -18271,13 +18323,13 @@ "dependencies": { "delegates": { "version": "0.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-tLV74RoWU1F6BLJ/CUm9wyff45A=", "dev": true }, "readable-stream": { "version": "1.1.13", - "resolved": false, + "resolved": "", "integrity": "sha1-9u73ZPUUyJ4rniMUanW6EGdW0j4=", "dev": true, "requires": { @@ -18289,19 +18341,19 @@ "dependencies": { "core-util-is": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, "isarray": { "version": "0.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", "dev": true }, "string_decoder": { "version": "0.10.31", - "resolved": false, + "resolved": "", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true } @@ -18311,7 +18363,7 @@ }, "gauge": { "version": "1.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-BbZzChmo/K08NAoULwlFIio/gVs=", "dev": true, "requires": { @@ -18324,7 +18376,7 @@ "dependencies": { "lodash.pad": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-LgeOvDOzMdK6NL+HMq8Sn9XARiQ=", "dev": true, "requires": { @@ -18334,13 +18386,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18349,7 +18401,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18362,7 +18414,7 @@ }, "lodash.padleft": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-FQFR8eAkXtuhXVCvLXHx1c/0ZTA=", "dev": true, "requires": { @@ -18372,13 +18424,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18387,7 +18439,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18400,7 +18452,7 @@ }, "lodash.padright": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-efd3C6qjlzjAQK61Rl6NiPKqzsA=", "dev": true, "requires": { @@ -18410,13 +18462,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18425,7 +18477,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18442,7 +18494,7 @@ }, "path-array": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-bBQTDDMITwFQVTxlezg5erZ6qk4=", "dev": true, "requires": { @@ -18451,7 +18503,7 @@ "dependencies": { "array-index": { "version": "0.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-TV6vBsw9klhHzXPRU1whe6MG0+E=", "dev": true, "requires": { @@ -18460,7 +18512,7 @@ "dependencies": { "debug": { "version": "2.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", "dev": true, "requires": { @@ -18469,7 +18521,7 @@ "dependencies": { "ms": { "version": "0.7.1", - "resolved": false, + "resolved": "", "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=", "dev": true } @@ -18483,7 +18535,7 @@ }, "nopt": { "version": "3.0.6", - "resolved": false, + "resolved": "", "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", "dev": true, "requires": { @@ -18492,13 +18544,13 @@ }, "normalize-git-url": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-1A1BnQWhWHAnHlBTTbt7jM2bClw=", "dev": true }, "normalize-package-data": { "version": "2.3.5", - "resolved": false, + "resolved": "", "integrity": "sha1-jZJPFClg4Xd+f/4XBUNjHMfLAt8=", "dev": true, "requires": { @@ -18510,7 +18562,7 @@ "dependencies": { "is-builtin-module": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", "dev": true, "requires": { @@ -18519,7 +18571,7 @@ "dependencies": { "builtin-modules": { "version": "1.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-EFOVX9mUpXRuUl5Kxxe4HK8HSRw=", "dev": true } @@ -18529,13 +18581,13 @@ }, "npm-cache-filename": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-3tMGxbC/yHCp6fr4I7xfKD4FrhE=", "dev": true }, "npm-install-checks": { "version": "2.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-qTVAtT8E+p2RbScz1lQfbbfYjkY=", "dev": true, "requires": { @@ -18551,7 +18603,7 @@ }, "npmlog": { "version": "1.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-KOe+YZYJtT960d0wChDWTXFiaLY=", "dev": true, "requires": { @@ -18562,13 +18614,13 @@ "dependencies": { "ansi": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-dLLx8YfIVTx/lQFby3YAn7Q9OOA=", "dev": true }, "are-we-there-yet": { "version": "1.0.4", - "resolved": false, + "resolved": "", "integrity": "sha1-Un/jife8upCAYQa5kkTqoH6Ib4U=", "dev": true, "requires": { @@ -18578,13 +18630,13 @@ "dependencies": { "delegates": { "version": "0.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-tLV74RoWU1F6BLJ/CUm9wyff45A=", "dev": true }, "readable-stream": { "version": "1.1.13", - "resolved": false, + "resolved": "", "integrity": "sha1-9u73ZPUUyJ4rniMUanW6EGdW0j4=", "dev": true, "requires": { @@ -18596,19 +18648,19 @@ "dependencies": { "core-util-is": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-awcIWu+aPMrG7lO/nT3wwVIaVTg=", "dev": true }, "isarray": { "version": "0.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", "dev": true }, "string_decoder": { "version": "0.10.31", - "resolved": false, + "resolved": "", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true } @@ -18618,7 +18670,7 @@ }, "gauge": { "version": "1.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-BbZzChmo/K08NAoULwlFIio/gVs=", "dev": true, "requires": { @@ -18631,7 +18683,7 @@ "dependencies": { "lodash.pad": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-LgeOvDOzMdK6NL+HMq8Sn9XARiQ=", "dev": true, "requires": { @@ -18641,13 +18693,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18656,7 +18708,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18669,7 +18721,7 @@ }, "lodash.padleft": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-FQFR8eAkXtuhXVCvLXHx1c/0ZTA=", "dev": true, "requires": { @@ -18679,13 +18731,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18694,7 +18746,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18707,7 +18759,7 @@ }, "lodash.padright": { "version": "3.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-efd3C6qjlzjAQK61Rl6NiPKqzsA=", "dev": true, "requires": { @@ -18717,13 +18769,13 @@ "dependencies": { "lodash._basetostring": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", "dev": true }, "lodash._createpadding": { "version": "3.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-SQe0OFla3FTuiTVSemxCTALIGoc=", "dev": true, "requires": { @@ -18732,7 +18784,7 @@ "dependencies": { "lodash.repeat": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-9LmNx+9nJWzmHnh04YZe2yCODt8=", "dev": true, "requires": { @@ -18761,7 +18813,7 @@ }, "npm-user-validate": { "version": "0.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-1YXaC0fJ9BqebKaEtv2EukHr6H0=", "dev": true }, @@ -18778,7 +18830,7 @@ }, "once": { "version": "1.3.3", - "resolved": false, + "resolved": "", "integrity": "sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA=", "dev": true, "requires": { @@ -18787,13 +18839,13 @@ }, "opener": { "version": "1.4.1", - "resolved": false, + "resolved": "", "integrity": "sha1-iXWQrNGu0zEbcDtYvMtNQ/VvKJU=", "dev": true }, "osenv": { "version": "0.1.3", - "resolved": false, + "resolved": "", "integrity": "sha1-g88FxtZFj8TVrGNi6jJdkvJ1Qhc=", "dev": true, "requires": { @@ -18803,13 +18855,13 @@ "dependencies": { "os-homedir": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-DWK99EuRb9O73PLKsZGUj7CU8Ac=", "dev": true }, "os-tmpdir": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-6bQjoe2vR5iCVi6S7XHXdDoHG24=", "dev": true } @@ -18817,13 +18869,13 @@ }, "path-is-inside": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-mNjx0DC/BL167uShulSF1AMY/Yk=", "dev": true }, "read": { "version": "1.0.7", - "resolved": false, + "resolved": "", "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", "dev": true, "requires": { @@ -18832,7 +18884,7 @@ "dependencies": { "mute-stream": { "version": "0.0.5", - "resolved": false, + "resolved": "", "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=", "dev": true } @@ -18840,7 +18892,7 @@ }, "read-cmd-shim": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-LV0Vd4ajfAVdIgd8MsU/gynpHHs=", "dev": true, "requires": { @@ -18849,7 +18901,7 @@ }, "read-installed": { "version": "4.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-/5uLZ/GH0eTCm5/rMfayI6zRkGc=", "dev": true, "requires": { @@ -18864,7 +18916,7 @@ "dependencies": { "util-extend": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-u3A7eUgCk93Nz7PGqf6iD0g0Fbw=", "dev": true } @@ -18872,7 +18924,7 @@ }, "read-package-tree": { "version": "5.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-46SIeS9Az0cIGfAaYQ5xnWTwkJQ=", "dev": true, "requires": { @@ -18885,7 +18937,7 @@ }, "readable-stream": { "version": "2.0.5", - "resolved": false, + "resolved": "", "integrity": "sha1-okJvjc1FUcd6M/lu3yiGojyClmk=", "dev": true, "requires": { @@ -18899,13 +18951,13 @@ "dependencies": { "process-nextick-args": { "version": "1.0.6", - "resolved": false, + "resolved": "", "integrity": "sha1-D5awAc6pCxJZLOVm7bl+wR5pvQU=", "dev": true }, "util-deprecate": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true } @@ -18913,7 +18965,7 @@ }, "readdir-scoped-modules": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-n6+jfShr5dksuuve4DDcm19AZ0c=", "dev": true, "requires": { @@ -18925,7 +18977,7 @@ }, "request": { "version": "2.67.0", - "resolved": false, + "resolved": "", "integrity": "sha1-ivdHgOK/EeoK6aqWXBHxGv0nJ0I=", "dev": true, "requires": { @@ -18953,13 +19005,13 @@ "dependencies": { "aws-sign2": { "version": "0.6.0", - "resolved": false, + "resolved": "", "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", "dev": true }, "bl": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-ramoqJptesYIYvfex9sgeHPgw/U=", "dev": true, "requires": { @@ -18968,13 +19020,13 @@ }, "caseless": { "version": "0.11.0", - "resolved": false, + "resolved": "", "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", "dev": true }, "combined-stream": { "version": "1.0.5", - "resolved": false, + "resolved": "", "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", "dev": true, "requires": { @@ -18983,7 +19035,7 @@ "dependencies": { "delayed-stream": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true } @@ -18991,19 +19043,19 @@ }, "extend": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-WkdDU7nzNT3dgXbf03uRyDpG8dQ=", "dev": true }, "forever-agent": { "version": "0.6.1", - "resolved": false, + "resolved": "", "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", "dev": true }, "form-data": { "version": "1.0.0-rc3", - "resolved": false, + "resolved": "", "integrity": "sha1-01vGLn+8KTeuePlIqqDTjZBgdXc=", "dev": true, "requires": { @@ -19014,7 +19066,7 @@ "dependencies": { "async": { "version": "1.5.0", - "resolved": false, + "resolved": "", "integrity": "sha1-J5ZkJyNXOFlWVjP8YnRES+4vjOM=", "dev": true } @@ -19022,7 +19074,7 @@ }, "har-validator": { "version": "2.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-Wp4SVkpXHPC4Hvk8IVe9FhcWiIM=", "dev": true, "requires": { @@ -19034,7 +19086,7 @@ "dependencies": { "chalk": { "version": "1.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-UJr7ZwZudJn36zU1x3RFdyri0Bk=", "dev": true, "requires": { @@ -19047,19 +19099,19 @@ "dependencies": { "ansi-styles": { "version": "2.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-mQ90cUaSe1Wakyv5KVkWPWDA0OI=", "dev": true }, "escape-string-regexp": { "version": "1.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-ni2LJbwlVcMzZyN1DgPwmcJzW7U=", "dev": true }, "has-ansi": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", "dev": true, "requires": { @@ -19068,7 +19120,7 @@ }, "supports-color": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true } @@ -19076,7 +19128,7 @@ }, "commander": { "version": "2.9.0", - "resolved": false, + "resolved": "", "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", "dev": true, "requires": { @@ -19085,7 +19137,7 @@ "dependencies": { "graceful-readlink": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", "dev": true } @@ -19093,7 +19145,7 @@ }, "is-my-json-valid": { "version": "2.12.3", - "resolved": false, + "resolved": "", "integrity": "sha1-WjnR12stu4MUC70Vex1e5L3IWtY=", "dev": true, "requires": { @@ -19105,13 +19157,13 @@ "dependencies": { "generate-function": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", "dev": true }, "generate-object-property": { "version": "1.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", "dev": true, "requires": { @@ -19120,7 +19172,7 @@ "dependencies": { "is-property": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", "dev": true } @@ -19128,13 +19180,13 @@ }, "jsonpointer": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-OvHdIP6FRjkQ1GmjheMwF9KgMNk=", "dev": true }, "xtend": { "version": "4.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", "dev": true } @@ -19142,7 +19194,7 @@ }, "pinkie-promise": { "version": "2.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-TINTjeH25mDCngoTRGhE96foglk=", "dev": true, "requires": { @@ -19151,7 +19203,7 @@ "dependencies": { "pinkie": { "version": "2.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-QjbIb8KfJhwgRbvoH3jLsqXoMGw=", "dev": true } @@ -19161,7 +19213,7 @@ }, "hawk": { "version": "3.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-kMkBGIhuIZddGtSumz4oTtGaLeg=", "dev": true, "requires": { @@ -19173,7 +19225,7 @@ "dependencies": { "boom": { "version": "2.10.1", - "resolved": false, + "resolved": "", "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", "dev": true, "requires": { @@ -19182,7 +19234,7 @@ }, "cryptiles": { "version": "2.0.5", - "resolved": false, + "resolved": "", "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", "dev": true, "requires": { @@ -19191,13 +19243,13 @@ }, "hoek": { "version": "2.16.3", - "resolved": false, + "resolved": "", "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", "dev": true }, "sntp": { "version": "1.0.9", - "resolved": false, + "resolved": "", "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", "dev": true, "requires": { @@ -19208,7 +19260,7 @@ }, "http-signature": { "version": "1.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-XS1+m270mYCtWxKNjk7wmjHJDZU=", "dev": true, "requires": { @@ -19219,13 +19271,13 @@ "dependencies": { "assert-plus": { "version": "0.1.5", - "resolved": false, + "resolved": "", "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=", "dev": true }, "jsprim": { "version": "1.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-8gyQaskqvVjjt5rIvHCkiDJRLaE=", "dev": true, "requires": { @@ -19236,19 +19288,19 @@ "dependencies": { "extsprintf": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA=", "dev": true }, "json-schema": { "version": "0.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-UDVPGfYDkXxpX3C4Wvp3w7DyNQY=", "dev": true }, "verror": { "version": "1.3.6", - "resolved": false, + "resolved": "", "integrity": "sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw=", "dev": true, "requires": { @@ -19259,7 +19311,7 @@ }, "sshpk": { "version": "1.7.1", - "resolved": false, + "resolved": "", "integrity": "sha1-Vl44bEKnfmBi+9FMBHL/Ic1TOYw=", "dev": true, "requires": { @@ -19274,19 +19326,19 @@ "dependencies": { "asn1": { "version": "0.2.3", - "resolved": false, + "resolved": "", "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", "dev": true }, "assert-plus": { "version": "0.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", "dev": true }, "dashdash": { "version": "1.10.1", - "resolved": false, + "resolved": "", "integrity": "sha1-Cr8a+JqPUSmoHxjCs1sh3yJiL2A=", "dev": true, "requires": { @@ -19303,7 +19355,7 @@ }, "ecc-jsbn": { "version": "0.1.1", - "resolved": false, + "resolved": "", "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", "dev": true, "optional": true, @@ -19313,7 +19365,7 @@ }, "jodid25519": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-BtSRIlUJNBlHfUJWM2BuDpB4KWc=", "dev": true, "optional": true, @@ -19323,14 +19375,14 @@ }, "jsbn": { "version": "0.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-ZQmH2g3XT06/WhE3eiqi0nPpff0=", "dev": true, "optional": true }, "tweetnacl": { "version": "0.13.2", - "resolved": false, + "resolved": "", "integrity": "sha1-RTFhdwRp1FzSZsNkBOK8maj6mUQ=", "dev": true, "optional": true @@ -19341,25 +19393,25 @@ }, "is-typedarray": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, "isstream": { "version": "0.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "dev": true }, "json-stringify-safe": { "version": "5.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true }, "mime-types": { "version": "2.1.8", - "resolved": false, + "resolved": "", "integrity": "sha1-+vV4I94EvHy/9O6CxrY5RugSrnI=", "dev": true, "requires": { @@ -19368,7 +19420,7 @@ "dependencies": { "mime-db": { "version": "1.20.0", - "resolved": false, + "resolved": "", "integrity": "sha1-SW+Q/QH+DgMciCPsOqlFD/2hjtg=", "dev": true } @@ -19376,37 +19428,37 @@ }, "node-uuid": { "version": "1.4.7", - "resolved": false, + "resolved": "", "integrity": "sha1-baWhdmjEs91ZYjvaEc9/pMH2Cm8=", "dev": true }, "oauth-sign": { "version": "0.8.0", - "resolved": false, + "resolved": "", "integrity": "sha1-k4/ch1dlulJxN9iuydF44k3rxVM=", "dev": true }, "qs": { "version": "5.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-qfMRQq9GjLcrJbMBNrokVoNJFr4=", "dev": true }, "stringstream": { "version": "0.0.5", - "resolved": false, + "resolved": "", "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=", "dev": true }, "tough-cookie": { "version": "2.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-OwUWt5nnDoFkQ2oURuflh3/aEY4=", "dev": true }, "tunnel-agent": { "version": "0.4.2", - "resolved": false, + "resolved": "", "integrity": "sha1-EQTj82rIcSXChycAZ9WC0YEzv+4=", "dev": true } @@ -19414,13 +19466,13 @@ }, "retry": { "version": "0.8.0", - "resolved": false, + "resolved": "", "integrity": "sha1-I2dijcDtskex6rZJ3FOshiisLV8=", "dev": true }, "rimraf": { "version": "2.5.0", - "resolved": false, + "resolved": "", "integrity": "sha1-MMCWzfdy4mvz4dLP+EwhllQam7Y=", "dev": true, "requires": { @@ -19429,7 +19481,7 @@ "dependencies": { "glob": { "version": "6.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-XwLNiVh85YsVSuCFXeAqLmOYb8o=", "dev": true, "requires": { @@ -19442,7 +19494,7 @@ "dependencies": { "minimatch": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-UjYVelHk8ATBd/s8Un/33Xjw74M=", "dev": true, "requires": { @@ -19451,7 +19503,7 @@ "dependencies": { "brace-expansion": { "version": "1.1.2", - "resolved": false, + "resolved": "", "integrity": "sha1-8hRF0EiLZY4nce/YcO/1HfKfBO8=", "dev": true, "requires": { @@ -19461,13 +19513,13 @@ "dependencies": { "balanced-match": { "version": "0.3.0", - "resolved": false, + "resolved": "", "integrity": "sha1-qRzdHr7xqGZZ5w/03vAWJfwtZ1Y=", "dev": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true } @@ -19477,7 +19529,7 @@ }, "path-is-absolute": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-Jj2tpmqz8vsQv3+dJN2PPlcO+RI=", "dev": true } @@ -19487,13 +19539,13 @@ }, "semver": { "version": "5.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-hfLPhVBGXE3wAM99hvawVBBqueU=", "dev": true }, "sha": { "version": "2.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-YDCCL70smCOUn49y7WQR7lzyWq4=", "dev": true, "requires": { @@ -19503,13 +19555,13 @@ }, "slide": { "version": "1.1.6", - "resolved": false, + "resolved": "", "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=", "dev": true }, "sorted-object": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-XR9PnB+yzUiWWWcwTiEutEz7bQU=", "dev": true }, @@ -19521,7 +19573,7 @@ }, "strip-ansi": { "version": "3.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-dRC2ZVZ8qRTMtdfgcnY6yWi+NyQ=", "dev": true, "requires": { @@ -19530,7 +19582,7 @@ }, "tar": { "version": "2.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", "dev": true, "requires": { @@ -19541,7 +19593,7 @@ "dependencies": { "block-stream": { "version": "0.0.8", - "resolved": false, + "resolved": "", "integrity": "sha1-Boj0baK7+c/wxPaCJaDLlcvopGs=", "dev": true, "requires": { @@ -19552,25 +19604,25 @@ }, "text-table": { "version": "0.2.0", - "resolved": false, + "resolved": "", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, "uid-number": { "version": "0.0.6", - "resolved": false, + "resolved": "", "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=", "dev": true }, "umask": { "version": "1.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0=", "dev": true }, "unique-filename": { "version": "1.1.0", - "resolved": false, + "resolved": "", "integrity": "sha1-0F8v5AMlYIcfMOk8vnNe6iAVFPM=", "dev": true, "requires": { @@ -19579,13 +19631,13 @@ }, "unpipe": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", "dev": true }, "validate-npm-package-license": { "version": "3.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=", "dev": true, "requires": { @@ -19595,7 +19647,7 @@ "dependencies": { "spdx-correct": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-rAdfXy9qBsC/3RyEfrPd492CIeo=", "dev": true, "requires": { @@ -19604,7 +19656,7 @@ }, "spdx-expression-parse": { "version": "1.0.0", - "resolved": false, + "resolved": "", "integrity": "sha1-T7t+c4yemPoLCRTf2WGsZin7ze8=", "dev": true, "requires": { @@ -19614,7 +19666,7 @@ "dependencies": { "spdx-exceptions": { "version": "1.0.3", - "resolved": false, + "resolved": "", "integrity": "sha1-Oexe0s693wjRgFVdfpnDr/m0dko=", "dev": true } @@ -19622,7 +19674,7 @@ }, "spdx-license-ids": { "version": "1.0.2", - "resolved": false, + "resolved": "", "integrity": "sha1-BnTpyaIw+YABa1sHOhCqFlcBZ3w=", "dev": true } @@ -19630,7 +19682,7 @@ }, "validate-npm-package-name": { "version": "2.2.2", - "resolved": false, + "resolved": "", "integrity": "sha1-9laVsi9zJEQgGaPH+jmm5/0pkIU=", "dev": true, "requires": { @@ -19639,7 +19691,7 @@ "dependencies": { "builtins": { "version": "0.0.7", - "resolved": false, + "resolved": "", "integrity": "sha1-NVIZzWzxjb58Acx/0tznZc/cVJo=", "dev": true } @@ -19647,7 +19699,7 @@ }, "which": { "version": "1.2.1", - "resolved": false, + "resolved": "", "integrity": "sha1-oBDEOq3hp5ij5sGx5FPUXLSXorw=", "dev": true, "requires": { @@ -19656,7 +19708,7 @@ "dependencies": { "is-absolute": { "version": "0.1.7", - "resolved": false, + "resolved": "", "integrity": "sha1-hHSREZ/MtftDYhfMc39/qtUPYD8=", "dev": true, "requires": { @@ -19665,7 +19717,7 @@ "dependencies": { "is-relative": { "version": "0.1.3", - "resolved": false, + "resolved": "", "integrity": "sha1-kF/uiuhvRbPsYUvDwVyGnfCHboI=", "dev": true } @@ -19675,7 +19727,7 @@ }, "wrappy": { "version": "1.0.1", - "resolved": false, + "resolved": "", "integrity": "sha1-HmWWmWXMvC20VIxrhKbyxa7dRzk=", "dev": true }, diff --git a/frontend/package.json b/frontend/package.json index dcdd068cd..206c01f95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "pretzel-frontend", - "version": "1.2.0", + "version": "1.3.0", "description": "Frontend code for Pretzel", "license": "MIT", "author": "", @@ -59,13 +59,15 @@ }, "private": true, "dependencies": { + "colresizable": "^1.6.0", "ember-array-helper": ">=0", "ember-cli-htmlbars": "^2.0.3", "ember-cli-htmlbars-inline-precompile": "^1.0.2", "ember-cli-shims": "^1.1.0", "ember-composable-helpers": "^2.1.0", "ember-concurrency": "^0.8.17", - "ember-contextual-table": "^1.10.0", + "ember-contextual-table": "^2", + "ember-csv": "^1.0.2", "ember-lodash": "^4.19.4", "ember-promise-helpers": "^1.0.6", "ember-radio-button": "^1.2.4", diff --git a/package.json b/package.json index 666d5d0a9..a072bba51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pretzel", "private": true, - "version": "1.2.0", + "version": "1.3.0", "dependencies": { }, "repository" :