UNPKG

@uwdata/mosaic-plot

Version:

A Mosaic-powered plotting framework based on Observable Plot.

154 lines (137 loc) 4.83 kB
/** @import { ClauseSource } from '@uwdata/mosaic-core' */ import { clausePoint, clausePoints, isSelection } from '@uwdata/mosaic-core'; import { select, pointer, min } from 'd3'; import { getField } from './util/get-field.js'; /** * @import {Activatable} from '@uwdata/mosaic-core' * @implements {Activatable} */ export class Nearest { constructor(mark, { selection, pointer, channels, fields, maxRadius = 40 }) { this.mark = mark; this.selection = selection; this.clients = new Set().add(mark); this.pointer = pointer; this.channels = channels || ( pointer === 'x' ? ['x'] : pointer === 'y' ? ['y'] : ['x', 'y'] ); this.fields = fields || this.channels.map(c => getField(mark, [c])); this.maxRadius = maxRadius; this.valueIndex = -1; } clause(value) { const { clients, fields } = this; const opt = { source: /** @type {ClauseSource} */(this), clients }; // if only one field, use a simpler clause that passes the value // this allows a single field selection value to act like a param return fields.length > 1 ? clausePoints(fields, value ? [value] : value, opt) : clausePoint(fields[0], value?.[0], opt); } init(svg) { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; const { mark, channels, selection, maxRadius } = this; const { data: { columns } } = mark; const keys = channels.map(c => mark.channelField(c).as); const param = !isSelection(selection); // extract x, y coordinates for data values and determine scale factors const [X, Y] = calculateXY(svg, mark); const sx = this.pointer === 'y' ? 0.01 : 1; const sy = this.pointer === 'x' ? 0.01 : 1; const root = select(svg); // find value nearest to pointer and update param or selection // we don't pass undefined values to params, but do allow empty selections root.on('pointerenter pointerdown pointermove', function(evt) { const [px, py] = pointer(evt, this); const i = findNearest(X, Y, px, py, sx, sy, maxRadius); if (i !== this.valueIndex) { this.valueIndex = i; const v = i < 0 ? undefined : keys.map(k => columns[k][i]); selection.update( // provide value for param, clause for selection param ? (!v || v.length > 1 ? v : v[0]) : that.clause(v) ); } }); // if not a selection, we're done if (param) return; // clear selection upon pointer exit root.on('pointerleave', () => { selection.update(that.clause(undefined)); }); // trigger activation updates svg.addEventListener('pointerenter', evt => { if (!evt.buttons) this.activate(); }); } activate() { const v = this.channels.map(() => 0); this.selection.activate(this.clause(v)); } } /** * Extract x, y coordinates for data values. */ function calculateXY(svg, mark) { const { data: { columns } } = mark; const data = c => columns[mark.channelField(c)?.as]; const scale = c => svg.scale(c); const sx = svg.scale('x'); const sy = svg.scale('y'); const sfx = scale('fx')?.apply; const sfy = scale('fy')?.apply; const X = Array.from(data('x'), sx.apply); const Y = Array.from(data('y'), sy.apply); // as needed, adjust coordinates by facets if (sfx) { const dx = min(sx.range); const FX = data('fx'); for (let i = 0; i < FX.length; ++i) { X[i] += sfx(FX[i]) - dx; } } if (sfy) { const dy = min(sy.range); const FY = data('fy'); for (let i = 0; i < FY.length; ++i) { Y[i] += sfy(FY[i]) - dy; } } return [X, Y]; } /** * Find the nearest data point to the pointer. The nearest point * is found via Euclidean distance, but with scale factors *sx* and * *sy* applied to the x and y distances. For example, to prioritize * selection along the x-axis, use *sx* = 1, *sy* = 0.01. * @param {number[]} x Array of data point x coordinate values. * @param {number[]} y Array of data point y coordinate values. * @param {number} px The x coordinate of the pointer. * @param {number} py The y coordinate of the pointer. * @param {number} sx A scale factor for x coordinate spans. * @param {number} sy A scale factor for y coordinate spans. * @param {number} maxRadius The maximum pointer distance for selection. * @returns {number} An integer index into the data array corresponding * to the nearest data point, or -1 if no nearest point is found. */ function findNearest(x, y, px, py, sx, sy, maxRadius) { let dist = maxRadius * maxRadius; let nearest = -1; for (let i = 0; i < x.length; ++i) { const dx = sx * (x[i] - px); const dy = sy * (y[i] - py); const dd = dx * dx + dy * dy; if (dd <= dist) { dist = dd; nearest = i; } } return nearest; }