UNPKG

vega-functions

Version:

Custom functions for the Vega expression language.

792 lines (739 loc) 24.6 kB
import { truthy, hasOwnProperty, error, stringValue, isString, isFunction, extend, isArray, isObject, field, ascending, isRegExp, peek, identity, array as array$1, zoomSymlog, zoomPow, zoomLog, zoomLinear, panSymlog, panPow, panLog, panLinear, clampRange, utcquarter, quarter, truncate, inrange, span, pad, lerp, flush, toString, toNumber, toBoolean, isNumber, isDate, isBoolean, extent, toDate } from 'vega-util'; import { Literal, codegenExpression, constants, functions, parseExpression, CallExpression } from 'vega-expression'; import { isRegisteredScale, bandSpace, scale as scale$1, scaleFraction } from 'vega-scale'; import { geoBounds as geoBounds$1, geoCentroid as geoCentroid$1, geoArea as geoArea$1 } from 'd3-geo'; import { rgb, hsl, hcl, lab } from 'd3-color'; import { isTuple } from 'vega-dataflow'; import { Gradient, pathRender, pathParse, Bounds, intersect as intersect$1 } from 'vega-scenegraph'; import { selectionVisitor, selectionTest, selectionIdTest, selectionResolve, selectionTuples } from 'vega-selections'; import { sampleUniform, sampleLogNormal, sampleNormal, quantileUniform, quantileLogNormal, quantileNormal, densityUniform, densityLogNormal, densityNormal, cumulativeUniform, cumulativeLogNormal, cumulativeNormal, random } from 'vega-statistics'; import { utcdayofyear, dayofyear, utcweek, week, timeUnitSpecifier, timeSequence, timeOffset, utcSequence, utcOffset } from 'vega-time'; import { range as range$1 } from 'd3-array'; function data(name) { const data = this.context.data[name]; return data ? data.values.value : []; } function indata(name, field, value) { const index = this.context.data[name]['index:' + field], entry = index ? index.value.get(value) : undefined; return entry ? entry.count : entry; } function setdata(name, tuples) { const df = this.context.dataflow, data = this.context.data[name], input = data.input; df.pulse(input, df.changeset().remove(truthy).insert(tuples)); return 1; } function encode (item, name, retval) { if (item) { const df = this.context.dataflow, target = item.mark.source; df.pulse(target, df.changeset().encode(item, name)); } return retval !== undefined ? retval : item; } const wrap = method => function (value, spec) { const locale = this.context.dataflow.locale(); return value === null ? 'null' : locale[method](spec)(value); }; const format = wrap('format'); const timeFormat = wrap('timeFormat'); const utcFormat = wrap('utcFormat'); const timeParse = wrap('timeParse'); const utcParse = wrap('utcParse'); const dateObj = new Date(2000, 0, 1); function time(month, day, specifier) { if (!Number.isInteger(month) || !Number.isInteger(day)) return ''; dateObj.setYear(2000); dateObj.setMonth(month); dateObj.setDate(day); return timeFormat.call(this, dateObj, specifier); } function monthFormat(month) { return time.call(this, month, 1, '%B'); } function monthAbbrevFormat(month) { return time.call(this, month, 1, '%b'); } function dayFormat(day) { return time.call(this, 0, 2 + day, '%A'); } function dayAbbrevFormat(day) { return time.call(this, 0, 2 + day, '%a'); } const DataPrefix = ':'; const IndexPrefix = '@'; const ScalePrefix = '%'; const SignalPrefix = '$'; function dataVisitor(name, args, scope, params) { if (args[0].type !== Literal) { error('First argument to data functions must be a string literal.'); } const data = args[0].value, dataName = DataPrefix + data; if (!hasOwnProperty(dataName, params)) { try { params[dataName] = scope.getData(data).tuplesRef(); } catch (err) { // if data set does not exist, there's nothing to track } } } function indataVisitor(name, args, scope, params) { if (args[0].type !== Literal) error('First argument to indata must be a string literal.'); if (args[1].type !== Literal) error('Second argument to indata must be a string literal.'); const data = args[0].value, field = args[1].value, indexName = IndexPrefix + field; if (!hasOwnProperty(indexName, params)) { params[indexName] = scope.getData(data).indataRef(scope, field); } } function scaleVisitor(name, args, scope, params) { if (args[0].type === Literal) { // add scale dependency addScaleDependency(scope, params, args[0].value); } else { // indirect scale lookup; add all scales as parameters for (name in scope.scales) { addScaleDependency(scope, params, name); } } } function addScaleDependency(scope, params, name) { const scaleName = ScalePrefix + name; if (!hasOwnProperty(params, scaleName)) { try { params[scaleName] = scope.scaleRef(name); } catch (err) { // TODO: error handling? warning? } } } /** * nameOrFunction must be a string or function that was registered. * Return undefined if scale is not recognized. */ function getScale(nameOrFunction, ctx) { if (isString(nameOrFunction)) { const maybeScale = ctx.scales[nameOrFunction]; return maybeScale && isRegisteredScale(maybeScale.value) ? maybeScale.value : undefined; } else if (isFunction(nameOrFunction)) { return isRegisteredScale(nameOrFunction) ? nameOrFunction : undefined; } return undefined; } function internalScaleFunctions(codegen, fnctx, visitors) { // add helper method to the 'this' expression function context fnctx.__bandwidth = s => s && s.bandwidth ? s.bandwidth() : 0; // register AST visitors for internal scale functions visitors._bandwidth = scaleVisitor; visitors._range = scaleVisitor; visitors._scale = scaleVisitor; // resolve scale reference directly to the signal hash argument const ref = arg => '_[' + (arg.type === Literal ? stringValue(ScalePrefix + arg.value) : stringValue(ScalePrefix) + '+' + codegen(arg)) + ']'; // define and return internal scale function code generators // these internal functions are called by mark encoders return { _bandwidth: args => `this.__bandwidth(${ref(args[0])})`, _range: args => `${ref(args[0])}.range()`, _scale: args => `${ref(args[0])}(${codegen(args[1])})` }; } function geoMethod(methodName, globalMethod) { return function (projection, geojson, group) { if (projection) { // projection defined, use it const p = getScale(projection, (group || this).context); return p && p.path[methodName](geojson); } else { // projection undefined, use global method return globalMethod(geojson); } }; } const geoArea = geoMethod('area', geoArea$1); const geoBounds = geoMethod('bounds', geoBounds$1); const geoCentroid = geoMethod('centroid', geoCentroid$1); function geoScale(projection, group) { const p = getScale(projection, (group || this).context); return p && p.scale(); } function inScope (item) { const group = this.context.group; let value = false; if (group) while (item) { if (item === group) { value = true; break; } item = item.mark.group; } return value; } function log(df, method, args) { try { df[method].apply(df, ['EXPRESSION'].concat([].slice.call(args))); } catch (err) { df.warn(err); } return args[args.length - 1]; } function warn() { return log(this.context.dataflow, 'warn', arguments); } function info() { return log(this.context.dataflow, 'info', arguments); } function debug() { return log(this.context.dataflow, 'debug', arguments); } // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef function channel_luminance_value(channelValue) { const val = channelValue / 255; if (val <= 0.03928) { return val / 12.92; } return Math.pow((val + 0.055) / 1.055, 2.4); } function luminance(color) { const c = rgb(color), r = channel_luminance_value(c.r), g = channel_luminance_value(c.g), b = channel_luminance_value(c.b); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef function contrast(color1, color2) { const lum1 = luminance(color1), lum2 = luminance(color2), lumL = Math.max(lum1, lum2), lumD = Math.min(lum1, lum2); return (lumL + 0.05) / (lumD + 0.05); } function merge () { const args = [].slice.call(arguments); args.unshift({}); return extend(...args); } function equal(a, b) { return a === b || a !== a && b !== b ? true : isArray(a) ? isArray(b) && a.length === b.length ? equalArray(a, b) : false : isObject(a) && isObject(b) ? equalObject(a, b) : false; } function equalArray(a, b) { for (let i = 0, n = a.length; i < n; ++i) { if (!equal(a[i], b[i])) return false; } return true; } function equalObject(a, b) { for (const key in a) { if (!equal(a[key], b[key])) return false; } return true; } function removePredicate(props) { return _ => equalObject(props, _); } function modify (name, insert, remove, toggle, modify, values) { const df = this.context.dataflow, data = this.context.data[name], input = data.input, stamp = df.stamp(); let changes = data.changes, predicate, key; if (df._trigger === false || !(input.value.length || insert || toggle)) { // nothing to do! return 0; } if (!changes || changes.stamp < stamp) { data.changes = changes = df.changeset(); changes.stamp = stamp; df.runAfter(() => { data.modified = true; df.pulse(input, changes).run(); }, true, 1); } if (remove) { predicate = remove === true ? truthy : isArray(remove) || isTuple(remove) ? remove : removePredicate(remove); changes.remove(predicate); } if (insert) { changes.insert(insert); } if (toggle) { predicate = removePredicate(toggle); if (input.value.some(predicate)) { changes.remove(predicate); } else { changes.insert(toggle); } } if (modify) { for (key in values) { changes.modify(modify, key, values[key]); } } return 1; } function pinchDistance(event) { const t = event.touches, dx = t[0].clientX - t[1].clientX, dy = t[0].clientY - t[1].clientY; return Math.hypot(dx, dy); } function pinchAngle(event) { const t = event.touches; return Math.atan2(t[0].clientY - t[1].clientY, t[0].clientX - t[1].clientX); } // memoize accessor functions const accessors = {}; function pluck (data, name) { const accessor = accessors[name] || (accessors[name] = field(name)); return isArray(data) ? data.map(accessor) : accessor(data); } function array(seq) { return isArray(seq) || ArrayBuffer.isView(seq) ? seq : null; } function sequence(seq) { return array(seq) || (isString(seq) ? seq : null); } function join(seq) { for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { args[_key - 1] = arguments[_key]; } return array(seq).join(...args); } function indexof(seq) { for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { args[_key2 - 1] = arguments[_key2]; } return sequence(seq).indexOf(...args); } function lastindexof(seq) { for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { args[_key3 - 1] = arguments[_key3]; } return sequence(seq).lastIndexOf(...args); } function slice(seq) { for (var _len4 = arguments.length, args = new Array(_len4 > 1 ? _len4 - 1 : 0), _key4 = 1; _key4 < _len4; _key4++) { args[_key4 - 1] = arguments[_key4]; } return sequence(seq).slice(...args); } function replace(str, pattern, repl) { if (isFunction(repl)) error('Function argument passed to replace.'); if (!isString(pattern) && !isRegExp(pattern)) error('Please pass a string or RegExp argument to replace.'); return String(str).replace(pattern, repl); } function reverse(seq) { return array(seq).slice().reverse(); } function sort(seq) { return array(seq).slice().sort(ascending); } function bandspace(count, paddingInner, paddingOuter) { return bandSpace(count || 0, paddingInner || 0, paddingOuter || 0); } function bandwidth(name, group) { const s = getScale(name, (group || this).context); return s && s.bandwidth ? s.bandwidth() : 0; } function copy(name, group) { const s = getScale(name, (group || this).context); return s ? s.copy() : undefined; } function domain(name, group) { const s = getScale(name, (group || this).context); return s ? s.domain() : []; } function invert(name, range, group) { const s = getScale(name, (group || this).context); return !s ? undefined : isArray(range) ? (s.invertRange || s.invert)(range) : (s.invert || s.invertExtent)(range); } function range(name, group) { const s = getScale(name, (group || this).context); return s && s.range ? s.range() : []; } function scale(name, value, group) { const s = getScale(name, (group || this).context); return s ? s(value) : undefined; } function scaleGradient (scale, p0, p1, count, group) { scale = getScale(scale, (group || this).context); const gradient = Gradient(p0, p1); let stops = scale.domain(), min = stops[0], max = peek(stops), fraction = identity; if (!(max - min)) { // expand scale if domain has zero span, fix #1479 scale = (scale.interpolator ? scale$1('sequential')().interpolator(scale.interpolator()) : scale$1('linear')().interpolate(scale.interpolate()).range(scale.range())).domain([min = 0, max = 1]); } else { fraction = scaleFraction(scale, min, max); } if (scale.ticks) { stops = scale.ticks(+count || 15); if (min !== stops[0]) stops.unshift(min); if (max !== peek(stops)) stops.push(max); } stops.forEach(_ => gradient.stop(fraction(_), scale(_))); return gradient; } function geoShape(projection, geojson, group) { const p = getScale(projection, (group || this).context); return function (context) { return p ? p.path.context(context)(geojson) : ''; }; } function pathShape(path) { let p = null; return function (context) { return context ? pathRender(context, p = p || pathParse(path)) : path; }; } const datum = d => d.data; function treeNodes(name, context) { const tree = data.call(context, name); return tree.root && tree.root.lookup || {}; } function treePath(name, source, target) { const nodes = treeNodes(name, this), s = nodes[source], t = nodes[target]; return s && t ? s.path(t).map(datum) : undefined; } function treeAncestors(name, node) { const n = treeNodes(name, this)[node]; return n ? n.ancestors().map(datum) : undefined; } const _window = () => typeof window !== 'undefined' && window || null; function screen() { const w = _window(); return w ? w.screen : {}; } function windowSize() { const w = _window(); return w ? [w.innerWidth, w.innerHeight] : [undefined, undefined]; } function containerSize() { const view = this.context.dataflow, el = view.container && view.container(); return el ? [el.clientWidth, el.clientHeight] : [undefined, undefined]; } function intersect (b, opt, group) { if (!b) return []; const [u, v] = b, box = new Bounds().set(u[0], u[1], v[0], v[1]), scene = group || this.context.dataflow.scenegraph().root; return intersect$1(scene, box, filter(opt)); } function filter(opt) { let p = null; if (opt) { const types = array$1(opt.marktype), names = array$1(opt.markname); p = _ => (!types.length || types.some(t => _.marktype === t)) && (!names.length || names.some(s => _.name === s)); } return p; } /** * Appends a new point to the lasso * * @param {*} lasso the lasso in pixel space * @param {*} x the x coordinate in pixel space * @param {*} y the y coordinate in pixel space * @param {*} minDist the minimum distance, in pixels, that thenew point needs to be apart from the last point * @returns a new array containing the lasso with the new point */ function lassoAppend(lasso, x, y) { let minDist = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 5; lasso = array$1(lasso); const last = lasso[lasso.length - 1]; // Add point to lasso if its the first point or distance to last point exceed minDist return last === undefined || Math.hypot(last[0] - x, last[1] - y) > minDist ? [...lasso, [x, y]] : lasso; } /** * Generates a svg path command which draws a lasso * * @param {*} lasso the lasso in pixel space in the form [[x,y], [x,y], ...] * @returns the svg path command that draws the lasso */ function lassoPath(lasso) { return array$1(lasso).reduce((svg, _ref, i) => { let [x, y] = _ref; return svg += i == 0 ? `M ${x},${y} ` : i === lasso.length - 1 ? ' Z' : `L ${x},${y} `; }, ''); } /** * Inverts the lasso from pixel space to an array of vega scenegraph tuples * * @param {*} data the dataset * @param {*} pixelLasso the lasso in pixel space, [[x,y], [x,y], ...] * @param {*} unit the unit where the lasso is defined * * @returns an array of vega scenegraph tuples */ function intersectLasso(markname, pixelLasso, unit) { const { x, y, mark } = unit; const bb = new Bounds().set(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER); // Get bounding box around lasso for (const [px, py] of pixelLasso) { if (px < bb.x1) bb.x1 = px; if (px > bb.x2) bb.x2 = px; if (py < bb.y1) bb.y1 = py; if (py > bb.y2) bb.y2 = py; } // Translate bb against unit coordinates bb.translate(x, y); const intersection = intersect([[bb.x1, bb.y1], [bb.x2, bb.y2]], markname, mark); // Check every point against the lasso return intersection.filter(tuple => pointInPolygon(tuple.x, tuple.y, pixelLasso)); } /** * Performs a test if a point is inside a polygon based on the idea from * https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html * * This method will not need the same start/end point since it wraps around the edges of the array * * @param {*} test a point to test against * @param {*} polygon a polygon in the form [[x,y], [x,y], ...] * @returns true if the point lies inside the polygon, false otherwise */ function pointInPolygon(testx, testy, polygon) { let intersections = 0; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const [prevX, prevY] = polygon[j]; const [x, y] = polygon[i]; // count intersections if (y > testy != prevY > testy && testx < (prevX - x) * (testy - y) / (prevY - y) + x) { intersections++; } } // point is in polygon if intersection count is odd return intersections & 1; } // Expression function context object const functionContext = { random() { return random(); }, // override default cumulativeNormal, cumulativeLogNormal, cumulativeUniform, densityNormal, densityLogNormal, densityUniform, quantileNormal, quantileLogNormal, quantileUniform, sampleNormal, sampleLogNormal, sampleUniform, isArray, isBoolean, isDate, isDefined(_) { return _ !== undefined; }, isNumber, isObject, isRegExp, isString, isTuple, isValid(_) { return _ != null && _ === _; }, toBoolean, toDate(_) { return toDate(_); }, // suppress extra arguments toNumber, toString, indexof, join, lastindexof, replace, reverse, sort, slice, flush, lerp, merge, pad, peek, pluck, span, inrange, truncate, rgb, lab, hcl, hsl, luminance, contrast, sequence: range$1, format, utcFormat, utcParse, utcOffset, utcSequence, timeFormat, timeParse, timeOffset, timeSequence, timeUnitSpecifier, monthFormat, monthAbbrevFormat, dayFormat, dayAbbrevFormat, quarter, utcquarter, week, utcweek, dayofyear, utcdayofyear, warn, info, debug, extent(_) { return extent(_); }, // suppress extra arguments inScope, intersect, clampRange, pinchDistance, pinchAngle, screen, containerSize, windowSize, bandspace, setdata, pathShape, panLinear, panLog, panPow, panSymlog, zoomLinear, zoomLog, zoomPow, zoomSymlog, encode, modify, lassoAppend, lassoPath, intersectLasso }; const eventFunctions = ['view', 'item', 'group', 'xy', 'x', 'y'], // event functions eventPrefix = 'event.vega.', // event function prefix thisPrefix = 'this.', // function context prefix astVisitors = {}; // AST visitors for dependency analysis // export code generator parameters const codegenParams = { forbidden: ['_'], allowed: ['datum', 'event', 'item'], fieldvar: 'datum', globalvar: id => `_[${stringValue(SignalPrefix + id)}]`, functions: buildFunctions, constants: constants, visitors: astVisitors }; // export code generator const codeGenerator = codegenExpression(codegenParams); // Build expression function registry function buildFunctions(codegen) { const fn = functions(codegen); eventFunctions.forEach(name => fn[name] = eventPrefix + name); for (const name in functionContext) { fn[name] = thisPrefix + name; } extend(fn, internalScaleFunctions(codegen, functionContext, astVisitors)); return fn; } // Register an expression function function expressionFunction(name, fn, visitor) { if (arguments.length === 1) { return functionContext[name]; } // register with the functionContext functionContext[name] = fn; // if there is an astVisitor register that, too if (visitor) astVisitors[name] = visitor; // if the code generator has already been initialized, // we need to also register the function with it if (codeGenerator) codeGenerator.functions[name] = thisPrefix + name; return this; } // register expression functions with ast visitors expressionFunction('bandwidth', bandwidth, scaleVisitor); expressionFunction('copy', copy, scaleVisitor); expressionFunction('domain', domain, scaleVisitor); expressionFunction('range', range, scaleVisitor); expressionFunction('invert', invert, scaleVisitor); expressionFunction('scale', scale, scaleVisitor); expressionFunction('gradient', scaleGradient, scaleVisitor); expressionFunction('geoArea', geoArea, scaleVisitor); expressionFunction('geoBounds', geoBounds, scaleVisitor); expressionFunction('geoCentroid', geoCentroid, scaleVisitor); expressionFunction('geoShape', geoShape, scaleVisitor); expressionFunction('geoScale', geoScale, scaleVisitor); expressionFunction('indata', indata, indataVisitor); expressionFunction('data', data, dataVisitor); expressionFunction('treePath', treePath, dataVisitor); expressionFunction('treeAncestors', treeAncestors, dataVisitor); // register Vega-Lite selection functions expressionFunction('vlSelectionTest', selectionTest, selectionVisitor); expressionFunction('vlSelectionIdTest', selectionIdTest, selectionVisitor); expressionFunction('vlSelectionResolve', selectionResolve, selectionVisitor); expressionFunction('vlSelectionTuples', selectionTuples); function parser (expr, scope) { const params = {}; // parse the expression to an abstract syntax tree (ast) let ast; try { expr = isString(expr) ? expr : stringValue(expr) + ''; ast = parseExpression(expr); } catch (err) { error('Expression parse error: ' + expr); } // analyze ast function calls for dependencies ast.visit(node => { if (node.type !== CallExpression) return; const name = node.callee.name, visit = codegenParams.visitors[name]; if (visit) visit(name, node.arguments, scope, params); }); // perform code generation const gen = codeGenerator(ast); // collect signal dependencies gen.globals.forEach(name => { const signalName = SignalPrefix + name; if (!hasOwnProperty(params, signalName) && scope.getSignal(name)) { params[signalName] = scope.signalRef(name); } }); // return generated expression code and dependencies return { $expr: extend({ code: gen.code }, scope.options.ast ? { ast } : null), $fields: gen.fields, $params: params }; } export { DataPrefix, IndexPrefix, ScalePrefix, SignalPrefix, bandspace, bandwidth, codeGenerator, codegenParams, containerSize, contrast, copy, data, dataVisitor, dayAbbrevFormat, dayFormat, debug, domain, encode, expressionFunction, format, functionContext, geoArea, geoBounds, geoCentroid, geoScale, geoShape, inScope, indata, indataVisitor, indexof, info, invert, join, lastindexof, luminance, merge, modify, monthAbbrevFormat, monthFormat, parser as parseExpression, pathShape, pinchAngle, pinchDistance, pluck, range, replace, reverse, scale, scaleGradient, scaleVisitor, screen, setdata, slice, sort, timeFormat, timeParse, treeAncestors, treePath, utcFormat, utcParse, warn, windowSize }; //# sourceMappingURL=vega-functions.js.map