UNPKG

@antv/g2

Version:

the Grammar of Graphics in Javascript

778 lines (707 loc) 21.3 kB
import { DisplayObject, Rect, Path } from '@antv/g'; import { subObject, omitPrefixObject } from '../utils/helper'; import { selectionOf, pixelsOf } from '../utils/scale'; import { createElement } from '../utils/createElement'; import { G2Element, select, Selection } from '../utils/selection'; import { selectG2Elements, selectPlotArea, createDatumof, useState, createValueof, setCursor, brushMousePosition, selectFacetG2Elements, mergeState, selectFacetViews, } from './utils'; function intersect(bbox1: any, bbox2: any) { const [minX1, minY1, maxX1, maxY1] = bbox1; const [minX2, minY2, maxX2, maxY2] = bbox2; return !(minX2 > maxX1 || maxX2 < minX1 || minY2 > maxY1 || maxY2 < minY1); } function normalizeBounds(x, y, x1, y1, extent) { const [minX, minY, maxX, maxY] = extent; return [ Math.max(minX, Math.min(x, x1)), Math.max(minY, Math.min(y, y1)), Math.min(maxX, Math.max(x, x1)), Math.min(maxY, Math.max(y, y1)), ]; } function bboxOf(root: DisplayObject) { const { width, height } = root.getBBox(); return [0, 0, width, height]; } function applyStyle(selection: Selection, style: Record<string, any>) { for (const [key, value] of Object.entries(style)) { selection.style(key, value); } } const ResizableMask = createElement((g) => { const { x, y, width, height, class: className, renders = {}, handleSize: size = 10, document, ...style } = g.attributes; if ( !document || width === undefined || height === undefined || x === undefined || y === undefined ) return; const half = size / 2; const renderRect = (g, options, document) => { if (!g.handle) { g.handle = document.createElement('rect'); g.append(g.handle); } const { handle } = g; handle.attr(options); return handle; }; const { render: handleNRender = renderRect, ...handleNStyle } = subObject( omitPrefixObject(style, 'handleNW', 'handleNE'), 'handleN', ); const { render: handleERender = renderRect, ...handleEStyle } = subObject( style, 'handleE', ); const { render: handleSRender = renderRect, ...handleSStyle } = subObject( omitPrefixObject(style, 'handleSE', 'handleSW'), 'handleS', ); const { render: handleWRender = renderRect, ...handleWStyle } = subObject( style, 'handleW', ); const { render: handleNWRender = renderRect, ...handleNWStyle } = subObject( style, 'handleNW', ); const { render: handleNERender = renderRect, ...handleNEStyle } = subObject( style, 'handleNE', ); const { render: handleSERender = renderRect, ...handleSEStyle } = subObject( style, 'handleSE', ); const { render: handleSWRender = renderRect, ...handleSWStyle } = subObject( style, 'handleSW', ); const renderHandle = (g, renderNode) => { const { id } = g; const handle = renderNode(g, g.attributes, document); handle.id = id; handle.style.draggable = true; }; const appendHandle = (handleRender) => { return () => { const Node = createElement((g) => renderHandle(g, handleRender)); return new Node({}); }; }; const container = select(g) .attr('className', className) .style('transform', `translate(${x}, ${y})`) .style('draggable', true); container .maybeAppend('selection', 'rect') .style('draggable', true) .style('fill', 'transparent') .call(applyStyle, { width, height, ...omitPrefixObject(style, 'handle'), transform: undefined, }); container .maybeAppend('handle-n', appendHandle(handleNRender)) .style('x', half) .style('y', -half) .style('width', width - size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleNStyle); container .maybeAppend('handle-e', appendHandle(handleERender)) .style('x', width - half) .style('y', half) .style('width', size) .style('height', height - size) .style('fill', 'transparent') .call(applyStyle, handleEStyle); container .maybeAppend('handle-s', appendHandle(handleSRender)) .style('x', half) .style('y', height - half) .style('width', width - size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleSStyle); container .maybeAppend('handle-w', appendHandle(handleWRender)) .style('x', -half) .style('y', half) .style('width', size) .style('height', height - size) .style('fill', 'transparent') .call(applyStyle, handleWStyle); container .maybeAppend('handle-nw', appendHandle(handleNWRender)) .style('x', -half) .style('y', -half) .style('width', size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleNWStyle); container .maybeAppend('handle-ne', appendHandle(handleNERender)) .style('x', width - half) .style('y', -half) .style('width', size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleNEStyle); container .maybeAppend('handle-se', appendHandle(handleSERender)) .style('x', width - half) .style('y', height - half) .style('width', size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleSEStyle); container .maybeAppend('handle-sw', appendHandle(handleSWRender)) .style('x', -half) .style('y', height - half) .style('width', size) .style('height', size) .style('fill', 'transparent') .call(applyStyle, handleSWStyle); }); export function brush( root: DisplayObject, { brushed = () => {}, brushended = () => {}, brushcreated = () => {}, brushstarted = () => {}, brushupdated = () => {}, extent = bboxOf(root), brushRegion = (x, y, x1, y1, extent) => [x, y, x1, y1], reverse = false, fill = '#777', fillOpacity = '0.3', stroke = '#fff', selectedHandles = [ 'handle-n', 'handle-e', 'handle-s', 'handle-w', 'handle-nw', 'handle-ne', 'handle-se', 'handle-sw', ], ...style }: Record<string, any>, ) { let start = null; // Start point of mask. let end = null; // End point of mask. let moveStart = null; // Start point of moving mask. let mask = null; // Mask instance. let background = null; let creating = false; const [originX, originY, width, height] = extent; setCursor(root, 'crosshair'); root.style.draggable = true; // Make it response to drag event. // Remove old mask and init new mask. const initMask = (x, y, event) => { brushstarted(event); if (mask) mask.remove(); if (background) background.remove(); start = [x, y]; if (reverse) return initReverseMask(); initNormalMask(); }; const initReverseMask = () => { background = new Path({ style: { ...style, fill, fillOpacity, stroke, pointerEvents: 'none', }, }); mask = new ResizableMask({ // @ts-ignore style: { x: 0, y: 0, width: 0, height: 0, draggable: true, document: root.ownerDocument, }, className: 'mask', }); root.appendChild(background); root.appendChild(mask); }; const initNormalMask = () => { mask = new ResizableMask({ // @ts-ignore style: { document: root.ownerDocument, x: 0, y: 0, ...style, fill, fillOpacity, stroke, draggable: true, // Make it response to drag event. }, className: 'mask', }); root.appendChild(mask); }; // Remove mask and reset states. const removeMask = (emit = true) => { if (mask) mask.remove(); if (background) background.remove(); start = null; end = null; moveStart = null; creating = false; mask = null; background = null; brushended(emit); }; // Update mask and invoke brushended callback. const updateMask = (start, end, emit = true) => { const [x, y, x1, y1] = normalizeBounds( start[0], start[1], end[0], end[1], extent, ); const [fx, fy, fx1, fy1] = brushRegion(x, y, x1, y1, extent); if (reverse) updateReverseMask(fx, fy, fx1, fy1); else updateNormalMask(fx, fy, fx1, fy1); brushed(fx, fy, fx1, fy1, emit); return [fx, fy, fx1, fy1]; }; const updateNormalMask = (x, y, x1, y1) => { mask.style.x = x; mask.style.y = y; mask.style.width = x1 - x; mask.style.height = y1 - y; }; const updateReverseMask = (x, y, x1, y1) => { background.style.d = ` M${originX},${originY}L${width},${originY}L${width},${height}L${originX},${height}Z M${x},${y}L${x},${y1}L${x1},${y1}L${x1},${y}Z `; mask.style.x = x; mask.style.y = y; mask.style.width = x1 - x; mask.style.height = y1 - y; }; // Move and update mask. const moveMask = (current) => { const clip = (dt, start, end, min, max) => { if (dt + start < min) return min - start; if (dt + end > max) return max - end; return dt; }; const dx = current[0] - moveStart[0]; const dy = current[1] - moveStart[1]; const dx1 = clip(dx, start[0], end[0], originX, width); const dy1 = clip(dy, start[1], end[1], originY, height); const currentStart = [start[0] + dx1, start[1] + dy1]; const currentEnd = [end[0] + dx1, end[1] + dy1]; updateMask(currentStart, currentEnd); }; const handles = { 'handle-n': { vector: [0, 1, 0, 0], cursor: 'ns-resize' }, 'handle-e': { vector: [0, 0, 1, 0], cursor: 'ew-resize' }, 'handle-s': { vector: [0, 0, 0, 1], cursor: 'ns-resize' }, 'handle-w': { vector: [1, 0, 0, 0], cursor: 'ew-resize' }, 'handle-nw': { vector: [1, 1, 0, 0], cursor: 'nwse-resize' }, 'handle-ne': { vector: [0, 1, 1, 0], cursor: 'nesw-resize' }, 'handle-se': { vector: [0, 0, 1, 1], cursor: 'nwse-resize' }, 'handle-sw': { vector: [1, 0, 0, 1], cursor: 'nesw-resize' }, }; const isMask = (target) => { return isSelection(target) || isHandle(target); }; const isHandle = (target) => { const { id } = target; if (selectedHandles.indexOf(id) === -1) return false; return new Set(Object.keys(handles)).has(id); }; const isSelection = (target) => { return target === mask.getElementById('selection'); }; // If target is plot area, create mask. // If target is mask, about to update position. const dragstart = (event) => { const { target } = event; const [offsetX, offsetY] = brushMousePosition(root, event); if (!mask || !isMask(target)) { initMask(offsetX, offsetY, event); creating = true; return; } if (isMask(target)) { moveStart = [offsetX, offsetY]; } }; const drag = (event) => { const { target } = event; const mouse = brushMousePosition(root, event); if (!start) return; // If target is plot area, resize mask. if (!moveStart) return updateMask(start, mouse); // If target is selection area, move mask. if (isSelection(target)) return moveMask(mouse); // If target is handle area, resize mask. const [dx, dy] = [mouse[0] - moveStart[0], mouse[1] - moveStart[1]]; const { id } = target; if (handles[id]) { const [sx, sy, ex, ey] = handles[id].vector; return updateMask( [start[0] + dx * sx, start[1] + dy * sy], [end[0] + dx * ex, end[1] + dy * ey], ); } }; // If target is plot area, finish creating. // If target is mask, finish moving mask. const dragend = (event) => { if (moveStart) { moveStart = null; // Update start and end; const { x, y, width, height } = mask.style; start = [x, y]; end = [x + width, y + height]; brushupdated(x, y, x + width, y + height, event); return; } end = brushMousePosition(root, event); const [fx, fy, fx1, fy1] = updateMask(start, end); creating = false; brushcreated(fx, fy, fx1, fy1, event); }; // Hide mask. const click = (event) => { const { target } = event; if (mask && !isMask(target)) removeMask(); }; // Update cursor depends on hovered element. const pointermove = (event) => { const { target } = event; if (!mask || !isMask(target) || creating) setCursor(root, 'crosshair'); else if (isSelection(target)) setCursor(root, 'move'); else if (isHandle(target)) setCursor(root, handles[target.id].cursor); }; const pointerleave = () => { setCursor(root, 'default'); }; root.addEventListener('dragstart', dragstart); root.addEventListener('drag', drag); root.addEventListener('dragend', dragend); root.addEventListener('click', click); root.addEventListener('pointermove', pointermove); root.addEventListener('pointerleave', pointerleave); return { mask, move(x, y, x1, y1, emit = true) { if (!mask) initMask(x, y, {}); start = [x, y]; end = [x1, y1]; updateMask([x, y], [x1, y1], emit); }, remove(emit = true) { if (mask) removeMask(emit); }, destroy() { // Do not emit brush:end event. if (mask) removeMask(false); setCursor(root, 'default'); root.removeEventListener('dragstart', dragstart); root.removeEventListener('drag', drag); root.removeEventListener('dragend', dragend); root.removeEventListener('click', click); root.removeEventListener('pointermove', pointermove); root.removeEventListener('pointerleave', pointerleave); }, }; } function selectSiblingViews(target, viewInstances, brushKey) { return viewInstances.filter((d) => { if (d === target) return false; const { interaction = {} } = d.options; return Object.values(interaction as Record<string, any>).find( (d) => d.brushKey === brushKey, ); }); } function selectSiblingContainers(target, viewInstances, brushKey) { return selectSiblingViews(target, viewInstances, brushKey).map((d) => selectPlotArea(d.container), ); } function selectSiblingOptions(target, viewInstances, brushKey) { return selectSiblingViews(target, viewInstances, brushKey).map( (d) => d.options, ); } /** * @todo Brush over view for series view. * @todo Test perf. */ export function brushHighlight( root, { elements: elementof, selectedHandles, siblings: siblingsof = (root) => [], datum, brushRegion, extent: optionalExtent, reverse, scale, coordinate, series = false, key = (d) => d, bboxOf = (root) => { const { x, y, width, height } = root.style; return { x, y, width, height }; }, state = {}, emitter, ...rest }, ) { const elements = elementof(root); const siblings = siblingsof(root); const siblingElements = siblings.flatMap(elementof); const valueof = createValueof(elements, datum); const brushStyle = subObject(rest, 'mask'); const { setState, removeState } = useState(state, valueof); const clonedElement = new Map(); const { width: rootWidth, height: rootHeight, x: ordinalX = 0, y: ordinalY = 0, } = bboxOf(root); const extent = optionalExtent ? optionalExtent : [0, 0, rootWidth, rootHeight]; const brushended = () => { for (const element of [...elements, ...siblingElements]) { removeState(element, 'active', 'inactive'); } }; const brushed = (x, y, x1, y1) => { // Hide brush for the sibling view. for (const sibling of siblings) sibling.brush?.remove(); // Store the key of the active element. const keys = new Set(); // Highlight and store selected elements. for (const element of elements) { const { min, max } = element.getLocalBounds(); const [ex, ey] = min; const [ex1, ey1] = max; if (!intersect([ex, ey, ex1, ey1], [x, y, x1, y1])) { setState(element, 'inactive'); } else { setState(element, 'active'); keys.add(key(element)); } } // Highlight elements with same key in sibling view. for (const element of siblingElements) { if (keys.has(key(element))) setState(element, 'active'); else setState(element, 'inactive'); } }; const seriesBrushend = () => { for (const element of elements) removeState(element, 'inactive'); for (const cloned of clonedElement.values()) cloned.remove(); clonedElement.clear(); }; const seriesBrushed = (x, y, x1, y1) => { const clone = (element) => { const cloned = element.cloneNode(); cloned.__data__ = element.__data__; element.parentNode.appendChild(cloned); clonedElement.set(element, cloned); return cloned; }; // Create a clipPath shared between all children. const clipPath = new Rect({ style: { x: x + ordinalX, y: y + ordinalY, width: x1 - x, height: y1 - y, }, }); root.appendChild(clipPath); for (const element of elements) { const cloned = clonedElement.get(element) || clone(element); cloned.style.clipPath = clipPath; setState(element, 'inactive'); setState(cloned, 'active'); } }; const brushHandler = brush(root, { ...brushStyle, extent, brushRegion, reverse, selectedHandles, brushended: (emit) => { const handler = series ? seriesBrushend : brushended; if (emit) { emitter.emit('brush:remove', { nativeEvent: true }); } handler(); }, brushed: (x, y, x1, y1, emit) => { const selection = selectionOf(x, y, x1, y1, scale, coordinate); if (emit) { emitter.emit('brush:highlight', { nativeEvent: true, data: { selection }, }); } const handler = series ? seriesBrushed : brushed; handler(x, y, x1, y1); }, brushcreated: (x, y, x1, y1, event) => { const selection = selectionOf(x, y, x1, y1, scale, coordinate); emitter.emit('brush:end', { ...event, nativeEvent: true, data: { selection }, }); }, brushupdated: (x, y, x1, y1, event) => { const selection = selectionOf(x, y, x1, y1, scale, coordinate); emitter.emit('brush:end', { ...event, nativeEvent: true, data: { selection }, }); }, brushstarted: (e) => { emitter.emit('brush:start', e); }, }); // Move brush and highlight data. const onHighlight = ({ nativeEvent, data }) => { if (nativeEvent) return; const { selection } = data; const [x, y, x1, y1] = pixelsOf(selection, scale, coordinate); brushHandler.move(x, y, x1, y1, false); }; emitter.on('brush:highlight', onHighlight); // Remove brush and reset data. const onRemove = ({ nativeEvent }: any = {}) => { if (nativeEvent) return; brushHandler.remove(false); }; emitter.on('brush:remove', onRemove); // Remove event handlers. const preBrushDestroy = brushHandler.destroy.bind(brushHandler); brushHandler.destroy = () => { emitter.off('brush:highlight', onHighlight); emitter.off('brush:remove', onRemove); preBrushDestroy(); }; return brushHandler; } export function BrushHighlight({ facet, brushKey, ...rest }) { return (target, viewInstances, emitter) => { const { container, view, options } = target; const plotArea = selectPlotArea(container); const defaultOptions = { maskFill: '#777', maskFillOpacity: '0.3', maskStroke: '#fff', reverse: false, }; const defaultStates = ['active', ['inactive', { opacity: 0.5 }]]; const { scale, coordinate } = view; if (facet) { const bbox = plotArea.getBounds(); const x = bbox.min[0]; const y = bbox.min[1]; const x1 = bbox.max[0]; const y1 = bbox.max[1]; return brushHighlight(plotArea.parentNode.parentNode, { elements: () => selectFacetG2Elements(target, viewInstances), datum: createDatumof( selectFacetViews(target, viewInstances).map((d) => d.view), ), brushRegion: (x, y, x1, y1) => [x, y, x1, y1], extent: [x, y, x1, y1], state: mergeState( selectFacetViews(target, viewInstances).map((d) => d.options), defaultStates, ), emitter, scale, coordinate, selectedHandles: undefined, ...defaultOptions, ...rest, }); } const brush = brushHighlight(plotArea, { elements: selectG2Elements, key: (element) => element.__data__.key, siblings: () => selectSiblingContainers(target, viewInstances, brushKey), datum: createDatumof([ view, ...selectSiblingViews(target, viewInstances, brushKey).map( (d) => d.view, ), ]), brushRegion: (x, y, x1, y1) => [x, y, x1, y1], extent: undefined, state: mergeState( [options, ...selectSiblingOptions(target, viewInstances, brushKey)], defaultStates, ), emitter, scale, coordinate, selectedHandles: undefined, ...defaultOptions, ...rest, }); // Bind brush to the view it belongs to. //@ts-ignore plotArea.brush = brush; return () => brush.destroy(); }; }