/*
|
* 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 renderResult = this._doRender(containerGroup, seriesModel, reRoot);
|
(
|
!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
|
) {
|
if (!seriesModel.get('animation')) {
|
return;
|
}
|
|
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);
|
|
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');
|
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(
|
bg, visualBorderColor, upperLabelWidth, upperHeight, 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');
|
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);
|
|
prepareText(content, visualColor, contentWidth, nodeStyle.opacity, contentHeight);
|
|
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,
|
width: number,
|
height: number,
|
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();
|
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;
|