/* * 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 * as graphic from '../../util/graphic'; import {getECData} from '../../util/innerStore'; import SymbolClz from '../helper/Symbol'; import {radialCoordinate} from './layoutHelper'; import * as bbox from 'zrender/src/core/bbox'; import View from '../../coord/View'; import * as roamHelper from '../../component/helper/roamHelper'; import RoamController, { RoamControllerHost } from '../../component/helper/RoamController'; import {onIrrelevantElement} from '../../component/helper/cursorHelper'; import {parsePercent} from '../../util/number'; import ChartView from '../../view/Chart'; import TreeSeriesModel, { TreeSeriesOption, TreeSeriesNodeItemOption } from './TreeSeries'; import Path, { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { TreeNode } from '../../data/Tree'; import SeriesData from '../../data/SeriesData'; import { setStatesStylesFromModel, setStatesFlag, setDefaultStateProxy, HOVER_STATE_BLUR } from '../../util/states'; import { AnimationOption, ECElement } from '../../util/types'; type TreeSymbol = SymbolClz & { __edge: graphic.BezierCurve | TreePath __radialOldRawX: number __radialOldRawY: number __radialRawX: number __radialRawY: number __oldX: number __oldY: number }; class TreeEdgeShape { parentPoint: number[] = []; childPoints: number[][] = []; orient: TreeSeriesOption['orient']; forkPosition: TreeSeriesOption['edgeForkPosition']; } interface TreeEdgePathProps extends PathProps { shape?: Partial } interface TreeNodeLayout { x: number y: number rawX: number rawY: number } class TreePath extends Path { shape: TreeEdgeShape; constructor(opts?: TreeEdgePathProps) { super(opts); } getDefaultStyle() { return { stroke: '#000', fill: null as string }; } getDefaultShape() { return new TreeEdgeShape(); } buildPath(ctx: CanvasRenderingContext2D, shape: TreeEdgeShape) { const childPoints = shape.childPoints; const childLen = childPoints.length; const parentPoint = shape.parentPoint; const firstChildPos = childPoints[0]; const lastChildPos = childPoints[childLen - 1]; if (childLen === 1) { ctx.moveTo(parentPoint[0], parentPoint[1]); ctx.lineTo(firstChildPos[0], firstChildPos[1]); return; } const orient = shape.orient; const forkDim = (orient === 'TB' || orient === 'BT') ? 0 : 1; const otherDim = 1 - forkDim; const forkPosition = parsePercent(shape.forkPosition, 1); const tmpPoint = []; tmpPoint[forkDim] = parentPoint[forkDim]; tmpPoint[otherDim] = parentPoint[otherDim] + (lastChildPos[otherDim] - parentPoint[otherDim]) * forkPosition; ctx.moveTo(parentPoint[0], parentPoint[1]); ctx.lineTo(tmpPoint[0], tmpPoint[1]); ctx.moveTo(firstChildPos[0], firstChildPos[1]); tmpPoint[forkDim] = firstChildPos[forkDim]; ctx.lineTo(tmpPoint[0], tmpPoint[1]); tmpPoint[forkDim] = lastChildPos[forkDim]; ctx.lineTo(tmpPoint[0], tmpPoint[1]); ctx.lineTo(lastChildPos[0], lastChildPos[1]); for (let i = 1; i < childLen - 1; i++) { const point = childPoints[i]; ctx.moveTo(point[0], point[1]); tmpPoint[forkDim] = point[forkDim]; ctx.lineTo(tmpPoint[0], tmpPoint[1]); } } } class TreeView extends ChartView { static readonly type = 'tree'; readonly type = TreeView.type; private _mainGroup = new graphic.Group(); private _controller: RoamController; private _controllerHost: RoamControllerHost; private _data: SeriesData; private _nodeScaleRatio: number; private _min: number[]; private _max: number[]; init(ecModel: GlobalModel, api: ExtensionAPI) { this._controller = new RoamController(api.getZr()); this._controllerHost = { target: this.group } as RoamControllerHost; this.group.add(this._mainGroup); } render( seriesModel: TreeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI ) { const data = seriesModel.getData(); const layoutInfo = seriesModel.layoutInfo; const group = this._mainGroup; const layout = seriesModel.get('layout'); if (layout === 'radial') { group.x = layoutInfo.x + layoutInfo.width / 2; group.y = layoutInfo.y + layoutInfo.height / 2; } else { group.x = layoutInfo.x; group.y = layoutInfo.y; } this._updateViewCoordSys(seriesModel, api); this._updateController(seriesModel, ecModel, api); const oldData = this._data; data.diff(oldData) .add(function (newIdx) { if (symbolNeedsDraw(data, newIdx)) { // Create node and edge updateNode(data, newIdx, null, group, seriesModel); } }) .update(function (newIdx, oldIdx) { const symbolEl = oldData.getItemGraphicEl(oldIdx) as TreeSymbol; if (!symbolNeedsDraw(data, newIdx)) { symbolEl && removeNode(oldData, oldIdx, symbolEl, group, seriesModel); return; } // Update node and edge updateNode(data, newIdx, symbolEl, group, seriesModel); }) .remove(function (oldIdx) { const symbolEl = oldData.getItemGraphicEl(oldIdx) as TreeSymbol; // When remove a collapsed node of subtree, since the collapsed // node haven't been initialized with a symbol element, // you can't found it's symbol element through index. // so if we want to remove the symbol element we should insure // that the symbol element is not null. if (symbolEl) { removeNode(oldData, oldIdx, symbolEl, group, seriesModel); } }) .execute(); this._nodeScaleRatio = seriesModel.get('nodeScaleRatio'); this._updateNodeAndLinkScale(seriesModel); if (seriesModel.get('expandAndCollapse') === true) { data.eachItemGraphicEl(function (el, dataIndex) { el.off('click').on('click', function () { api.dispatchAction({ type: 'treeExpandAndCollapse', seriesId: seriesModel.id, dataIndex: dataIndex }); }); }); } this._data = data; } _updateViewCoordSys(seriesModel: TreeSeriesModel, api: ExtensionAPI) { const data = seriesModel.getData(); const points: number[][] = []; data.each(function (idx) { const layout = data.getItemLayout(idx); if (layout && !isNaN(layout.x) && !isNaN(layout.y)) { points.push([+layout.x, +layout.y]); } }); const min: number[] = []; const max: number[] = []; bbox.fromPoints(points, min, max); // If don't Store min max when collapse the root node after roam, // the root node will disappear. const oldMin = this._min; const oldMax = this._max; // If width or height is 0 if (max[0] - min[0] === 0) { min[0] = oldMin ? oldMin[0] : min[0] - 1; max[0] = oldMax ? oldMax[0] : max[0] + 1; } if (max[1] - min[1] === 0) { min[1] = oldMin ? oldMin[1] : min[1] - 1; max[1] = oldMax ? oldMax[1] : max[1] + 1; } const viewCoordSys = seriesModel.coordinateSystem = new View(); viewCoordSys.zoomLimit = seriesModel.get('scaleLimit'); viewCoordSys.setBoundingRect(min[0], min[1], max[0] - min[0], max[1] - min[1]); viewCoordSys.setCenter(seriesModel.get('center'), api); viewCoordSys.setZoom(seriesModel.get('zoom')); // Here we use viewCoordSys just for computing the 'position' and 'scale' of the group this.group.attr({ x: viewCoordSys.x, y: viewCoordSys.y, scaleX: viewCoordSys.scaleX, scaleY: viewCoordSys.scaleY }); this._min = min; this._max = max; } _updateController( seriesModel: TreeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI ) { const controller = this._controller; const controllerHost = this._controllerHost; const group = this.group; controller.setPointerChecker(function (e, x, y) { const rect = group.getBoundingRect(); rect.applyTransform(group.transform); return rect.contain(x, y) && !onIrrelevantElement(e, api, seriesModel); }); controller.enable(seriesModel.get('roam')); controllerHost.zoomLimit = seriesModel.get('scaleLimit'); controllerHost.zoom = seriesModel.coordinateSystem.getZoom(); controller .off('pan') .off('zoom') .on('pan', (e) => { roamHelper.updateViewOnPan(controllerHost, e.dx, e.dy); api.dispatchAction({ seriesId: seriesModel.id, type: 'treeRoam', dx: e.dx, dy: e.dy }); }) .on('zoom', (e) => { roamHelper.updateViewOnZoom(controllerHost, e.scale, e.originX, e.originY); api.dispatchAction({ seriesId: seriesModel.id, type: 'treeRoam', zoom: e.scale, originX: e.originX, originY: e.originY }); this._updateNodeAndLinkScale(seriesModel); // Only update label layout on zoom api.updateLabelLayout(); }); } _updateNodeAndLinkScale(seriesModel: TreeSeriesModel) { const data = seriesModel.getData(); const nodeScale = this._getNodeGlobalScale(seriesModel); data.eachItemGraphicEl(function (el: SymbolClz, idx) { el.setSymbolScale(nodeScale); }); } _getNodeGlobalScale(seriesModel: TreeSeriesModel) { const coordSys = seriesModel.coordinateSystem; if (coordSys.type !== 'view') { return 1; } const nodeScaleRatio = this._nodeScaleRatio; const groupZoom = coordSys.scaleX || 1; // Scale node when zoom changes const roamZoom = coordSys.getZoom(); const nodeScale = (roamZoom - 1) * nodeScaleRatio + 1; return nodeScale / groupZoom; } dispose() { this._controller && this._controller.dispose(); this._controllerHost = null; } remove() { this._mainGroup.removeAll(); this._data = null; } } function symbolNeedsDraw(data: SeriesData, dataIndex: number) { const layout = data.getItemLayout(dataIndex); return layout && !isNaN(layout.x) && !isNaN(layout.y); } function updateNode( data: SeriesData, dataIndex: number, symbolEl: TreeSymbol, group: graphic.Group, seriesModel: TreeSeriesModel ) { const isInit = !symbolEl; const node = data.tree.getNodeByDataIndex(dataIndex); const itemModel = node.getModel(); const visualColor = (node.getVisual('style') as PathStyleProps).fill; const symbolInnerColor = node.isExpand === false && node.children.length !== 0 ? visualColor : '#fff'; const virtualRoot = data.tree.root; const source = node.parentNode === virtualRoot ? node : node.parentNode || node; const sourceSymbolEl = data.getItemGraphicEl(source.dataIndex) as TreeSymbol; const sourceLayout = source.getLayout() as TreeNodeLayout; const sourceOldLayout = sourceSymbolEl ? { x: sourceSymbolEl.__oldX, y: sourceSymbolEl.__oldY, rawX: sourceSymbolEl.__radialOldRawX, rawY: sourceSymbolEl.__radialOldRawY } : sourceLayout; const targetLayout = node.getLayout(); if (isInit) { symbolEl = new SymbolClz(data, dataIndex, null, { symbolInnerColor, useNameLabel: true }) as TreeSymbol; symbolEl.x = sourceOldLayout.x; symbolEl.y = sourceOldLayout.y; } else { symbolEl.updateData(data, dataIndex, null, { symbolInnerColor, useNameLabel: true }); } symbolEl.__radialOldRawX = symbolEl.__radialRawX; symbolEl.__radialOldRawY = symbolEl.__radialRawY; symbolEl.__radialRawX = targetLayout.rawX; symbolEl.__radialRawY = targetLayout.rawY; group.add(symbolEl); data.setItemGraphicEl(dataIndex, symbolEl); symbolEl.__oldX = symbolEl.x; symbolEl.__oldY = symbolEl.y; graphic.updateProps(symbolEl, { x: targetLayout.x, y: targetLayout.y }, seriesModel); const symbolPath = symbolEl.getSymbolPath(); if (seriesModel.get('layout') === 'radial') { const realRoot = virtualRoot.children[0]; const rootLayout = realRoot.getLayout(); const length = realRoot.children.length; let rad; let isLeft; if (targetLayout.x === rootLayout.x && node.isExpand === true && realRoot.children.length) { const center = { x: (realRoot.children[0].getLayout().x + realRoot.children[length - 1].getLayout().x) / 2, y: (realRoot.children[0].getLayout().y + realRoot.children[length - 1].getLayout().y) / 2 }; rad = Math.atan2(center.y - rootLayout.y, center.x - rootLayout.x); if (rad < 0) { rad = Math.PI * 2 + rad; } isLeft = center.x < rootLayout.x; if (isLeft) { rad = rad - Math.PI; } } else { rad = Math.atan2(targetLayout.y - rootLayout.y, targetLayout.x - rootLayout.x); if (rad < 0) { rad = Math.PI * 2 + rad; } if (node.children.length === 0 || (node.children.length !== 0 && node.isExpand === false)) { isLeft = targetLayout.x < rootLayout.x; if (isLeft) { rad = rad - Math.PI; } } else { isLeft = targetLayout.x > rootLayout.x; if (!isLeft) { rad = rad - Math.PI; } } } const textPosition = isLeft ? 'left' as const : 'right' as const; const normalLabelModel = itemModel.getModel('label'); const rotate = normalLabelModel.get('rotate'); const labelRotateRadian = rotate * (Math.PI / 180); const textContent = symbolPath.getTextContent(); if (textContent) { symbolPath.setTextConfig({ position: normalLabelModel.get('position') || textPosition, rotation: rotate == null ? -rad : labelRotateRadian, origin: 'center' }); textContent.setStyle('verticalAlign', 'middle'); } } // Handle status const focus = itemModel.get(['emphasis', 'focus']); const focusDataIndices: number[] = focus === 'relative' ? zrUtil.concatArray(node.getAncestorsIndices(), node.getDescendantIndices()) as number[] : focus === 'ancestor' ? node.getAncestorsIndices() : focus === 'descendant' ? node.getDescendantIndices() : null; if (focusDataIndices) { // Modify the focus to data indices. getECData(symbolEl).focus = focusDataIndices; } drawEdge( seriesModel, node, virtualRoot, symbolEl, sourceOldLayout, sourceLayout, targetLayout, group ); if (symbolEl.__edge) { (symbolEl as ECElement).onHoverStateChange = function (toState) { if (toState !== 'blur') { // NOTE: Ensure the parent elements will been blurred firstly. // According to the return of getAncestorsIndices and getDescendantIndices // TODO: A bit tricky. const parentEl = node.parentNode && data.getItemGraphicEl(node.parentNode.dataIndex); if (!(parentEl && (parentEl as ECElement).hoverState === HOVER_STATE_BLUR)) { setStatesFlag(symbolEl.__edge, toState); } } }; } } function drawEdge( seriesModel: TreeSeriesModel, node: TreeNode, virtualRoot: TreeNode, symbolEl: TreeSymbol, sourceOldLayout: TreeNodeLayout, sourceLayout: TreeNodeLayout, targetLayout: TreeNodeLayout, group: graphic.Group ) { const itemModel = node.getModel(); const edgeShape = seriesModel.get('edgeShape'); const layout = seriesModel.get('layout'); const orient = seriesModel.getOrient(); const curvature = seriesModel.get(['lineStyle', 'curveness']); const edgeForkPosition = seriesModel.get('edgeForkPosition'); const lineStyle = itemModel.getModel('lineStyle').getLineStyle(); let edge = symbolEl.__edge; // curve edge from node -> parent // polyline edge from node -> children if (edgeShape === 'curve') { if (node.parentNode && node.parentNode !== virtualRoot) { if (!edge) { edge = symbolEl.__edge = new graphic.BezierCurve({ shape: getEdgeShape(layout, orient, curvature, sourceOldLayout, sourceOldLayout) }); } graphic.updateProps(edge as Path, { shape: getEdgeShape(layout, orient, curvature, sourceLayout, targetLayout) }, seriesModel); } } else if (edgeShape === 'polyline') { if (layout === 'orthogonal') { if (node !== virtualRoot && node.children && (node.children.length !== 0) && (node.isExpand === true)) { const children = node.children; const childPoints = []; for (let i = 0; i < children.length; i++) { const childLayout = children[i].getLayout(); childPoints.push([childLayout.x, childLayout.y]); } if (!edge) { edge = symbolEl.__edge = new TreePath({ shape: { parentPoint: [targetLayout.x, targetLayout.y], childPoints: [[targetLayout.x, targetLayout.y]], orient: orient, forkPosition: edgeForkPosition } }); } graphic.updateProps(edge as Path, { shape: { parentPoint: [targetLayout.x, targetLayout.y], childPoints: childPoints } }, seriesModel); } } else { if (__DEV__) { throw new Error('The polyline edgeShape can only be used in orthogonal layout'); } } } // show all edge when edgeShape is 'curve', filter node `isExpand` is false when edgeShape is 'polyline' if (edge && !(edgeShape === 'polyline' && !node.isExpand)) { edge.useStyle(zrUtil.defaults({ strokeNoScale: true, fill: null }, lineStyle)); setStatesStylesFromModel(edge, itemModel, 'lineStyle'); setDefaultStateProxy(edge); group.add(edge); } } function removeNodeEdge( node: TreeNode, data: SeriesData, group: graphic.Group, seriesModel: TreeSeriesModel, removeAnimationOpt: AnimationOption ) { const virtualRoot = data.tree.root; const { source, sourceLayout } = getSourceNode(virtualRoot, node); const symbolEl: TreeSymbol = data.getItemGraphicEl(node.dataIndex) as TreeSymbol; if (!symbolEl) { return; } const sourceSymbolEl = data.getItemGraphicEl(source.dataIndex) as TreeSymbol; const sourceEdge = sourceSymbolEl.__edge; // 1. when expand the sub tree, delete the children node should delete the edge of // the source at the same time. because the polyline edge shape is only owned by the source. // 2.when the node is the only children of the source, delete the node should delete the edge of // the source at the same time. the same reason as above. const edge = symbolEl.__edge || ((source.isExpand === false || source.children.length === 1) ? sourceEdge : undefined); const edgeShape = seriesModel.get('edgeShape'); const layoutOpt = seriesModel.get('layout'); const orient = seriesModel.get('orient'); const curvature = seriesModel.get(['lineStyle', 'curveness']); if (edge) { if (edgeShape === 'curve') { graphic.removeElement(edge as Path, { shape: getEdgeShape( layoutOpt, orient, curvature, sourceLayout, sourceLayout ), style: { opacity: 0 } }, seriesModel, { cb() { group.remove(edge); }, removeOpt: removeAnimationOpt }); } else if (edgeShape === 'polyline' && seriesModel.get('layout') === 'orthogonal') { graphic.removeElement(edge as Path, { shape: { parentPoint: [sourceLayout.x, sourceLayout.y], childPoints: [[sourceLayout.x, sourceLayout.y]] }, style: { opacity: 0 } }, seriesModel, { cb() { group.remove(edge); }, removeOpt: removeAnimationOpt }); } } } function getSourceNode(virtualRoot: TreeNode, node: TreeNode): { source: TreeNode, sourceLayout: TreeNodeLayout } { let source = node.parentNode === virtualRoot ? node : node.parentNode || node; let sourceLayout; while (sourceLayout = source.getLayout(), sourceLayout == null) { source = source.parentNode === virtualRoot ? source : source.parentNode || source; } return { source, sourceLayout }; } function removeNode( data: SeriesData, dataIndex: number, symbolEl: TreeSymbol, group: graphic.Group, seriesModel: TreeSeriesModel ) { const node = data.tree.getNodeByDataIndex(dataIndex); const virtualRoot = data.tree.root; const { sourceLayout } = getSourceNode(virtualRoot, node); // Use same duration and easing with update to have more consistent animation. const removeAnimationOpt = { duration: seriesModel.get('animationDurationUpdate') as number, easing: seriesModel.get('animationEasingUpdate') }; graphic.removeElement(symbolEl, { x: sourceLayout.x + 1, y: sourceLayout.y + 1 }, seriesModel, { cb() { group.remove(symbolEl); data.setItemGraphicEl(dataIndex, null); }, removeOpt: removeAnimationOpt }); symbolEl.fadeOut(null, data.hostModel as TreeSeriesModel, { fadeLabel: true, animation: removeAnimationOpt }); // remove edge as parent node node.children.forEach(childNode => { removeNodeEdge(childNode, data, group, seriesModel, removeAnimationOpt); }); // remove edge as child node removeNodeEdge(node, data, group, seriesModel, removeAnimationOpt); } function getEdgeShape( layoutOpt: TreeSeriesOption['layout'], orient: TreeSeriesOption['orient'], curvature: number, sourceLayout: TreeNodeLayout, targetLayout: TreeNodeLayout ) { let cpx1: number; let cpy1: number; let cpx2: number; let cpy2: number; let x1: number; let x2: number; let y1: number; let y2: number; if (layoutOpt === 'radial') { x1 = sourceLayout.rawX; y1 = sourceLayout.rawY; x2 = targetLayout.rawX; y2 = targetLayout.rawY; const radialCoor1 = radialCoordinate(x1, y1); const radialCoor2 = radialCoordinate(x1, y1 + (y2 - y1) * curvature); const radialCoor3 = radialCoordinate(x2, y2 + (y1 - y2) * curvature); const radialCoor4 = radialCoordinate(x2, y2); return { x1: radialCoor1.x || 0, y1: radialCoor1.y || 0, x2: radialCoor4.x || 0, y2: radialCoor4.y || 0, cpx1: radialCoor2.x || 0, cpy1: radialCoor2.y || 0, cpx2: radialCoor3.x || 0, cpy2: radialCoor3.y || 0 }; } else { x1 = sourceLayout.x; y1 = sourceLayout.y; x2 = targetLayout.x; y2 = targetLayout.y; if (orient === 'LR' || orient === 'RL') { cpx1 = x1 + (x2 - x1) * curvature; cpy1 = y1; cpx2 = x2 + (x1 - x2) * curvature; cpy2 = y2; } if (orient === 'TB' || orient === 'BT') { cpx1 = x1; cpy1 = y1 + (y2 - y1) * curvature; cpx2 = x2; cpy2 = y2 + (y1 - y2) * curvature; } } return { x1: x1, y1: y1, x2: x2, y2: y2, cpx1: cpx1, cpy1: cpy1, cpx2: cpx2, cpy2: cpy2 }; } export default TreeView;