Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dependency Tracking #406

Merged
merged 9 commits into from
Sep 27, 2016
142 changes: 142 additions & 0 deletions src/js/actions/bindChannel/aggregateDependencies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use strict';

var dl = require('datalib'),
Scale = require('../../store/factory/Scale'),
scaleActions = require('../scaleActions'),
addScale = scaleActions.addScale,
updateScaleProperty = scaleActions.updateScaleProperty,
amendDataRef = scaleActions.amendDataRef,
updateMarkProperty = require('../markActions').updateMarkProperty,
updateGuideProperty = require('../guideActions').updateGuideProperty,
helperActions = require('./helperActions'),
addScaleToGroup = helperActions.addScaleToGroup,
imutils = require('../../util/immutable-utils'),
getInVis = imutils.getInVis,
getIn = imutils.getIn,
GTYPES = require('../../store/factory/Guide').GTYPES;

/**
* When a new group by field is added to an aggregation, Lyra produces a new
* aggregated dataset rather than ammending an already existing one. This
* ensures that we do not interfere with any other scales/marks that are backed
* by the existing aggregation. Once we create a new aggregated dataset,
* however, we need to ensure that scales (+guides) this mark depends on are
* correspondingly updated.
*
* The basic strategy is as follows:
* - For scales that are not used by any other marks, we update the domain
* in place to avoid any additional churn.
* - For scales that are used by others, we follow Vega-Lite's default scale
* resolution strategy for facets. Quantitative scale domains are unioned
* but ordinal scales are cloned. This achieves the best balance for the
* experience of doing layout interactively in Lyra.
*
* @param {Function} dispatch Redux dispatch function.
* @param {ImmutableMap} state Redux store.
* @param {Object} parsed An object containing the parsed and output Vega
* specifications as well as a mapping of output spec names to Lyra IDs.
* @returns {void}
*/
module.exports = function(dispatch, state, parsed) {
var map = parsed.map,
aggId = map.data.summary,
markId = parsed.markId,
mark = getInVis(state, 'marks.' + markId),
counts = require('../../ctrl/export').counts(true),
clones = {};

function dataRefHasAgg(ref) {
return ref.get('data') === aggId;
}

// If this function is executing, this mark is backed by an aggregated dataset.
// So update all scales it uses to draw from the aggregated dataset rather
// than the source as well. This ensures that any transformations applied to
// the aggregated dataset (e.g., filtering) will be reflected in the scales.
dl.vals(map.scales).forEach(function(scaleId) {
var type = getInVis(state, 'scales.' + scaleId + '.type'),
domain = getInVis(state, 'scales.' + scaleId + '._domain'),
range = getInVis(state, 'scales.' + scaleId + '._range'),
count = counts.scales[scaleId].markTotal;

function updateDataRef(property, ref, idx) {
if (ref.get('data') === aggId) {
return;
}

if (count === 1) {
dispatch(updateScaleProperty(scaleId, property + '.' + idx + '.data', aggId));
} else if (type === 'ordinal') {
clones[scaleId] = true;
} else {
dispatch(amendDataRef(scaleId, property, ref.set('data', aggId)));
}
}

if (domain.size && !domain.filter(dataRefHasAgg).size) {
domain.forEach(updateDataRef.bind(null, '_domain'));
}

if (range.size && !range.filter(dataRefHasAgg).size) {
range.forEach(updateDataRef.bind(null, '_range'));
}
});

// For any scales marked as needing clones, we duplicate the scale and update
// its datarefs to the new aggregated dataset. We do not duplicate guides, but
// instead just update existing ones them to point to the new scale instead.
// This behavior reduces guide churning, and keeps guides always visualizing
// the most recent scale. Finally, any mark properties that referred to the
// pre-cloned scales are updated.
if (dl.keys(clones).length > 0) {
dl.keys(clones).forEach(function(scaleId) {
var scale = getInVis(state, 'scales.' + scaleId),
newScale = addScale(cloneScale(scale, aggId)),
newScaleId = clones[scaleId] = newScale.id,
groupId = mark.get('_parent'),
guide, guideId;

dispatch(newScale);
dispatch(addScaleToGroup(newScaleId, groupId));

for (guideId in counts.scales[scaleId].guides) {
guide = getInVis(state, 'guides.' + guideId);

if (guide.get('_gtype') === GTYPES.AXIS) {
dispatch(updateGuideProperty(guideId, 'scale', newScaleId));
} else {
dispatch(updateGuideProperty(guideId, guide.get('_type'), newScaleId));
}
}
});

getIn(mark, 'properties.update').forEach(function(def, name) {
var newScaleId = clones[def.get('scale')];
if (newScaleId) {
dispatch(updateMarkProperty(markId,
'properties.update.' + name + '.scale', newScaleId));
}
});
}
};

function cloneScale(def, aggId) {
function updateDataRef(ref) {
return {data: aggId, field: ref.field};
}

var scale = Scale(def.get('_origName'), def.get('type'));
scale.nice = def.get('nice');
scale.round = def.get('round');
scale.zero = def.get('zero');
scale.points = def.get('points');
scale.padding = def.get('padding');

scale.domain = def.get('domain');
scale._domain = def.get('_domain').toJS().map(updateDataRef);

scale.range = def.get('range');
scale._range = def.get('_range').toJS().map(updateDataRef);

return scale;
}
31 changes: 31 additions & 0 deletions src/js/actions/bindChannel/cleanupUnused.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

var deleteScale = require('../scaleActions').deleteScale,
deleteDataset = require('../datasetActions').deleteDataset,
getInVis = require('../../util/immutable-utils').getInVis;

module.exports = function(dispatch, state) {
var exporter = require('../../ctrl/export'),
key;

// First, clean up unused scales. We do scales first, to ensure that any
// unused scales do not prevent upstream datasets from being cleaned.
var scales = exporter.counts(true).scales;
for (key in scales) {
if (scales[key].markTotal === 0) {
dispatch(deleteScale(+key));
}
}

// Then, clean up unused datasets.
var data = exporter.counts(true).data,
plId;
for (key in data) {
if (data[key].total === 0) {
plId = getInVis(state, 'datasets.' + (key = +key) + '._parent');
if (getInVis(state, 'pipelines.' + plId + '._source') !== key) {
dispatch(deleteDataset(key, plId));
}
}
}
};
22 changes: 19 additions & 3 deletions src/js/actions/bindChannel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ var dl = require('datalib'),
parseData = require('./parseData'),
parseScales = require('./parseScales'),
parseMarks = require('./parseMarks'),
parseGuides = require('./parseGuides');
parseGuides = require('./parseGuides'),
updateAggregateDependencies = require('./aggregateDependencies'),
cleanupUnused = require('./cleanupUnused');

// Vega mark types to Vega-Lite mark types.
var TYPES = {
Expand Down Expand Up @@ -74,7 +76,18 @@ function bindChannel(dsId, field, markId, property) {
parseData(dispatch, state, parsed);
parseScales(dispatch, state, parsed);
parseMarks(dispatch, state, parsed);
parseGuides(dispatch, state, parsed);

if (parsed.map.data.summary) {
updateAggregateDependencies(dispatch, getState(), parsed);
}

// At this point, we know enough to clean up any unused scales and
// data sources. We do this here (rather than in the ctrl) to (1) avoid
// unnecessary re-renders triggered by deleting primitives and (2) to get
// the most accurate guide orientation as possible.
cleanupUnused(dispatch, state);

parseGuides(dispatch, getState(), parsed);

dispatch(setVlUnit(markId, spec));
dispatch(endBatch());
Expand Down Expand Up @@ -145,7 +158,8 @@ function map(vlUnit) {
data: {},
scales: {},
axes: {},
legends: {}
legends: {},
marks: {}
});
}

Expand All @@ -155,6 +169,8 @@ function map(vlUnit) {
* @returns {string} A Vega-Lite encoding channel.
*/
function channelName(name) {
// We don't use Vega-Lite's x2/y2 channels because a user may bind them
// first in Lyra which Vega-Lite does not expect.
switch (name) {
case 'x':
case 'x+':
Expand Down
6 changes: 4 additions & 2 deletions src/js/actions/bindChannel/parseData.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

var aggregatePipeline = require('../pipelineActions').aggregatePipeline,
summarizeAggregate = require('../datasetActions').summarizeAggregate,
var aggregatePipeline = require('../pipelineActions').aggregatePipeline,
summarizeAggregate = require('../datasetActions').summarizeAggregate,
getInVis = require('../../util/immutable-utils').getInVis;

/**
Expand Down Expand Up @@ -45,6 +45,8 @@ function parseAggregate(dispatch, state, parsed, summary) {
aggId = getInVis(state, 'pipelines.' + plId + '._aggregates.' + keys);

if (!aggId) {
// TODO: What about if a previous parsed.map.data.summary exists? How do
// we derive a new agg DS to preserve transforms.
dispatch(aggregatePipeline(plId, aggregate));
aggId = aggregate._id;
} else {
Expand Down
46 changes: 35 additions & 11 deletions src/js/actions/bindChannel/parseGuides.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ var merge = require('lodash.merge'),
addAxisToGroup = actions.addAxisToGroup,
addLegendToGroup = actions.addLegendToGroup,
Guide = require('../../store/factory/Guide'),
getInVis = require('../../util/immutable-utils').getInVis;
imutils = require('../../util/immutable-utils'),
getIn = imutils.getIn,
getInVis = imutils.getInVis;

var TYPES = Guide.GTYPES,
CTYPE = {
Expand Down Expand Up @@ -61,11 +63,12 @@ module.exports = function(dispatch, state, parsed) {
* @returns {void}
*/
function findOrCreateAxis(dispatch, state, parsed, scaleId, defs) {
var map = parsed.map,
mark = parsed.mark,
var map = parsed.map,
mark = parsed.mark,
parentId = mark.get('_parent'),
axes = getInVis(state, 'marks.' + parentId).get('axes'),
def, count = 0, foundAxis = false;
scale = getInVis(state, 'scales.' + scaleId),
axes = getInVis(state, 'marks.' + parentId).get('axes'),
def, count = 0, foundAxis = false, prevOrient;

// First, find an def and then iterate through axes for the current group
// to see if an axis exists for this scale or if we have room to add one more.
Expand All @@ -75,20 +78,39 @@ function findOrCreateAxis(dispatch, state, parsed, scaleId, defs) {

axes.valueSeq().forEach(function(axisId) {
var axis = getInVis(state, 'guides.' + axisId);
if (!axis) {
return true;
}

if (axis.get('type') === def.type) {
++count;
prevOrient = axis.get('orient');
}

if (axis.get('scale') === scaleId) {
foundAxis = true;
return false; // Early exit.
}

// TODO: If we're here, the scales don't match. But, we might have two
// ordinal scales only differing by "points," so check the domains.
// Test domain/range since point/band-ordinal scales can share an axis.
var axisScale = getInVis(state, 'scales.' + axis.get('scale'));
if (axisScale.get('type') === 'ordinal') {
foundAxis = ['domain', 'range'].every(function(x) {
var araw = axisScale.get(x),
sraw = scale.get(x),
aref = axisScale.get('_' + x),
sref = scale.get('_' + x),
apl = getInVis(state, 'datasets.' + getIn(aref, '0.data') + '._parent'),
spl = getInVis(state, 'datasets.' + getIn(sref, '0.data') + '._parent'),
afl = getIn(aref, '0.field'),
sfl = getIn(sref, '0.field');
return araw ? araw === sraw || araw.equals(sraw) :
apl === spl && afl === sfl;
});
return !foundAxis;
}
});


if (foundAxis) {
return;
}
Expand All @@ -99,8 +121,8 @@ function findOrCreateAxis(dispatch, state, parsed, scaleId, defs) {
axis.layer = def.layer;
axis.grid = def.grid;
axis.orient = def.orient || axis.orient;
if (count === 1) {
axis.orient = SWAP_ORIENT[axis.orient];
if (count === 1 && prevOrient) {
axis.orient = SWAP_ORIENT[prevOrient];
}
merge(axis.properties, def.properties);
dispatch(axis = addGuide(axis));
Expand Down Expand Up @@ -134,7 +156,9 @@ function findOrCreateLegend(dispatch, state, parsed, scaleId, defs) {

legends.valueSeq().forEach(function(legendId) {
var legend = getInVis(state, 'guides.' + legendId);
foundLegend = foundLegend || legend.get(property) === scaleId;
if (legend) {
foundLegend = foundLegend || legend.get(property) === scaleId;
}
});

if (!foundLegend) {
Expand Down
Loading