Skip to content

Commit

Permalink
Optimize ID-driven selections.
Browse files Browse the repository at this point in the history
Stores for ID-driven selections use a flattened structure,
with tuples in the form of {unit, _vgsid_} sorted by _vgsid_.
And inclusion testing now occurs via a new Vega expression function
that binary searches through the store to determine a hit.
  • Loading branch information
arvind committed Jan 20, 2022
1 parent 75745a1 commit a4e7b83
Show file tree
Hide file tree
Showing 5 changed files with 47 additions and 31 deletions.
22 changes: 14 additions & 8 deletions src/compile/selection/assemble.ts
Expand Up @@ -4,7 +4,7 @@ import {identity, isArray, stringValue} from 'vega-util';
import {MODIFY, STORE, unitName, VL_SELECTION_RESOLVE, TUPLE, selectionCompilers} from '.';
import {dateTimeToExpr, isDateTime, dateTimeToTimestamp} from '../../datetime';
import {hasContinuousDomain} from '../../scale';
import {SelectionInit, SelectionInitInterval, ParameterExtent} from '../../selection';
import {SelectionInit, SelectionInitInterval, ParameterExtent, SELECTION_ID} from '../../selection';
import {keys, stringify, vals} from '../../util';
import {VgData, VgDomain} from '../../vega.schema';
import {FacetModel} from '../facet';
Expand Down Expand Up @@ -114,23 +114,29 @@ export function assembleTopLevelSignals(model: UnitModel, signals: Signal[]) {

export function assembleUnitSelectionData(model: UnitModel, data: readonly VgData[]): VgData[] {
const dataCopy = [...data];
const unit = unitName(model, {escape: false});

for (const selCmpt of vals(model.component.selection ?? {})) {
const init: VgData = {name: selCmpt.name + STORE};
const store: VgData = {name: selCmpt.name + STORE};

if (selCmpt.project.hasSelectionId) {
store.transform = [{type: 'collect', sort: {field: SELECTION_ID}}];
}

if (selCmpt.init) {
const fields = selCmpt.project.items.map(proj => {
const {signals, ...rest} = proj;
return rest;
});

init.values = selCmpt.init.map(i => ({
unit: unitName(model, {escape: false}),
fields,
values: assembleInit(i, false)
}));
store.values = selCmpt.project.hasSelectionId
? selCmpt.init.map(i => ({unit, [SELECTION_ID]: assembleInit(i, false)[0]}))
: selCmpt.init.map(i => ({unit, fields, values: assembleInit(i, false)}));
}

const contains = dataCopy.filter(d => d.name === selCmpt.name + STORE);
if (!contains.length) {
dataCopy.push(init);
dataCopy.push(store);
}
}

Expand Down
5 changes: 2 additions & 3 deletions src/compile/selection/index.ts
Expand Up @@ -7,8 +7,7 @@ import {
SelectionInit,
SelectionInitInterval,
SelectionResolution,
SelectionType,
SELECTION_ID
SelectionType
} from '../../selection';
import {Dict, vals} from '../../util';
import {OutputNode} from '../data/dataflow';
Expand Down Expand Up @@ -110,7 +109,7 @@ export function unitName(model: Model, {escape} = {escape: true}) {

export function requiresSelectionId(model: Model) {
return vals(model.component.selection ?? {}).reduce((identifier, selCmpt) => {
return identifier || selCmpt.project.items.some(proj => proj.field === SELECTION_ID);
return identifier || selCmpt.project.hasSelectionId;
}, false);
}

Expand Down
6 changes: 3 additions & 3 deletions src/compile/selection/parse.ts
Expand Up @@ -90,9 +90,9 @@ export function parseSelectionPredicate(
}
}

const test = `vlSelectionTest(${store}, ${datum}${
selCmpt.resolve === 'global' ? ')' : `, ${stringValue(selCmpt.resolve)})`
}`;
const fn = selCmpt.project.hasSelectionId ? 'vlSelectionIdTest(' : 'vlSelectionTest(';
const resolve = selCmpt.resolve === 'global' ? ')' : `, ${stringValue(selCmpt.resolve)})`;
const test = `${fn}${store}, ${datum}${resolve}`;
const length = `length(data(${store}))`;

return pred.empty === false ? `${length} && ${test}` : `!${length} || ${test}`;
Expand Down
37 changes: 22 additions & 15 deletions src/compile/selection/point.ts
@@ -1,6 +1,7 @@
import {Stream} from 'vega';
import {stringValue} from 'vega-util';
import {SelectionCompiler, TUPLE, unitName} from '.';
import {SELECTION_ID} from '../../selection';
import {vals} from '../../util';
import {BRUSH} from './interval';
import {TUPLE_FIELDS} from './project';
Expand All @@ -13,16 +14,6 @@ const point: SelectionCompiler<'point'> = {
const fieldsSg = name + TUPLE_FIELDS;
const project = selCmpt.project;
const datum = '(item().isVoronoi ? datum.datum : datum)';
const values = project.items
.map(p => {
const fieldDef = model.fieldDef(p.channel);
// Binned fields should capture extents, for a range test against the raw field.
return fieldDef?.bin
? `[${datum}[${stringValue(model.vgField(p.channel, {}))}], ` +
`${datum}[${stringValue(model.vgField(p.channel, {binSuffix: 'end'}))}]]`
: `${datum}[${stringValue(p.field)}]`;
})
.join(', ');

// Only add a discrete selection to the store if a datum is present _and_
// the interaction isn't occurring on a group mark. This guards against
Expand All @@ -31,10 +22,6 @@ const point: SelectionCompiler<'point'> = {
// for constant null states but varying toggles (e.g., shift-click in
// whitespace followed by a click in whitespace; the store should only
// be cleared on the second click).
const update = `unit: ${unitName(model)}, fields: ${fieldsSg}, values`;

const events: Stream[] = selCmpt.events;

const brushes = vals(model.component.selection ?? {})
.reduce((acc, cmpt) => {
return cmpt.type === 'interval' ? acc.concat(cmpt.name + BRUSH) : acc;
Expand All @@ -44,14 +31,34 @@ const point: SelectionCompiler<'point'> = {

const test = `datum && item().mark.marktype !== 'group'${brushes ? ` && ${brushes}` : ''}`;

let update = `unit: ${unitName(model)}, `;

if (selCmpt.project.hasSelectionId) {
update += `${SELECTION_ID}: ${datum}[${stringValue(SELECTION_ID)}]`;
} else {
const values = project.items
.map(p => {
const fieldDef = model.fieldDef(p.channel);
// Binned fields should capture extents, for a range test against the raw field.
return fieldDef?.bin
? `[${datum}[${stringValue(model.vgField(p.channel, {}))}], ` +
`${datum}[${stringValue(model.vgField(p.channel, {binSuffix: 'end'}))}]]`
: `${datum}[${stringValue(p.field)}]`;
})
.join(', ');

update += `fields: ${fieldsSg}, values: [${values}]`;
}

const events: Stream[] = selCmpt.events;
return signals.concat([
{
name: name + TUPLE,
on: events
? [
{
events,
update: `${test} ? {${update}: [${values}]} : null`,
update: `${test} ? {${update}} : null`,
force: true
}
]
Expand Down
8 changes: 6 additions & 2 deletions src/compile/selection/project.ts
Expand Up @@ -2,7 +2,7 @@ import {array, isObject} from 'vega-util';
import {isSingleDefUnitChannel, ScaleChannel, SingleDefUnitChannel} from '../../channel';
import * as log from '../../log';
import {hasContinuousDomain} from '../../scale';
import {PointSelectionConfig, SelectionInitIntervalMapping, SelectionInitMapping} from '../../selection';
import {PointSelectionConfig, SelectionInitIntervalMapping, SelectionInitMapping, SELECTION_ID} from '../../selection';
import {Dict, hash, keys, replacePathInField, varName, isEmpty} from '../../util';
import {TimeUnitComponent, TimeUnitNode} from '../data/timeunit';
import {SelectionCompiler} from '.';
Expand Down Expand Up @@ -30,13 +30,15 @@ export interface SelectionProjection {
export class SelectionProjectionComponent {
public hasChannel: Partial<Record<SingleDefUnitChannel, SelectionProjection>>;
public hasField: Record<string, SelectionProjection>;
public hasSelectionId: boolean;
public timeUnit?: TimeUnitNode;
public items: SelectionProjection[];

constructor(...items: SelectionProjection[]) {
this.items = items;
this.hasChannel = {};
this.hasField = {};
this.hasSelectionId = false;
}
}

Expand Down Expand Up @@ -152,6 +154,7 @@ const project: SelectionCompiler = {
p.signals = {...signalName(p, 'data'), ...signalName(p, 'visual')};
proj.items.push((parsed[field] = p));
proj.hasField[field] = proj.hasChannel[channel] = parsed[field];
proj.hasSelectionId = proj.hasSelectionId || field === SELECTION_ID;
}
} else {
log.warn(log.message.cannotProjectOnChannelWithoutField(channel));
Expand All @@ -164,6 +167,7 @@ const project: SelectionCompiler = {
p.signals = {...signalName(p, 'data')};
proj.items.push(p);
proj.hasField[field] = p;
proj.hasSelectionId = proj.hasSelectionId || field === SELECTION_ID;
}

if (init) {
Expand All @@ -182,7 +186,7 @@ const project: SelectionCompiler = {
signals: (model, selCmpt, allSignals) => {
const name = selCmpt.name + TUPLE_FIELDS;
const hasSignal = allSignals.filter(s => s.name === name);
return hasSignal.length > 0
return hasSignal.length > 0 || selCmpt.project.hasSelectionId
? allSignals
: allSignals.concat({
name,
Expand Down

0 comments on commit a4e7b83

Please sign in to comment.