UNPKG

@uwdata/mosaic-plot

Version:

A Mosaic-powered plotting framework based on Observable Plot.

193 lines (167 loc) 5.7 kB
import { scale } from '@observablehq/plot'; import { Interval1D } from './interactors/Interval1D.js'; import { Toggle } from './interactors/Toggle.js'; const TOGGLE_SELECTOR = ':scope > div, :scope > span'; const SWATCH = 'swatch'; const RAMP = 'ramp'; export class Legend { constructor(channel, options) { const { as, field, ...rest } = options; this.channel = channel; this.options = rest; this.type = null; this.handler = null; this.selection = as; this.field = field; this.legend = null; this.element = document.createElement('div'); this.element.setAttribute('class', 'legend'); Object.defineProperty(this.element, 'value', { value: this }); } setPlot(plot) { this.plot = plot; } init(svg) { // createLegend sets this.legend, may set this.handler const el = createLegend(this, svg); this.element.replaceChildren(el); return this.element; } update() { if (!this.legend) return; const { selection, handler } = this; const { single, value } = selection; // extract currently selected values const vals = single ? value : selection.valueFor(handler); const curr = vals && vals.length ? new Set(vals.map(v => v[0])) : null; const nodes = this.legend.querySelectorAll(TOGGLE_SELECTOR); for (const node of nodes) { const selected = curr ? curr.has(node.__data__) : true; node.style.opacity = selected ? 1 : 0.2; } } } function createLegend(legend, svg) { const { channel, plot, selection } = legend; const scale = svg.scale(channel); const type = scale.type === 'ordinal' ? SWATCH : RAMP; const options = { label: plot.getAttribute(`${channel}Label`) ?? null, ...legend.options }; // labels for swatch legends are not yet supported by Plot // track here: https://github.com/observablehq/plot/issues/834 // for consistent layout, adjust sizing when there is no label const opt = type === SWATCH ? options : options.label ? { tickSize: 2, ...options } : { tickSize: 2, marginTop: 1, height: 29, ...options }; // instantiate new legend element, bind to Legend class const el = svg.legend(channel, opt); legend.legend = el; // if this is an interactive legend, add a scale lookup function // this allows interval interactors to access encoding information let interactive = !!selection; if (interactive && type === RAMP) { const width = opt.width ?? 240; // 240 is default ramp length const spatial = spatialScale(scale, width); if (spatial) { el.scale = function(type) { return type === 'x' ? { range: [0, width] } : type === 'y' ? { range: [-10, 0] } : type === channel ? spatial : undefined; }; } else { // spatial scale construction failed, disable interaction interactive = false; } } // initialize interactors to use updated legend element if (interactive) { const handler = getInteractor(legend, type); if (type === SWATCH) { handler.init(el, TOGGLE_SELECTOR, el => [el.__data__]); legend.update(); } else { handler.init(el, el.querySelector('g:last-of-type')); } } return el; } function getInteractor(legend, type) { const { channel, handler, selection } = legend; // exit early if already instantiated if (handler) return handler; // otherwise instantiate an appropriate interactor const mark = interactorMark(legend); if (type === SWATCH) { legend.handler = new Toggle(mark, { selection, channels: [channel], peers: false }); selection.addEventListener('value', () => legend.update()); } else { legend.handler = new Interval1D(mark, { selection, channel, brush: { fill: 'none', stroke: 'currentColor' }, peers: false }); } return legend.handler; } // generate a faux mark to pass to an interactor function interactorMark(legend) { const { channel, plot } = legend; const field = legend.field ?? findField(plot.marks, channel) ?? 'value'; if (field) { const f = { field }; return { plot, channelField: c => channel === c ? f : undefined }; } } // search marks for a backing data field for the legend function findField(marks, channel) { const channels = channel === 'color' ? ['fill', 'stroke'] : channel === 'opacity' ? ['opacity', 'fillOpacity', 'strokeOpacity'] : null; if (channels == null) return null; for (let i = marks.length - 1; i > -1; --i) { for (const c of channels) { const field = marks[i].channelField(c, { exact: true }); if (field) return field.field; } } return null; } // generate a spatial scale to brush within color or opacity ramps function spatialScale(sourceScale, width) { // separate out reusable parts of the scale definition // eslint-disable-next-line no-unused-vars const { apply, invert, interpolate, ...rest } = sourceScale; // extract basic source scale type let src = sourceScale.type; if (src.startsWith('diverging-')) src = src.slice(11); // determine spatial scale type let type; switch (src) { case 'log': case 'pow': case 'sqrt': case 'symlog': type = src; break; case 'threshold': case 'quantize': case 'quantile': // these scales do not expose an invert method // the legends use color ramps with discrete swatches // in the future we could try to support toggle-style // interactions that map to threshold range selections console.warn(`Legends do not yet support ${src} scales.`); return null; default: type = 'linear'; } return scale({ x: { ...rest, type, range: [0, width] } }); }