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