You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@echarts.apache.org by su...@apache.org on 2021/09/23 18:32:50 UTC

[echarts] 04/04: fix: (1) Fix the self-loop edge layout strategy in 'simple' layout. (2) Add test case `test/graph-self-loop.html`. (3) Remove some necessary code.

This is an automated email from the ASF dual-hosted git repository.

sushuang pushed a commit to branch feat/HCLacids-NodeSelf-fix
in repository https://gitbox.apache.org/repos/asf/echarts.git

commit 0566c7b0d7ca8216adf0e197bf89f118d0a7d126
Author: sushuang <su...@gmail.com>
AuthorDate: Fri Sep 24 02:31:05 2021 +0800

    fix:
    (1) Fix the self-loop edge layout strategy in 'simple' layout.
    (2) Add test case `test/graph-self-loop.html`.
    (3) Remove some necessary code.
---
 src/chart/graph/GraphView.ts                |  20 +-
 src/chart/graph/edgeVisual.ts               |  24 --
 src/chart/graph/graphHelper.ts              |   6 +
 src/chart/graph/layoutHelper.ts             | 262 ++++++++++++++
 src/chart/graph/simpleLayoutHelper.ts       |  88 +----
 src/chart/helper/multipleGraphEdgeHelper.ts |   3 +-
 test/graph-self-loop.html                   | 355 +++++++++++++++++++
 test/lib/enableGraphEditRoughly.js          | 512 ++++++++++++++++++++++++++++
 8 files changed, 1165 insertions(+), 105 deletions(-)

diff --git a/src/chart/graph/GraphView.ts b/src/chart/graph/GraphView.ts
index 1c8d10f..98b5e3b 100644
--- a/src/chart/graph/GraphView.ts
+++ b/src/chart/graph/GraphView.ts
@@ -36,6 +36,7 @@ import Symbol from '../helper/Symbol';
 import List from '../../data/List';
 import Line from '../helper/Line';
 import { getECData } from '../../util/innerStore';
+import { layoutSelfLoopEdges } from './layoutHelper';
 
 function isViewCoordSys(coordSys: CoordinateSystem): coordSys is View {
     return coordSys.type === 'view';
@@ -102,7 +103,7 @@ class GraphView extends ChartView {
             }
         }
         // Fix edge contact point with node
-        adjustEdge(seriesModel.getGraph(), getNodeGlobalScale(seriesModel));
+        postLayoutEdges(seriesModel);
 
         const data = seriesModel.getData();
         symbolDraw.updateData(data as ListForSymbolDraw);
@@ -274,7 +275,7 @@ class GraphView extends ChartView {
                     originY: e.originY
                 });
                 this._updateNodeAndLinkScale();
-                adjustEdge(seriesModel.getGraph(), getNodeGlobalScale(seriesModel));
+                postLayoutEdges(seriesModel);
                 this._lineDraw.updateLayout();
                 // Only update label layout on zoom
                 api.updateLabelLayout();
@@ -293,7 +294,7 @@ class GraphView extends ChartView {
     }
 
     updateLayout(seriesModel: GraphSeriesModel) {
-        adjustEdge(seriesModel.getGraph(), getNodeGlobalScale(seriesModel));
+        postLayoutEdges(seriesModel);
 
         this._symbolDraw.updateLayout();
         this._lineDraw.updateLayout();
@@ -305,4 +306,17 @@ class GraphView extends ChartView {
     }
 }
 
+function postLayoutEdges(seriesModel: GraphSeriesModel): void {
+    const graph = seriesModel.getGraph();
+
+    // PENDING:
+    // `scaleOnCoordSys` will be changed when zooming.
+    // At present the layout stage will not be called when zooming. So
+    // we put these process here.
+    const nodeScaleOnCoordSys = getNodeGlobalScale(seriesModel);
+
+    layoutSelfLoopEdges(graph, nodeScaleOnCoordSys);
+    adjustEdge(graph, nodeScaleOnCoordSys);
+}
+
 export default GraphView;
\ No newline at end of file
diff --git a/src/chart/graph/edgeVisual.ts b/src/chart/graph/edgeVisual.ts
index f0d0208..7107399 100644
--- a/src/chart/graph/edgeVisual.ts
+++ b/src/chart/graph/edgeVisual.ts
@@ -20,9 +20,6 @@
 import GlobalModel from '../../model/Global';
 import GraphSeriesModel, { GraphEdgeItemOption } from './GraphSeries';
 import { extend } from 'zrender/src/core/util';
-import { intersectCurveCircle } from './adjustEdge'
-import { getNodeGlobalScale, getSymbolSize } from './graphHelper';
-import { cubicDerivativeAt } from 'zrender/src/core/curve';
 
 function normalize(a: string | string[]): string[];
 function normalize(a: number | number[]): number[];
@@ -53,7 +50,6 @@ export default function graphEdgeVisual(ecModel: GlobalModel) {
         edgeData.each(function (idx) {
             const itemModel = edgeData.getItemModel<GraphEdgeItemOption>(idx);
             const edge = graph.getEdgeByIndex(idx);
-            const toSymbol = edge.getVisual('toSymbol');
             const symbolType = normalize(itemModel.getShallow('symbol', true));
             const symbolSize = normalize(itemModel.getShallow('symbolSize', true));
             // Edge visual must after node visual
@@ -79,26 +75,6 @@ export default function graphEdgeVisual(ecModel: GlobalModel) {
             symbolType[1] && edge.setVisual('toSymbol', symbolType[1]);
             symbolSize[0] && edge.setVisual('fromSymbolSize', symbolSize[0]);
             symbolSize[1] && edge.setVisual('toSymbolSize', symbolSize[1]);
-
-           
-            if (edge.node1 === edge.node2 && toSymbol && toSymbol !== 'none') {
-                const edgeData = edge.getLayout();
-                const size = getSymbolSize(edge.node1);
-                const radius = getNodeGlobalScale(seriesModel) * size / 2;
-                
-                let t = intersectCurveCircle(edgeData, edgeData[0], radius);
-                if (t < 0.5) {
-                    t = 1 - t;
-                }
-                const tdx = cubicDerivativeAt(edgeData[0][0], edgeData[1][0], edgeData[2][0], edgeData[3][0], t);
-                const tdy = cubicDerivativeAt(edgeData[0][1], edgeData[1][1], edgeData[2][1], edgeData[3][1], t);
-                const degree = Math.atan2(tdy, tdx) / Math.PI * 180;
-                if( degree > 90 || degree < 0 && degree > -90) {
-                    edge.setVisual('toSymbolRotate', degree + 188);
-                } else {
-                    edge.setVisual('toSymbolRotate', degree - 8);
-                }
-            }
         });
     });
 }
\ No newline at end of file
diff --git a/src/chart/graph/graphHelper.ts b/src/chart/graph/graphHelper.ts
index 745a79b..6f03fb3 100644
--- a/src/chart/graph/graphHelper.ts
+++ b/src/chart/graph/graphHelper.ts
@@ -21,6 +21,12 @@ import GraphSeriesModel from './GraphSeries';
 import View from '../../coord/View';
 import { GraphNode } from '../../data/Graph';
 
+/**
+ * @return scale based on `View` coordinate system.
+ *         The final displayed pixel size is
+ *         `option.symbolSize * scale * view.getGlobalScale()`,
+ *         which will be changed when zooming.
+ */
 export function getNodeGlobalScale(seriesModel: GraphSeriesModel) {
     const coordSys = seriesModel.coordinateSystem as View;
     if (coordSys.type !== 'view') {
diff --git a/src/chart/graph/layoutHelper.ts b/src/chart/graph/layoutHelper.ts
new file mode 100644
index 0000000..cd1761b
--- /dev/null
+++ b/src/chart/graph/layoutHelper.ts
@@ -0,0 +1,262 @@
+/*
+* 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 Graph, { GraphEdge, GraphNode } from '../../data/Graph';
+import { sub, VectorArray } from 'zrender/src/core/vector';
+import { assert, bind, each, retrieve2 } from 'zrender/src/core/util';
+import { GraphEdgeItemOption } from './GraphSeries';
+import { getSymbolSize } from './graphHelper';
+
+
+type Radian = number;
+
+type NodeAttrOnEdge = 'node1' | 'node2';
+
+interface EdgeWrap {
+    /**
+     * Vector of the tangent line at the center node.
+     */
+    tangentVec: VectorArray;
+    /**
+     * Radian of tangentVec (to x positive) of tangentVec. [-Math.PI / 2, Math.PI / 2].
+     */
+    radToXPosi: Radian;
+}
+
+interface SectionWrap {
+    /**
+     * Radian of tangentVec (to x positive) of tangentVec. [-Math.PI / 2, Math.PI / 2].
+     * Make sure radToXPosiStart <= radToXPosiEnd.
+     */
+    radToXPosiStart: Radian;
+    radToXPosiEnd: Radian;
+
+    /**
+     * The count of edges that assign to this section.
+     */
+    edgeCount: number;
+}
+
+const MATH_PI = Math.PI;
+const MATH_2PI = MATH_PI * 2;
+
+/**
+ * This is the radian of the intersection angle of the two control
+ * point of a self-loop cubic bezier edge.
+ * If the angle is bigger or smaller, the cubic curve is not pretty.
+ */
+const MAX_EDGE_SECTION_RADIAN = MATH_PI - getRadianToXPositive([4, 5.5]) * 2;
+const MIN_EDGE_SECTION_RADIAN = MATH_PI / 3;
+
+
+/**
+ * @caution This method should only be called after all
+ * nodes and non-self-loop edges layout finished.
+ *
+ * @note [Self-loop edge layout strategy]:
+ * To make it have good looking when there are muliple self-loop edges,
+ * place them from the biggest angle (> 60 degree) one by one.
+ * If there is no enough angles, put mulitple edges in one angle
+ * and use different curvenesses.
+ *
+ * @pending Should the section angle and self-loop edge direction be able to set by user?
+ * `curveness` can not express it.
+ *
+ * @pending Consider the self-loop edge might overlow the canvas.
+ * When calculating the view transform, there is no self-loop layout info yet.
+ */
+export function layoutSelfLoopEdges(
+    graph: Graph,
+    // Get from `getNodeGlobalScale(seriesModel)`
+    nodeScaleOnCoordSys: number
+): void {
+    graph.eachNode(node => {
+        const selfLoopEdges: GraphEdge[] = [];
+        // inEdges includes outEdges if self-loop.
+        each(node.inEdges, edge => {
+            if (isSelfLoopEdge(edge)) {
+                selfLoopEdges.push(edge);
+            }
+        });
+
+        if (selfLoopEdges.length) {
+            const sectionList = prepareSectionList(node, selfLoopEdges.length);
+            placeSelfLoopEdges(node, sectionList, selfLoopEdges, nodeScaleOnCoordSys);
+        }
+    });
+}
+
+/**
+ * @return Sections that can arrange self-loop angles. Ensure that:
+ *         `selfLoopEdgeCount <= sectionList.reduce((sum, sec) === sec.edgeCount + sum, 0)`
+ */
+function prepareSectionList(centerNode: GraphNode, selfLoopEdgeCount: number): SectionWrap[] {
+    const adjacentEdges: EdgeWrap[] = [];
+    function addAdjacentEdge(centerNodeAttr: NodeAttrOnEdge, edge: GraphEdge): void {
+        if (isSelfLoopEdge(edge)) {
+            return;
+        }
+        const tangentVec = getTangentVector(edge, centerNodeAttr);
+        const radToXPosi = getRadianToXPositive(tangentVec);
+        adjacentEdges.push({ tangentVec, radToXPosi });
+    }
+    each(centerNode.inEdges, bind(addAdjacentEdge, null, 'node2'));
+    each(centerNode.outEdges, bind(addAdjacentEdge, null, 'node1'));
+
+    // Sort by radian asc.
+    adjacentEdges.sort((edgeA, edgeB) => edgeA.radToXPosi - edgeB.radToXPosi);
+
+    let availableEdgeCount = 0;
+    const sectionList: SectionWrap[] = [];
+    for (let i = 0, len = adjacentEdges.length; i < len; i++) {
+        const radToXPosiStart = adjacentEdges[i].radToXPosi;
+        const radToXPosiEnd = i < len - 1
+            ? adjacentEdges[i + 1].radToXPosi
+            : adjacentEdges[0].radToXPosi + MATH_2PI;
+
+        // Make sure radToXPosiStart <= radToXPosiEnd.
+        const rad2Minus1 = radToXPosiEnd - radToXPosiStart;
+
+        if (rad2Minus1 >= MIN_EDGE_SECTION_RADIAN) {
+            sectionList.push({ radToXPosiStart, radToXPosiEnd, edgeCount: 0 });
+        }
+        availableEdgeCount += rad2Minus1 / MIN_EDGE_SECTION_RADIAN;
+    }
+
+    if (availableEdgeCount >= selfLoopEdgeCount) {
+        for (let iEdge = 0; iEdge < selfLoopEdgeCount; iEdge++) {
+            // Find the largest section to arrange an edge.
+            let iSecInMax = 0;
+            let secRadInMax = 0;
+            for (let iSec = 0; iSec < sectionList.length; iSec++) {
+                const thisSec = sectionList[iSec];
+                // If a section is too larger than anohter section, split that large section and
+                // arrange multiple edges in it is probably better then arrange only one edge in
+                // the large section.
+                const rad = (thisSec.radToXPosiEnd - thisSec.radToXPosiStart) / (thisSec.edgeCount + 1);
+                if (rad > secRadInMax) {
+                    secRadInMax = rad;
+                    iSecInMax = iSec;
+                }
+            }
+            sectionList[iSecInMax].edgeCount++;
+        }
+    }
+    // In this case there are probably too many edge on a node, and intersection between
+    // edges can not avoid. So we do not care about intersection any more.
+    else {
+        sectionList.length = 0;
+        sectionList.push({
+            radToXPosiStart: -MATH_PI / 2,
+            radToXPosiEnd: -MATH_PI / 2 + MATH_2PI,
+            edgeCount: selfLoopEdgeCount
+        });
+    }
+
+    return sectionList;
+}
+
+/**
+ * @return cubic bezier curve: [p1, p2, cp1, cp2]
+ */
+function placeSelfLoopEdges(
+    centerNode: GraphNode,
+    sectionList: SectionWrap[],
+    selfLoopEdges: GraphEdge[],
+    nodeScaleOnCoordSys: number
+): void {
+    const symbolSize = getSymbolSize(centerNode);
+    const centerPt = centerNode.getLayout();
+
+    function getCubicControlPoint(radToXPosi: number, cpDistToCenter: number): number[] {
+        return [
+            Math.cos(radToXPosi) * cpDistToCenter + centerPt[0],
+            Math.sin(radToXPosi) * cpDistToCenter + centerPt[1]
+        ];
+    };
+
+    let iEdge = 0;
+    each(sectionList, section => {
+        const secEdgeCount = section.edgeCount;
+        if (!secEdgeCount) {
+            // No self-loop edge arranged in this section.
+            return;
+        }
+
+        const secRadStart = section.radToXPosiStart;
+        const secRadEnd = section.radToXPosiEnd;
+        const splitRadHalfSpan = (secRadEnd - secRadStart) / section.edgeCount / 2;
+        const edgeRadHalfSpan = Math.min(splitRadHalfSpan, MAX_EDGE_SECTION_RADIAN / 2);
+
+        // const radMid = secRadStart + secRadSpan / section.edgeCount * (iEdge - iEdgeFirstInSec);
+        for (let iEdgeInSec = 0; iEdgeInSec < section.edgeCount; iEdgeInSec++) {
+            const edge = selfLoopEdges[iEdge++];
+            const cpMidRad = secRadStart + splitRadHalfSpan * (iEdgeInSec * 2 + 1);
+
+            // This is a experimental strategy to make it look better:
+            // If the symbol size is small, the bezier control point need to be far from the
+            // center to make the buckle obvious, while if the symbol size is big, the control
+            // ponit should not too far to make the buckle too significant.
+            // So we alway make control point dist to symbol radius `100`, and enable users to
+            // use option `curveness` to adjust it.
+            // Becuase at present we do not layout multiple self-loop edges into single
+            // `[cp1Rad, cp2Rad]`, we do not use option `autoCurveness`.
+            const curveness = retrieve2(
+                edge.getModel<GraphEdgeItemOption>().get(['lineStyle', 'curveness']),
+                0
+            );
+            const cpDistToCenter = (symbolSize / 2 + 100) * nodeScaleOnCoordSys
+                * (curveness + 1)
+                // Formula:
+                // If `cpDistToCenter = symbolSize / 2 * nodeScaleOnCoordSys / 3 * 4 / Math.cos(edgeRadHalfSpan)`,
+                // the control point can be tangent to the symbol circle.
+                // Hint: `distCubicMiddlePtToCenterPt / 3 * 4` get the hight of the isosceles triangle made by
+                // control points and center point.
+                / 3 * 4 / Math.cos(edgeRadHalfSpan);
+
+            edge.setLayout([
+                centerPt.slice(),
+                centerPt.slice(),
+                getCubicControlPoint(cpMidRad - edgeRadHalfSpan, cpDistToCenter),
+                getCubicControlPoint(cpMidRad + edgeRadHalfSpan, cpDistToCenter)
+            ]);
+        }
+    });
+    assert(iEdge === selfLoopEdges.length);
+
+}
+
+/**
+ * @return vector representing the tangant line
+ *         (from edge['node1' | 'node2'] to cp1 of the cubic bezier curve)
+ */
+function getTangentVector(edge: GraphEdge, nodeAttr: NodeAttrOnEdge): VectorArray {
+    // points is [p1, p2] or [p1, p2, cp1].
+    const points = edge.getLayout();
+    const targetPt = points[2] ? points[2] : points[1];
+    return sub([], targetPt, edge[nodeAttr].getLayout());
+}
+
+function getRadianToXPositive(vec: VectorArray): Radian {
+    return Math.atan2(vec[1], vec[0]);
+}
+
+export function isSelfLoopEdge(edge: GraphEdge): boolean {
+    return edge.node1 === edge.node2;
+}
diff --git a/src/chart/graph/simpleLayoutHelper.ts b/src/chart/graph/simpleLayoutHelper.ts
index b0c54b3..d65a4a6 100644
--- a/src/chart/graph/simpleLayoutHelper.ts
+++ b/src/chart/graph/simpleLayoutHelper.ts
@@ -19,10 +19,11 @@
 
 import * as vec2 from 'zrender/src/core/vector';
 import GraphSeriesModel, { GraphNodeItemOption, GraphEdgeItemOption } from './GraphSeries';
-import Graph, { GraphNode } from '../../data/Graph';
+import Graph from '../../data/Graph';
 import * as zrUtil from 'zrender/src/core/util';
-import {getCurvenessForEdge} from '../helper/multipleGraphEdgeHelper';
-import { getNodeGlobalScale } from './graphHelper';
+import { getCurvenessForEdge } from '../helper/multipleGraphEdgeHelper';
+import { isSelfLoopEdge } from './layoutHelper';
+
 
 export function cubicPosition(pt: number[], center: number[], radius: number) {
     const rSquare = radius * radius;
@@ -61,6 +62,12 @@ export function simpleLayout(seriesModel: GraphSeriesModel) {
 
 export function simpleLayoutEdge(graph: Graph, seriesModel: GraphSeriesModel) {
     graph.eachEdge(function (edge, index) {
+
+        if (isSelfLoopEdge(edge)) {
+            // Self-loop edge will be layout later in `layoutSelfLoopEdges`.
+            return;
+        }
+
         const curveness = zrUtil.retrieve3(
             edge.getModel<GraphEdgeItemOption>().get(['lineStyle', 'curveness']),
             -getCurvenessForEdge(edge, seriesModel, index, true),
@@ -69,80 +76,7 @@ export function simpleLayoutEdge(graph: Graph, seriesModel: GraphSeriesModel) {
         const p1 = vec2.clone(edge.node1.getLayout());
         const p2 = vec2.clone(edge.node2.getLayout());
         const points = [p1, p2];
-        if (edge.node1 === edge.node2) {
-            const curve = getCurvenessForEdge(edge, seriesModel, index, true);
-            const curveness = curve >= 1 ? curve : 1 - curve;
-            const symbolSize = seriesModel.get('symbolSize');
-            const size = zrUtil.isArray(symbolSize) ? Number((symbolSize[0] + symbolSize[1]) / 2) : Number(symbolSize);
-            const radius = getNodeGlobalScale(seriesModel) * size / 2 * curveness;
-            const inEdges = edge.node1.inEdges.filter((edge) => {
-                return edge.node1 !== edge.node2;
-            });
-            const outEdges = edge.node1.outEdges.filter((edge) => {
-                return edge.node1 !== edge.node2;
-            });
-            const allNodes: GraphNode[] = [];
-            inEdges.forEach((edge) => {
-                allNodes.push(edge.node1);
-            });
-            outEdges.forEach((edge) => {
-                allNodes.push(edge.node2);
-            });
-            const vectors: any[][] = [];
-            let d = -Infinity;
-            let pt1: number[] = [];
-            let pt2: number[] = [];
-            if (allNodes.length > 1) {
-                allNodes.forEach(node => {
-                    const v: any[] = [];
-                    vec2.sub(v, node.getLayout(), edge.node1.getLayout());
-                    vec2.normalize(v, v);
-                    vectors.push(v);
-                });
-                // find the max angle
-                for (let i = 0; i < vectors.length; i++) {
-                    for (let j = i + 1; j < vectors.length; j++) {
-                        if (vec2.distSquare(vectors[i], vectors[j]) > d) {
-                            d = vec2.distSquare(vectors[i], vectors[j]);
-                            pt1 = vectors[i];
-                            pt2 = vectors[j];
-                        }
-                    }
-                }
-                // if the angle is more than sixty degree
-                if (vec2.distSquare(pt1, pt2) > Math.sqrt(3)) {
-                    vec2.scaleAndAdd(pt1, p1, pt1, radius);
-                    vec2.scaleAndAdd(pt2, p2, pt2, radius);
-                    const point1 = cubicPosition(pt1, p1, 10 * radius);
-                    const point2 = cubicPosition(pt2, p2, 10 * radius);
-                    const mid = [(point1[0] + point2[0]) / 2, (point1[1] + point2[1]) / 2];
-                    vec2.sub(mid, mid, p1);
-                    const degree = Math.atan2(mid[1], mid[0]) / Math.PI * 180;
-                    const v1 = [Math.cos((degree - 30) * Math.PI / 180), Math.sin((degree - 30) * Math.PI / 180)];
-                    const v2 = [Math.cos((degree + 30) * Math.PI / 180), Math.sin((degree + 30) * Math.PI / 180)];
-                    vec2.scaleAndAdd(v1, p1, v1, 10 * radius);
-                    vec2.scaleAndAdd(v2, p2, v2, 10 * radius);
-                    points.push(v1, v2);
-                }
-                else {
-                    vec2.scaleAndAdd(pt1, p1, pt1, radius);
-                    vec2.scaleAndAdd(pt2, p2, pt2, radius);
-                    points.push(cubicPosition(pt1, p1, 10 * radius));
-                    points.push(cubicPosition(pt2, p2, 10 * radius));
-                }
-            }
-            else {
-                points.push([
-                    p1[0] - radius * 4,
-                    p2[1] - radius * 6
-                ]);
-                points.push([
-                    p1[0] + radius * 4,
-                    p2[1] - radius * 6
-                ]);
-            }
-        }
-        else if (+curveness) {
+        if (+curveness) {
             points.push([
                 (p1[0] + p2[0]) / 2 - (p1[1] - p2[1]) * curveness,
                 (p1[1] + p2[1]) / 2 - (p2[0] - p1[0]) * curveness
diff --git a/src/chart/helper/multipleGraphEdgeHelper.ts b/src/chart/helper/multipleGraphEdgeHelper.ts
index f267359..568f252 100644
--- a/src/chart/helper/multipleGraphEdgeHelper.ts
+++ b/src/chart/helper/multipleGraphEdgeHelper.ts
@@ -207,7 +207,8 @@ export function getCurvenessForEdge(edge, seriesModel, index, needReverse?: bool
     // if pass array no need parity
     const parityCorrection = isArrayParam ? 0 : totalLen % 2 ? 0 : 1;
     if (isLoopEdge(edge)) {
-        const curveness = curvenessList.filter(num => num > 0);
+        // PENDING: this strategy is not applicable in self-loop edges yet.
+        const curveness = zrUtil.filter(curvenessList, num => num > 0);
         return curveness[edgeIndex];
     }
     else if (!edgeArray.isForward) {
diff --git a/test/graph-self-loop.html b/test/graph-self-loop.html
new file mode 100755
index 0000000..0fd95e1
--- /dev/null
+++ b/test/graph-self-loop.html
@@ -0,0 +1,355 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+
+        <script src="lib/simpleRequire.js"></script>
+        <script src="lib/config.js"></script>
+        <script src="lib/jquery.min.js"></script>
+        <script src="lib/facePrint.js"></script>
+        <script src="lib/testHelper.js"></script>
+        <script src="lib/enableGraphEditRoughly.js"></script>
+        <link rel="stylesheet" href="lib/reset.css" />
+
+    </head>
+    <body>
+        <style>
+        </style>
+
+
+        <div id="main0"></div>
+        <div id="main1"></div>
+
+
+
+
+        <script>
+        function addScaleplate(chart) {
+            if (!chart) {
+                return;
+            }
+            var dom = chart.getDom();
+            var zr = chart.getZr();
+
+            window.__upateScaleplate = upateScaleplate;
+            var els = [];
+
+
+            upateScaleplate(100);
+
+            function upateScaleplate(r) {
+                for (var i = 0; i < els.length; i++) {
+                    zr.remove(els[i]);
+                }
+                els.length = 0;
+
+                var cx = 500;
+                var cy = 300;
+                var rCurve = r;
+
+                // var cpx1 = cx + rCurve;
+                // var cpy1 = cy;
+                // var cpx2 = cx;
+                // var cpy2 = cy + rCurve;
+
+                var rCp = rCurve / 3 * 4 / Math.cos(Math.PI / 4);
+                var cpx1 = cx + rCp;
+                var cpy1 = cy;
+                var cpx2 = cx;
+                var cpy2 = cy + rCp;
+
+                els.push(new echarts.graphic.Circle({
+                    shape: {cx: cx, cy: cy, r: r},
+                    style: {fill: 'rgba(0,0,0,0.2)'}
+                }));
+                els.push(new echarts.graphic.BezierCurve({
+                    shape: {
+                        x1: cx, y1: cy,
+                        x2: cx, y2: cy,
+                        cpx1: cpx1, cpy1: cpy1,
+                        cpx2: cpx2, cpy2: cpy2
+                    },
+                    style: {lineWidth: 2, stroke: 'red'}
+                }));
+                els.push(new echarts.graphic.Circle({
+                    shape: {cx: cpx1, cy: cpy1, r: 5},
+                    style: {fill: 'rgb(10,30,50)'}
+                }));
+                els.push(new echarts.graphic.Circle({
+                    shape: {cx: cpx2, cy: cpy2, r: 5},
+                    style: {fill: 'rgb(10,30,50)'}
+                }));
+                els.push(new echarts.graphic.Polyline({
+                    shape: {points: [[cx, cy], [cpx1, cpy1], [cpx2, cpy2], [cx, cy]]},
+                    style: {stroke: 'rgb(10,30,50)'}
+                }));
+                for (var i = 0; i < els.length; i++) {
+                    zr.add(els[i]);
+                }
+            }
+        }
+        </script>
+
+
+
+        <script>
+        require(['echarts'], function (echarts) {
+
+            var option = {
+                tooltip: {},
+                series: [{
+                    id: 'grh',
+                    type: 'graph',
+                    symbolSize: 100,
+                    roam: 'scale',
+                    // draggable : true,1
+                    // selectedMode: true,
+                    label: {
+                        show: true,
+                    },
+                    edgeSymbol: ['circle', 'arrow'],
+                    edgeSymbolSize: [4, 10],
+                    edgeLabel: {
+                        color: 'green',
+                        fontSize: 12,
+                        color: 'red',
+                        fontSize: 30,
+                    },
+                    // focusNodeAdjacency: focusNodeAdjacency,
+                    lineStyle: {
+                        width: 3,
+                        color: '#184029',
+                        curveness: 0
+                    },
+                    itemStyle: {
+                        opacity: 0.5
+                    },
+                    emphasis: {
+                        focus: 'adjacency'
+                    },
+                    data: [{
+                        name: 'node_1',
+                        x: 300,
+                        y: 300,
+                        value: 'set_style_on_item'
+                    }, {
+                        name: 'node_2',
+                        x: 800,
+                        y: 300
+                    }, {
+                        name: 'node_3',
+                        x: 350,
+                        y: 100
+                    }, {
+                        name: 'node_4',
+                        x: 550,
+                        y: 500
+                    }
+                    ],
+                    links: [{
+                        id: 'a',
+                        source: 0,
+                        target: 1,
+                        symbolSize: [5, 20],
+                        lineStyle: {
+                            width: 5,
+                            opacity: 1,
+                            curveness: 0.2
+                        },
+                        emphasis: {
+                            lineStyle: {
+                                color: 'blue',
+                                width: 20,
+                                opacity: 0.1
+                            }
+                        }
+                    }, {
+                        id: 'b',
+                        source: 'node_2',
+                        target: 'node_1',
+                        label: {
+                            show: true
+                        },
+                        lineStyle: {
+                            curveness: 0.2
+                        }
+                    }, {
+                        id: 'c',
+                        source: 'node_2',
+                        target: 'node_1',
+                        lineStyle: {
+                            curveness: 0.8
+                        }
+                    }, {
+                        id: 'd',
+                        source: 'node_1',
+                        target: 'node_3',
+                        emphasis: {
+                            label: {
+                                show: true
+                            }
+                        }
+                    }, {
+                        id: 'e',
+                        source: 'node_1',
+                        target: 'node_4'
+                    },
+
+                    {
+                        id: 'f',
+                        source: 'node_1',
+                        target: 'node_1',
+                    },
+                    {
+                        id: 'g',
+                        source: 'node_1',
+                        target: 'node_1',
+                        lineStyle: {
+                            // curveness: 0.9
+                        },
+                        label: {
+                            show: true
+                        }
+                    },
+
+                    ],
+                    autoCurveness: true
+                }
+            ]};
+
+
+            var chart = testHelper.create(echarts, 'main0', {
+                option: option,
+                height: 600,
+                title: [
+                    'Drag a node to modify the angles',
+                    'Drag an edge to modify the curveness',
+                    'Wheel to scale',
+                ]
+            });
+
+            if (chart) {
+                enableGraphEditRoughly({
+                    chart: chart,
+                    option: option,
+                    seriesId: 'grh',
+                    drag: true,
+                    editNodeSize: true,
+                    editSelfLoopEdgeCount: true,
+                    selfLoopEdgeNodeName: 'node_1'
+                });
+                // addScaleplate(chart);
+            }
+
+        });
+        </script>
+
+
+
+
+
+
+
+
+
+
+        <script>
+        require(['echarts'], function (echarts) {
+            var option = {
+                tooltip: {},
+                animationDurationUpdate: 1500,
+                animationEasingUpdate: 'quinticInOut',
+                series : [
+                    {
+                        type: 'graph',
+                        id: 'grh',
+                        symbolSize: 120,
+                        roam: 'scale',
+                        // draggable : true,1
+                        // selectedMode: true,
+                        label: {
+                            normal: {
+                                show: true
+                            }
+                        },
+                        edgeSymbol: ['circle', 'arrow'],
+                        edgeSymbolSize: [4, 10],
+                        // focusNodeAdjacency: focusNodeAdjacency,
+                        lineStyle: {
+                            width: 3,
+                            color: '#184029',
+                            curveness: 0
+                        },
+                        itemStyle: {
+                            opacity: 0.5
+                        },
+                        data: [{
+                            name: 'node_1',
+                            x: 300,
+                            y: 300,
+                            value: 'set_style_on_item'
+                        }, {
+                            name: 'node_2',
+                            x: 800,
+                            y: 300
+                        }
+                        ],
+                        links: [{
+                            source: 'node_2',
+                            target: 'node_1',
+                            label: {
+                                show: true
+                            },
+                            lineStyle: {
+                                curveness: 0.2
+                            }
+                        },
+                        {
+                            source: 'node_1',
+                            target: 'node_1',
+                        },
+                        {
+                            source: 'node_1',
+                            target: 'node_1',
+                            lineStyle: {
+                                curveness: 0.9
+                            },
+                            label: {
+                                show: true
+                            }
+                        },
+
+                        ],
+                        autoCurveness: true
+                    }
+                ]
+            };
+            var chart = testHelper.create(echarts, 'main1', {
+                option: option,
+                height: 500,
+                title: [
+                    'Drag a node to modify the angles',
+                    'Drag an edge to modify the curveness',
+                    'Wheel to scale',
+                ]
+            });
+
+            if (chart) {
+                enableGraphEditRoughly({
+                    chart: chart,
+                    option: option,
+                    seriesId: 'grh',
+                    drag: true,
+                    editNodeSize: true,
+                    editSelfLoopEdgeCount: true,
+                    selfLoopEdgeNodeName: 'node_1'
+                });
+            }
+        });
+        </script>
+
+
+
+    </body>
+</html>
\ No newline at end of file
diff --git a/test/lib/enableGraphEditRoughly.js b/test/lib/enableGraphEditRoughly.js
new file mode 100644
index 0000000..80b53e1
--- /dev/null
+++ b/test/lib/enableGraphEditRoughly.js
@@ -0,0 +1,512 @@
+(function () {
+
+    var NODE_SIZE_MIN_DEFAULT = 0;
+    var NODE_SIZE_MAX_DEFAULT = 300;
+    var SELF_LOOP_EDGE_COUNT_MAX = 10;
+
+    /**
+     * @param opt
+     * @param opt.chart
+     * @param opt.option
+     * @param opt.seriesId
+     *
+     * @param opt.drag {boolean} Enable drag nodes and edges.
+     *
+     * @param opt.editNodeSize {boolean}
+     * @param opt.nodeSizeMin `NODE_SIZE_MIN_DEFAULT` by default.
+     * @param opt.nodeSizeMax `NODE_SIZE_MAX_DEFAULT` by default.
+     *
+     * @param opt.editSelfLoopEdgeCount {boolean}
+     * @param opt.selfLoopEdgeCountMax `SELF_LOOP_EDGE_COUNT_MAX` by default.
+     * @param opt.selfLoopEdgeNodeName {string}
+     */
+    window.enableGraphEditRoughly = function (opt) {
+        if (!opt.chart) {
+            return;
+        }
+
+        if (opt.drag) {
+            enableGraphDrag(opt);
+        }
+        if (opt.editNodeSize) {
+            enableEditNodeSize(opt);
+        }
+        if (opt.editSelfLoopEdgeCount) {
+            enableEditSelfLoopEdgeCount(opt);
+        }
+    };
+
+    /**
+     * @param opt
+     * @param opt.chart
+     * @param opt.option
+     * @param opt.seriesId
+     */
+    function enableGraphDrag(opt) {
+        opt = opt || {};
+        var chart = opt.chart;
+        var option = opt.option;
+        var seriesId = opt.seriesId;
+
+        assert(chart && option && seriesId);
+
+        var zr = chart.getZr();
+
+        /**
+         * type Dragging = {
+         *    type: 'node',
+         *    dataIndex: number,
+         *    mouseDownPoint: [number, number],
+         * } | {
+         *    type: 'edge',
+         *    edgeDataIndex: number,
+         *    mouseDownPoint: [number, number],
+         *    curveness: number
+         * }
+         */
+        var dragging = null;
+
+        var seriesNodesOption = findSeriesNodesOption(option, seriesId);
+        var seriesEdgesOption = findSeriesEdgesOption(option, seriesId);
+
+        var seriesModel = findSeriesModel(chart, seriesId);
+        var seriesData = seriesModel.getData();
+        var seriesEdgeData = seriesModel.getData('edge');
+
+        zr.on('mousedown', function (event) {
+            mouseDownPoint = [event.offsetX, event.offsetY];
+            var nodeResult = findSeriesDataItemByEvent(seriesData, event);
+            if (nodeResult) {
+                dragging = {
+                    type: 'node',
+                    dataIndex: nodeResult.dataIndex,
+                    mouseDownPoint: mouseDownPoint
+                };
+                return;
+            }
+
+            var edgeResult = findSeriesDataItemByEvent(seriesEdgeData, event);
+            if (edgeResult) {
+                dragging = {
+                    type: 'edge',
+                    edgeDataIndex: edgeResult.dataIndex,
+                    mouseDownPoint: mouseDownPoint,
+                    curveness: getCurrentCurveness(seriesEdgesOption, edgeResult.dataIndex)
+                };
+                return;
+            }
+        });
+        zr.on('mousemove', function (event) {
+            if (!dragging) {
+                return;
+            }
+
+            if (dragging.type === 'node') {
+                var dataItemOption = seriesNodesOption[dragging.dataIndex];
+                var nextDataXY = chart.convertFromPixel(
+                    {seriesId: seriesId},
+                    [event.offsetX, event.offsetY]
+                );
+                dataItemOption.x = nextDataXY[0];
+                dataItemOption.y = nextDataXY[1];
+                chart.setOption({
+                    animation: false,
+                    series: {
+                        id: seriesId,
+                        data: seriesNodesOption
+                    }
+                });
+            }
+            else if (dragging.type === 'edge') {
+                var nextCurveness = getNextCurveness(
+                    chart,
+                    seriesId,
+                    dragging.curveness,
+                    [event.offsetX, event.offsetY],
+                    seriesEdgeData,
+                    dragging.edgeDataIndex
+                );
+                updateCurvenessOption(seriesEdgesOption, dragging.edgeDataIndex, nextCurveness);
+
+                chart.setOption({
+                    animation: false,
+                    series: {
+                        id: seriesId,
+                        edges: seriesEdgesOption
+                    }
+                });
+            }
+        });
+        zr.on('mouseup', function (event) {
+            dragging = null;
+        });
+    };
+
+    function getNextCurveness(
+        chart, seriesId, mouseDownCurveness, mouseMovePoint,
+        seriesEdgeData, edgeDataIndex
+    ) {
+        var edgePoints = getEdgePoints(chart, seriesId, seriesEdgeData, edgeDataIndex)
+
+        var vv = makeVector(edgePoints.from, edgePoints.to);
+        var vSqrDist = vectorSquareDist(vv);
+        var sign = 1;
+        if (!aroundZero(vSqrDist)) {
+            var detResult = det(vv, makeVector(mouseMovePoint, edgePoints.from));
+            sign = detResult > 0 ? 1 : -1;
+        }
+
+        mouseDownCurveness = mouseDownCurveness || 0;
+        var dist = distPointToLine(mouseMovePoint, edgePoints.from, edgePoints.to);
+        var curveDist = dist / 300;
+        return sign * curveDist;
+    }
+
+    /**
+     * @param opt
+     * @param opt.chart
+     * @param opt.seriesId
+     * @param opt.nodeSizeMin
+     * @param opt.nodeSizeMax
+     */
+    function enableEditNodeSize(opt) {
+        opt = opt || {};
+        var chart = opt.chart;
+        var seriesId = opt.seriesId;
+
+        assert(chart && seriesId);
+
+        prepareControlPanel(chart);
+
+        var nodeSizeMin = opt.nodeSizeMin || NODE_SIZE_MIN_DEFAULT;
+        var nodeSizeMax = opt.nodeSizeMax || NODE_SIZE_MAX_DEFAULT;
+
+        addSlider(
+            chart.__controlPanelEl,
+            'symbol size:',
+            nodeSizeMin,
+            nodeSizeMax,
+            1,
+            function (newValue) {
+                console.log('symbolSize:', newValue);
+                chart.setOption({
+                    animation: false,
+                    series: {
+                        id: seriesId,
+                        symbolSize: +newValue
+                    }
+                });
+            }
+        );
+    };
+
+    /**
+     * @param opt
+     * @param opt.chart
+     * @param opt.seriesId
+     * @param opt.option
+     * @param opt.selfLoopEdgeCountMax
+     * @param opt.selfLoopEdgeNodeName
+     */
+    function enableEditSelfLoopEdgeCount(opt) {
+        opt = opt || {};
+        var chart = opt.chart;
+        var option = opt.option;
+        var seriesId = opt.seriesId;
+        var selfLoopEdgeNodeName = opt.selfLoopEdgeNodeName;
+        var selfLoopEdgeCountMax = opt.selfLoopEdgeCountMax || SELF_LOOP_EDGE_COUNT_MAX;
+
+        assert(chart && seriesId && option);
+
+        prepareControlPanel(chart);
+
+        addSlider(
+            chart.__controlPanelEl,
+            'self-loop edge count:',
+            0,
+            selfLoopEdgeCountMax,
+            1,
+            function (newValue) {
+                console.log('self_loop_edge_count:', newValue);
+
+                var seriesEdgesOption = findSeriesEdgesOption(option, seriesId);
+                var seriesModel = findSeriesModel(chart, seriesId);
+                var seriesData = seriesModel.getData();
+
+                var edgeCount = 0;
+                for (var i = 0; i < seriesEdgesOption.length;) {
+                    var seriesEdgeItemOption = seriesEdgesOption[i];
+                    var sourceName = findNodeNameByNameOrIndex(seriesData, seriesEdgeItemOption.source);
+                    var targetName = findNodeNameByNameOrIndex(seriesData, seriesEdgeItemOption.target);
+
+                    if (sourceName && sourceName === targetName && sourceName === selfLoopEdgeNodeName) {
+                        edgeCount++;
+                    }
+                    if (edgeCount > newValue) {
+                        seriesEdgesOption.splice(i, 1);
+                    }
+                    else {
+                        i++;
+                    }
+                }
+                for (var i = edgeCount; i < newValue; i++) {
+                    seriesEdgesOption.push({
+                        source: selfLoopEdgeNodeName,
+                        target: selfLoopEdgeNodeName
+                    });
+                }
+
+                chart.setOption({
+                    animation: false,
+                    series: {
+                        id: seriesId,
+                        edges: seriesEdgesOption,
+                        links: null
+                    }
+                });
+            }
+        );
+    }
+
+
+    // ----------------------------------
+    // Utils
+    // ----------------------------------
+
+
+    function prepareControlPanel(chart) {
+        if (chart.__controlPanelEl) {
+            return;
+        }
+
+        var el = document.createElement('div');
+        el.style.cssText = [
+            'position: absolute',
+            'top: 0',
+            'right: 0',
+            'padding: 15px;',
+            'box-shadow: 0 2px 5px #000',
+        ].join(';');
+
+        chart.getDom().appendChild(el);
+        chart.__controlPanelEl = el;
+    }
+
+    function addSlider(controlPanelEl, labelHTML, min, max, step, onInput) {
+        var lineEl = document.createElement('div');
+        lineEl.style.cssText = [
+            'text-align: right'
+        ].join('');
+        controlPanelEl.appendChild(lineEl);
+
+        var label = document.createElement('span');
+        label.innerHTML = labelHTML;
+        label.style.cssText = [
+            'vertical-align: middle',
+            'padding-right: 10px;'
+        ].join(';');
+        lineEl.appendChild(label);
+
+        var slider = document.createElement('input');
+        slider.style.cssText = [
+            'vertical-align: middle'
+        ].join('');
+        slider.setAttribute('type', 'range');
+        slider.setAttribute('min', min);
+        slider.setAttribute('max', max);
+        slider.setAttribute('step', step);
+        slider.oninput = function () {
+            valueEl.innerHTML = this.value;
+            onInput(this.value);
+        };
+        lineEl.appendChild(slider);
+
+        var valueEl = document.createElement('span');
+        valueEl.style.cssText = [
+            'display: inline-block',
+            'vertical-align: middle',
+            'padding-left: 10px;',
+            'min-width: 30px'
+        ].join(';');
+        lineEl.appendChild(valueEl);
+    }
+
+    function findEdgeItemOption(seriesEdgesOption, edgeDataIndex) {
+        var edgeItemOption = seriesEdgesOption[edgeDataIndex];
+        return edgeItemOption.lineStyle || (edgeItemOption.lineStyle = {});
+    }
+
+    function getCurrentCurveness(seriesEdgesOption, edgeDataIndex) {
+        var lineStyleOption = findEdgeItemOption(seriesEdgesOption, edgeDataIndex);
+        return (lineStyleOption || lineStyleOption.normal || {}).curveness || 0;
+    }
+
+    function updateCurvenessOption(seriesEdgesOption, edgeDataIndex, curveness) {
+        // format legacy option: `lineStyle.normal.curveness`
+        for (var i = 0; i < seriesEdgesOption.length; i++) {
+            var edgeItemOption = seriesEdgesOption[i];
+            var lineStyleOption = edgeItemOption.lineStyle;
+            if (lineStyleOption && lineStyleOption.normal) {
+                extend(lineStyleOption, lineStyleOption.normal);
+            }
+        }
+
+        var lineStyleOption = findEdgeItemOption(seriesEdgesOption, edgeDataIndex);
+        lineStyleOption.curveness = curveness || 0;
+
+        console.log('edgeDataIndex: ', edgeDataIndex, 'curveness: ', lineStyleOption.curveness);
+    }
+
+    function getEdgePoints(chart, seriesId, seriesEdgeData, edgeDataIndex) {
+        var edgeLayout = seriesEdgeData.getItemLayout(edgeDataIndex);
+        assert(edgeLayout && edgeLayout.__original);
+        var originalPoints = edgeLayout.__original;
+        assert(originalPoints[0] && originalPoints[1]);
+        return {
+            from: chart.convertToPixel({seriesId: seriesId}, originalPoints[0]),
+            to: chart.convertToPixel({seriesId: seriesId}, originalPoints[1])
+        };
+    }
+
+    function findSeriesNodesOption(option, seriesId) {
+        var seriesOption = findSeriesOption(option, seriesId);
+        var seriesNodesOption = seriesOption.data || seriesOption.nodes;
+        assert(isArray(seriesNodesOption));
+        return seriesNodesOption;
+    }
+
+    function findSeriesEdgesOption(option, seriesId) {
+        var seriesOption = findSeriesOption(option, seriesId);
+        var seriesEdgesOption = seriesOption.edges || seriesOption.links;
+        assert(isArray(seriesEdgesOption));
+        return seriesEdgesOption;
+    }
+
+    function findSeriesOption(option, seriesId) {
+        var seriesOption = (
+            isArray(option.series) ? option.series : [option.series]
+        ).filter(function (seriesOpt) {
+            return seriesOpt.id === seriesId;
+        })[0];
+        assert(seriesOption);
+        return seriesOption;
+    }
+
+    function findSeriesModel(chart, seriesId) {
+        var seriesModel = chart.getModel().getSeries().filter(function (series) {
+            return series.id === seriesId
+        })[0];
+        assert(seriesModel);
+        return seriesModel;
+    }
+
+    function findNodeNameByNameOrIndex(seriesEdgeData, nameOfIndex) {
+        if (isNumber(nameOfIndex)) {
+            return seriesEdgeData.getName(nameOfIndex);
+        }
+        else {
+            return nameOfIndex;
+        }
+    }
+
+    // The input el will also be called to cb.
+    function travelAncestor(el, cb) {
+        var currEl = el
+        while (currEl) {
+            var stop = cb(currEl);
+            if (stop) {
+                break;
+            }
+            currEl = currEl.parent ? currEl.parent
+                // text el attached to some element
+                : currEl.__hostTarget ? currEl.__hostTarget
+                : null;
+        }
+    }
+
+    function findSeriesDataItemByEvent(seriesData, event) {
+        var eventTarget = event.target;
+        if (!eventTarget) {
+            return;
+        }
+
+        for (var i = 0, len = seriesData.count(); i < len; i++) {
+            var itemEl = seriesData.getItemGraphicEl(i);
+
+            var isThisItem;
+            travelAncestor(eventTarget, function (ancestorEl) {
+                if (ancestorEl === itemEl) {
+                    isThisItem = true;
+                    return true;
+                }
+            });
+
+            if (isThisItem) {
+                return {
+                    dataIndex: i,
+                    el: itemEl
+                };
+            }
+        }
+    }
+
+    function assert(condition, message) {
+        if (!condition) {
+            throw new Error(message);
+        }
+    }
+
+    function isArray(some) {
+        return Object.prototype.toString.call(some) === '[object Array]';
+    }
+
+    function makeVector(from, to) {
+        return [to[0] - from[0], to[1] - from[1]];
+    }
+
+    function det(v0, v1) {
+        return v0[0] * v1[1] - v0[1] * v1[0];
+    }
+
+    function dot(v0, v1) {
+        return v0[0] * v1[0] + v0[1] * v1[1];
+    }
+
+    function vectorSquareDist(vv) {
+        return vv[0] * vv[0] + vv[1] * vv[1];
+    }
+
+    function distPointToLine(point, v0, v1) {
+        var pv = makeVector(v0, point);
+        var vv = makeVector(v0, v1);
+        var vSqrDist = vectorSquareDist(vv);
+        if (aroundZero(vSqrDist)) {
+            return vectorSquareDist(pv);
+        }
+
+        var tt = dot(pv, vv) / vSqrDist;
+        tt = Math.max(0, Math.min(1, tt));
+        var crossPoint = [v0[0] + tt * vv[0], v0[1] + tt * vv[1]];
+        var sqrDist = vectorSquareDist(makeVector(point, crossPoint));
+
+        return Math.sqrt(sqrDist);
+    }
+
+    function aroundZero(v) {
+        return Math.abs(v) < 1e-4;
+    }
+
+    function isNumber(v) {
+        return Object.prototype.toString.call(v) === '[object Number]';
+    }
+
+    function extend(target, source) {
+        for (var key in source) {
+            if (source.hasOwnProperty(key)) {
+                target[key] = source[key];
+            }
+        }
+        return target;
+    }
+
+})();

---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@echarts.apache.org
For additional commands, e-mail: commits-help@echarts.apache.org