import { contains, deepMix, each, get, isArray, isFunction, isNil, isString, keys, upperFirst, find, includes, } from '@antv/util'; import { Annotation as AnnotationComponent, IElement, IGroup } from '../../dependents'; import { AnnotationBaseOption as BaseOption, AnnotationPosition as Position, ArcOption, ComponentOption, ShapeAnnotationOption, Data, DataMarkerOption, DataRegionOption, Datum, HtmlAnnotationOption, ImageOption, LineOption, Point, RegionFilterOption, RegionOption, RegionPositionBaseOption, TextOption, } from '../../interface'; import { DEFAULT_ANIMATE_CFG } from '../../animate/'; import { COMPONENT_TYPE, DIRECTION, GEOMETRY_LIFE_CIRCLE, LAYER, VIEW_LIFE_CIRCLE } from '../../constant'; import Geometry from '../../geometry/base'; import Element from '../../geometry/element'; import { getAngleByPoint, getDistanceToCenter } from '../../util/coordinate'; import { omit } from '../../util/helper'; import { getNormalizedValue } from '../../util/annotation'; import View from '../view'; import { Controller } from './base'; import { Scale } from '@antv/attr'; /** 需要在图形绘制完成后才渲染的辅助组件类型列表 */ const ANNOTATIONS_AFTER_RENDER = ['regionFilter', 'shape']; /** * Annotation controller, 主要作用: * 1. 创建 Annotation: line、text、arc ... * 2. 生命周期: init、layout、render、clear、destroy */ export default class Annotation extends Controller { private foregroundContainer: IGroup; private backgroundContainer: IGroup; /* 组件更新的 cache,组件配置 object : 组件 */ private cache = new Map(); constructor(view: View) { super(view); this.foregroundContainer = this.view.getLayer(LAYER.FORE).addGroup(); this.backgroundContainer = this.view.getLayer(LAYER.BG).addGroup(); this.option = []; } public get name(): string { return 'annotation'; } public init() { } /** * 因为 annotation 需要依赖坐标系信息,所以 render 阶段为空方法,实际的创建逻辑都在 layout 中 */ public layout() { this.update(); } // 因为 Annotation 不参与布局,但是渲染的位置依赖于坐标系,所以可以将绘制阶段延迟到 layout() 进行 public render() { } /** * 更新 */ public update() { // 1. 先处理需要在图形渲染之后的辅助组件 需要在 Geometry 完成之后,拿到图形信息 this.onAfterRender(() => { const updated = new Map(); // 先看是否有 regionFilter/shape 要更新 each(this.option, (option: BaseOption) => { if (includes(ANNOTATIONS_AFTER_RENDER, option.type)) { const co = this.updateOrCreate(option); // 存储已经处理过的 if (co) { updated.set(this.getCacheKey(option), co); } } }); // 处理完成之后,更新 cache // 处理完成之后,销毁删除的 this.cache = this.syncCache(updated); }); // 2. 处理非 regionFilter const updateCache = new Map(); each(this.option, (option: BaseOption) => { if (!includes(ANNOTATIONS_AFTER_RENDER, option.type)) { const co = this.updateOrCreate(option); // 存储已经处理过的 if (co) { updateCache.set(this.getCacheKey(option), co); } } }); this.cache = this.syncCache(updateCache); } /** * 清空 * @param includeOption 是否清空 option 配置项 */ public clear(includeOption = false) { super.clear(); this.clearComponents(); this.foregroundContainer.clear(); this.backgroundContainer.clear(); // clear all option if (includeOption) { this.option = []; } } public destroy() { this.clear(true); this.foregroundContainer.remove(true); this.backgroundContainer.remove(true); } /** * 复写基类的方法 */ public getComponents(): ComponentOption[] { const co = []; this.cache.forEach((value: ComponentOption) => { co.push(value); }); return co; } /** * 清除当前的组件 */ private clearComponents() { this.getComponents().forEach((co) => { co.component.destroy(); }); this.cache.clear(); } /** * region filter 比较特殊的渲染时机 * @param doWhat */ private onAfterRender(doWhat: () => void) { let done = false; if (this.view.getOptions().animate) { this.view.geometries.forEach((g: Geometry) => { // 如果 geometry 开启,则监听 if (g.animateOption) { g.once(GEOMETRY_LIFE_CIRCLE.AFTER_DRAW_ANIMATE, () => { doWhat(); }); done = true; } }); } if (!done) { this.view.getRootView().once(VIEW_LIFE_CIRCLE.AFTER_RENDER, () => { doWhat(); }); } } private createAnnotation(option: BaseOption) { const { type } = option; const Ctor = AnnotationComponent[upperFirst(type)]; if (Ctor) { const theme = this.getAnnotationTheme(type); const cfg = this.getAnnotationCfg(type, option, theme); // 不创建 if (!cfg) { return null; } const annotation = new Ctor(cfg); return { component: annotation, layer: this.isTop(cfg) ? LAYER.FORE : LAYER.BG, direction: DIRECTION.NONE, type: COMPONENT_TYPE.ANNOTATION, extra: option, }; } } // APIs for creating annotation component public annotation(option: any) { this.option.push(option); } /** * 创建 Arc * @param option * @returns AnnotationController */ public arc(option: ArcOption) { this.annotation({ type: 'arc', ...option, }); return this; } /** * 创建 image * @param option * @returns AnnotationController */ public image(option: ImageOption) { this.annotation({ type: 'image', ...option, }); return this; } /** * 创建 Line * @param option * @returns AnnotationController */ public line(option: LineOption) { this.annotation({ type: 'line', ...option, }); return this; } /** * 创建 Region * @param option * @returns AnnotationController */ public region(option: RegionOption) { this.annotation({ type: 'region', ...option, }); return this; } /** * 创建 Text * @param option * @returns AnnotationController */ public text(option: TextOption) { this.annotation({ type: 'text', ...option, }); return this; } /** * 创建 DataMarker * @param option * @returns AnnotationController */ public dataMarker(option: DataMarkerOption) { this.annotation({ type: 'dataMarker', ...option, }); return this; } /** * 创建 DataRegion * @param option * @returns AnnotationController */ public dataRegion(option: DataRegionOption) { this.annotation({ type: 'dataRegion', ...option, }); } /** * 创建 RegionFilter * @param option * @returns AnnotationController */ public regionFilter(option: RegionFilterOption) { this.annotation({ type: 'regionFilter', ...option, }); } /** * 创建 ShapeAnnotation * @param option */ public shape(option: ShapeAnnotationOption) { this.annotation({ type: 'shape', ...option, }); } /** * 创建 HtmlAnnotation * @param option */ public html(option: HtmlAnnotationOption) { this.annotation({ type: 'html', ...option, }); } // end API /** * parse the point position to [x, y] * @param p Position * @returns { x, y } */ private parsePosition( p: | [string | number, string | number] | Datum | ((xScale: Scale, yScale: Scale) => [string | number, string | number] | number | Datum) ): Point { const xScale = this.view.getXScale(); // 转成 object const yScales = this.view.getScalesByDim('y'); const position: Position = isFunction(p) ? p.call(null, xScale, yScales) : p; let x = 0; let y = 0; // 入参是 [24, 24] 这类时 if (isArray(position)) { const [xPos, yPos] = position; // 如果数据格式是 ['50%', '50%'] 的格式 // fix: 原始数据中可能会包含 'xxx5%xxx' 这样的数据,需要判断下 https://github.com/antvis/f2/issues/590 // @ts-ignore if (isString(xPos) && xPos.indexOf('%') !== -1 && !isNaN(xPos.slice(0, -1))) { return this.parsePercentPosition(position as [string, string]); } x = getNormalizedValue(xPos, xScale); y = getNormalizedValue(yPos, Object.values(yScales)[0]); } else if (!isNil(position)) { // 入参是 object 结构,数据点 for (const key of keys(position)) { const value = position[key]; if (key === xScale.field) { x = getNormalizedValue(value, xScale); } if (yScales[key]) { y = getNormalizedValue(value, yScales[key]); } } } if (isNaN(x) || isNaN(y)) { return null; } return this.view.getCoordinate().convert({ x, y }); } /** * parse all the points between start and end * @param start * @param end * @return Point[] */ private getRegionPoints(start: Position | Data, end: Position | Data): Point[] { const xScale = this.view.getXScale(); const yScales = this.view.getScalesByDim('y'); const yScale = Object.values(yScales)[0]; const xField = xScale.field; const viewData = this.view.getData(); const startXValue = isArray(start) ? start[0] : start[xField]; const endXValue = isArray(end) ? end[0] : end[xField]; const arr = []; let startIndex; each(viewData, (item, idx) => { if (item[xField] === startXValue) { startIndex = idx; } if (idx >= startIndex) { const point = this.parsePosition([item[xField], item[yScale.field]]); if (point) { arr.push(point); } } if (item[xField] === endXValue) { return false; } }); return arr; } /** * parse percent position * @param position */ private parsePercentPosition(position: [string, string]): Point { const xPercent = parseFloat(position[0]) / 100; const yPercent = parseFloat(position[1]) / 100; const coordinate = this.view.getCoordinate(); const { start, end } = coordinate; const topLeft = { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), }; const x = coordinate.getWidth() * xPercent + topLeft.x; const y = coordinate.getHeight() * yPercent + topLeft.y; return { x, y }; } /** * get coordinate bbox */ private getCoordinateBBox() { const coordinate = this.view.getCoordinate(); const { start, end } = coordinate; const width = coordinate.getWidth(); const height = coordinate.getHeight(); const topLeft = { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), }; return { x: topLeft.x, y: topLeft.y, minX: topLeft.x, minY: topLeft.y, maxX: topLeft.x + width, maxY: topLeft.y + height, width, height, }; } /** * get annotation component config by different type * @param type * @param option 用户的配置 * @param theme */ private getAnnotationCfg(type: string, option: any, theme: object): object | null { const coordinate = this.view.getCoordinate(); const canvas = this.view.getCanvas(); let o = {}; if (isNil(option)) { return null; } const { start, end, position } = option; const sp = this.parsePosition(start); const ep = this.parsePosition(end); const textPoint = this.parsePosition(position); if (['arc', 'image', 'line', 'region', 'regionFilter'].includes(type) && (!sp || !ep)) { return null; } else if (['text', 'dataMarker', 'html'].includes(type) && !textPoint) { return null; } if (type === 'arc') { const { start, end, ...rest } = option as ArcOption; const startAngle = getAngleByPoint(coordinate, sp); let endAngle = getAngleByPoint(coordinate, ep); if (startAngle > endAngle) { endAngle = Math.PI * 2 + endAngle; } o = { ...rest, center: coordinate.getCenter(), radius: getDistanceToCenter(coordinate, sp), startAngle, endAngle, }; } else if (type === 'image') { const { start, end, ...rest } = option as ImageOption; o = { ...rest, start: sp, end: ep, src: option.src, }; } else if (type === 'line') { const { start, end, ...rest } = option as LineOption; o = { ...rest, start: sp, end: ep, text: get(option, 'text', null), }; } else if (type === 'region') { const { start, end, ...rest } = option as RegionPositionBaseOption; o = { ...rest, start: sp, end: ep, }; } else if (type === 'text') { const filteredData = this.view.getData(); const { position, content, ...rest } = option as TextOption; let textContent = content; if (isFunction(content)) { textContent = content(filteredData); } o = { ...textPoint, ...rest, content: textContent, }; } else if (type === 'dataMarker') { const { position, point, line, text, autoAdjust, direction, ...rest } = option as DataMarkerOption; o = { ...rest, ...textPoint, coordinateBBox: this.getCoordinateBBox(), point, line, text, autoAdjust, direction, }; } else if (type === 'dataRegion') { const { start, end, region, text, lineLength, ...rest } = option as DataRegionOption; o = { ...rest, points: this.getRegionPoints(start, end), region, text, lineLength, }; } else if (type === 'regionFilter') { const { start, end, apply, color, ...rest } = option as RegionFilterOption; const geometries: Geometry[] = this.view.geometries; const shapes = []; const addShapes = (item?: IElement) => { if (!item) { return; } if (item.isGroup()) { (item as IGroup).getChildren().forEach((child) => addShapes(child)); } else { shapes.push(item); } }; each(geometries, (geom: Geometry) => { if (apply) { if (contains(apply, geom.type)) { each(geom.elements, (elem: Element) => { addShapes(elem.shape); }); } } else { each(geom.elements, (elem: Element) => { addShapes(elem.shape); }); } }); o = { ...rest, color, shapes, start: sp, end: ep, }; } else if (type === 'shape') { const { render, ...restOptions } = option as ShapeAnnotationOption; const wrappedRender = (container: IGroup) => { if (isFunction(option.render)) { return render(container, this.view, { parsePosition: this.parsePosition.bind(this) }); } }; o = { ...restOptions, render: wrappedRender, }; } else if (type === 'html') { const { html, position, ...restOptions } = option as HtmlAnnotationOption; const wrappedHtml = (container: HTMLElement) => { if (isFunction(html)) { return html(container, this.view); } return html; }; o = { ...restOptions, ...textPoint, // html 组件需要指定 parent parent: canvas.get('el').parentNode, html: wrappedHtml, }; } // 合并主题,用户配置优先级高于默认主题 const cfg = deepMix({}, theme, { ...o, top: option.top, style: option.style, offsetX: option.offsetX, offsetY: option.offsetY, }); if (type !== 'html') { // html 类型不使用 G container cfg.container = this.getComponentContainer(cfg); } cfg.animate = this.view.getOptions().animate && cfg.animate && get(option, 'animate', cfg.animate); // 如果 view 关闭动画,则不执行 cfg.animateOption = deepMix({}, DEFAULT_ANIMATE_CFG, cfg.animateOption, option.animateOption); return cfg; } /** * is annotation render on top * @param option * @return whethe on top */ private isTop(option: any): boolean { return get(option, 'top', true); } /** * get the container by option.top * default is on top * @param option * @returns the container */ private getComponentContainer(option: any) { return this.isTop(option) ? this.foregroundContainer : this.backgroundContainer; } private getAnnotationTheme(type: string) { return get(this.view.getTheme(), ['components', 'annotation', type], {}); } /** * 创建或者更新 annotation * @param option */ private updateOrCreate(option: BaseOption) { // 拿到缓存的内容 let co = this.cache.get(this.getCacheKey(option)); // 存在则更新,不存在在创建 if (co) { const { type } = option; const theme = this.getAnnotationTheme(type); const cfg = this.getAnnotationCfg(type, option, theme); // 忽略掉一些配置 if (cfg) { omit(cfg, ['container']); } co.component.update({ ...(cfg || {}), visible: !!cfg }); // 对于 regionFilter/shape,因为生命周期的原因,需要额外 render if (includes(ANNOTATIONS_AFTER_RENDER, option.type)) { co.component.render(); } } else { // 不存在,创建 co = this.createAnnotation(option); if (co) { co.component.init(); // Note:regionFilter/shape 特殊处理,regionFilter/shape 需要取到 Geometry 中的 Shape,需要在 view render 之后处理 // 其他组件使用外层的统一 render 逻辑 if (includes(ANNOTATIONS_AFTER_RENDER, option.type)) { co.component.render(); } } } return co; } /** * 更新缓存,以及销毁组件 * @param updated 更新或者创建的组件 */ private syncCache(updated: Map) { const newCache = new Map(this.cache); // clone 一份 // 将 update 更新到 cache updated.forEach((co: ComponentOption, key: BaseOption) => { newCache.set(key, co); }); // 另外和 options 进行对比,删除 newCache.forEach((co: ComponentOption, key: BaseOption) => { // option 中已经找不到,那么就是删除的 if ( !find(this.option, (option: BaseOption) => { return key === this.getCacheKey(option); }) ) { co.component.destroy(); newCache.delete(key); } }); return newCache; } /** * 获得缓存组件的 key * @param option */ private getCacheKey(option: BaseOption) { // 如果存在 id,则使用 id string,否则直接使用 option 引用作为 key return option; // 后续扩展 id 用 // const id = get(option, 'id'); // return id ? id : option; } }