UNPKG

vega-selections

Version:

Vega expression functions for Vega-Lite selections.

336 lines (320 loc) 11.7 kB
import { bisector, intersection, union } from 'd3-array'; import { field, isDate, toNumber, isArray, inrange, extend, array, error, peek, hasOwnProperty } from 'vega-util'; import { Literal } from 'vega-expression'; // Registers vega-util field accessors to protect against XSS attacks const SELECTION_GETTER = Symbol('vega_selection_getter'); function getter(f) { if (!f.getter || !f.getter[SELECTION_GETTER]) { f.getter = field(f.field); f.getter[SELECTION_GETTER] = true; } return f.getter; } const Intersect = 'intersect'; const Union = 'union'; const VlMulti = 'vlMulti'; const VlPoint = 'vlPoint'; const Or = 'or'; const And = 'and'; const SelectionId = '_vgsid_'; const $selectionId = field(SelectionId); const TYPE_ENUM = 'E', TYPE_RANGE_INC = 'R', TYPE_RANGE_EXC = 'R-E', TYPE_RANGE_LE = 'R-LE', TYPE_RANGE_RE = 'R-RE', TYPE_PRED_LT = 'E-LT', TYPE_PRED_LTE = 'E-LTE', TYPE_PRED_GT = 'E-GT', TYPE_PRED_GTE = 'E-GTE', TYPE_PRED_VALID = 'E-VALID', TYPE_PRED_ONE_OF = 'E-ONE', UNIT_INDEX = 'index:unit'; // TODO: revisit date coercion? function testPoint(datum, entry) { var fields = entry.fields, values = entry.values, n = fields.length, i = 0, dval, f; for (; i < n; ++i) { f = fields[i]; dval = getter(f)(datum); if (isDate(dval)) dval = toNumber(dval); if (isDate(values[i])) values[i] = toNumber(values[i]); if (isArray(values[i]) && isDate(values[i][0])) values[i] = values[i].map(toNumber); if (f.type === TYPE_ENUM) { // Enumerated fields can either specify individual values (single/multi selections) // or an array of values (interval selections). if (isArray(values[i]) ? !values[i].includes(dval) : dval !== values[i]) { return false; } } else { if (f.type === TYPE_RANGE_INC) { if (!inrange(dval, values[i])) return false; } else if (f.type === TYPE_RANGE_RE) { // Discrete selection of bins test within the range [bin_start, bin_end). if (!inrange(dval, values[i], true, false)) return false; } else if (f.type === TYPE_RANGE_EXC) { // 'R-E'/'R-LE' included for completeness. if (!inrange(dval, values[i], false, false)) return false; } else if (f.type === TYPE_RANGE_LE) { if (!inrange(dval, values[i], false, true)) return false; } else if (f.type === TYPE_PRED_LT) { if (dval >= values[i]) return false; } else if (f.type === TYPE_PRED_LTE) { if (dval > values[i]) return false; } else if (f.type === TYPE_PRED_GT) { if (dval <= values[i]) return false; } else if (f.type === TYPE_PRED_GTE) { if (dval < values[i]) return false; } else if (f.type === TYPE_PRED_VALID) { if (dval === null || isNaN(dval)) return false; } else if (f.type === TYPE_PRED_ONE_OF) { if (values[i].indexOf(dval) === -1) return false; } } } return true; } /** * Tests if a tuple is contained within an interactive selection. * @param {string} name - The name of the data set representing the selection. * Tuples in the dataset are of the form * {unit: string, fields: array<fielddef>, values: array<*>}. * Fielddef is of the form * {field: string, channel: string, type: 'E' | 'R'} where * 'type' identifies whether tuples in the dataset enumerate * values for the field, or specify a continuous range. * @param {object} datum - The tuple to test for inclusion. * @param {string} op - The set operation for combining selections. * One of 'intersect' or 'union' (default). * @return {boolean} - True if the datum is in the selection, false otherwise. */ function selectionTest(name, datum, op) { var data = this.context.data[name], entries = data ? data.values.value : [], unitIdx = data ? data[UNIT_INDEX] && data[UNIT_INDEX].value : undefined, intersect = op === Intersect, n = entries.length, i = 0, entry, miss, count, unit, b; for (; i < n; ++i) { entry = entries[i]; if (unitIdx && intersect) { // multi selections union within the same unit and intersect across units. miss = miss || {}; count = miss[unit = entry.unit] || 0; // if we've already matched this unit, skip. if (count === -1) continue; b = testPoint(datum, entry); miss[unit] = b ? -1 : ++count; // if we match and there are no other units return true // if we've missed against all tuples in this unit return false if (b && unitIdx.size === 1) return true; if (!b && count === unitIdx.get(unit).count) return false; } else { b = testPoint(datum, entry); // if we find a miss and we do require intersection return false // if we find a match and we don't require intersection return true if (intersect ^ b) return b; } } // if intersecting and we made it here, then we saw no misses // if not intersecting, then we saw no matches // if no active selections, return false return n && intersect; } const bisect = bisector($selectionId), bisectLeft = bisect.left, bisectRight = bisect.right; function selectionIdTest(name, datum, op) { const data = this.context.data[name], entries = data ? data.values.value : [], unitIdx = data ? data[UNIT_INDEX] && data[UNIT_INDEX].value : undefined, intersect = op === Intersect, value = $selectionId(datum), index = bisectLeft(entries, value); if (index === entries.length) return false; if ($selectionId(entries[index]) !== value) return false; if (unitIdx && intersect) { if (unitIdx.size === 1) return true; if (bisectRight(entries, value) - index < unitIdx.size) return false; } return true; } /** * Maps an array of scene graph items to an array of selection tuples. * @param {string} name - The name of the dataset representing the selection. * @param {string} base - The base object that generated tuples extend. * * @returns {array} An array of selection entries for the given unit. */ function selectionTuples(array, base) { return array.map(x => extend(base.fields ? { values: base.fields.map(f => getter(f)(x.datum)) } : { [SelectionId]: $selectionId(x.datum) }, base)); } /** * Resolves selection for use as a scale domain or reads via the API. * @param {string} name - The name of the dataset representing the selection * @param {string} [op='union'] - The set operation for combining selections. * One of 'intersect' or 'union' (default). * @param {boolean} isMulti - Identifies a "multi" selection to perform more * expensive resolution computation. * @param {boolean} vl5 - With Vega-Lite v5, "multi" selections are now called "point" * selections, and thus the resolved tuple should reflect this name. * This parameter allows us to reflect this change without triggering * a major version bump for Vega. * @returns {object} An object of selected fields and values. */ function selectionResolve(name, op, isMulti, vl5) { var data = this.context.data[name], entries = data ? data.values.value : [], resolved = {}, multiRes = {}, types = {}, entry, fields, values, unit, field, value, res, resUnit, type, union, n = entries.length, i = 0, j, m; // First union all entries within the same unit. for (; i < n; ++i) { entry = entries[i]; unit = entry.unit; fields = entry.fields; values = entry.values; if (fields && values) { // Intentional selection stores for (j = 0, m = fields.length; j < m; ++j) { field = fields[j]; res = resolved[field.field] || (resolved[field.field] = {}); resUnit = res[unit] || (res[unit] = []); types[field.field] = type = field.type.charAt(0); union = ops[`${type}_union`]; res[unit] = union(resUnit, array(values[j])); } // If the same multi-selection is repeated over views and projected over // an encoding, it may operate over different fields making it especially // tricky to reliably resolve it. At best, we can de-dupe identical entries // but doing so may be more computationally expensive than it is worth. // Instead, for now, we simply transform our store representation into // a more human-friendly one. if (isMulti) { resUnit = multiRes[unit] || (multiRes[unit] = []); resUnit.push(array(values).reduce((obj, curr, j) => (obj[fields[j].field] = curr, obj), {})); } } else { // Short circuit extensional selectionId stores which hold sorted IDs unique to each unit. field = SelectionId; value = $selectionId(entry); res = resolved[field] || (resolved[field] = {}); resUnit = res[unit] || (res[unit] = []); resUnit.push(value); if (isMulti) { resUnit = multiRes[unit] || (multiRes[unit] = []); resUnit.push({ [SelectionId]: value }); } } } // Then resolve fields across units as per the op. op = op || Union; if (resolved[SelectionId]) { resolved[SelectionId] = ops[`${SelectionId}_${op}`](...Object.values(resolved[SelectionId])); } else { Object.keys(resolved).forEach(field => { resolved[field] = Object.keys(resolved[field]).map(unit => resolved[field][unit]).reduce((acc, curr) => acc === undefined ? curr : ops[`${types[field]}_${op}`](acc, curr)); }); } entries = Object.keys(multiRes); if (isMulti && entries.length) { const key = vl5 ? VlPoint : VlMulti; resolved[key] = op === Union ? { [Or]: entries.reduce((acc, k) => (acc.push(...multiRes[k]), acc), []) } : { [And]: entries.map(k => ({ [Or]: multiRes[k] })) }; } return resolved; } var ops = { [`${SelectionId}_union`]: union, [`${SelectionId}_intersect`]: intersection, E_union: function (base, value) { if (!base.length) return value; var i = 0, n = value.length; for (; i < n; ++i) if (!base.includes(value[i])) base.push(value[i]); return base; }, E_intersect: function (base, value) { return !base.length ? value : base.filter(v => value.includes(v)); }, R_union: function (base, value) { var lo = toNumber(value[0]), hi = toNumber(value[1]); if (lo > hi) { lo = value[1]; hi = value[0]; } if (!base.length) return [lo, hi]; if (base[0] > lo) base[0] = lo; if (base[1] < hi) base[1] = hi; return base; }, R_intersect: function (base, value) { var lo = toNumber(value[0]), hi = toNumber(value[1]); if (lo > hi) { lo = value[1]; hi = value[0]; } if (!base.length) return [lo, hi]; if (hi < base[0] || base[1] < lo) { return []; } else { if (base[0] < lo) base[0] = lo; if (base[1] > hi) base[1] = hi; } return base; } }; const DataPrefix = ':', IndexPrefix = '@'; function selectionVisitor(name, args, scope, params) { if (args[0].type !== Literal) error('First argument to selection functions must be a string literal.'); const data = args[0].value, op = args.length >= 2 && peek(args).value, field = 'unit', indexName = IndexPrefix + field, dataName = DataPrefix + data; if (op === Intersect && !hasOwnProperty(params, indexName)) { params[indexName] = scope.getData(data).indataRef(scope, field); } if (!hasOwnProperty(params, dataName)) { params[dataName] = scope.getData(data).tuplesRef(); } } export { selectionIdTest, selectionResolve, selectionTest, selectionTuples, selectionVisitor }; //# sourceMappingURL=vega-selection.js.map