UNPKG

@antv/g2

Version:

the Grammar of Graphics in Javascript

931 lines (863 loc) 24.7 kB
import { deepMix, get, throttle, upperFirst } from '@antv/util'; import { CustomEvent } from '@antv/g'; import { isTranspose } from '../utils/coordinate'; import { invert, domainOf, sliderAbstractOf } from '../utils/scale'; import { SliderFilterInteraction } from '../spec/interaction'; import { Mark } from '../spec'; import { G2ViewDescriptor, G2MarkState } from '../runtime/types/common'; import { extractChannelValues, isFalsyValue, calculateMultiAxisChannelDomains, calculateAllIndependentScaleInfo, } from './utils'; import { RuntimeScale, extractSingleAxisScaleInfo, extractMultiAxisScaleInfo, processSingleAxisFiltering, processMultiAxisViewFiltering, processMultiAxisMarkFiltering, updateChannelDomains, } from './adaptiveFilter'; export const SLIDER_CLASS_NAME = 'slider'; /** * Calculates extra inset needed for point marks based on size scale range or values * * @param view - View descriptor containing markState * @returns Calculated inset value from size scale range or values */ function calculatePointInset(view: G2ViewDescriptor): number { if (!view?.markState) return 0; let maxSize = 0; for (const [mark, state] of view.markState.entries()) { if (mark.type !== 'point' || !state?.channels) continue; const sizeChannel = state.channels?.find((ch) => ch.name === 'size'); if (!sizeChannel) continue; // Priority 1: Use scale range if available if (sizeChannel.scale?.range?.length > 0) { const rangeMax = Math.max( ...sizeChannel.scale.range.filter((val) => typeof val === 'number'), ); maxSize = Math.max(maxSize, rangeMax); continue; } // Priority 2: Fallback to values maximum if (sizeChannel.values?.length > 0) { const sizes = sizeChannel.values .filter((item) => item.value !== undefined) .flatMap((item) => Array.isArray(item.value) ? item.value : [item.value], ) .filter( (value): value is number => typeof value === 'number' && !isNaN(value), ); if (sizes.length > 0) { maxSize = Math.max(maxSize, ...sizes); } } } return maxSize; } /** * Options for filtering data by domain. * Uses Mark[] type for better type safety. */ interface FilterDataByDomainOptions { marks: Mark[]; [key: string]: unknown; } /** * Scale configuration options. * Uses Record<string, unknown> to accommodate various scale property types. */ interface ScaleOptions { [key: string]: unknown; } /** * Emits filter events with proper X/Y domain mapping. * * @param emitter - Event emitter instance * @param eventName - Name of the event to emit * @param event - Event data object * @param domain0 - Primary domain values * @param channelDomain - Channel domain configuration * @param isX - Whether this is an X-axis event * @param nativeEvent - Whether this is a native DOM event */ function emitFilterEvent( emitter: { emit: (eventName: string, data: unknown) => void }, eventName: string, event: Record<string, unknown>, domain0: unknown[], channelDomain: Record<string, unknown[]>, isX: boolean, nativeEvent: boolean, ): void { if (nativeEvent) { const X = isX ? domain0 : channelDomain.x; const Y = isX ? channelDomain.y : domain0; emitter.emit(eventName, { ...event, nativeEvent, data: { selection: [extentOf(X), extentOf(Y)] }, }); } } /** * Updates slider state with appropriate filter function. * Handles both single-axis and multi-axis scenarios. * * @param setState - State setter function * @param slider - Slider component instance * @param params - Configuration parameters for state update */ function updateSliderState( setState: (slider: unknown, fn: (options: unknown) => unknown) => void, slider: unknown, view: G2ViewDescriptor, params: { domain0: unknown[]; filteredDomain: unknown[] | Map<string, unknown[]>; channel0: string; channel1: string; prefix: string; hasState: boolean; isMultiAxis: boolean; markToScaleMap?: Map<string, string>; enableAdaptiveFiltering: boolean; }, ): void { const { domain0, filteredDomain, channel0, channel1, prefix, hasState, isMultiAxis, markToScaleMap, enableAdaptiveFiltering, } = params; if (isMultiAxis && filteredDomain instanceof Map) { setState(slider, (options: FilterDataByDomainOptions) => ({ ...filterDataByDomainMultiAxis( options, view, { [channel0]: { domain: domain0, nice: false }, }, prefix, hasState, channel0, channel1, markToScaleMap || new Map(), filteredDomain, ), })); } else { setState(slider, (options: FilterDataByDomainOptions) => ({ ...filterDataByDomain( options, view, { [channel0]: { domain: domain0, nice: false }, ...(enableAdaptiveFiltering && Array.isArray(filteredDomain) ? { [channel1]: { domain: filteredDomain, nice: true, }, } : {}), }, prefix, hasState, channel0, channel1, ), })); } } /** * Filters data by domain for single-axis scenarios. * Applies scale options and preserves slider state. * * @param options - Filter data options * @param scaleOptions - Scale configuration * @param prefix - Slider prefix identifier * @param hasState - Whether slider has state * @param channel0 - Primary channel (x or y) * @param channel1 - Secondary channel (y or x) * @returns Filtered options with updated marks */ function filterDataByDomain( options: FilterDataByDomainOptions, view: G2ViewDescriptor, scaleOptions: ScaleOptions, prefix: string, hasState = false, channel0 = 'x', channel1 = 'y', ) { const { marks } = options; const extraInset = calculatePointInset(view); const newMarks = marks.map((mark) => deepMix( { // Hide label to keep smooth transition. axis: { x: { transform: [{ type: 'hide' }] }, y: { transform: [{ type: 'hide' }] }, }, }, mark, { scale: scaleOptions, [prefix]: { ...(mark[prefix]?.[channel0] && { [channel0]: { preserve: true, ...(hasState && { ratio: null }) }, }), ...(mark[prefix]?.[channel1] && { [channel1]: { preserve: true }, }), }, animate: false, }, ), ); return { ...options, marks: newMarks, // Add adaptive inset based on actual point sizes from markState insetLeft: extraInset, insetRight: extraInset, insetTop: extraInset, insetBottom: extraInset, clip: true, animate: false, }; } /** * Filters data by domain for multi-axis scenarios. * Handles independent scales and mark-specific filtering. * * @param options - Filter data options * @param scaleOptions - Scale configuration * @param prefix - Slider prefix identifier * @param hasState - Whether slider has state * @param channel0 - Primary channel (x or y) * @param channel1 - Secondary channel (y or x) * @param markToScaleMap - Mapping of marks to scale keys * @param filteredDomainList - Map of filtered domains by scale key * @returns Filtered options with updated marks */ function filterDataByDomainMultiAxis( options: FilterDataByDomainOptions, view: G2ViewDescriptor, scaleOptions: ScaleOptions, prefix: string, hasState = false, channel0 = 'x', channel1 = 'y', markToScaleMap = new Map<string, string>(), filteredDomainList = new Map<string, unknown[]>(), ) { const { marks } = options; const extraInset = calculatePointInset(view); const newMarks = marks.map((mark: Record<string, unknown>) => { const markKey = typeof mark?.key === 'string' ? mark.key : String(mark?.key || ''); const markToScale = markToScaleMap.get(markKey); const filterDomain = filteredDomainList.get(markToScale); const scaleNew = filterDomain && { y: { domain: filterDomain, nice: true, ...(markToScale !== 'y' ? { independent: true, } : undefined), }, }; return deepMix( { // Hide label to keep smooth transition. axis: { x: { transform: [{ type: 'hide' }] }, y: { transform: [{ type: 'hide' }] }, }, }, mark, { scale: { ...scaleOptions, ...scaleNew }, [prefix]: { ...(mark[prefix]?.[channel0] && { [channel0]: { preserve: true, ...(hasState && { ratio: null }) }, }), ...(mark[prefix]?.[channel1] && { [channel1]: { preserve: true }, }), }, animate: false, }, ); }); return { ...options, marks: newMarks, // Add adaptive inset based on actual point sizes from markState insetLeft: extraInset, insetRight: extraInset, insetTop: extraInset, insetBottom: extraInset, clip: true, animate: false, }; } /** * Converts slider values to abstract domain values. * * @param values - Slider value range [start, end] * @param scale - Scale instance for conversion * @param reverse - Whether to reverse the mapping * @returns Abstract domain values */ function abstractValue( values: [number, number], scale: RuntimeScale, reverse: boolean, ) { const [x, x1] = values; const v = reverse ? (d: number) => 1 - d : (d: number) => d; const d0 = invert(scale, v(x), true); const d1 = invert(scale, v(x1), false); return domainOf(scale, [d0, d1]); } /** * Gets the extent (first and last) values from a domain array. * * @param domain - Domain array * @returns Array containing first and last domain values */ function extentOf(domain: unknown[]): unknown[] { return [domain[0], domain[domain.length - 1]]; } /** * @todo Support click to reset after fix click and dragend conflict. */ export function SliderFilter({ initDomain = {}, className = SLIDER_CLASS_NAME, prefix = 'slider', setValue = (component, values) => component.setValues(values), hasState = false, wait = 50, leading = true, trailing = false, adaptiveMode = 'filter', getInitValues = (slider) => { const values = slider?.attributes?.values; if (values[0] !== 0 || values[1] !== 1) return values; }, }: SliderFilterInteraction) { return (context: any, _: any, emitter: any) => { const { container, view, update, setState } = context; const sliders = container.getElementsByClassName(className); if (!sliders.length) return () => {}; let filtering = false; const { scale, coordinate } = view; const { x: scaleX, y: scaleY } = scale; const transposed = isTranspose(coordinate); const channelOf = (orientation: string) => { const channel0 = orientation === 'vertical' ? 'y' : 'x'; const channel1 = orientation === 'vertical' ? 'x' : 'y'; if (transposed) return [channel1, channel0]; return [channel0, channel1]; }; const sliderHandler = new Map(); const emitHandlers = new Set<[string, (event: any) => void]>(); const independentScaleInfo = calculateAllIndependentScaleInfo(view); const channelDomain = calculateMultiAxisChannelDomains( view, initDomain, scaleX, scaleY, independentScaleInfo, ); const sliderArray = Array.from(sliders); const hasSliderOfType = (type: string) => sliderArray.some((slider: any) => { const { orientation } = slider.attributes; const [channel0] = channelOf(orientation); return channel0 === type; }); const hasOnlyXSlider = hasSliderOfType('x') && !hasSliderOfType('y'); const hasOnlyYSlider = hasSliderOfType('y') && !hasSliderOfType('x'); const enableAdaptiveFiltering = !isFalsyValue(adaptiveMode) && (hasOnlyXSlider || hasOnlyYSlider); for (const slider of sliders) { const { orientation } = slider.attributes; const [channel0, channel1] = channelOf(orientation); const eventName = `${prefix}${upperFirst(channel0)}:filter`; const isX = channel0 === 'x'; const { ratio: ratioX } = scaleX.getOptions(); const { ratio: ratioY } = scaleY.getOptions(); const domainsOf = (event: any): [unknown[], unknown[]] => { if (event.data) { const { selection } = event.data; const [X = extentOf(channelDomain.x), Y = extentOf(channelDomain.y)] = selection; return isX ? [domainOf(scaleX, X, ratioX), domainOf(scaleY, Y, ratioY)] : [domainOf(scaleY, Y, ratioY), domainOf(scaleX, X, ratioX)]; } const { value: values } = event.detail; const scale0 = scale[channel0]; const domain0 = abstractValue( values, scale0, transposed && orientation === 'horizontal', ); const domain1 = channelDomain[channel1]; return [domain0, domain1]; }; // Create value change handler with independent filtering state // Each slider maintains its own filtering state to prevent mutual interference in dual-axis scenarios let isFiltering = false; const setFiltering = (value: boolean) => { isFiltering = value; // Only reset global state when all sliders are not filtering if (!value) { filtering = false; } }; const onValueChange = createValueChangeHandler({ getFiltering: () => isFiltering, setFiltering, domainsOf, view, independentScaleInfo, enableAdaptiveFiltering, hasOnlyXSlider, hasOnlyYSlider, adaptiveMode, scaleX, scaleY, scale, channelDomain, channel0, channel1, isX, emitter, eventName, setState, slider, prefix, hasState, update, wait, leading, trailing, }); const emitHandler = (event: any) => { const { nativeEvent } = event; if (nativeEvent) return; const { data } = event; const { selection } = data; const [X, Y] = selection; slider.dispatchEvent( new CustomEvent('valuechange', { data, nativeEvent: false, }), ); const V = isX ? sliderAbstractOf(X, scaleX) : sliderAbstractOf(Y, scaleY); setValue(slider, V); }; emitter.on(eventName, emitHandler); slider.addEventListener('valuechange', onValueChange); sliderHandler.set(slider, onValueChange); emitHandlers.add([eventName, emitHandler]); const values = getInitValues(slider); if (values) { slider.dispatchEvent( new CustomEvent('valuechange', { detail: { value: values, }, nativeEvent: false, initValue: true, }), ); } } return () => { for (const [slider, handler] of sliderHandler) { slider.removeEventListener('valuechange', handler); } for (const [name, handler] of emitHandlers) { emitter.off(name, handler); } }; }; } /** * Processes multi-axis filtering for view-level sliders. * Handles both view-level and mark-level slider configurations. * * @param params - Multi-axis filtering parameters * @returns Filtered domain mapping */ function processMultiAxisFiltering({ view, domain0, shouldFilterXAxis, enableAdaptiveFiltering, markDataPairs, adaptiveMode, scaleX, scaleY, scale, channelDomain, independentScaleInfo, channel0, }: { view: any; domain0: unknown[]; shouldFilterXAxis: boolean; enableAdaptiveFiltering: boolean; markDataPairs: any[]; adaptiveMode: any; scaleX: RuntimeScale; scaleY: RuntimeScale; scale: Record<string, RuntimeScale>; channelDomain: Record<string, unknown[]>; independentScaleInfo: any; channel0: string; }): { filteredDomain: Map<string, unknown[]>; markToScaleMap: Map<string, string>; } { const filteredDomain = new Map<string, unknown[]>(); const markToScaleMap = new Map<string, string>(); if ( !enableAdaptiveFiltering || markDataPairs.length === 0 || !domain0?.length ) { return { filteredDomain, markToScaleMap }; } const viewSlider = get(view, 'options.slider'); const isViewSlider = Object.keys(viewSlider).length > 0 && Object.prototype.hasOwnProperty.call(viewSlider, channel0); if (isViewSlider) { // Handle view-level slider const multiAxisScaleInfo = extractMultiAxisScaleInfo( shouldFilterXAxis, scale, scaleX, scaleY, channelDomain, ); const scaleMapToUse = shouldFilterXAxis ? independentScaleInfo.markToXScaleMap : independentScaleInfo.markToYScaleMap; scaleMapToUse.forEach((scaleKey, markKey) => { markToScaleMap.set(markKey, scaleKey); }); const processedFilteredDomain = processMultiAxisViewFiltering({ markDataPairs, domain: domain0, scaleInfo: multiAxisScaleInfo, markToScaleMap, adaptiveMode, isViewSlider: true, shouldFilterXAxis, }); updateChannelDomains( channelDomain, processedFilteredDomain, shouldFilterXAxis, true, ); return { filteredDomain: processedFilteredDomain, markToScaleMap }; } else { // Handle mark-level slider const targetMarkKey = findTargetMarkKey(view, channel0); if (targetMarkKey) { const singleAxisScaleInfo = extractSingleAxisScaleInfo( shouldFilterXAxis, scaleX, scaleY, ); const scaleMapping = shouldFilterXAxis ? independentScaleInfo.markToXScaleMap : independentScaleInfo.markToYScaleMap; const targetScaleKey = scaleMapping.get(targetMarkKey) || ''; if (targetScaleKey) { markToScaleMap.set(targetMarkKey, targetScaleKey); const processedFilteredDomain = processMultiAxisMarkFiltering( markDataPairs, domain0, singleAxisScaleInfo, targetMarkKey, targetScaleKey, adaptiveMode, shouldFilterXAxis, scaleMapping, ); return { filteredDomain: processedFilteredDomain, markToScaleMap }; } } } return { filteredDomain, markToScaleMap }; } /** * Finds the target mark key for mark-level sliders. * * @param view - View instance * @param channel0 - Channel identifier * @returns Target mark key or null if not found */ function findTargetMarkKey(view: any, channel0: string): string | null { for (const [mark] of view.markState.entries()) { const markSlider = get(mark, 'slider'); const hasMarkSlider = Object.keys(markSlider || {}).length > 0 && Object.prototype.hasOwnProperty.call(markSlider, channel0); if (hasMarkSlider) { return String(mark.key || ''); } } return null; } /** * Processes single-axis filtering for scenarios without independent scales. * * @param params - Single-axis filtering parameters * @returns Filtered domain array */ function processSingleAxisFilteringWithDomainUpdate({ domain0, domain1, shouldFilterXAxis, enableAdaptiveFiltering, markDataPairs, adaptiveMode, scaleX, scaleY, channelDomain, hasOnlyXSlider, hasOnlyYSlider, isX, }: { domain0: unknown[]; domain1: unknown[]; shouldFilterXAxis: boolean; enableAdaptiveFiltering: boolean; markDataPairs: any[]; adaptiveMode: any; scaleX: RuntimeScale; scaleY: RuntimeScale; channelDomain: Record<string, unknown[]>; hasOnlyXSlider: boolean; hasOnlyYSlider: boolean; isX: boolean; }): unknown[] { let filteredDomain: unknown[] = domain1; if ( enableAdaptiveFiltering && markDataPairs.length > 0 && ((hasOnlyXSlider && isX) || (hasOnlyYSlider && !isX)) && domain0?.length > 0 ) { const singleAxisScaleInfo = extractSingleAxisScaleInfo( shouldFilterXAxis, scaleX, scaleY, ); filteredDomain = processSingleAxisFiltering({ markDataPairs, domain: domain0, scaleInfo: singleAxisScaleInfo, adaptiveMode, shouldFilterXAxis, }); // Update channelDomain if filtering was applied updateChannelDomains( channelDomain, filteredDomain, shouldFilterXAxis, false, ); } return filteredDomain; } /** * Creates the main value change handler for slider filtering. * * @param params - Handler creation parameters * @returns Throttled value change handler */ function createValueChangeHandler({ getFiltering, setFiltering, domainsOf, view, independentScaleInfo, enableAdaptiveFiltering, hasOnlyXSlider, hasOnlyYSlider, adaptiveMode, scaleX, scaleY, scale, channelDomain, channel0, channel1, isX, emitter, eventName, setState, slider, prefix, hasState, update, wait, leading, trailing, }: { getFiltering: () => boolean; setFiltering: (value: boolean) => void; domainsOf: (event: any) => [unknown[], unknown[]]; view: any; independentScaleInfo: any; enableAdaptiveFiltering: boolean; hasOnlyXSlider: boolean; hasOnlyYSlider: boolean; adaptiveMode: any; scaleX: RuntimeScale; scaleY: RuntimeScale; scale: Record<string, RuntimeScale>; channelDomain: Record<string, unknown[]>; channel0: string; channel1: string; isX: boolean; emitter: any; eventName: string; setState: any; slider: any; prefix: string; hasState: boolean; update: () => Promise<void>; wait: number; leading: boolean; trailing: boolean; }) { return throttle( async (event: any) => { const { initValue = false } = event; if (getFiltering() && !initValue) { return; } setFiltering(true); const { nativeEvent = true } = event; const { markDataPairs } = extractChannelValues(view); const hasIndependentScale = independentScaleInfo[`hasIndependent${channel1.toUpperCase()}`]; if (hasIndependentScale) { // Handle multi-axis scenario const [domain0] = domainsOf(event); const shouldFilterXAxis = hasOnlyYSlider && !isX; const { filteredDomain, markToScaleMap } = processMultiAxisFiltering({ view, domain0, shouldFilterXAxis, enableAdaptiveFiltering: enableAdaptiveFiltering && ((hasOnlyXSlider && isX) || (hasOnlyYSlider && !isX)), markDataPairs, adaptiveMode, scaleX, scaleY, scale, channelDomain, independentScaleInfo, channel0, }); // Update channelDomain to reflect the current filter state channelDomain[channel0] = domain0; emitFilterEvent( emitter, eventName, event, domain0, channelDomain, isX, nativeEvent, ); updateSliderState(setState, slider, view, { domain0, filteredDomain, channel0, channel1, prefix, hasState, isMultiAxis: true, markToScaleMap, enableAdaptiveFiltering, }); } else { // Handle single-axis scenario const [domain0, domain1] = domainsOf(event); const shouldFilterXAxis = hasOnlyYSlider && !isX; const filteredDomain = processSingleAxisFilteringWithDomainUpdate({ domain0, domain1, shouldFilterXAxis, enableAdaptiveFiltering, markDataPairs, adaptiveMode, scaleX, scaleY, channelDomain, hasOnlyXSlider, hasOnlyYSlider, isX, }); // Update channelDomain to reflect the current filter state channelDomain[channel0] = domain0; emitFilterEvent( emitter, eventName, event, domain0, channelDomain, isX, nativeEvent, ); updateSliderState(setState, slider, view, { domain0, filteredDomain, channel0, channel1, prefix, hasState, isMultiAxis: false, markToScaleMap: undefined, enableAdaptiveFiltering, }); } await update(); setFiltering(false); }, wait, { leading, trailing }, ); }