UNPKG

@antv/g2

Version:

the Grammar of Graphics in Javascript

696 lines (638 loc) 18.2 kB
import { Coordinate } from '@antv/coord'; import type { DisplayObject } from '@antv/g'; import { Axis as AxisComponent } from '@antv/component'; import { Linear as LinearScale } from '@antv/scale'; import { deepMix, omit, upperFirst } from '@antv/util'; import { extent } from 'd3-array'; import { format } from 'd3-format'; import { BBox, G2Theme, GuideComponentComponent as GCC, GuideComponentOrientation as GCO, GuideComponentPosition as GCP, GuideComponentPlane, Scale, Vector3, } from '../runtime'; import { angleOf, isFisheye, isParallel, isPolar, isRadial, isTheta, isTranspose, radiusOf, } from '../utils/coordinate'; import { prettyNumber } from '../utils/number'; import { capitalizeFirst } from '../utils/helper'; import { adaptor, isVertical, titleContent } from './utils'; export type AxisOptions = { position?: GCP; plane?: GuideComponentPlane; zIndex?: number; title?: string | string[]; direction?: 'left' | 'center' | 'right'; labelFormatter?: (datum: any, index: number, array: any[]) => string; labelFilter?: (datum: any, index: number, array: any[]) => boolean; tickFormatter?: ( datum: any, index: number, array: any[], vector: [number, number], ) => DisplayObject; tickFilter?: (datum: any, index: number, array: any[]) => boolean; tickMethod?: ( start: number | Date, end: number | Date, tickCount: number, ) => number[]; tickCount?: number; grid: any; // options won't be overridden important: Record<string, any>; /** * Rotation origin. */ origin?: Vector3; /** * EulerAngles of rotation. */ eulerAngles?: Vector3; [key: string]: any; }; export function rotateAxis(axis: DisplayObject, options: AxisOptions) { const { eulerAngles, origin } = options; if (origin) { axis.setOrigin(origin); } if (eulerAngles) { axis.rotate(eulerAngles[0], eulerAngles[1], eulerAngles[2]); } } function sizeOf(coordinate: Coordinate): [number, number, number] { // @ts-ignore const { innerWidth, innerHeight, depth } = coordinate.getOptions(); return [innerWidth, innerHeight, depth]; } function createFisheye(position, coordinate) { const { width, height } = coordinate.getOptions(); return (tick) => { if (!isFisheye(coordinate)) return tick; const tickPoint = position === 'bottom' ? [tick, 1] : [0, tick]; const vector = coordinate.map(tickPoint); if (position === 'bottom') { const v = vector[0]; const x = new LinearScale({ domain: [0, width], range: [0, 1], }); return x.map(v); } else if (position === 'left') { const v = vector[1]; const x = new LinearScale({ domain: [0, height], range: [0, 1], }); return x.map(v); } return tick; }; } function ticksOf( scale: Scale, domain: any[], tickMethod: AxisOptions['tickMethod'], ) { if (scale.getTicks) return scale.getTicks(); if (!tickMethod) return domain; const [min, max] = extent(domain, (d) => +d); const { tickCount } = scale.getOptions(); return tickMethod(min, max, tickCount); } // Set inset for axis. function createInset(position, coordinate) { if (isPolar(coordinate)) return (d) => d; const options = coordinate.getOptions(); const { innerWidth, innerHeight, insetTop, insetBottom, insetLeft, insetRight, } = options; const [start, end, size] = position === 'left' || position === 'right' ? [insetTop, insetBottom, innerHeight] : [insetLeft, insetRight, innerWidth]; const x = new LinearScale({ domain: [0, 1], range: [start / size, 1 - end / size], }); return (i) => x.map(i); } /** * Calc ticks based on scale and coordinate. */ function getData( scale: Scale, domain: any[], tickCount: number, defaultTickFormatter: AxisOptions['labelFormatter'], tickFilter: AxisOptions['tickFilter'], tickMethod: AxisOptions['tickMethod'], position: GCP, coordinate: Coordinate, ) { if (tickCount !== undefined || tickMethod !== undefined) { scale.update({ ...(tickCount && { tickCount }), ...(tickMethod && { tickMethod }), }); } const ticks = ticksOf(scale, domain, tickMethod); const filteredTicks = tickFilter ? ticks.filter(tickFilter) : ticks; const toString = (d) => d instanceof Date ? String(d) : typeof d === 'object' && !!d ? d : String(d); const labelFormatter = defaultTickFormatter || scale.getFormatter?.() || toString; const applyInset = createInset(position, coordinate); const applyFisheye = createFisheye(position, coordinate); const isHorizontal = (position) => ['top', 'bottom', 'center', 'outer'].includes(position); const isVertical = (position) => ['left', 'right'].includes(position); // @todo GUI should consider the overlap problem for the first // and label of arc axis. if (isPolar(coordinate) || isTranspose(coordinate)) { return filteredTicks.map((d, i, array) => { const offset = scale.getBandWidth?.(d) / 2 || 0; const tick = applyInset(scale.map(d) + offset); const shouldReverse = (isRadial(coordinate) && position === 'center') || (isTranspose(coordinate) && scale.getTicks?.() && isHorizontal(position)) || (isTranspose(coordinate) && isVertical(position)); return { value: shouldReverse ? 1 - tick : tick, label: toString(labelFormatter(prettyNumber(d), i, array)), id: String(i), }; }); } return filteredTicks.map((d, i, array) => { const offset = scale.getBandWidth?.(d) / 2 || 0; const tick = applyFisheye(applyInset(scale.map(d) + offset)); const shouldReverse = isVertical(position); return { value: shouldReverse ? 1 - tick : tick, label: toString(labelFormatter(prettyNumber(d), i, array)), id: String(i), }; }); } function inferGridLength( position: GCP, coordinate: Coordinate, plane: GuideComponentPlane = 'xy', ) { const [width, height, depth] = sizeOf(coordinate); if (plane === 'xy') { if (position.includes('bottom') || position.includes('top')) return height; return width; } else if (plane === 'xz') { if (position.includes('bottom') || position.includes('top')) return depth; return width; } else { if (position.includes('bottom') || position.includes('top')) return height; return depth; } } function inferLabelOverlap(transform = [], style: Record<string, any>) { if (transform.length > 0) return transform; const { labelAutoRotate, labelAutoHide, labelAutoEllipsis, labelAutoWrap } = style; const finalTransforms = []; const addToTransforms = (overlap, state) => { if (state) { finalTransforms.push({ ...overlap, ...state }); } }; addToTransforms( { type: 'rotate', optionalAngles: [0, 15, 30, 45, 60, 90], }, labelAutoRotate, ); addToTransforms({ type: 'ellipsis', minLength: 20 }, labelAutoEllipsis); addToTransforms({ type: 'hide' }, labelAutoHide); addToTransforms( { type: 'wrap', wordWrapWidth: 100, maxLines: 3, recoveryWhenFail: true }, labelAutoWrap, ); return finalTransforms; } function inferArcStyle( position: GCP, bbox: BBox, innerRadius: number, outerRadius: number, coordinate: Coordinate, ) { const { x, y, width, height } = bbox; const center: [number, number] = [x + width / 2, y + height / 2]; const radius = Math.min(width, height) / 2; const [startAngle, endAngle] = angleOf(coordinate); const [w, h] = sizeOf(coordinate); const r = Math.min(w, h) / 2; const common = { center, radius, startAngle, endAngle, gridLength: (outerRadius - innerRadius) * r, }; if (position === 'inner') { // @ts-ignore const { insetLeft, insetTop } = coordinate.getOptions(); return { ...common, center: [center[0] - insetLeft, center[1] - insetTop], labelAlign: 'perpendicular', labelDirection: 'positive', tickDirection: 'positive', gridDirection: 'negative', }; } // arc outer return { ...common, labelAlign: 'parallel', labelDirection: 'negative', tickDirection: 'negative', gridDirection: 'positive', }; } function inferGrid(value: boolean, coordinate: Coordinate, scale: Scale) { if (isTheta(coordinate) || isParallel(coordinate)) return false; // Display axis grid for non-discrete values. return value === undefined ? !!scale.getTicks : value; } function infer3DAxisLinearOverrideStyle(coordinate: Coordinate) { // @ts-ignore const { depth } = coordinate.getOptions(); return depth ? { tickIsBillboard: true, lineIsBillboard: true, labelIsBillboard: true, titleIsBillboard: true, gridIsBillboard: true, } : {}; } function inferAxisLinearOverrideStyle( position: GCP, orientation: GCO, bbox: BBox, coordinate: Coordinate, xScale: Scale, ): { startPos?: [number, number]; endPos?: [number, number]; [k: string]: any; } { const { x, y, width, height } = bbox; if (position === 'bottom') { return { startPos: [x, y], endPos: [x + width, y] }; } if (position === 'left') { return { startPos: [x + width, y + height], endPos: [x + width, y] }; } if (position === 'right') { return { startPos: [x, y + height], endPos: [x, y] }; } if (position === 'top') { return { startPos: [x, y + height], endPos: [x + width, y + height] }; } // linear axis, maybe in parallel, polar, radial or radar systems. if (position === 'center') { // axisY if (orientation === 'vertical') { return { startPos: [x, y], endPos: [x, y + height], }; } // axisX else if (orientation === 'horizontal') { return { startPos: [x, y], endPos: [x + width, y], }; } // axis with rotate else if (typeof orientation === 'number') { const [cx, cy] = coordinate.getCenter(); const [innerRadius, outerRadius] = radiusOf(coordinate); const [startAngle, endAngle] = angleOf(coordinate); const r = Math.min(width, height) / 2; // @ts-ignore const { insetLeft, insetTop } = coordinate.getOptions(); const innerR = innerRadius * r; const outerR = outerRadius * r; const [actualCx, actualCy] = [cx + x - insetLeft, cy + y - insetTop]; const [cos, sin] = [Math.cos(orientation), Math.sin(orientation)]; const startPos: [number, number] = [ actualCx + outerR * cos, actualCy + outerR * sin, ]; const endPos: [number, number] = [ actualCx + innerR * cos, actualCy + innerR * sin, ]; const getAxisXDomainLength = () => { const { domain } = xScale.getOptions(); return domain.length; }; const controllAngleCount = isPolar(coordinate) && xScale ? getAxisXDomainLength() : 3; return { startPos, endPos, gridClosed: Math.abs(endAngle - startAngle - 360) < 1e-6, gridCenter: [actualCx, actualCy], gridControlAngles: new Array(controllAngleCount) .fill(0) .map( (d, i, arr) => ((endAngle - startAngle) / controllAngleCount) * i, ), }; } } // position is inner or outer for arc axis won't be here return {}; } const ArcAxisComponent: GCC<AxisOptions> = (options) => { const { order, size, position, orientation, labelFormatter, tickFilter, tickCount, tickMethod, important = {}, style = {}, indexBBox, title, grid = false, ...rest } = options; return ({ scales: [scale], value, coordinate, theme }) => { const { bbox } = value; const { domain } = scale.getOptions(); const data = getData( scale, domain, tickCount, labelFormatter, tickFilter, tickMethod, position, coordinate, ); // Bind computed bbox if exists. const labels = indexBBox ? data.map((d, i) => { const bbox = indexBBox.get(i); if (!bbox) return d; // bbox: [label, bbox] // Make than indexBBox can match current label. if (bbox[0] !== d.label) return d; return { ...d, bbox: bbox[1] }; }) : data; const [innerRadius, outerRadius] = radiusOf(coordinate); const defaultStyle = inferArcStyle( position, bbox, innerRadius, outerRadius, coordinate, ); const { axis: axisTheme, axisArc = {} } = theme; const finalStyle = adaptor( deepMix({}, axisTheme, axisArc, defaultStyle, { type: 'arc', data: labels, titleText: titleContent(title), grid, ...rest, ...important, }), ); return new AxisComponent({ // @fixme transform is not valid for arcAxis. // @ts-ignore style: omit(finalStyle, ['transform']), }) as unknown as DisplayObject; }; }; function inferThemeStyle( scale: Scale, coordinate: Coordinate, theme: G2Theme, direction, position: GCP, orientation: GCO, ) { const baseStyle = theme.axis; const positionStyle = ['top', 'right', 'bottom', 'left'].includes(position) ? theme[`axis${capitalizeFirst(position)}`] : theme.axisLinear; const channel = scale.getOptions().name; const channelStyle = theme[`axis${upperFirst(channel)}`] || {}; return Object.assign({}, baseStyle, positionStyle, channelStyle); } function inferDefaultStyle( scale: Scale, coordinate: Coordinate, theme: G2Theme, direction, position: GCP, orientation: GCO, ) { const themeStyle = inferThemeStyle( scale, coordinate, theme, direction, position, orientation, ); if (position === 'center') { return { ...themeStyle, labelDirection: direction === 'right' ? 'negative' : 'positive', ...(direction === 'center' ? { labelTransform: 'translate(50%,0)' } : null), tickDirection: direction === 'right' ? 'negative' : 'positive', labelSpacing: direction === 'center' ? 0 : 4, titleSpacing: isVertical(orientation) ? 10 : 0, tick: direction === 'center' ? false : undefined, }; } return themeStyle; } const LinearAxisComponent: GCC<AxisOptions> = (options) => { const { direction = 'left', important = {}, labelFormatter, order, orientation, actualPosition, position, size, style = {}, title, tickCount, tickFilter, tickMethod, transform, indexBBox, ...userDefinitions } = options; return ({ scales, value, coordinate, theme }) => { const { bbox } = value; const [scale] = scales; const { domain, xScale } = scale.getOptions(); const defaultStyle = inferDefaultStyle( scale, coordinate, theme, direction, position, orientation, ); const internalAxisStyle = { ...defaultStyle, ...style, ...userDefinitions, }; const gridLength = inferGridLength( actualPosition || position, coordinate, options.plane, ); const overrideStyle = inferAxisLinearOverrideStyle( position, orientation, bbox, coordinate, xScale, ); const threeDOverrideStyle = infer3DAxisLinearOverrideStyle(coordinate); const data = getData( scale, domain, tickCount, labelFormatter, tickFilter, tickMethod, position, coordinate, ); // Bind computed bbox if exists. const labels = indexBBox ? data.map((d, i) => { const bbox = indexBBox.get(i); if (!bbox) return d; // bbox: [label, bbox] // Make than indexBBox can match current label. if (bbox[0] !== d.label) return d; return { ...d, bbox: bbox[1] }; }) : data; const finalAxisStyle = { ...internalAxisStyle, type: 'linear' as const, data: labels, crossSize: size, titleText: titleContent(title), labelOverlap: inferLabelOverlap(transform, internalAxisStyle), grid: inferGrid(internalAxisStyle.grid, coordinate, scale), gridLength, // Always showLine, make title could align the end of axis. line: true, indexBBox, ...(!internalAxisStyle.line ? { lineOpacity: 0 } : null), ...overrideStyle, ...threeDOverrideStyle, ...important, }; // For hide overlap, do not set crossSize. const hasHide = finalAxisStyle.labelOverlap.find((d) => d.type === 'hide'); if (hasHide) finalAxisStyle.crossSize = false; return new AxisComponent({ className: 'axis', style: adaptor(finalAxisStyle), }) as unknown as DisplayObject; }; }; const axisFactor: ( axis: typeof ArcAxisComponent | typeof LinearAxisComponent, ) => GCC<AxisOptions> = (axis) => { return (options) => { const { labelFormatter: useDefinedLabelFormatter, labelFilter: userDefinedLabelFilter = () => true, } = options; return (context) => { const { scales: [scale], } = context; const ticks = scale.getTicks?.() || scale.getOptions().domain; const labelFormatter = typeof useDefinedLabelFormatter === 'string' ? format(useDefinedLabelFormatter) : useDefinedLabelFormatter; const labelFilter = (datum: any, index: number, array: any[]) => userDefinedLabelFilter(ticks[index], index, ticks); const normalizedOptions = { ...options, labelFormatter, labelFilter, scale, }; return axis(normalizedOptions)(context); }; }; }; export const LinearAxis = axisFactor(LinearAxisComponent); export const ArcAxis = axisFactor(ArcAxisComponent); LinearAxis.props = { defaultPosition: 'center', defaultSize: 45, defaultOrder: 0, defaultCrossPadding: [12, 12], defaultPadding: [12, 12], }; ArcAxis.props = { defaultPosition: 'outer', defaultOrientation: 'vertical', defaultSize: 45, defaultOrder: 0, defaultCrossPadding: [12, 12], defaultPadding: [12, 12], };