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 <sushuang0...@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-unsubscr...@echarts.apache.org For additional commands, e-mail: commits-h...@echarts.apache.org