import {isObject, MergedStream, NewSignal, Stream} from 'vega'; import {parseSelector} from 'vega-event-selector'; import {array, isString} from 'vega-util'; import {disableDirectManipulation, TUPLE} from './index.js'; import {NonPositionScaleChannel} from '../../channel.js'; import * as log from '../../log/index.js'; import {isLegendBinding, isLegendStreamBinding, SELECTION_ID} from '../../selection.js'; import {duplicate, vals, varName} from '../../util.js'; import {LegendComponent} from '../legend/component.js'; import {UnitModel} from '../unit.js'; import {TUPLE_FIELDS} from './project.js'; import {TOGGLE} from './toggle.js'; import {SelectionCompiler} from './index.js'; const legendBindings: SelectionCompiler<'point'> = { defined: (selCmpt) => { const spec = selCmpt.resolve === 'global' && selCmpt.bind && isLegendBinding(selCmpt.bind); const projLen = selCmpt.project.items.length === 1 && selCmpt.project.items[0].field !== SELECTION_ID; if (spec && !projLen) { log.warn(log.message.LEGEND_BINDINGS_MUST_HAVE_PROJECTION); } return spec && projLen; }, parse: (model, selCmpt, selDef) => { // Allow legend items to be toggleable by default even though direct manipulation is disabled. const selDef_ = duplicate(selDef); selDef_.select = isString(selDef_.select) ? {type: selDef_.select, toggle: selCmpt.toggle} : {...selDef_.select, toggle: selCmpt.toggle}; disableDirectManipulation(selCmpt, selDef_); if (isObject(selDef.select) && (selDef.select.on || selDef.select.clear)) { const legendFilter = 'event.item && indexof(event.item.mark.role, "legend") < 0'; for (const evt of selCmpt.events) { evt.filter = array(evt.filter ?? []); if (!evt.filter.includes(legendFilter)) { evt.filter.push(legendFilter); } } } const evt = isLegendStreamBinding(selCmpt.bind) ? selCmpt.bind.legend : 'click'; const stream: Stream[] = isString(evt) ? parseSelector(evt, 'view') : array(evt); selCmpt.bind = {legend: {merge: stream}}; }, topLevelSignals: (model, selCmpt, signals) => { const selName = selCmpt.name; const stream = isLegendStreamBinding(selCmpt.bind) && (selCmpt.bind.legend as MergedStream); const markName = (name: string) => (s: Stream) => { const ds = duplicate(s); ds.markname = name; return ds; }; for (const proj of selCmpt.project.items) { if (!proj.hasLegend) continue; const prefix = `${varName(proj.field)}_legend`; const sgName = `${selName}_${prefix}`; const hasSignal = signals.filter((s) => s.name === sgName); if (hasSignal.length === 0) { const events = stream.merge .map(markName(`${prefix}_symbols`)) .concat(stream.merge.map(markName(`${prefix}_labels`))) .concat(stream.merge.map(markName(`${prefix}_entries`))); signals.unshift({ name: sgName, ...(!selCmpt.init ? {value: null} : {}), on: [ // Legend entries do not store values, so we need to walk the scenegraph to the symbol datum. { events, update: 'isDefined(datum.value) ? datum.value : item().items[0].items[0].datum.value', force: true, }, {events: stream.merge, update: `!event.item || !datum ? null : ${sgName}`, force: true}, ], }); } } return signals; }, signals: (model, selCmpt, signals) => { const name = selCmpt.name; const proj = selCmpt.project; const tuple: NewSignal = signals.find((s) => s.name === name + TUPLE); const fields = name + TUPLE_FIELDS; const values = proj.items.filter((p) => p.hasLegend).map((p) => varName(`${name}_${varName(p.field)}_legend`)); const valid = values.map((v) => `${v} !== null`).join(' && '); const update = `${valid} ? {fields: ${fields}, values: [${values.join(', ')}]} : null`; if (selCmpt.events && values.length > 0) { tuple.on.push({ events: values.map((signal) => ({signal})), update, }); } else if (values.length > 0) { tuple.update = update; delete tuple.value; delete tuple.on; } const toggle = signals.find((s) => s.name === name + TOGGLE); const events = isLegendStreamBinding(selCmpt.bind) && selCmpt.bind.legend; if (toggle) { if (!selCmpt.events) toggle.on[0].events = events; else toggle.on.push({...toggle.on[0], events}); } return signals; }, }; export default legendBindings; export function parseInteractiveLegend( model: UnitModel, channel: NonPositionScaleChannel, legendCmpt: LegendComponent, ) { const field = model.fieldDef(channel)?.field; for (const selCmpt of vals(model.component.selection ?? {})) { const proj = selCmpt.project.hasField[field] ?? selCmpt.project.hasChannel[channel]; if (proj && legendBindings.defined(selCmpt)) { const legendSelections = legendCmpt.get('selections') ?? []; legendSelections.push(selCmpt.name); legendCmpt.set('selections', legendSelections, false); proj.hasLegend = true; } } }