Skip to content

Commit

Permalink
Merge pull request #406 from vega/as/dependencyTracking
Browse files Browse the repository at this point in the history
Dependency Tracking
  • Loading branch information
arvind authored Sep 27, 2016
2 parents ad69ce7 + 2ce507b commit b1b8e69
Show file tree
Hide file tree
Showing 19 changed files with 450 additions and 64 deletions.
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

0 comments on commit b1b8e69

Please sign in to comment.