import { CustomEvent } from '@antv/g'; import { Linear } from '@antv/scale'; import { clamp, isUndefined } from '@antv/util'; import { Component } from '../../core'; import type { DisplayObject, TextStyleProps } from '../../shapes'; import { Group } from '../../shapes'; import { Point } from '../../types'; import { BBox, Selection, deepAssign, getEventPos, hide, ifShow, select, show, subStyleProps, superStyleProps, toPrecision, } from '../../util'; import type { LinearAxisStyleProps } from '../axis'; import { Axis } from '../axis'; import { CLASS_NAMES as AXIS_CLASS_NAMES } from '../axis/constant'; import type { IndicatorStyleProps } from '../indicator'; import { Indicator } from '../indicator'; import { Handle as SliderHandle } from '../slider/handle'; import { Title } from './title'; import { CLASS_NAMES, CONTINUOUS_DEFAULT_OPTIONS, STEP_RATIO } from './constant'; import type { HandleStyleProps, HandleType } from './continuous/handle'; import { Handle } from './continuous/handle'; import type { RibbonStyleProps } from './continuous/ribbon'; import { Ribbon } from './continuous/ribbon'; import { getNextTickValue } from './continuous/utils'; import { ContinuousDatum, ContinuousOptions, ContinuousStyleProps } from './types'; import { getSafetySelections, getStepValueByValue, ifHorizontal } from './utils'; export type { ContinuousOptions, ContinuousStyleProps }; function getMinMax(data: ContinuousDatum[]) { return { min: Math.min(...data.map((d) => d.value)), max: Math.max(...data.map((d) => d.value)), }; } export class Continuous extends Component { constructor(options: ContinuousOptions) { super(options, CONTINUOUS_DEFAULT_OPTIONS); } protected eventToOffsetScale = new Linear({}); protected innerRibbonScale = new Linear({}); protected title?: Title; protected label!: Axis; protected ribbon!: Selection; protected indicator!: Selection; protected get handleOffsetRatio() { return this.ifHorizontal(0.5, 0.5); } protected handlesGroup!: Selection; protected startHandle?: Handle; protected endHandle?: Handle; public getBBox(): DOMRect { const { width, height } = this.attributes; return new BBox(0, 0, width, height); } public render(attributes: Required, container: Group) { // 渲染顺序 // 1. 绘制 title, 获得可用空间 // 2. 绘制 label, handle // 3. 基于可用空间、label高度、handle 宽高,计算 ribbon 宽高 // 4. 绘制 ribbon // 5. 调整 label、handle 位置 const { showLabel } = attributes; /** title */ this.renderTitle(select(container)); const { x, y } = this.availableSpace; /** label */ /** content */ const contentGroup = select(container) .maybeAppendByClassName(CLASS_NAMES.contentGroup, 'g') .styles({ transform: `translate(${x}, ${y})` }); const labelGroup = contentGroup.maybeAppendByClassName(CLASS_NAMES.labelGroup, 'g').styles({ zIndex: 1 }); ifShow(!!showLabel, labelGroup, (group) => { this.renderLabel(group); }); const ribbonGroup = contentGroup.maybeAppendByClassName(CLASS_NAMES.ribbonGroup, 'g').styles({ zIndex: 0 }); /** handle */ this.handlesGroup = contentGroup.maybeAppendByClassName(CLASS_NAMES.handlesGroup, 'g').styles({ zIndex: 2 }); this.renderHandles(); /** ribbon */ this.renderRibbon(ribbonGroup); this.renderIndicator(contentGroup); /** adjust */ this.adjustLabel(); this.adjustHandles(); // this.adjustTitle(); } private get range() { const { data, domain } = this.attributes; return domain ? { min: domain[0], max: domain[1] } : getMinMax(data); } private get ribbonScale() { const { min, max } = this.range; this.innerRibbonScale.update({ domain: [min, max], range: [0, 1], }); return this.innerRibbonScale; } private get ribbonRange() { const [min, max] = this.selection; const scale = this.ribbonScale; return [scale.map(min), scale.map(max)]; } public get selection() { const { min, max } = this.range; const { defaultValue: [start, end] = [min, max] } = this.attributes; return [start, end] as [number, number]; } protected ifHorizontal(a: T, b: T): T { return ifHorizontal( this.attributes.orientation, typeof a === 'function' ? a() : a, typeof b === 'function' ? b() : b ); } private renderTitle(container: Selection) { const { showTitle, titleText, width, height } = this.attributes; const style = subStyleProps(this.attributes, 'title'); const finalTitleStyle = { ...style, width, height, text: titleText }; const that = this; container .selectAll(CLASS_NAMES.title.class) .data(showTitle ? [titleText] : []) .join( (enter) => enter .append(() => new Title({ style: finalTitleStyle })) .attr('className', CLASS_NAMES.title.name) .each(function () { that.title = this; }), (update) => update.update(finalTitleStyle), (exit) => exit .each(() => { that.title = undefined; }) .remove() ); } private get availableSpace() { if (this.title) return this.title.getAvailableSpace(); const { width, height } = this.attributes; return new BBox(0, 0, width, height); } private get labelFixedSpacing() { const { showTick } = this.attributes; return showTick ? 5 : 0; } private get labelPosition() { const { orientation, labelDirection } = this.attributes; const positions = { vertical: { positive: 'right', negative: 'left' }, horizontal: { positive: 'bottom', negative: 'top' }, } as const; return positions[orientation][labelDirection]; } private cacheLabelBBox: DOMRect | null = null; private get labelBBox() { const { showLabel } = this.attributes; if (!showLabel) return new BBox(0, 0, 0, 0); if (this.cacheLabelBBox) return this.cacheLabelBBox; const { width, height } = ( this.label.querySelector(AXIS_CLASS_NAMES.labelGroup.class)?.children.slice(-1)[0] as DisplayObject ).getBBox(); this.cacheLabelBBox = new BBox(0, 0, width, height); return this.cacheLabelBBox; } private get labelShape() { const { showLabel, labelSpacing = 0 } = this.attributes; if (!showLabel) return { width: 0, height: 0, size: 0, length: 0 }; const { width, height } = this.labelBBox; const size = this.ifHorizontal(height, width) + labelSpacing + this.labelFixedSpacing; const length = this.ifHorizontal(width, height); return { width, height, size, length }; } private get ribbonBBox(): DOMRect { const { showHandle, ribbonSize: userDefinedRibbonSize } = this.attributes; const { width: availableWidth, height: availableHeight } = this.availableSpace; const { size: labelSize, length: labelLength } = this.labelShape; const [availableSize, availableLength] = this.ifHorizontal( [availableHeight, availableWidth], [availableWidth, availableHeight] ); const { size: handleSize, length: handleLength } = showHandle ? this.handleShape : { size: 0, length: 0 }; const handleRatio = this.handleOffsetRatio; let ribbonSize = 0; const labelPosition = this.labelPosition; if (userDefinedRibbonSize) { ribbonSize = userDefinedRibbonSize; } else if (['bottom', 'right'].includes(labelPosition)) { ribbonSize = Math.min(availableSize - labelSize, (availableSize - handleSize) / handleRatio); } else if (availableSize * (1 - handleRatio) > handleSize) { ribbonSize = Math.max(availableSize - labelSize, 0); } else ribbonSize = Math.max((availableSize - labelSize - handleSize) / handleRatio, 0); const edgeLength = Math.max(handleLength, labelLength); const ribbonLength = availableLength - edgeLength; const [width, height] = this.ifHorizontal([ribbonLength, ribbonSize], [ribbonSize, ribbonLength]); // 需要考虑 handle 的占用空间 // todo 为了防止因为 handle 文本变化导致的 ribbon 位置变化,handle size 取最大值 const finalLabelOccupy = ['top', 'left'].includes(labelPosition) ? labelSize : 0; const [x, y] = this.ifHorizontal([edgeLength / 2, finalLabelOccupy], [finalLabelOccupy, edgeLength / 2]); return new BBox(x, y, width, height); } private get ribbonShape() { const { width, height } = this.ribbonBBox; return this.ifHorizontal({ size: height, length: width }, { size: width, length: height }); } private renderRibbon(container: Selection) { const { data, type, orientation, color, block } = this.attributes; const ribbonStyle = subStyleProps(this.attributes, 'ribbon'); const { min, max } = this.range; const { x, y } = this.ribbonBBox; const { length, size } = this.ribbonShape; const style: Required = deepAssign( { transform: `translate(${x}, ${y})`, length, size, type, orientation, color, block, partition: data.map((d) => (d.value - min) / (max - min)), range: this.ribbonRange, }, ribbonStyle ); this.ribbon = container.maybeAppendByClassName(CLASS_NAMES.ribbon, () => new Ribbon({ style })).update(style); } private getHandleClassName(type: HandleType) { // @ts-ignore return `${CLASS_NAMES.prefix(`${type}-handle`)}`; } private renderHandles() { const { showHandle, orientation } = this.attributes; const handleStyle = subStyleProps(this.attributes, 'handle'); const [min, max] = this.selection; const style = { ...handleStyle, orientation }; const { shape = 'slider' } = handleStyle; const HandleCtor = shape === 'basic' ? Handle : SliderHandle; const that = this; this.handlesGroup .selectAll(CLASS_NAMES.handle.class) .data( showHandle ? [ { value: min, type: 'start' }, { value: max, type: 'end' }, ] : [], (d) => d.type ) .join( (enter) => enter .append(() => new HandleCtor({ style })) .attr( 'className', ({ type }: any) => `${CLASS_NAMES.handle} ${that.getHandleClassName(type as HandleType)}` ) .each(function ({ type, value: labelText }) { this.update({ labelText }); const name = `${type}Handle` as `${HandleType}Handle`; that[name] = this; this.addEventListener('pointerdown', that.onDragStart(type)); }), (update) => update.update(style).each(function ({ value: labelText }) { this.update({ labelText }); }), (exit) => exit .each(({ type }) => { const name = `${type}Handle` as `${HandleType}Handle`; that[name] = undefined; }) .remove() ); } private adjustHandles() { const [min, max] = this.selection; this.setHandlePosition('start', min); this.setHandlePosition('end', max); } private cacheHandleBBox: DOMRect | null = null; private get handleBBox() { if (this.cacheHandleBBox) return this.cacheHandleBBox; if (!this.attributes.showHandle) return new BBox(0, 0, 0, 0); const { width: startHandleWidth, height: startHandleHeight } = this.startHandle!.getBBox(); const { width: endHandleWidth, height: endHandleHeight } = this.endHandle!.getBBox(); const [width, height] = [Math.max(startHandleWidth, endHandleWidth), Math.max(startHandleHeight, endHandleHeight)]; this.cacheHandleBBox = new BBox(0, 0, width, height); return this.cacheHandleBBox; } /** * 因为 handle label 的宽高是动态的,所以 handle bbox 是第一次渲染时的 bbox */ private get handleShape() { const { width, height } = this.handleBBox; const [size, length] = this.ifHorizontal([height, width], [width, height]); return { width, height, size, length }; } private setHandlePosition(type: HandleType, value: number) { const { handleFormatter } = this.attributes; const { x: ribbonX, y: ribbonY } = this.ribbonBBox; const { size: ribbonSize } = this.ribbonShape; const offset = this.getOffset(value); const [x, y] = this.ifHorizontal( [ribbonX + offset, ribbonY + ribbonSize * this.handleOffsetRatio], [ribbonX + ribbonSize * this.handleOffsetRatio, ribbonY + offset] ); const handle: Handle = this.handlesGroup.select(`.${this.getHandleClassName(type)}`).node(); handle?.update({ transform: `translate(${x}, ${y})`, formatter: handleFormatter }); } private renderIndicator(container: Selection) { const style = subStyleProps(this.attributes, 'indicator'); this.indicator = container.maybeAppendByClassName(CLASS_NAMES.indicator, () => new Indicator({})).update(style); // this.hideIndicator(); } private get labelData(): ContinuousDatum[] { const { data } = this.attributes; return data.reduce((acc, curr, index, arr) => { const id = curr?.id ?? index.toString(); acc.push({ ...curr, id, index, type: 'value', label: curr?.label ?? curr.value.toString(), value: this.ribbonScale.map(curr.value), }); if (index < arr.length - 1) { const next = arr[index + 1]; const [cr, nx] = [curr.value, next.value]; const midVal = (cr + nx) / 2; acc.push({ ...curr, id, index, type: 'range', range: [cr, nx], label: [cr, nx].join('~'), value: this.ribbonScale.map(midVal), }); } return acc; }, [] as ContinuousDatum[]); } private get labelStyle() { let [labelTextAlign, labelTextBaseline] = ['center', 'middle']; const labelPosition = this.labelPosition; if (labelPosition === 'top') labelTextBaseline = 'bottom'; else if (labelPosition === 'bottom') labelTextBaseline = 'top'; else if (labelPosition === 'left') labelTextAlign = 'end'; else if (labelPosition === 'right') labelTextAlign = 'start'; return { labelTextAlign, labelTextBaseline, }; } private renderLabel(container: Selection) { const { showTick = false, labelFilter, labelFormatter } = this.attributes; const tickStyle = subStyleProps(this.attributes, 'tick'); const labelStyle = subStyleProps(this.attributes, 'label'); const { align } = labelStyle; const style = deepAssign( { showLine: false, showGrid: false, showTick, type: 'linear', startPos: [0, 0], endPos: [0, 0], tickDirection: 'negative', labelTransform: 'rotate(0)', ...this.labelStyle, }, superStyleProps(tickStyle, 'tick'), superStyleProps(labelStyle, 'label'), { data: this.labelData } ) as LinearAxisStyleProps; const functionStyle = { tickFilter: (datum: ContinuousDatum, index: number, data: ContinuousDatum[]) => { if (datum?.type !== 'value') return false; if (labelFilter) return labelFilter( datum, datum.index, data.filter((d) => d.type !== 'value') ); return true; }, labelFilter: (datum: ContinuousDatum, index: number, data: ContinuousDatum[]) => { if (datum?.type !== align) return false; if (labelFilter) return labelFilter( datum, datum.index, data.filter((d) => d.type === align) ); return true; }, labelFormatter, }; const finalLabelStyle = { ...style, ...functionStyle, labelOverlap: [{ type: 'hide' }] } as LinearAxisStyleProps; this.label = container.maybeAppendByClassName(CLASS_NAMES.label, () => new Axis({ style: finalLabelStyle })).node(); this.label.update(finalLabelStyle, false); } private get labelAxisStyle() { // @ts-ignore const { showTick, labelDirection, labelSpacing, tickLength: definedTickLength } = this.attributes; const { size: ribbonSize } = this.ribbonShape; const labelPosition = this.labelPosition; const labelFixedSpacing = this.labelFixedSpacing; let [offset, spacing, tickLength] = [0, 0, 0]; const internalVal = definedTickLength ?? ribbonSize; if (showTick) { tickLength = internalVal; spacing = labelFixedSpacing; if (labelDirection === 'positive') { if (labelPosition === 'right') { offset = internalVal; tickLength = internalVal; } else if (labelPosition === 'bottom') offset = tickLength; } else if (labelDirection === 'negative') { if (labelPosition === 'top') offset = ribbonSize; else if (labelPosition === 'left') offset = ribbonSize; } } else if (labelDirection === 'positive') { if (labelPosition === 'right') spacing = internalVal; else if (labelPosition === 'bottom') { offset = ribbonSize + labelFixedSpacing; spacing = labelSpacing; } } else if (labelDirection === 'negative') { if (labelPosition === 'left') spacing = labelSpacing; else if (labelPosition === 'top') spacing = labelSpacing; } return { offset, spacing, tickLength }; } private adjustLabel() { const { showLabel } = this.attributes as Required; if (!showLabel) return; const { x, y, width, height } = this.ribbonBBox; const { offset: axisOffset, spacing: axisSpacing, tickLength: axisTickLength } = this.labelAxisStyle; const [startPos, endPos]: [[number, number], [number, number]] = this.ifHorizontal( [ [x, y + axisOffset], [x + width, y + axisOffset], ], [ [x + axisOffset, y + height], [x + axisOffset, y], ] ); this.label.update( { startPos, endPos, tickLength: axisTickLength, labelSpacing: axisSpacing, }, false ); } /** 当前交互的对象 */ private target!: string | undefined; /** 上次鼠标事件的位置 */ private prevValue!: number; public bindEvents() { this.style.cursor = 'pointer'; // 绑定 drag 开始事件 this.ribbon.on('pointerdown', this.onDragStart('ribbon')); this.ribbon.on('pointermove', this.onHovering); this.addEventListener('pointerout', this.hideIndicator); } private onHovering = (e: any) => { const { data, block } = this.attributes; e.stopPropagation(); const value = this.getValueByCanvasPoint(e); if (block) { const { range } = getNextTickValue( data.map(({ value }) => value), value ); const selection = this.getRealSelection(range); this.showIndicator((range[0] + range[1]) / 2, `${selection[0]}-${selection[1]}`); this.dispatchIndicated(value, range); } else { const safetyValue = this.getTickValue(value); this.showIndicator(safetyValue, `${this.getRealValue(safetyValue)}`); this.dispatchIndicated(safetyValue); } }; public showIndicator(value: number, text = `${value}`) { const { showIndicator } = this.attributes; if (!showIndicator || typeof value !== 'number') { this.hideIndicator(); return; } const { min, max } = this.range; const { x, y } = this.ribbonBBox; const safeValue = clamp(value, min, max); const offset = this.getOffset(safeValue); const pos: Point = this.ifHorizontal([offset + x, y], [x, offset + y]); this.indicator.update({ x: pos[0], y: pos[1], position: this.ifHorizontal('top', 'left'), labelText: text, }); show(this.indicator.node()); } private hideIndicator() { hide(this.indicator.node()); } private onDragStart = (target: string) => (e: any) => { e.stopPropagation(); // 关闭滑动 if (!this.attributes.slidable) return; this.target = target; this.prevValue = this.getTickValue(this.getValueByCanvasPoint(e)); document.addEventListener('mousemove', this.onDragging); document.addEventListener('touchmove', this.onDragging); document.addEventListener('mouseleave', this.onDragEnd); document.addEventListener('mouseup', this.onDragEnd); document.addEventListener('mouseup', this.onDragEnd); document.addEventListener('touchend', this.onDragEnd); }; private onDragging = (e: any) => { const { target } = this; this.updateMouse(); const [start, end] = this.selection; const currValue = this.getTickValue(this.getValueByCanvasPoint(e)); const diffValue = currValue - this.prevValue; if (target === 'start') start !== currValue && this.updateSelection(currValue, end); else if (target === 'end') end !== currValue && this.updateSelection(start, currValue); else if (target === 'ribbon' && diffValue !== 0) { this.prevValue = currValue; this.updateSelection(diffValue, diffValue, true); } }; private onDragEnd = () => { this.style.cursor = 'pointer'; document.removeEventListener('mousemove', this.onDragging); document.removeEventListener('touchmove', this.onDragging); document.removeEventListener('mouseup', this.onDragEnd); document.removeEventListener('touchend', this.onDragEnd); }; private updateMouse() { if (this.attributes.slidable) this.style.cursor = 'grabbing'; } public setSelection(start: number, end: number) { this.updateSelection(start, end); } private updateSelection(stVal: number, endVal: number, isOffset: boolean = false) { const [currSt, currEnd] = this.selection; let [start, end] = [stVal, endVal]; if (isOffset) { // 获取当前值 start += currSt; end += currEnd; } // 值校验 const { min, max } = this.range; [start, end] = getSafetySelections([min, max], [start, end], this.selection); this.update({ defaultValue: [start, end] }); this.dispatchSelection(); } private get step(): number { const { step = 1 } = this.attributes; const { min, max } = this.range; if (isUndefined(step)) return toPrecision((max - min) * STEP_RATIO, 0); return step; } private getTickValue(value: number): number { const { data, block } = this.attributes; const { min } = this.range; if (block) return getNextTickValue( data.map(({ value }) => value), value ).tick; return getStepValueByValue(value, this.step, min); } /** * 事件触发的位置对应的value值 */ private getValueByCanvasPoint(e: any) { const { min, max } = this.range; const [x, y] = this.ribbon.node().getPosition(); const startPos = this.ifHorizontal(x, y); const currValue = this.ifHorizontal(...getEventPos(e)); const offset = currValue - startPos; const value = clamp(this.getOffset(offset, true), min, max); return value; } /** reverse: 屏幕偏移量 -> 值 */ private getOffset(value: number, reverse = false) { const { min, max } = this.range; const { length: ribbonLen } = this.ribbonShape; const scale = this.eventToOffsetScale; scale.update({ domain: [min, max], range: [0, ribbonLen] }); if (reverse) return scale.invert(value); return scale.map(value); } private getRealSelection(range: number[]) { const { max } = this.range; const [start, end] = range; return this.ifHorizontal([start, end], [max - end, max - start]); } private getRealValue(value: number) { const { max } = this.range; return this.ifHorizontal(value, max - value); } private dispatchSelection() { const selection = this.getRealSelection(this.selection); const evt = new CustomEvent('valuechange', { detail: { value: selection, }, }); this.dispatchEvent(evt as any); } private dispatchIndicated(value: number, range?: number[]) { const { max } = this.range; const detail = this.ifHorizontal( () => { return { value, range, }; }, () => { return { value: max - value, range: range ? this.getRealSelection(range) : undefined, }; } ); const evt = new CustomEvent('indicate', { detail, }); this.dispatchEvent(evt as any); } }