| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import * as zrUtil from 'zrender/src/core/util'; |
| import { TextStyleProps } from 'zrender/src/graphic/Text'; |
| import Displayable from 'zrender/src/graphic/Displayable'; |
| import Element from 'zrender/src/Element'; |
| import * as modelUtil from '../../util/model'; |
| import * as graphicUtil from '../../util/graphic'; |
| import * as layoutUtil from '../../util/layout'; |
| import { parsePercent } from '../../util/number'; |
| import GlobalModel from '../../model/Global'; |
| import ComponentView from '../../view/Component'; |
| import ExtensionAPI from '../../core/ExtensionAPI'; |
| import { getECData } from '../../util/innerStore'; |
| import { isEC4CompatibleStyle, convertFromEC4CompatibleStyle } from '../../util/styleCompat'; |
| import { |
| ElementMap, |
| GraphicComponentModel, |
| GraphicComponentDisplayableOption, |
| GraphicComponentZRPathOption, |
| GraphicComponentGroupOption, |
| GraphicComponentElementOption |
| } from './GraphicModel'; |
| import { |
| applyLeaveTransition, |
| applyUpdateTransition, |
| isTransitionAll, |
| updateLeaveTo |
| } from '../../animation/customGraphicTransition'; |
| import { updateProps } from '../../animation/basicTransition'; |
| import { |
| applyKeyframeAnimation, |
| stopPreviousKeyframeAnimationAndRestore |
| } from '../../animation/customGraphicKeyframeAnimation'; |
| |
| const nonShapeGraphicElements = { |
| // Reserved but not supported in graphic component. |
| path: null as unknown, |
| compoundPath: null as unknown, |
| |
| // Supported in graphic component. |
| group: graphicUtil.Group, |
| image: graphicUtil.Image, |
| text: graphicUtil.Text |
| } as const; |
| type NonShapeGraphicElementType = keyof typeof nonShapeGraphicElements; |
| |
| export const inner = modelUtil.makeInner<{ |
| width: number; |
| height: number; |
| isNew: boolean; |
| id: string; |
| type: string; |
| option: GraphicComponentElementOption |
| }, Element>(); |
| // ------------------------ |
| // View |
| // ------------------------ |
| export class GraphicComponentView extends ComponentView { |
| |
| static type = 'graphic'; |
| type = GraphicComponentView.type; |
| |
| private _elMap: ElementMap; |
| private _lastGraphicModel: GraphicComponentModel; |
| |
| init() { |
| this._elMap = zrUtil.createHashMap(); |
| } |
| |
| render(graphicModel: GraphicComponentModel, ecModel: GlobalModel, api: ExtensionAPI): void { |
| // Having leveraged between use cases and algorithm complexity, a very |
| // simple layout mechanism is used: |
| // The size(width/height) can be determined by itself or its parent (not |
| // implemented yet), but can not by its children. (Top-down travel) |
| // The location(x/y) can be determined by the bounding rect of itself |
| // (can including its descendants or not) and the size of its parent. |
| // (Bottom-up travel) |
| |
| // When `chart.clear()` or `chart.setOption({...}, true)` with the same id, |
| // view will be reused. |
| if (graphicModel !== this._lastGraphicModel) { |
| this._clear(); |
| } |
| this._lastGraphicModel = graphicModel; |
| |
| this._updateElements(graphicModel); |
| this._relocate(graphicModel, api); |
| } |
| |
| /** |
| * Update graphic elements. |
| */ |
| private _updateElements(graphicModel: GraphicComponentModel): void { |
| const elOptionsToUpdate = graphicModel.useElOptionsToUpdate(); |
| |
| if (!elOptionsToUpdate) { |
| return; |
| } |
| |
| const elMap = this._elMap; |
| const rootGroup = this.group; |
| |
| const globalZ = graphicModel.get('z'); |
| const globalZLevel = graphicModel.get('zlevel'); |
| |
| // Top-down tranverse to assign graphic settings to each elements. |
| zrUtil.each(elOptionsToUpdate, function (elOption) { |
| const id = modelUtil.convertOptionIdName(elOption.id, null); |
| const elExisting = id != null ? elMap.get(id) : null; |
| const parentId = modelUtil.convertOptionIdName(elOption.parentId, null); |
| const targetElParent = (parentId != null ? elMap.get(parentId) : rootGroup) as graphicUtil.Group; |
| |
| const elType = elOption.type; |
| const elOptionStyle = (elOption as GraphicComponentDisplayableOption).style; |
| if (elType === 'text' && elOptionStyle) { |
| // In top/bottom mode, textVerticalAlign should not be used, which cause |
| // inaccurately locating. |
| if (elOption.hv && elOption.hv[1]) { |
| (elOptionStyle as any).textVerticalAlign = |
| (elOptionStyle as any).textBaseline = |
| (elOptionStyle as TextStyleProps).verticalAlign = |
| (elOptionStyle as TextStyleProps).align = null; |
| } |
| } |
| |
| let textContentOption = (elOption as GraphicComponentZRPathOption).textContent; |
| let textConfig = (elOption as GraphicComponentZRPathOption).textConfig; |
| if (elOptionStyle |
| && isEC4CompatibleStyle(elOptionStyle, elType, !!textConfig, !!textContentOption)) { |
| const convertResult = |
| convertFromEC4CompatibleStyle(elOptionStyle, elType, true) as GraphicComponentZRPathOption; |
| if (!textConfig && convertResult.textConfig) { |
| textConfig = (elOption as GraphicComponentZRPathOption).textConfig = convertResult.textConfig; |
| } |
| if (!textContentOption && convertResult.textContent) { |
| textContentOption = convertResult.textContent; |
| } |
| } |
| |
| // Remove unnecessary props to avoid potential problems. |
| const elOptionCleaned = getCleanedElOption(elOption); |
| |
| |
| // For simple, do not support parent change, otherwise reorder is needed. |
| if (__DEV__) { |
| elExisting && zrUtil.assert( |
| targetElParent === elExisting.parent, |
| 'Changing parent is not supported.' |
| ); |
| } |
| |
| const $action = elOption.$action || 'merge'; |
| const isMerge = $action === 'merge'; |
| const isReplace = $action === 'replace'; |
| if (isMerge) { |
| const isInit = !elExisting; |
| let el = elExisting; |
| if (isInit) { |
| el = createEl(id, targetElParent, elOption.type, elMap); |
| } |
| else { |
| el && (inner(el).isNew = false); |
| // Stop and restore before update any other attributes. |
| stopPreviousKeyframeAnimationAndRestore(el); |
| } |
| if (el) { |
| applyUpdateTransition( |
| el, |
| elOptionCleaned, |
| graphicModel, |
| { isInit } |
| ); |
| updateCommonAttrs(el, elOption, globalZ, globalZLevel); |
| } |
| } |
| else if (isReplace) { |
| removeEl(elExisting, elOption, elMap, graphicModel); |
| const el = createEl(id, targetElParent, elOption.type, elMap); |
| if (el) { |
| applyUpdateTransition( |
| el, |
| elOptionCleaned, |
| graphicModel, |
| { isInit: true} |
| ); |
| updateCommonAttrs(el, elOption, globalZ, globalZLevel); |
| } |
| } |
| else if ($action === 'remove') { |
| updateLeaveTo(elExisting, elOption); |
| removeEl(elExisting, elOption, elMap, graphicModel); |
| } |
| |
| const el = elMap.get(id); |
| |
| if (el && textContentOption) { |
| if (isMerge) { |
| const textContentExisting = el.getTextContent(); |
| textContentExisting |
| ? textContentExisting.attr(textContentOption) |
| : el.setTextContent(new graphicUtil.Text(textContentOption)); |
| } |
| else if (isReplace) { |
| el.setTextContent(new graphicUtil.Text(textContentOption)); |
| } |
| } |
| |
| if (el) { |
| const clipPathOption = elOption.clipPath; |
| if (clipPathOption) { |
| const clipPathType = clipPathOption.type; |
| let clipPath: graphicUtil.Path; |
| let isInit = false; |
| if (isMerge) { |
| const oldClipPath = el.getClipPath(); |
| isInit = !oldClipPath |
| || inner(oldClipPath).type !== clipPathType; |
| clipPath = isInit ? newEl(clipPathType) as graphicUtil.Path : oldClipPath; |
| } |
| else if (isReplace) { |
| isInit = true; |
| clipPath = newEl(clipPathType) as graphicUtil.Path; |
| } |
| |
| el.setClipPath(clipPath); |
| |
| applyUpdateTransition( |
| clipPath, |
| clipPathOption, |
| graphicModel, |
| { isInit} |
| ); |
| applyKeyframeAnimation( |
| clipPath, |
| clipPathOption.keyframeAnimation, |
| graphicModel |
| ); |
| } |
| |
| const elInner = inner(el); |
| |
| el.setTextConfig(textConfig); |
| |
| elInner.option = elOption; |
| setEventData(el, graphicModel, elOption); |
| |
| graphicUtil.setTooltipConfig({ |
| el: el, |
| componentModel: graphicModel, |
| itemName: el.name, |
| itemTooltipOption: elOption.tooltip |
| }); |
| |
| applyKeyframeAnimation(el, elOption.keyframeAnimation, graphicModel); |
| } |
| }); |
| } |
| |
| /** |
| * Locate graphic elements. |
| */ |
| private _relocate(graphicModel: GraphicComponentModel, api: ExtensionAPI): void { |
| const elOptions = graphicModel.option.elements; |
| const rootGroup = this.group; |
| const elMap = this._elMap; |
| const apiWidth = api.getWidth(); |
| const apiHeight = api.getHeight(); |
| |
| const xy = ['x', 'y'] as const; |
| |
| // Top-down to calculate percentage width/height of group |
| for (let i = 0; i < elOptions.length; i++) { |
| const elOption = elOptions[i]; |
| const id = modelUtil.convertOptionIdName(elOption.id, null); |
| const el = id != null ? elMap.get(id) : null; |
| |
| if (!el || !el.isGroup) { |
| continue; |
| } |
| const parentEl = el.parent; |
| const isParentRoot = parentEl === rootGroup; |
| // Like 'position:absolut' in css, default 0. |
| const elInner = inner(el); |
| const parentElInner = inner(parentEl); |
| elInner.width = parsePercent( |
| (elInner.option as GraphicComponentGroupOption).width, |
| isParentRoot ? apiWidth : parentElInner.width |
| ) || 0; |
| elInner.height = parsePercent( |
| (elInner.option as GraphicComponentGroupOption).height, |
| isParentRoot ? apiHeight : parentElInner.height |
| ) || 0; |
| } |
| |
| // Bottom-up tranvese all elements (consider ec resize) to locate elements. |
| for (let i = elOptions.length - 1; i >= 0; i--) { |
| const elOption = elOptions[i]; |
| const id = modelUtil.convertOptionIdName(elOption.id, null); |
| const el = id != null ? elMap.get(id) : null; |
| |
| if (!el) { |
| continue; |
| } |
| |
| const parentEl = el.parent; |
| const parentElInner = inner(parentEl); |
| const containerInfo = parentEl === rootGroup |
| ? { |
| width: apiWidth, |
| height: apiHeight |
| } |
| : { |
| width: parentElInner.width, |
| height: parentElInner.height |
| }; |
| |
| // PENDING |
| // Currently, when `bounding: 'all'`, the union bounding rect of the group |
| // does not include the rect of [0, 0, group.width, group.height], which |
| // is probably weird for users. Should we make a break change for it? |
| const layoutPos = {} as Record<'x' | 'y', number>; |
| const layouted = layoutUtil.positionElement( |
| el, elOption, containerInfo, null, |
| { hv: elOption.hv, boundingMode: elOption.bounding }, |
| layoutPos |
| ); |
| |
| if (!inner(el).isNew && layouted) { |
| const transition = elOption.transition; |
| const animatePos = {} as Record<'x' | 'y', number>; |
| for (let k = 0; k < xy.length; k++) { |
| const key = xy[k]; |
| const val = layoutPos[key]; |
| if (transition && (isTransitionAll(transition) || zrUtil.indexOf(transition, key) >= 0)) { |
| animatePos[key] = val; |
| } |
| else { |
| el[key] = val; |
| } |
| } |
| updateProps(el, animatePos, graphicModel, 0); |
| } |
| else { |
| el.attr(layoutPos); |
| } |
| } |
| } |
| |
| /** |
| * Clear all elements. |
| */ |
| private _clear(): void { |
| const elMap = this._elMap; |
| elMap.each((el) => { |
| removeEl(el, inner(el).option, elMap, this._lastGraphicModel); |
| }); |
| this._elMap = zrUtil.createHashMap(); |
| } |
| |
| dispose(): void { |
| this._clear(); |
| } |
| } |
| |
| function newEl(graphicType: string) { |
| if (__DEV__) { |
| zrUtil.assert(graphicType, 'graphic type MUST be set'); |
| } |
| |
| const Clz = ( |
| zrUtil.hasOwn(nonShapeGraphicElements, graphicType) |
| // Those graphic elements are not shapes. They should not be |
| // overwritten by users, so do them first. |
| ? nonShapeGraphicElements[graphicType as NonShapeGraphicElementType] |
| : graphicUtil.getShapeClass(graphicType) |
| ) as { new(opt: GraphicComponentElementOption): Element; }; |
| |
| if (__DEV__) { |
| zrUtil.assert(Clz, `graphic type ${graphicType} can not be found`); |
| } |
| |
| const el = new Clz({}); |
| inner(el).type = graphicType; |
| return el; |
| } |
| function createEl( |
| id: string, |
| targetElParent: graphicUtil.Group, |
| graphicType: string, |
| elMap: ElementMap |
| ): Element { |
| |
| const el = newEl(graphicType); |
| |
| targetElParent.add(el); |
| elMap.set(id, el); |
| inner(el).id = id; |
| inner(el).isNew = true; |
| |
| return el; |
| } |
| function removeEl( |
| elExisting: Element, |
| elOption: GraphicComponentElementOption, |
| elMap: ElementMap, |
| graphicModel: GraphicComponentModel |
| ): void { |
| const existElParent = elExisting && elExisting.parent; |
| if (existElParent) { |
| elExisting.type === 'group' && elExisting.traverse(function (el) { |
| removeEl(el, elOption, elMap, graphicModel); |
| }); |
| applyLeaveTransition(elExisting, elOption, graphicModel); |
| elMap.removeKey(inner(elExisting).id); |
| } |
| } |
| |
| function updateCommonAttrs( |
| el: Element, |
| elOption: GraphicComponentElementOption, |
| defaultZ: number, |
| defaultZlevel: number |
| ) { |
| if (!el.isGroup) { |
| zrUtil.each([ |
| ['cursor', Displayable.prototype.cursor], |
| // We should not support configure z and zlevel in the element level. |
| // But seems we didn't limit it previously. So here still use it to avoid breaking. |
| ['zlevel', defaultZlevel || 0], |
| ['z', defaultZ || 0], |
| // z2 must not be null/undefined, otherwise sort error may occur. |
| ['z2', 0] |
| ], item => { |
| const prop = item[0] as any; |
| if (zrUtil.hasOwn(elOption, prop)) { |
| (el as any)[prop] = zrUtil.retrieve2( |
| (elOption as any)[prop], |
| item[1] |
| ); |
| } |
| else if ((el as any)[prop] == null) { |
| (el as any)[prop] = item[1]; |
| } |
| }); |
| } |
| |
| zrUtil.each(zrUtil.keys(elOption), key => { |
| // Assign event handlers. |
| // PENDING: should enumerate all event names or use pattern matching? |
| if (key.indexOf('on') === 0) { |
| const val = (elOption as any)[key]; |
| (el as any)[key] = zrUtil.isFunction(val) ? val : null; |
| } |
| }); |
| if (zrUtil.hasOwn(elOption, 'draggable')) { |
| el.draggable = elOption.draggable; |
| } |
| |
| // Other attributes |
| elOption.name != null && (el.name = elOption.name); |
| elOption.id != null && ((el as any).id = elOption.id); |
| } |
| // Remove unnecessary props to avoid potential problems. |
| function getCleanedElOption( |
| elOption: GraphicComponentElementOption |
| ): Omit<GraphicComponentElementOption, 'textContent'> { |
| elOption = zrUtil.extend({}, elOption); |
| zrUtil.each( |
| ['id', 'parentId', '$action', 'hv', 'bounding', 'textContent', 'clipPath'].concat(layoutUtil.LOCATION_PARAMS), |
| function (name) { |
| delete (elOption as any)[name]; |
| } |
| ); |
| return elOption; |
| } |
| |
| function setEventData( |
| el: Element, |
| graphicModel: GraphicComponentModel, |
| elOption: GraphicComponentElementOption |
| ): void { |
| let eventData = getECData(el).eventData; |
| // Simple optimize for large amount of elements that no need event. |
| if (!el.silent && !el.ignore && !eventData) { |
| eventData = getECData(el).eventData = { |
| componentType: 'graphic', |
| componentIndex: graphicModel.componentIndex, |
| name: el.name |
| }; |
| } |
| |
| // `elOption.info` enables user to mount some info on |
| // elements and use them in event handlers. |
| if (eventData) { |
| eventData.info = elOption.info; |
| } |
| } |