| /* |
| * 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 {bind, each, indexOf, curry, extend, normalizeCssArray, isFunction} from 'zrender/src/core/util'; |
| import * as graphic from '../../util/graphic'; |
| import {getECData} from '../../util/innerStore'; |
| import { |
| isHighDownDispatcher, |
| setAsHighDownDispatcher, |
| setDefaultStateProxy, |
| enableHoverFocus, |
| Z2_EMPHASIS_LIFT |
| } from '../../util/states'; |
| import DataDiffer from '../../data/DataDiffer'; |
| import * as helper from '../helper/treeHelper'; |
| import Breadcrumb from './Breadcrumb'; |
| import RoamController, { RoamEventParams } from '../../component/helper/RoamController'; |
| import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; |
| import * as matrix from 'zrender/src/core/matrix'; |
| import * as animationUtil from '../../util/animation'; |
| import makeStyleMapper from '../../model/mixin/makeStyleMapper'; |
| import ChartView from '../../view/Chart'; |
| import Tree, { TreeNode } from '../../data/Tree'; |
| import TreemapSeriesModel, { TreemapSeriesNodeItemOption } from './TreemapSeries'; |
| import GlobalModel from '../../model/Global'; |
| import ExtensionAPI from '../../core/ExtensionAPI'; |
| import Model from '../../model/Model'; |
| import { LayoutRect } from '../../util/layout'; |
| import { TreemapLayoutNode } from './treemapLayout'; |
| import Element from 'zrender/src/Element'; |
| import Displayable from 'zrender/src/graphic/Displayable'; |
| import { makeInner, convertOptionIdName } from '../../util/model'; |
| import { PathStyleProps, PathProps } from 'zrender/src/graphic/Path'; |
| import { TreeSeriesNodeItemOption } from '../tree/TreeSeries'; |
| import { |
| TreemapRootToNodePayload, |
| TreemapMovePayload, |
| TreemapRenderPayload, |
| TreemapZoomToNodePayload |
| } from './treemapAction'; |
| import { ColorString, ECElement } from '../../util/types'; |
| import { windowOpen } from '../../util/format'; |
| import { TextStyleProps } from 'zrender/src/graphic/Text'; |
| import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; |
| |
| const Group = graphic.Group; |
| const Rect = graphic.Rect; |
| |
| const DRAG_THRESHOLD = 3; |
| const PATH_LABEL_NOAMAL = 'label'; |
| const PATH_UPPERLABEL_NORMAL = 'upperLabel'; |
| // Should larger than emphasis states lift z |
| const Z2_BASE = Z2_EMPHASIS_LIFT * 10; // Should bigger than every z2. |
| const Z2_BG = Z2_EMPHASIS_LIFT * 2; |
| const Z2_CONTENT = Z2_EMPHASIS_LIFT * 3; |
| |
| const getStateItemStyle = makeStyleMapper([ |
| ['fill', 'color'], |
| // `borderColor` and `borderWidth` has been occupied, |
| // so use `stroke` to indicate the stroke of the rect. |
| ['stroke', 'strokeColor'], |
| ['lineWidth', 'strokeWidth'], |
| ['shadowBlur'], |
| ['shadowOffsetX'], |
| ['shadowOffsetY'], |
| ['shadowColor'] |
| // Option decal is in `DecalObject` but style.decal is in `PatternObject`. |
| // So do not transfer decal directly. |
| ]); |
| const getItemStyleNormal = function (model: Model<TreemapSeriesNodeItemOption['itemStyle']>): PathStyleProps { |
| // Normal style props should include emphasis style props. |
| const itemStyle = getStateItemStyle(model) as PathStyleProps; |
| // Clear styles set by emphasis. |
| itemStyle.stroke = itemStyle.fill = itemStyle.lineWidth = null; |
| return itemStyle; |
| }; |
| |
| interface RenderElementStorage { |
| nodeGroup: graphic.Group[] |
| background: graphic.Rect[] |
| content: graphic.Rect[] |
| } |
| |
| type LastCfgStorage = { |
| [key in keyof RenderElementStorage]: LastCfg[] |
| // nodeGroup: { |
| // old: Pick<graphic.Group, 'position'>[] |
| // fadein: boolean |
| // }[] |
| // background: { |
| // old: Pick<graphic.Rect, 'shape'> |
| // fadein: boolean |
| // }[] |
| // content: { |
| // old: Pick<graphic.Rect, 'shape'> |
| // fadein: boolean |
| // }[] |
| }; |
| |
| interface FoundTargetInfo { |
| node: TreeNode |
| |
| offsetX?: number |
| offsetY?: number |
| } |
| |
| interface RenderResult { |
| lastsForAnimation: LastCfgStorage |
| willInvisibleEls?: graphic.Rect[] |
| willDeleteEls: RenderElementStorage |
| renderFinally: () => void |
| } |
| |
| interface ReRoot { |
| rootNodeGroup: graphic.Group |
| direction: 'drillDown' | 'rollUp' |
| } |
| |
| interface LastCfg { |
| oldX?: number |
| oldY?: number |
| oldShape?: graphic.Rect['shape'] |
| fadein: boolean |
| } |
| |
| const inner = makeInner<{ |
| nodeWidth: number |
| nodeHeight: number |
| willDelete: boolean |
| }, Element>(); |
| |
| class TreemapView extends ChartView { |
| |
| static type = 'treemap'; |
| type = TreemapView.type; |
| |
| private _containerGroup: graphic.Group; |
| private _breadcrumb: Breadcrumb; |
| private _controller: RoamController; |
| |
| private _oldTree: Tree; |
| |
| private _state: 'ready' | 'animating' = 'ready'; |
| |
| private _storage = createStorage() as RenderElementStorage; |
| |
| seriesModel: TreemapSeriesModel; |
| api: ExtensionAPI; |
| ecModel: GlobalModel; |
| |
| /** |
| * @override |
| */ |
| render( |
| seriesModel: TreemapSeriesModel, |
| ecModel: GlobalModel, |
| api: ExtensionAPI, |
| payload: TreemapZoomToNodePayload | TreemapRenderPayload | TreemapMovePayload | TreemapRootToNodePayload |
| ) { |
| |
| const models = ecModel.findComponents({ |
| mainType: 'series', subType: 'treemap', query: payload |
| }); |
| if (indexOf(models, seriesModel) < 0) { |
| return; |
| } |
| |
| this.seriesModel = seriesModel; |
| this.api = api; |
| this.ecModel = ecModel; |
| |
| const types = ['treemapZoomToNode', 'treemapRootToNode']; |
| const targetInfo = helper |
| .retrieveTargetInfo(payload, types, seriesModel); |
| const payloadType = payload && payload.type; |
| const layoutInfo = seriesModel.layoutInfo; |
| const isInit = !this._oldTree; |
| const thisStorage = this._storage; |
| |
| // Mark new root when action is treemapRootToNode. |
| const reRoot = (payloadType === 'treemapRootToNode' && targetInfo && thisStorage) |
| ? { |
| rootNodeGroup: thisStorage.nodeGroup[targetInfo.node.getRawIndex()], |
| direction: (payload as TreemapRootToNodePayload).direction |
| } |
| : null; |
| |
| const containerGroup = this._giveContainerGroup(layoutInfo); |
| const hasAnimation = seriesModel.get('animation'); |
| |
| const renderResult = this._doRender(containerGroup, seriesModel, reRoot); |
| ( |
| hasAnimation && |
| !isInit && ( |
| !payloadType |
| || payloadType === 'treemapZoomToNode' |
| || payloadType === 'treemapRootToNode' |
| ) |
| ) |
| ? this._doAnimation(containerGroup, renderResult, seriesModel, reRoot) |
| : renderResult.renderFinally(); |
| |
| this._resetController(api); |
| |
| this._renderBreadcrumb(seriesModel, api, targetInfo); |
| } |
| |
| private _giveContainerGroup(layoutInfo: LayoutRect) { |
| let containerGroup = this._containerGroup; |
| if (!containerGroup) { |
| // FIXME |
| // åŠ ä¸€å±‚containerGroup是为了clip,但是现在clip功能并没有实现。 |
| containerGroup = this._containerGroup = new Group(); |
| this._initEvents(containerGroup); |
| this.group.add(containerGroup); |
| } |
| containerGroup.x = layoutInfo.x; |
| containerGroup.y = layoutInfo.y; |
| |
| return containerGroup; |
| } |
| |
| private _doRender(containerGroup: graphic.Group, seriesModel: TreemapSeriesModel, reRoot: ReRoot): RenderResult { |
| const thisTree = seriesModel.getData().tree; |
| const oldTree = this._oldTree; |
| |
| // Clear last shape records. |
| const lastsForAnimation = createStorage() as LastCfgStorage; |
| const thisStorage = createStorage() as RenderElementStorage; |
| const oldStorage = this._storage; |
| const willInvisibleEls: RenderResult['willInvisibleEls'] = []; |
| |
| function doRenderNode(thisNode: TreeNode, oldNode: TreeNode, parentGroup: graphic.Group, depth: number) { |
| return renderNode( |
| seriesModel, |
| thisStorage, oldStorage, reRoot, |
| lastsForAnimation, willInvisibleEls, |
| thisNode, oldNode, parentGroup, depth |
| ); |
| } |
| |
| // Notice: when thisTree and oldTree are the same tree (see list.cloneShallow), |
| // the oldTree is actually losted, so we can not find all of the old graphic |
| // elements from tree. So we use this stragegy: make element storage, move |
| // from old storage to new storage, clear old storage. |
| |
| dualTravel( |
| thisTree.root ? [thisTree.root] : [], |
| (oldTree && oldTree.root) ? [oldTree.root] : [], |
| containerGroup, |
| thisTree === oldTree || !oldTree, |
| 0 |
| ); |
| |
| // Process all removing. |
| const willDeleteEls = clearStorage(oldStorage) as RenderElementStorage; |
| |
| this._oldTree = thisTree; |
| this._storage = thisStorage; |
| |
| return { |
| lastsForAnimation, |
| willDeleteEls, |
| renderFinally |
| }; |
| |
| function dualTravel( |
| thisViewChildren: TreemapLayoutNode[], |
| oldViewChildren: TreemapLayoutNode[], |
| parentGroup: graphic.Group, |
| sameTree: boolean, |
| depth: number |
| ) { |
| // When 'render' is triggered by action, |
| // 'this' and 'old' may be the same tree, |
| // we use rawIndex in that case. |
| if (sameTree) { |
| oldViewChildren = thisViewChildren; |
| each(thisViewChildren, function (child, index) { |
| !child.isRemoved() && processNode(index, index); |
| }); |
| } |
| // Diff hierarchically (diff only in each subtree, but not whole). |
| // because, consistency of view is important. |
| else { |
| (new DataDiffer(oldViewChildren, thisViewChildren, getKey, getKey)) |
| .add(processNode) |
| .update(processNode) |
| .remove(curry(processNode, null)) |
| .execute(); |
| } |
| |
| function getKey(node: TreeNode) { |
| // Identify by name or raw index. |
| return node.getId(); |
| } |
| |
| function processNode(newIndex: number, oldIndex?: number) { |
| const thisNode = newIndex != null ? thisViewChildren[newIndex] : null; |
| const oldNode = oldIndex != null ? oldViewChildren[oldIndex] : null; |
| |
| const group = doRenderNode(thisNode, oldNode, parentGroup, depth); |
| |
| group && dualTravel( |
| thisNode && thisNode.viewChildren || [], |
| oldNode && oldNode.viewChildren || [], |
| group, |
| sameTree, |
| depth + 1 |
| ); |
| } |
| } |
| |
| function clearStorage(storage: RenderElementStorage) { |
| const willDeleteEls = createStorage() as RenderElementStorage; |
| storage && each(storage, function (store, storageName) { |
| const delEls = willDeleteEls[storageName]; |
| each(store, function (el) { |
| el && (delEls.push(el as any), inner(el).willDelete = true); |
| }); |
| }); |
| return willDeleteEls; |
| } |
| |
| function renderFinally() { |
| each(willDeleteEls, function (els) { |
| each(els, function (el) { |
| el.parent && el.parent.remove(el); |
| }); |
| }); |
| each(willInvisibleEls, function (el) { |
| el.invisible = true; |
| // Setting invisible is for optimizing, so no need to set dirty, |
| // just mark as invisible. |
| el.dirty(); |
| }); |
| } |
| } |
| |
| private _doAnimation( |
| containerGroup: graphic.Group, |
| renderResult: RenderResult, |
| seriesModel: TreemapSeriesModel, |
| reRoot: ReRoot |
| ) { |
| const durationOption = seriesModel.get('animationDurationUpdate'); |
| const easingOption = seriesModel.get('animationEasing'); |
| // TODO: do not support function until necessary. |
| const duration = (isFunction(durationOption) ? 0 : durationOption) || 0; |
| const easing = (isFunction(easingOption) ? null : easingOption) || 'cubicOut'; |
| const animationWrap = animationUtil.createWrap(); |
| |
| // Make delete animations. |
| each(renderResult.willDeleteEls, function (store, storageName) { |
| each(store, function (el, rawIndex) { |
| if ((el as Displayable).invisible) { |
| return; |
| } |
| |
| const parent = el.parent; // Always has parent, and parent is nodeGroup. |
| let target: PathProps; |
| const innerStore = inner(parent); |
| |
| if (reRoot && reRoot.direction === 'drillDown') { |
| target = parent === reRoot.rootNodeGroup |
| // This is the content element of view root. |
| // Only `content` will enter this branch, because |
| // `background` and `nodeGroup` will not be deleted. |
| ? { |
| shape: { |
| x: 0, |
| y: 0, |
| width: innerStore.nodeWidth, |
| height: innerStore.nodeHeight |
| }, |
| style: { |
| opacity: 0 |
| } |
| } |
| // Others. |
| : {style: {opacity: 0}}; |
| } |
| else { |
| let targetX = 0; |
| let targetY = 0; |
| |
| if (!innerStore.willDelete) { |
| // Let node animate to right-bottom corner, cooperating with fadeout, |
| // which is appropriate for user understanding. |
| // Divided by 2 for reRoot rolling up effect. |
| targetX = innerStore.nodeWidth / 2; |
| targetY = innerStore.nodeHeight / 2; |
| } |
| |
| target = storageName === 'nodeGroup' |
| ? {x: targetX, y: targetY, style: {opacity: 0}} |
| : { |
| shape: {x: targetX, y: targetY, width: 0, height: 0}, |
| style: {opacity: 0} |
| }; |
| } |
| |
| // TODO: do not support delay until necessary. |
| target && animationWrap.add(el, target, duration, 0, easing); |
| }); |
| }); |
| |
| // Make other animations |
| each(this._storage, function (store, storageName) { |
| each(store, function (el, rawIndex) { |
| const last = renderResult.lastsForAnimation[storageName][rawIndex]; |
| const target: PathProps = {}; |
| |
| if (!last) { |
| return; |
| } |
| |
| if (el instanceof graphic.Group) { |
| if (last.oldX != null) { |
| target.x = el.x; |
| target.y = el.y; |
| el.x = last.oldX; |
| el.y = last.oldY; |
| } |
| } |
| else { |
| if (last.oldShape) { |
| target.shape = extend({}, el.shape); |
| el.setShape(last.oldShape); |
| } |
| |
| if (last.fadein) { |
| el.setStyle('opacity', 0); |
| target.style = {opacity: 1}; |
| } |
| // When animation is stopped for succedent animation starting, |
| // el.style.opacity might not be 1 |
| else if (el.style.opacity !== 1) { |
| target.style = {opacity: 1}; |
| } |
| } |
| |
| animationWrap.add(el, target, duration, 0, easing); |
| }); |
| }, this); |
| |
| this._state = 'animating'; |
| |
| animationWrap |
| .finished(bind(function () { |
| this._state = 'ready'; |
| renderResult.renderFinally(); |
| }, this)) |
| .start(); |
| } |
| |
| private _resetController(api: ExtensionAPI) { |
| let controller = this._controller; |
| |
| // Init controller. |
| if (!controller) { |
| controller = this._controller = new RoamController(api.getZr()); |
| controller.enable(this.seriesModel.get('roam')); |
| controller.on('pan', bind(this._onPan, this)); |
| controller.on('zoom', bind(this._onZoom, this)); |
| } |
| |
| const rect = new BoundingRect(0, 0, api.getWidth(), api.getHeight()); |
| controller.setPointerChecker(function (e, x, y) { |
| return rect.contain(x, y); |
| }); |
| } |
| |
| private _clearController() { |
| let controller = this._controller; |
| if (controller) { |
| controller.dispose(); |
| controller = null; |
| } |
| } |
| |
| private _onPan(e: RoamEventParams['pan']) { |
| if (this._state !== 'animating' |
| && (Math.abs(e.dx) > DRAG_THRESHOLD || Math.abs(e.dy) > DRAG_THRESHOLD) |
| ) { |
| // These param must not be cached. |
| const root = this.seriesModel.getData().tree.root; |
| |
| if (!root) { |
| return; |
| } |
| |
| const rootLayout = root.getLayout(); |
| |
| if (!rootLayout) { |
| return; |
| } |
| |
| this.api.dispatchAction({ |
| type: 'treemapMove', |
| from: this.uid, |
| seriesId: this.seriesModel.id, |
| rootRect: { |
| x: rootLayout.x + e.dx, y: rootLayout.y + e.dy, |
| width: rootLayout.width, height: rootLayout.height |
| } |
| } as TreemapMovePayload); |
| } |
| } |
| |
| private _onZoom(e: RoamEventParams['zoom']) { |
| let mouseX = e.originX; |
| let mouseY = e.originY; |
| |
| if (this._state !== 'animating') { |
| // These param must not be cached. |
| const root = this.seriesModel.getData().tree.root; |
| |
| if (!root) { |
| return; |
| } |
| |
| const rootLayout = root.getLayout(); |
| |
| if (!rootLayout) { |
| return; |
| } |
| |
| const rect = new BoundingRect( |
| rootLayout.x, rootLayout.y, rootLayout.width, rootLayout.height |
| ); |
| const layoutInfo = this.seriesModel.layoutInfo; |
| |
| // Transform mouse coord from global to containerGroup. |
| mouseX -= layoutInfo.x; |
| mouseY -= layoutInfo.y; |
| |
| // Scale root bounding rect. |
| const m = matrix.create(); |
| matrix.translate(m, m, [-mouseX, -mouseY]); |
| matrix.scale(m, m, [e.scale, e.scale]); |
| matrix.translate(m, m, [mouseX, mouseY]); |
| |
| rect.applyTransform(m); |
| |
| this.api.dispatchAction({ |
| type: 'treemapRender', |
| from: this.uid, |
| seriesId: this.seriesModel.id, |
| rootRect: { |
| x: rect.x, y: rect.y, |
| width: rect.width, height: rect.height |
| } |
| } as TreemapRenderPayload); |
| } |
| } |
| |
| private _initEvents(containerGroup: graphic.Group) { |
| containerGroup.on('click', (e) => { |
| if (this._state !== 'ready') { |
| return; |
| } |
| |
| const nodeClick = this.seriesModel.get('nodeClick', true); |
| |
| if (!nodeClick) { |
| return; |
| } |
| |
| const targetInfo = this.findTarget(e.offsetX, e.offsetY); |
| |
| if (!targetInfo) { |
| return; |
| } |
| |
| const node = targetInfo.node; |
| if (node.getLayout().isLeafRoot) { |
| this._rootToNode(targetInfo); |
| } |
| else { |
| if (nodeClick === 'zoomToNode') { |
| this._zoomToNode(targetInfo); |
| } |
| else if (nodeClick === 'link') { |
| const itemModel = node.hostTree.data.getItemModel<TreeSeriesNodeItemOption>(node.dataIndex); |
| const link = itemModel.get('link', true); |
| const linkTarget = itemModel.get('target', true) || 'blank'; |
| link && windowOpen(link, linkTarget); |
| } |
| } |
| |
| }, this); |
| } |
| |
| private _renderBreadcrumb(seriesModel: TreemapSeriesModel, api: ExtensionAPI, targetInfo: FoundTargetInfo) { |
| if (!targetInfo) { |
| targetInfo = seriesModel.get('leafDepth', true) != null |
| ? {node: seriesModel.getViewRoot()} |
| // FIXME |
| // better way? |
| // Find breadcrumb tail on center of containerGroup. |
| : this.findTarget(api.getWidth() / 2, api.getHeight() / 2); |
| |
| if (!targetInfo) { |
| targetInfo = {node: seriesModel.getData().tree.root}; |
| } |
| } |
| |
| (this._breadcrumb || (this._breadcrumb = new Breadcrumb(this.group))) |
| .render(seriesModel, api, targetInfo.node, (node) => { |
| if (this._state !== 'animating') { |
| helper.aboveViewRoot(seriesModel.getViewRoot(), node) |
| ? this._rootToNode({node: node}) |
| : this._zoomToNode({node: node}); |
| } |
| }); |
| } |
| |
| /** |
| * @override |
| */ |
| remove() { |
| this._clearController(); |
| this._containerGroup && this._containerGroup.removeAll(); |
| this._storage = createStorage() as RenderElementStorage; |
| this._state = 'ready'; |
| this._breadcrumb && this._breadcrumb.remove(); |
| } |
| |
| dispose() { |
| this._clearController(); |
| } |
| |
| private _zoomToNode(targetInfo: FoundTargetInfo) { |
| this.api.dispatchAction({ |
| type: 'treemapZoomToNode', |
| from: this.uid, |
| seriesId: this.seriesModel.id, |
| targetNode: targetInfo.node |
| }); |
| } |
| |
| private _rootToNode(targetInfo: FoundTargetInfo) { |
| this.api.dispatchAction({ |
| type: 'treemapRootToNode', |
| from: this.uid, |
| seriesId: this.seriesModel.id, |
| targetNode: targetInfo.node |
| }); |
| } |
| |
| /** |
| * @public |
| * @param {number} x Global coord x. |
| * @param {number} y Global coord y. |
| * @return {Object} info If not found, return undefined; |
| * @return {number} info.node Target node. |
| * @return {number} info.offsetX x refer to target node. |
| * @return {number} info.offsetY y refer to target node. |
| */ |
| findTarget(x: number, y: number): FoundTargetInfo { |
| let targetInfo; |
| const viewRoot = this.seriesModel.getViewRoot(); |
| |
| viewRoot.eachNode({attr: 'viewChildren', order: 'preorder'}, function (node) { |
| const bgEl = this._storage.background[node.getRawIndex()]; |
| // If invisible, there might be no element. |
| if (bgEl) { |
| const point = bgEl.transformCoordToLocal(x, y); |
| const shape = bgEl.shape; |
| |
| // For performance consideration, dont use 'getBoundingRect'. |
| if (shape.x <= point[0] |
| && point[0] <= shape.x + shape.width |
| && shape.y <= point[1] |
| && point[1] <= shape.y + shape.height |
| ) { |
| targetInfo = { |
| node: node, |
| offsetX: point[0], |
| offsetY: point[1] |
| }; |
| } |
| else { |
| return false; // Suppress visit subtree. |
| } |
| } |
| }, this); |
| |
| return targetInfo; |
| } |
| } |
| |
| /** |
| * @inner |
| */ |
| function createStorage(): RenderElementStorage | LastCfgStorage { |
| return { |
| nodeGroup: [], |
| background: [], |
| content: [] |
| }; |
| } |
| |
| /** |
| * @inner |
| * @return Return undefined means do not travel further. |
| */ |
| function renderNode( |
| seriesModel: TreemapSeriesModel, |
| thisStorage: RenderElementStorage, |
| oldStorage: RenderElementStorage, |
| reRoot: ReRoot, |
| lastsForAnimation: RenderResult['lastsForAnimation'], |
| willInvisibleEls: RenderResult['willInvisibleEls'], |
| thisNode: TreeNode, |
| oldNode: TreeNode, |
| parentGroup: graphic.Group, |
| depth: number |
| ) { |
| // Whether under viewRoot. |
| if (!thisNode) { |
| // Deleting nodes will be performed finally. This method just find |
| // element from old storage, or create new element, set them to new |
| // storage, and set styles. |
| return; |
| } |
| |
| // ------------------------------------------------------------------- |
| // Start of closure variables available in "Procedures in renderNode". |
| |
| const thisLayout = thisNode.getLayout(); |
| const data = seriesModel.getData(); |
| const nodeModel = thisNode.getModel<TreemapSeriesNodeItemOption>(); |
| |
| // Only for enabling highlight/downplay. Clear firstly. |
| // Because some node will not be rendered. |
| data.setItemGraphicEl(thisNode.dataIndex, null); |
| |
| if (!thisLayout || !thisLayout.isInView) { |
| return; |
| } |
| |
| const thisWidth = thisLayout.width; |
| const thisHeight = thisLayout.height; |
| const borderWidth = thisLayout.borderWidth; |
| const thisInvisible = thisLayout.invisible; |
| |
| const thisRawIndex = thisNode.getRawIndex(); |
| const oldRawIndex = oldNode && oldNode.getRawIndex(); |
| |
| const thisViewChildren = thisNode.viewChildren; |
| const upperHeight = thisLayout.upperHeight; |
| const isParent = thisViewChildren && thisViewChildren.length; |
| const itemStyleNormalModel = nodeModel.getModel('itemStyle'); |
| const itemStyleEmphasisModel = nodeModel.getModel(['emphasis', 'itemStyle']); |
| const itemStyleBlurModel = nodeModel.getModel(['blur', 'itemStyle']); |
| const itemStyleSelectModel = nodeModel.getModel(['select', 'itemStyle']); |
| const borderRadius = itemStyleNormalModel.get('borderRadius') || 0; |
| |
| // End of closure ariables available in "Procedures in renderNode". |
| // ----------------------------------------------------------------- |
| |
| // Node group |
| const group = giveGraphic('nodeGroup', Group); |
| |
| if (!group) { |
| return; |
| } |
| |
| parentGroup.add(group); |
| // x,y are not set when el is above view root. |
| group.x = thisLayout.x || 0; |
| group.y = thisLayout.y || 0; |
| group.markRedraw(); |
| inner(group).nodeWidth = thisWidth; |
| inner(group).nodeHeight = thisHeight; |
| |
| if (thisLayout.isAboveViewRoot) { |
| return group; |
| } |
| |
| // Background |
| const bg = giveGraphic('background', Rect, depth, Z2_BG); |
| bg && renderBackground(group, bg, isParent && thisLayout.upperLabelHeight); |
| |
| const focus = nodeModel.get(['emphasis', 'focus']); |
| const blurScope = nodeModel.get(['emphasis', 'blurScope']); |
| |
| const focusOrIndices = |
| focus === 'ancestor' ? thisNode.getAncestorsIndices() |
| : focus === 'descendant' ? thisNode.getDescendantIndices() |
| : focus; |
| |
| // No children, render content. |
| if (isParent) { |
| // Because of the implementation about "traverse" in graphic hover style, we |
| // can not set hover listener on the "group" of non-leaf node. Otherwise the |
| // hover event from the descendents will be listenered. |
| if (isHighDownDispatcher(group)) { |
| setAsHighDownDispatcher(group, false); |
| } |
| if (bg) { |
| setAsHighDownDispatcher(bg, true); |
| // Only for enabling highlight/downplay. |
| data.setItemGraphicEl(thisNode.dataIndex, bg); |
| |
| enableHoverFocus(bg, focusOrIndices, blurScope); |
| } |
| } |
| else { |
| const content = giveGraphic('content', Rect, depth, Z2_CONTENT); |
| content && renderContent(group, content); |
| |
| (bg as ECElement).disableMorphing = true; |
| |
| if (bg && isHighDownDispatcher(bg)) { |
| setAsHighDownDispatcher(bg, false); |
| } |
| setAsHighDownDispatcher(group, true); |
| // Only for enabling highlight/downplay. |
| data.setItemGraphicEl(thisNode.dataIndex, group); |
| |
| enableHoverFocus(group, focusOrIndices, blurScope); |
| } |
| |
| return group; |
| |
| // ---------------------------- |
| // | Procedures in renderNode | |
| // ---------------------------- |
| |
| function renderBackground(group: graphic.Group, bg: graphic.Rect, useUpperLabel: boolean) { |
| const ecData = getECData(bg); |
| // For tooltip. |
| ecData.dataIndex = thisNode.dataIndex; |
| ecData.seriesIndex = seriesModel.seriesIndex; |
| |
| bg.setShape({x: 0, y: 0, width: thisWidth, height: thisHeight, r: borderRadius}); |
| |
| if (thisInvisible) { |
| // If invisible, do not set visual, otherwise the element will |
| // change immediately before animation. We think it is OK to |
| // remain its origin color when moving out of the view window. |
| processInvisible(bg); |
| } |
| else { |
| bg.invisible = false; |
| const style = thisNode.getVisual('style') as PathStyleProps; |
| const visualBorderColor = style.stroke; |
| const normalStyle = getItemStyleNormal(itemStyleNormalModel); |
| normalStyle.fill = visualBorderColor; |
| const emphasisStyle = getStateItemStyle(itemStyleEmphasisModel); |
| emphasisStyle.fill = itemStyleEmphasisModel.get('borderColor'); |
| const blurStyle = getStateItemStyle(itemStyleBlurModel); |
| blurStyle.fill = itemStyleBlurModel.get('borderColor'); |
| const selectStyle = getStateItemStyle(itemStyleSelectModel); |
| selectStyle.fill = itemStyleSelectModel.get('borderColor'); |
| |
| if (useUpperLabel) { |
| const upperLabelWidth = thisWidth - 2 * borderWidth; |
| |
| prepareText( |
| // PENDING: convert ZRColor to ColorString for text. |
| bg, visualBorderColor as ColorString, style.opacity, |
| {x: borderWidth, y: 0, width: upperLabelWidth, height: upperHeight} |
| ); |
| } |
| // For old bg. |
| else { |
| bg.removeTextContent(); |
| } |
| |
| bg.setStyle(normalStyle); |
| |
| bg.ensureState('emphasis').style = emphasisStyle; |
| bg.ensureState('blur').style = blurStyle; |
| bg.ensureState('select').style = selectStyle; |
| setDefaultStateProxy(bg); |
| } |
| |
| group.add(bg); |
| } |
| |
| function renderContent(group: graphic.Group, content: graphic.Rect) { |
| const ecData = getECData(content); |
| // For tooltip. |
| ecData.dataIndex = thisNode.dataIndex; |
| ecData.seriesIndex = seriesModel.seriesIndex; |
| |
| const contentWidth = Math.max(thisWidth - 2 * borderWidth, 0); |
| const contentHeight = Math.max(thisHeight - 2 * borderWidth, 0); |
| |
| content.culling = true; |
| content.setShape({ |
| x: borderWidth, |
| y: borderWidth, |
| width: contentWidth, |
| height: contentHeight, |
| r: borderRadius |
| }); |
| |
| if (thisInvisible) { |
| // If invisible, do not set visual, otherwise the element will |
| // change immediately before animation. We think it is OK to |
| // remain its origin color when moving out of the view window. |
| processInvisible(content); |
| } |
| else { |
| content.invisible = false; |
| const nodeStyle = thisNode.getVisual('style') as PathStyleProps; |
| const visualColor = nodeStyle.fill; |
| const normalStyle = getItemStyleNormal(itemStyleNormalModel); |
| normalStyle.fill = visualColor; |
| normalStyle.decal = nodeStyle.decal; |
| const emphasisStyle = getStateItemStyle(itemStyleEmphasisModel); |
| const blurStyle = getStateItemStyle(itemStyleBlurModel); |
| const selectStyle = getStateItemStyle(itemStyleSelectModel); |
| |
| // PENDING: convert ZRColor to ColorString for text. |
| prepareText(content, visualColor as ColorString, nodeStyle.opacity, null); |
| |
| content.setStyle(normalStyle); |
| content.ensureState('emphasis').style = emphasisStyle; |
| content.ensureState('blur').style = blurStyle; |
| content.ensureState('select').style = selectStyle; |
| setDefaultStateProxy(content); |
| } |
| |
| group.add(content); |
| } |
| |
| function processInvisible(element: graphic.Rect) { |
| // Delay invisible setting utill animation finished, |
| // avoid element vanish suddenly before animation. |
| !element.invisible && willInvisibleEls.push(element); |
| } |
| |
| function prepareText( |
| rectEl: graphic.Rect, |
| visualColor: ColorString, |
| visualOpacity: number, |
| // Can be null/undefined |
| upperLabelRect: RectLike |
| ) { |
| const normalLabelModel = nodeModel.getModel( |
| upperLabelRect ? PATH_UPPERLABEL_NORMAL : PATH_LABEL_NOAMAL |
| ); |
| |
| const defaultText = convertOptionIdName(nodeModel.get('name'), null); |
| |
| const isShow = normalLabelModel.getShallow('show'); |
| |
| setLabelStyle( |
| rectEl, |
| getLabelStatesModels(nodeModel, upperLabelRect ? PATH_UPPERLABEL_NORMAL : PATH_LABEL_NOAMAL), |
| { |
| defaultText: isShow ? defaultText : null, |
| inheritColor: visualColor, |
| defaultOpacity: visualOpacity, |
| labelFetcher: seriesModel, |
| labelDataIndex: thisNode.dataIndex |
| } |
| ); |
| |
| const textEl = rectEl.getTextContent(); |
| if (!textEl) { |
| return; |
| } |
| const textStyle = textEl.style; |
| const textPadding = normalizeCssArray(textStyle.padding || 0); |
| |
| if (upperLabelRect) { |
| rectEl.setTextConfig({ |
| layoutRect: upperLabelRect |
| }); |
| (textEl as ECElement).disableLabelLayout = true; |
| } |
| textEl.beforeUpdate = function () { |
| const width = Math.max( |
| (upperLabelRect ? upperLabelRect.width : rectEl.shape.width) - textPadding[1] - textPadding[3], 0 |
| ); |
| const height = Math.max( |
| (upperLabelRect ? upperLabelRect.height : rectEl.shape.height) - textPadding[0] - textPadding[2], 0 |
| ); |
| if (textStyle.width !== width || textStyle.height !== height) { |
| textEl.setStyle({ |
| width, |
| height |
| }); |
| } |
| }; |
| |
| textStyle.truncateMinChar = 2; |
| textStyle.lineOverflow = 'truncate'; |
| |
| addDrillDownIcon(textStyle, upperLabelRect, thisLayout); |
| const textEmphasisState = textEl.getState('emphasis'); |
| addDrillDownIcon(textEmphasisState ? textEmphasisState.style : null, upperLabelRect, thisLayout); |
| } |
| |
| function addDrillDownIcon(style: TextStyleProps, upperLabelRect: RectLike, thisLayout: any) { |
| const text = style ? style.text : null; |
| if (!upperLabelRect && thisLayout.isLeafRoot && text != null) { |
| const iconChar = seriesModel.get('drillDownIcon', true); |
| style.text = iconChar ? iconChar + ' ' + text : text; |
| } |
| } |
| |
| function giveGraphic<T extends graphic.Group | graphic.Rect>( |
| storageName: keyof RenderElementStorage, |
| Ctor: {new(): T}, |
| depth?: number, |
| z?: number |
| ): T { |
| let element = oldRawIndex != null && oldStorage[storageName][oldRawIndex]; |
| const lasts = lastsForAnimation[storageName]; |
| |
| if (element) { |
| // Remove from oldStorage |
| oldStorage[storageName][oldRawIndex] = null; |
| prepareAnimationWhenHasOld(lasts, element); |
| } |
| // If invisible and no old element, do not create new element (for optimizing). |
| else if (!thisInvisible) { |
| element = new Ctor(); |
| if (element instanceof Displayable) { |
| element.z2 = calculateZ2(depth, z); |
| } |
| prepareAnimationWhenNoOld(lasts, element); |
| } |
| |
| // Set to thisStorage |
| return (thisStorage[storageName][thisRawIndex] = element) as T; |
| } |
| |
| function prepareAnimationWhenHasOld(lasts: LastCfg[], element: graphic.Group | graphic.Rect) { |
| const lastCfg = lasts[thisRawIndex] = {} as LastCfg; |
| if (element instanceof Group) { |
| lastCfg.oldX = element.x; |
| lastCfg.oldY = element.y; |
| } |
| else { |
| lastCfg.oldShape = extend({}, element.shape); |
| } |
| } |
| |
| // If a element is new, we need to find the animation start point carefully, |
| // otherwise it will looks strange when 'zoomToNode'. |
| function prepareAnimationWhenNoOld(lasts: LastCfg[], element: graphic.Group | graphic.Rect) { |
| const lastCfg = lasts[thisRawIndex] = {} as LastCfg; |
| const parentNode = thisNode.parentNode; |
| const isGroup = element instanceof graphic.Group; |
| |
| if (parentNode && (!reRoot || reRoot.direction === 'drillDown')) { |
| let parentOldX = 0; |
| let parentOldY = 0; |
| |
| // New nodes appear from right-bottom corner in 'zoomToNode' animation. |
| // For convenience, get old bounding rect from background. |
| const parentOldBg = lastsForAnimation.background[parentNode.getRawIndex()]; |
| if (!reRoot && parentOldBg && parentOldBg.oldShape) { |
| parentOldX = parentOldBg.oldShape.width; |
| parentOldY = parentOldBg.oldShape.height; |
| } |
| |
| // When no parent old shape found, its parent is new too, |
| // so we can just use {x:0, y:0}. |
| if (isGroup) { |
| lastCfg.oldX = 0; |
| lastCfg.oldY = parentOldY; |
| } |
| else { |
| lastCfg.oldShape = {x: parentOldX, y: parentOldY, width: 0, height: 0}; |
| } |
| } |
| |
| // Fade in, user can be aware that these nodes are new. |
| lastCfg.fadein = !isGroup; |
| } |
| |
| } |
| |
| // We can not set all backgroud with the same z, Because the behaviour of |
| // drill down and roll up differ background creation sequence from tree |
| // hierarchy sequence, which cause that lowser background element overlap |
| // upper ones. So we calculate z based on depth. |
| // Moreover, we try to shrink down z interval to [0, 1] to avoid that |
| // treemap with large z overlaps other components. |
| function calculateZ2(depth: number, z2InLevel: number) { |
| return depth * Z2_BASE + z2InLevel; |
| } |
| |
| export default TreemapView; |