Skip to content

Commit

Permalink
Initial work to support geo-interval selections.
Browse files Browse the repository at this point in the history
  • Loading branch information
arvind committed Apr 7, 2022
1 parent 9551dc4 commit 941e8e7
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 176 deletions.
2 changes: 0 additions & 2 deletions src/compile/selection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ export interface SelectionComponent<T extends SelectionType = SelectionType> {
bind?: 'scales' | Binding | Dict<Binding> | LegendBinding;
resolve: SelectionResolution;
mark?: BrushConfig;

// Transforms
project: SelectionProjectionComponent;
scales?: SelectionProjection[];
toggle?: string;
Expand Down
263 changes: 143 additions & 120 deletions src/compile/selection/interval.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {NewSignal, OnEvent, Stream} from 'vega';
import {isObject, NewSignal, OnEvent, SignalValue, Stream} from 'vega';
import {array, stringValue} from 'vega-util';
import {SelectionCompiler, SelectionComponent, STORE, TUPLE, unitName} from '.';
import {ScaleChannel, X, Y} from '../../channel';
import {GeoPositionChannel, LATITUDE, LONGITUDE, ScaleChannel, X, Y} from '../../channel';
import {FieldName} from '../../channeldef';
import {warn} from '../../log';
import {hasContinuousDomain} from '../../scale';
import {SelectionInitInterval} from '../../selection';
import {keys} from '../../util';
import {IntervalSelectionConfigWithoutType, SelectionInitInterval, SELECTION_ID} from '../../selection';
import {keys, vals} from '../../util';
import {UnitModel} from '../unit';
import {assembleInit} from './assemble';
import {SelectionProjection, TUPLE_FIELDS} from './project';
Expand All @@ -14,91 +15,119 @@ import scales from './scales';
export const BRUSH = '_brush';
export const SCALE_TRIGGER = '_scale_trigger';

// Separate type because the "fields" property is only used internally and we don't want to leak it to the schema.
export type IntervalSelectionConfigWithField = IntervalSelectionConfigWithoutType & {fields?: FieldName[]};

const interval: SelectionCompiler<'interval'> = {
defined: selCmpt => selCmpt.type === 'interval',

signals: (model, selCmpt, signals) => {
const name = selCmpt.name;
const fieldsSg = name + TUPLE_FIELDS;
const hasScales = scales.defined(selCmpt);
const init = selCmpt.init ? selCmpt.init[0] : null;
const dataSignals: string[] = [];
const scaleTriggers: {
scaleName: string;
expr: string;
}[] = [];

if (selCmpt.translate && !hasScales) {
const filterExpr = `!event.item || event.item.mark.name !== ${stringValue(name + BRUSH)}`;
events(selCmpt, (on: OnEvent[], evt: Stream) => {
parse: (model, selCmpt, selDef) => {
if (model.hasProjection) {
const def: IntervalSelectionConfigWithField = {...(isObject(selDef.select) ? selDef.select : {})};
def.fields = [SELECTION_ID];
if (!def.encodings) {
// Remap default x/y projection
def.encodings = selDef.value ? (keys(selDef.value) as GeoPositionChannel[]) : [LONGITUDE, LATITUDE];
}

selDef.select = {type: 'interval', ...def};
}

if (selCmpt.translate && !scales.defined(selCmpt)) {
const filterExpr = `!event.item || event.item.mark.name !== ${stringValue(selCmpt.name + BRUSH)}`;
for (const evt of selCmpt.events) {
if (!evt.between) {
warn(`${evt} is not an ordered event stream for interval selections.`);
continue;
}

const filters = array((evt.between[0].filter ??= []));
if (!filters.includes(filterExpr)) {
if (filters.indexOf(filterExpr) < 0) {
filters.push(filterExpr);
}
return on;
});
}
}
},

selCmpt.project.items.forEach((proj, i) => {
const channel = proj.channel;
if (channel !== X && channel !== Y) {
warn('Interval selections only support x and y encoding channels.');
return;
}
signals: (model, selCmpt, signals) => {
const name = selCmpt.name;
const tupleSg = name + TUPLE;
const channels = vals(selCmpt.project.hasChannel).filter(p => p.channel === X || p.channel === Y);
const init = selCmpt.init ? selCmpt.init[0] : null;

const val = init ? init[i] : null;
const cs = channelSignals(model, selCmpt, proj, val);
const dname = proj.signals.data;
const vname = proj.signals.visual;
const scaleName = stringValue(model.scaleName(channel));
const scaleType = model.getScaleComponent(channel).get('type');
const toNum = hasContinuousDomain(scaleType) ? '+' : '';

signals.push(...cs);
dataSignals.push(dname);

scaleTriggers.push({
scaleName: model.scaleName(channel),
expr:
`(!isArray(${dname}) || ` +
`(${toNum}invert(${scaleName}, ${vname})[0] === ${toNum}${dname}[0] && ` +
`${toNum}invert(${scaleName}, ${vname})[1] === ${toNum}${dname}[1]))`
});
});
signals.push(
...channels.reduce((arr, proj, i) => arr.concat(channelSignals(model, selCmpt, proj, init && init[i])), [])
);

// Proxy scale reactions to ensure that an infinite loop doesn't occur
// when an interval selection filter touches the scale.
if (!hasScales && scaleTriggers.length) {
signals.push({
name: name + SCALE_TRIGGER,
value: {},
on: [
{
events: scaleTriggers.map(t => ({scale: t.scaleName})),
update: `${scaleTriggers.map(t => t.expr).join(' && ')} ? ${name + SCALE_TRIGGER} : {}`
}
]
});
}
if (!model.hasProjection) {
// Proxy scale reactions to ensure that an infinite loop doesn't occur
// when an interval selection filter touches the scale.
if (!scales.defined(selCmpt)) {
const triggerSg = name + SCALE_TRIGGER;
const scaleTriggers = channels.map(proj => {
const channel = proj.channel as 'x' | 'y';
const {data: dname, visual: vname} = proj.signals;
const scaleName = stringValue(model.scaleName(channel));
const scaleType = model.getScaleComponent(channel).get('type');
const toNum = hasContinuousDomain(scaleType) ? '+' : '';
return (
`(!isArray(${dname}) || ` +
`(${toNum}invert(${scaleName}, ${vname})[0] === ${toNum}${dname}[0] && ` +
`${toNum}invert(${scaleName}, ${vname})[1] === ${toNum}${dname}[1]))`
);
});

// Only add an interval to the store if it has valid data extents. Data extents
// are set to null if pixel extents are equal to account for intervals over
// ordinal/nominal domains which, when inverted, will still produce a valid datum.
const update = `unit: ${unitName(model)}, fields: ${fieldsSg}, values`;
return signals.concat({
name: name + TUPLE,
...(init ? {init: `{${update}: ${assembleInit(init)}}`} : {}),
...(dataSignals.length
? {
if (scaleTriggers.length) {
signals.push({
name: triggerSg,
value: {},
on: [
{
events: [{signal: dataSignals.join(' || ')}], // Prevents double invocation, see https://github.com/vega/vega#1672.
update: `${dataSignals.join(' && ')} ? {${update}: [${dataSignals}]} : null`
events: channels.map(proj => ({scale: model.scaleName(proj.channel)})),
update: scaleTriggers.join(' && ') + ` ? ${triggerSg} : {}`
}
]
}
: {})
});
});
}
}

// Only add an interval to the store if it has valid data extents. Data extents
// are set to null if pixel extents are equal to account for intervals over
// ordinal/nominal domains which, when inverted, will still produce a valid datum.
const dataSignals = channels.map(proj => proj.signals.data);
const update = `unit: ${unitName(model)}, fields: ${name + TUPLE_FIELDS}, values`;
return signals.concat({
name: tupleSg,
...(init ? {init: `{${update}: ${assembleInit(init)}}`} : {}),
...(dataSignals.length
? {
on: [
{
events: [{signal: dataSignals.join(' || ')}], // Prevents double invocation, see https://github.com/vega/vega/issues/1672.
update: `${dataSignals.join(' && ')} ? {${update}: [${dataSignals}]} : null`
}
]
}
: {})
});
} else {
const {x, y} = selCmpt.project.hasChannel;
const xvname = x && x.signals.visual;
const yvname = y && y.signals.visual;
const bbox =
`[` +
`[${xvname ? xvname + '[0]' : '0'}, ${yvname ? yvname + '[0]' : '0'}],` +
`[${xvname ? xvname + '[1]' : 'width'}, ${yvname ? yvname + '[1]' : 'height'}]` +
`]`;

const intersect = `intersect(${bbox}, {markname: ${stringValue(model.getName('marks'))}}, unit.mark)`;
const base = `{unit: ${unitName(model)}}`;

return signals.concat({
name: tupleSg,
update: `vlSelectionTuples(${intersect}, ${base})`
});
}
},

marks: (model, selCmpt, marks) => {
Expand Down Expand Up @@ -192,62 +221,56 @@ function channelSignals(
model: UnitModel,
selCmpt: SelectionComponent<'interval'>,
proj: SelectionProjection,
init?: SelectionInitInterval
init: SelectionInitInterval
): NewSignal[] {
const scaledInterval = !model.hasProjection;
const channel = proj.channel;
const vname = proj.signals.visual;
const dname = proj.signals.data;
const hasScales = scales.defined(selCmpt);
const scaleName = stringValue(model.scaleName(channel));
const scale = model.getScaleComponent(channel as ScaleChannel);
const scaleType = scale ? scale.get('type') : undefined;

const scaleName = stringValue(scaledInterval ? model.scaleName(channel) : model.projectionName());
const scaled = (str: string) => `scale(${scaleName}, ${str})`;
const vinit: SignalValue = init ? {init: assembleInit(init, true, scaled)} : {value: []};

const size = model.getSizeSignalRef(channel === X ? 'width' : 'height').signal;
const coord = `${channel}(unit)`;

const on = events(selCmpt, (def: OnEvent[], evt: Stream) => {
const von = selCmpt.events.reduce((def: OnEvent[], evt: Stream) => {
return [
...def,
{events: evt.between[0], update: `[${coord}, ${coord}]`}, // Brush Start
{events: evt, update: `[${vname}[0], clamp(${coord}, 0, ${size})]`} // Brush End
];
});

// React to pan/zooms of continuous scales. Non-continuous scales
// (band, point) cannot be pan/zoomed and any other changes
// to their domains (e.g., filtering) should clear the brushes.
on.push({
events: {signal: selCmpt.name + SCALE_TRIGGER},
update: hasContinuousDomain(scaleType) ? `[${scaled(`${dname}[0]`)}, ${scaled(`${dname}[1]`)}]` : `[0, 0]`
});

return hasScales
? [{name: dname, on: []}]
: [
{
name: vname,
...(init ? {init: assembleInit(init, true, scaled)} : {value: []}),
on
},
{
name: dname,
...(init ? {init: assembleInit(init)} : {}), // Cannot be `value` as `init` may require datetime exprs.
on: [
{
events: {signal: vname},
update: `${vname}[0] === ${vname}[1] ? null : invert(${scaleName}, ${vname})`
}
]
}
];
}
}, []);

function events(selCmpt: SelectionComponent<'interval'>, cb: (def: OnEvent[], evt: Stream) => OnEvent[]): OnEvent[] {
return selCmpt.events.reduce((on, evt) => {
if (!evt.between) {
warn(`${evt} is not an ordered event stream for interval selections.`);
return on;
}
return cb(on, evt);
}, [] as OnEvent[]);
if (scaledInterval) {
const dname = proj.signals.data;
const hasScales = scales.defined(selCmpt);
const scale = model.getScaleComponent(channel as ScaleChannel);
const scaleType = scale ? scale.get('type') : undefined;

// React to pan/zooms of continuous scales. Non-continuous scales
// (band, point) cannot be pan/zoomed and any other changes
// to their domains (e.g., filtering) should clear the brushes.
von.push({
events: {signal: selCmpt.name + SCALE_TRIGGER},
update: hasContinuousDomain(scaleType) ? `[${scaled(`${dname}[0]`)}, ${scaled(`${dname}[1]`)}]` : `[0, 0]`
});

return hasScales
? [{name: dname, on: []}]
: [
{name: vname, ...vinit, on: von},
{
name: dname,
...(init ? {init: assembleInit(init)} : {}), // Cannot be `value` as `init` may require datetime exprs.
on: [
{
events: {signal: vname},
update: `${vname}[0] === ${vname}[1] ? null : invert(${scaleName}, ${vname})`
}
]
}
];
} else {
return [{name: vname, ...vinit, on: von}];
}
}
2 changes: 1 addition & 1 deletion src/compile/selection/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function parseUnitSelection(model: UnitModel, selDefs: SelectionParameter
}

if (defaults[key] === undefined || defaults[key] === true) {
defaults[key] = cfg[key] ?? defaults[key];
defaults[key] = duplicate(cfg[key] ?? defaults[key]);
}
}

Expand Down
36 changes: 23 additions & 13 deletions src/compile/selection/project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {array, isObject} from 'vega-util';
import {isSingleDefUnitChannel, ScaleChannel, SingleDefUnitChannel} from '../../channel';
import {
getPositionChannelFromLatLong,
isGeoPositionChannel,
isScaleChannel,
isSingleDefUnitChannel,
SingleDefUnitChannel
} from '../../channel';
import * as log from '../../log';
import {hasContinuousDomain} from '../../scale';
import {PointSelectionConfig, SelectionInitIntervalMapping, SelectionInitMapping, SELECTION_ID} from '../../selection';
Expand Down Expand Up @@ -140,20 +146,24 @@ const project: SelectionCompiler = {
// Determine whether the tuple will store enumerated or ranged values.
// Interval selections store ranges for continuous scales, and enumerations otherwise.
// Single/multi selections store ranges for binned fields, and enumerations otherwise.
let tplType: TupleStoreType = 'E';
if (type === 'interval') {
const scaleType = model.getScaleComponent(channel as ScaleChannel).get('type');
if (hasContinuousDomain(scaleType)) {
tplType = 'R';
}
} else if (fieldDef.bin) {
tplType = 'R-RE';
}

const p: SelectionProjection = {field, channel, type: tplType};
const tplType: TupleStoreType =
type === 'interval' &&
isScaleChannel(channel) &&
hasContinuousDomain(model.getScaleComponent(channel).get('type'))
? 'R'
: fieldDef.bin
? 'R-RE'
: 'E';

const posChannel = isGeoPositionChannel(channel) ? getPositionChannelFromLatLong(channel) : channel;
const p: SelectionProjection = {
field,
channel: posChannel,
type: tplType
};
p.signals = {...signalName(p, 'data'), ...signalName(p, 'visual')};
proj.items.push((parsed[field] = p));
proj.hasField[field] = proj.hasChannel[channel] = parsed[field];
proj.hasField[field] = proj.hasChannel[posChannel] = parsed[field];
proj.hasSelectionId = proj.hasSelectionId || field === SELECTION_ID;
}
} else {
Expand Down

0 comments on commit 941e8e7

Please sign in to comment.