From 8d2a4dc3091c50cdbeecb6c524e002e510b53531 Mon Sep 17 00:00:00 2001 From: yvonneyx Date: Mon, 2 Sep 2024 11:46:42 +0800 Subject: [PATCH] feat: add radial dendrogram demo --- packages/g6/__tests__/dataset/flare.json | 355 ++++++++++++++++++ .../__tests__/demos/case-radial-dendrogram.ts | 58 +++ packages/g6/__tests__/demos/index.ts | 2 + .../demos/layout-dendrogram-radial.ts | 27 ++ packages/g6/package.json | 2 +- packages/g6/src/behaviors/hover-activate.ts | 20 +- packages/g6/src/elements/combos/circle.ts | 4 +- .../g6/src/elements/edges/cubic-radial.ts | 77 ++++ packages/g6/src/elements/edges/index.ts | 2 + packages/g6/src/elements/nodes/base-node.ts | 5 +- packages/g6/src/elements/nodes/circle.ts | 4 +- packages/g6/src/elements/nodes/ellipse.ts | 4 +- packages/g6/src/elements/shapes/polygon.ts | 4 +- packages/g6/src/exports.ts | 21 +- packages/g6/src/registry/build-in.ts | 4 + packages/g6/src/runtime/data.ts | 4 + packages/g6/src/runtime/layout.ts | 7 + packages/g6/src/transforms/base-transform.ts | 2 + packages/g6/src/transforms/index.ts | 1 + .../src/transforms/position-radial-labels.ts | 75 ++++ packages/g6/src/types/element.ts | 3 +- packages/g6/src/utils/point.ts | 32 +- packages/g6/src/utils/relation.ts | 25 +- packages/g6/src/utils/vector.ts | 13 + 24 files changed, 715 insertions(+), 36 deletions(-) create mode 100644 packages/g6/__tests__/dataset/flare.json create mode 100644 packages/g6/__tests__/demos/case-radial-dendrogram.ts create mode 100644 packages/g6/__tests__/demos/layout-dendrogram-radial.ts create mode 100644 packages/g6/src/elements/edges/cubic-radial.ts create mode 100644 packages/g6/src/transforms/position-radial-labels.ts diff --git a/packages/g6/__tests__/dataset/flare.json b/packages/g6/__tests__/dataset/flare.json new file mode 100644 index 00000000000..67776f21f11 --- /dev/null +++ b/packages/g6/__tests__/dataset/flare.json @@ -0,0 +1,355 @@ +{ + "id": "flare", + "children": [ + { + "id": "analytics", + "children": [ + { + "id": "cluster", + "children": [ + { "id": "AgglomerativeCluster", "value": 3938 }, + { "id": "CommunityStructure", "value": 3812 }, + { "id": "HierarchicalCluster", "value": 6714 }, + { "id": "MergeEdge", "value": 743 } + ] + }, + { + "id": "graph", + "children": [ + { "id": "BetweennessCentrality", "value": 3534 }, + { "id": "LinkDistance", "value": 5731 }, + { "id": "MaxFlowMinCut", "value": 7840 }, + { "id": "ShortestPaths", "value": 5914 }, + { "id": "SpanningTree", "value": 3416 } + ] + }, + { + "id": "optimization", + "children": [{ "id": "AspectRatioBanker", "value": 7074 }] + } + ] + }, + { + "id": "animate", + "children": [ + { "id": "Easing", "value": 17010 }, + { "id": "FunctionSequence", "value": 5842 }, + { + "id": "interpolate", + "children": [ + { "id": "ArrayInterpolator", "value": 1983 }, + { "id": "ColorInterpolator", "value": 2047 }, + { "id": "DateInterpolator", "value": 1375 }, + { "id": "Interpolator", "value": 8746 }, + { "id": "MatrixInterpolator", "value": 2202 }, + { "id": "NumberInterpolator", "value": 1382 }, + { "id": "ObjectInterpolator", "value": 1629 }, + { "id": "PointInterpolator", "value": 1675 }, + { "id": "RectangleInterpolator", "value": 2042 } + ] + }, + { "id": "ISchedulable", "value": 1041 }, + { "id": "Parallel", "value": 5176 }, + { "id": "Pause", "value": 449 }, + { "id": "Scheduler", "value": 5593 }, + { "id": "Sequence", "value": 5534 }, + { "id": "Transition", "value": 9201 }, + { "id": "Transitioner", "value": 19975 }, + { "id": "TransitionEvent", "value": 1116 }, + { "id": "Tween", "value": 6006 } + ] + }, + { + "id": "display", + "children": [ + { "id": "DirtySprite", "value": 8833 }, + { "id": "LineSprite", "value": 1732 }, + { "id": "RectSprite", "value": 3623 }, + { "id": "TextSprite", "value": 10066 } + ] + }, + { + "id": "flex", + "children": [{ "id": "FlareVis", "value": 4116 }] + }, + { + "id": "physics", + "children": [ + { "id": "DragForce", "value": 1082 }, + { "id": "GravityForce", "value": 1336 }, + { "id": "IForce", "value": 319 }, + { "id": "NBodyForce", "value": 10498 }, + { "id": "Particle", "value": 2822 }, + { "id": "Simulation", "value": 9983 }, + { "id": "Spring", "value": 2213 }, + { "id": "SpringForce", "value": 1681 } + ] + }, + { + "id": "query", + "children": [ + { "id": "AggregateExpression", "value": 1616 }, + { "id": "And", "value": 1027 }, + { "id": "Arithmetic", "value": 3891 }, + { "id": "Average", "value": 891 }, + { "id": "BinaryExpression", "value": 2893 }, + { "id": "Comparison", "value": 5103 }, + { "id": "CompositeExpression", "value": 3677 }, + { "id": "Count", "value": 781 }, + { "id": "DateUtil", "value": 4141 }, + { "id": "Distinct", "value": 933 }, + { "id": "Expression", "value": 5130 }, + { "id": "ExpressionIterator", "value": 3617 }, + { "id": "Fn", "value": 3240 }, + { "id": "If", "value": 2732 }, + { "id": "IsA", "value": 2039 }, + { "id": "Literal", "value": 1214 }, + { "id": "Match", "value": 3748 }, + { "id": "Maximum", "value": 843 }, + { + "id": "methods", + "children": [ + { "id": "add", "value": 593 }, + { "id": "and", "value": 330 }, + { "id": "average", "value": 287 }, + { "id": "count", "value": 277 }, + { "id": "distinct", "value": 292 }, + { "id": "div", "value": 595 }, + { "id": "eq", "value": 594 }, + { "id": "fn", "value": 460 }, + { "id": "gt", "value": 603 }, + { "id": "gte", "value": 625 }, + { "id": "iff", "value": 748 }, + { "id": "isa", "value": 461 }, + { "id": "lt", "value": 597 }, + { "id": "lte", "value": 619 }, + { "id": "max", "value": 283 }, + { "id": "min", "value": 283 }, + { "id": "mod", "value": 591 }, + { "id": "mul", "value": 603 }, + { "id": "neq", "value": 599 }, + { "id": "not", "value": 386 }, + { "id": "or", "value": 323 }, + { "id": "orderby", "value": 307 }, + { "id": "range", "value": 772 }, + { "id": "select", "value": 296 }, + { "id": "stddev", "value": 363 }, + { "id": "sub", "value": 600 }, + { "id": "sum", "value": 280 }, + { "id": "update", "value": 307 }, + { "id": "variance", "value": 335 }, + { "id": "where", "value": 299 }, + { "id": "xor", "value": 354 }, + { "id": "-", "value": 264 } + ] + }, + { "id": "Minimum", "value": 843 }, + { "id": "Not", "value": 1554 }, + { "id": "Or", "value": 970 }, + { "id": "Query", "value": 13896 }, + { "id": "Range", "value": 1594 }, + { "id": "StringUtil", "value": 4130 }, + { "id": "Sum", "value": 791 }, + { "id": "Variable", "value": 1124 }, + { "id": "Variance", "value": 1876 }, + { "id": "Xor", "value": 1101 } + ] + }, + { + "id": "scale", + "children": [ + { "id": "IScaleMap", "value": 2105 }, + { "id": "LinearScale", "value": 1316 }, + { "id": "LogScale", "value": 3151 }, + { "id": "OrdinalScale", "value": 3770 }, + { "id": "QuantileScale", "value": 2435 }, + { "id": "QuantitativeScale", "value": 4839 }, + { "id": "RootScale", "value": 1756 }, + { "id": "Scale", "value": 4268 }, + { "id": "ScaleType", "value": 1821 }, + { "id": "TimeScale", "value": 5833 } + ] + }, + { + "id": "util", + "children": [ + { "id": "Arrays", "value": 8258 }, + { "id": "Colors", "value": 10001 }, + { "id": "Dates", "value": 8217 }, + { "id": "Displays", "value": 12555 }, + { "id": "Filter", "value": 2324 }, + { "id": "Geometry", "value": 10993 }, + { + "id": "heap", + "children": [ + { "id": "FibonacciHeap", "value": 9354 }, + { "id": "HeapNode", "value": 1233 } + ] + }, + { "id": "IEvaluable", "value": 335 }, + { "id": "IPredicate", "value": 383 }, + { "id": "IValueProxy", "value": 874 }, + { + "id": "math", + "children": [ + { "id": "DenseMatrix", "value": 3165 }, + { "id": "IMatrix", "value": 2815 }, + { "id": "SparseMatrix", "value": 3366 } + ] + }, + { "id": "Maths", "value": 17705 }, + { "id": "Orientation", "value": 1486 }, + { + "id": "palette", + "children": [ + { "id": "ColorPalette", "value": 6367 }, + { "id": "Palette", "value": 1229 }, + { "id": "ShapePalette", "value": 2059 }, + { "id": "SizePalette", "value": 2291 } + ] + }, + { "id": "Property", "value": 5559 }, + { "id": "Shapes", "value": 19118 }, + { "id": "Sort", "value": 6887 }, + { "id": "Stats", "value": 6557 }, + { "id": "Strings", "value": 22026 } + ] + }, + { + "id": "vis", + "children": [ + { + "id": "axis", + "children": [ + { "id": "Axes", "value": 1302 }, + { "id": "Axis", "value": 24593 }, + { "id": "AxisGridLine", "value": 652 }, + { "id": "AxisLabel", "value": 636 }, + { "id": "CartesianAxes", "value": 6703 } + ] + }, + { + "id": "controls", + "children": [ + { "id": "AnchorControl", "value": 2138 }, + { "id": "ClickControl", "value": 3824 }, + { "id": "Control", "value": 1353 }, + { "id": "ControlList", "value": 4665 }, + { "id": "DragControl", "value": 2649 }, + { "id": "ExpandControl", "value": 2832 }, + { "id": "HoverControl", "value": 4896 }, + { "id": "IControl", "value": 763 }, + { "id": "PanZoomControl", "value": 5222 }, + { "id": "SelectionControl", "value": 7862 }, + { "id": "TooltipControl", "value": 8435 } + ] + }, + { + "id": "data", + "children": [ + { "id": "Data", "value": 20544 }, + { "id": "DataList", "value": 19788 }, + { "id": "DataSprite", "value": 10349 }, + { "id": "EdgeSprite", "value": 3301 }, + { "id": "NodeSprite", "value": 19382 }, + { + "id": "render", + "children": [ + { "id": "ArrowType", "value": 698 }, + { "id": "EdgeRenderer", "value": 5569 }, + { "id": "IRenderer", "value": 353 }, + { "id": "ShapeRenderer", "value": 2247 } + ] + }, + { "id": "ScaleBinding", "value": 11275 }, + { "id": "Tree", "value": 7147 }, + { "id": "TreeBuilder", "value": 9930 } + ] + }, + { + "id": "events", + "children": [ + { "id": "DataEvent", "value": 2313 }, + { "id": "SelectionEvent", "value": 1880 }, + { "id": "TooltipEvent", "value": 1701 }, + { "id": "VisualizationEvent", "value": 1117 } + ] + }, + { + "id": "legend", + "children": [ + { "id": "Legend", "value": 20859 }, + { "id": "LegendItem", "value": 4614 }, + { "id": "LegendRange", "value": 10530 } + ] + }, + { + "id": "operator", + "children": [ + { + "id": "distortion", + "children": [ + { "id": "BifocalDistortion", "value": 4461 }, + { "id": "Distortion", "value": 6314 }, + { "id": "FisheyeDistortion", "value": 3444 } + ] + }, + { + "id": "encoder", + "children": [ + { "id": "ColorEncoder", "value": 3179 }, + { "id": "Encoder", "value": 4060 }, + { "id": "PropertyEncoder", "value": 4138 }, + { "id": "ShapeEncoder", "value": 1690 }, + { "id": "SizeEncoder", "value": 1830 } + ] + }, + { + "id": "filter", + "children": [ + { "id": "FisheyeTreeFilter", "value": 5219 }, + { "id": "GraphDistanceFilter", "value": 3165 }, + { "id": "VisibilityFilter", "value": 3509 } + ] + }, + { "id": "IOperator", "value": 1286 }, + { + "id": "label", + "children": [ + { "id": "Labeler", "value": 9956 }, + { "id": "RadialLabeler", "value": 3899 }, + { "id": "StackedAreaLabeler", "value": 3202 } + ] + }, + { + "id": "layout", + "children": [ + { "id": "AxisLayout", "value": 6725 }, + { "id": "BundledEdgeRouter", "value": 3727 }, + { "id": "CircleLayout", "value": 9317 }, + { "id": "CirclePackingLayout", "value": 12003 }, + { "id": "DendrogramLayout", "value": 4853 }, + { "id": "ForceDirectedLayout", "value": 8411 }, + { "id": "IcicleTreeLayout", "value": 4864 }, + { "id": "IndentedTreeLayout", "value": 3174 }, + { "id": "Layout", "value": 7881 }, + { "id": "NodeLinkTreeLayout", "value": 12870 }, + { "id": "PieLayout", "value": 2728 }, + { "id": "RadialTreeLayout", "value": 12348 }, + { "id": "RandomLayout", "value": 870 }, + { "id": "StackedAreaLayout", "value": 9121 }, + { "id": "TreeMapLayout", "value": 9191 } + ] + }, + { "id": "Operator", "value": 2490 }, + { "id": "OperatorList", "value": 5248 }, + { "id": "OperatorSequence", "value": 4190 }, + { "id": "OperatorSwitch", "value": 2581 }, + { "id": "SortOperator", "value": 2023 } + ] + }, + { "id": "Visualization", "value": 16540 } + ] + } + ] +} diff --git a/packages/g6/__tests__/demos/case-radial-dendrogram.ts b/packages/g6/__tests__/demos/case-radial-dendrogram.ts new file mode 100644 index 00000000000..24dbb6d33e6 --- /dev/null +++ b/packages/g6/__tests__/demos/case-radial-dendrogram.ts @@ -0,0 +1,58 @@ +import data from '@@/dataset/flare.json'; +import { Graph, treeToGraphData } from '@antv/g6'; + +export const caseRadialDendrogram: TestCase = async (context) => { + const graph = new Graph({ + ...context, + autoFit: 'view', + data: treeToGraphData(data), + node: { + style: { + size: 14, + labelText: (d) => d.id, + labelBackground: true, + }, + state: { + active: { + fill: '#00C9C9', + }, + }, + }, + edge: { + type: 'cubic-radial', + style: { + lineWidth: 2, + }, + state: { + active: { + stroke: '#009999', + }, + }, + }, + layout: [ + { + type: 'dendrogram', + radial: true, + nodeSep: 30, + rankSep: 200, + }, + ], + behaviors: [ + 'drag-canvas', + 'zoom-canvas', + 'drag-element', + { + key: 'hover-activate', + type: 'hover-activate', + degree: 5, + direction: 'in', + inactiveState: 'inactive', + }, + ], + transforms: ['position-radial-labels'], + }); + + await graph.render(); + + return graph; +}; diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts index 71dda06dc8b..61db715e167 100644 --- a/packages/g6/__tests__/demos/index.ts +++ b/packages/g6/__tests__/demos/index.ts @@ -25,6 +25,7 @@ export { caseDecisionTree } from './case-decision-tree'; export { caseIndentedTree } from './case-indented-tree'; export { caseMindmap } from './case-mindmap'; export { caseOrgChart } from './case-org-chart'; +export { caseRadialDendrogram } from './case-radial-dendrogram'; export { commonGraph } from './common-graph'; export { controllerViewport } from './controller-viewport'; export { demoAutosizeElementLabel } from './demo-autosize-element-label'; @@ -86,6 +87,7 @@ export { layoutCustomIterative } from './layout-custom-iterative'; export { layoutD3Force } from './layout-d3-force'; export { layoutDagre } from './layout-dagre'; export { layoutDendrogramBasic } from './layout-dendrogram-basic'; +export { layoutDendrogramRadial } from './layout-dendrogram-radial'; export { layoutDendrogramTb } from './layout-dendrogram-tb'; export { layoutForce } from './layout-force'; export { layoutForceCollision } from './layout-force-collision'; diff --git a/packages/g6/__tests__/demos/layout-dendrogram-radial.ts b/packages/g6/__tests__/demos/layout-dendrogram-radial.ts new file mode 100644 index 00000000000..2a607c34c7e --- /dev/null +++ b/packages/g6/__tests__/demos/layout-dendrogram-radial.ts @@ -0,0 +1,27 @@ +import data from '@@/dataset/algorithm-category.json'; +import { Graph, treeToGraphData } from '@antv/g6'; + +export const layoutDendrogramRadial: TestCase = async (context) => { + const graph = new Graph({ + ...context, + autoFit: 'view', + data: treeToGraphData(data), + node: { + style: { + labelText: (d) => d.id, + }, + }, + layout: [ + { + type: 'dendrogram', + radial: true, + nodeSep: 30, + rankSep: 200, + }, + ], + }); + + await graph.render(); + + return graph; +}; diff --git a/packages/g6/package.json b/packages/g6/package.json index 968aa2203a3..dc2483ebf8c 100644 --- a/packages/g6/package.json +++ b/packages/g6/package.json @@ -63,7 +63,7 @@ "@antv/g-canvas": "^2.0.10", "@antv/g-plugin-dragndrop": "^2.0.8", "@antv/graphlib": "^2.0.3", - "@antv/hierarchy": "^0.6.12", + "@antv/hierarchy": "^0.6.13", "@antv/layout": "1.2.14-beta.5", "@antv/util": "^3.3.7", "bubblesets-js": "^2.3.3", diff --git a/packages/g6/src/behaviors/hover-activate.ts b/packages/g6/src/behaviors/hover-activate.ts index 6d6e0c3267a..7ad59d30f54 100644 --- a/packages/g6/src/behaviors/hover-activate.ts +++ b/packages/g6/src/behaviors/hover-activate.ts @@ -2,7 +2,7 @@ import { isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import { ELEMENT_TYPES } from '../constants/element'; import type { RuntimeContext } from '../runtime/types'; -import type { Element, ElementType, ID, IDragEvent, IPointerEvent, State } from '../types'; +import type { EdgeDirection, Element, ElementType, ID, IDragEvent, IPointerEvent, State } from '../types'; import { idsOf } from '../utils/id'; import { getElementNthDegreeIds } from '../utils/relation'; import type { BaseBehaviorOptions } from './base-behavior'; @@ -39,6 +39,19 @@ export interface HoverActivateOptions extends BaseBehaviorOptions { * @defaultValue 0 */ degree?: number; + /** + * 指定边的方向 + * - `'both'`: 表示激活当前节点的所有关系 + * - `'in'`: 表示激活当前节点的入边和入节点 + * - `'out'`: 表示激活当前节点的出边和出节点 + * + * Specify the direction of the edge + * - `'both'`: Activate all relationships of the current node + * - `'in'`: Activate the incoming edges and nodes of the current node + * - `'out'`: Activate the outgoing edges and nodes of the current node + * @defaultValue 'both' + */ + direction?: EdgeDirection; /** * 激活元素的状态,默认为 `active` * @@ -80,6 +93,7 @@ export class HoverActivate extends BaseBehavior { animation: false, enable: true, degree: 0, + direction: 'both', state: 'active', inactiveState: undefined, }; @@ -123,11 +137,11 @@ export class HoverActivate extends BaseBehavior { if (!this.options.state && !this.options.inactiveState) return; const { graph } = this.context; - const { state, degree, animation, inactiveState } = this.options; + const { state, degree, direction, animation, inactiveState } = this.options; const { targetType, target } = event; const activeIds = degree - ? getElementNthDegreeIds(graph, targetType as ElementType, target.id, degree) + ? getElementNthDegreeIds(graph, targetType as ElementType, target.id, degree, direction) : [target.id]; const states: Record = {}; diff --git a/packages/g6/src/elements/combos/circle.ts b/packages/g6/src/elements/combos/circle.ts index 2cd059fae34..a1340cb6505 100644 --- a/packages/g6/src/elements/combos/circle.ts +++ b/packages/g6/src/elements/combos/circle.ts @@ -54,8 +54,8 @@ export class CircleCombo extends BaseCombo { return [expandedR * 2, expandedR * 2, 0]; } - public getIntersectPoint(point: Point): Point { + public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); - return getEllipseIntersectPoint(point, keyShapeBounds); + return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } diff --git a/packages/g6/src/elements/edges/cubic-radial.ts b/packages/g6/src/elements/edges/cubic-radial.ts new file mode 100644 index 00000000000..b6cb13942d0 --- /dev/null +++ b/packages/g6/src/elements/edges/cubic-radial.ts @@ -0,0 +1,77 @@ +import type { DisplayObjectConfig } from '@antv/g'; +import type { NodeData } from '../../spec'; +import type { Point } from '../../types'; +import { positionOf } from '../../utils/position'; +import { mergeOptions } from '../../utils/style'; +import { distance, rad, subtract } from '../../utils/vector'; +import type { CubicStyleProps } from './cubic'; +import { Cubic } from './cubic'; + +/** + * 径向贝塞尔曲线样式配置项 + * + * Radial cubic style props + */ +export interface CubicRadialStyleProps extends CubicStyleProps {} + +/** + * 径向贝塞尔曲线 + * + * Radial cubic edge + */ +export class CubicRadial extends Cubic { + static defaultStyleProps: Partial = { + curvePosition: 0.5, + curveOffset: 20, + }; + + constructor(options: DisplayObjectConfig) { + super(mergeOptions({ style: CubicRadial.defaultStyleProps }, options)); + } + + private get ref(): NodeData { + return this.context.model.getRootsData()[0]; + } + + protected getEndpoints(attributes: Required): [Point, Point] { + if (this.sourceNode.id === this.ref.id) { + return super.getEndpoints(attributes); + } + + const refPoint = positionOf(this.ref); + const sourcePoint = this.sourceNode.getIntersectPoint(refPoint, true); + const targetPoint = this.targetNode.getIntersectPoint(refPoint); + + return [sourcePoint, targetPoint]; + } + + private toRadialCoordinate(p: Point) { + const refPoint = positionOf(this.ref); + const r = distance(p, refPoint); + const radian = rad(subtract(p, refPoint)); + return [r, radian]; + } + + protected getControlPoints( + sourcePoint: Point, + targetPoint: Point, + curvePosition: [number, number], + curveOffset: [number, number], + ): [Point, Point] { + const [r1, rad1] = this.toRadialCoordinate(sourcePoint); + const [r2] = this.toRadialCoordinate(targetPoint); + + const rDist = r2 - r1; + + return [ + [ + sourcePoint[0] + (rDist * curvePosition[0] + curveOffset[0]) * Math.cos(rad1), + sourcePoint[1] + (rDist * curvePosition[0] + curveOffset[0]) * Math.sin(rad1), + ], + [ + targetPoint[0] - (rDist * curvePosition[1] - curveOffset[0]) * Math.cos(rad1), + targetPoint[1] - (rDist * curvePosition[1] - curveOffset[0]) * Math.sin(rad1), + ], + ]; + } +} diff --git a/packages/g6/src/elements/edges/index.ts b/packages/g6/src/elements/edges/index.ts index dcbc4068d01..fe53486a4e8 100644 --- a/packages/g6/src/elements/edges/index.ts +++ b/packages/g6/src/elements/edges/index.ts @@ -1,6 +1,7 @@ export { BaseEdge } from './base-edge'; export { Cubic } from './cubic'; export { CubicHorizontal } from './cubic-horizontal'; +export { CubicRadial } from './cubic-radial'; export { CubicVertical } from './cubic-vertical'; export { Line } from './line'; export { Polyline } from './polyline'; @@ -9,6 +10,7 @@ export { Quadratic } from './quadratic'; export type { BaseEdgeStyleProps } from './base-edge'; export type { CubicStyleProps } from './cubic'; export type { CubicHorizontalStyleProps } from './cubic-horizontal'; +export type { CubicRadialStyleProps } from './cubic-radial'; export type { CubicVerticalStyleProps } from './cubic-vertical'; export type { LineStyleProps } from './line'; export type { PolylineStyleProps } from './polyline'; diff --git a/packages/g6/src/elements/nodes/base-node.ts b/packages/g6/src/elements/nodes/base-node.ts index 279f5b361da..6f80690e41c 100644 --- a/packages/g6/src/elements/nodes/base-node.ts +++ b/packages/g6/src/elements/nodes/base-node.ts @@ -354,11 +354,12 @@ export abstract class BaseNode self.getHaloStyle(attributes)) diff --git a/packages/g6/src/elements/nodes/circle.ts b/packages/g6/src/elements/nodes/circle.ts index 4f806dbdd57..454c053a698 100644 --- a/packages/g6/src/elements/nodes/circle.ts +++ b/packages/g6/src/elements/nodes/circle.ts @@ -45,8 +45,8 @@ export class Circle extends BaseNode { return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } - public getIntersectPoint(point: Point): Point { + public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); - return getEllipseIntersectPoint(point, keyShapeBounds); + return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } diff --git a/packages/g6/src/elements/nodes/ellipse.ts b/packages/g6/src/elements/nodes/ellipse.ts index a63ba0050cc..c2ce377516b 100644 --- a/packages/g6/src/elements/nodes/ellipse.ts +++ b/packages/g6/src/elements/nodes/ellipse.ts @@ -51,8 +51,8 @@ export class Ellipse extends BaseNode { return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } - public getIntersectPoint(point: Point): Point { + public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); - return getEllipseIntersectPoint(point, keyShapeBounds); + return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } diff --git a/packages/g6/src/elements/shapes/polygon.ts b/packages/g6/src/elements/shapes/polygon.ts index 7499f504a34..57c0c28d277 100644 --- a/packages/g6/src/elements/shapes/polygon.ts +++ b/packages/g6/src/elements/shapes/polygon.ts @@ -43,9 +43,9 @@ export abstract class Polygon e protected abstract getPoints(attributes: Required): Point[]; - public getIntersectPoint(point: Point): Point { + public getIntersectPoint(point: Point, useExtendedLine = false): Point { const { points } = this.getShape('key').attributes; const center: Point = [+(this.attributes?.x || 0), +(this.attributes?.y || 0)]; - return getPolygonIntersectPoint(point, center, points!).point; + return getPolygonIntersectPoint(point, center, points!, useExtendedLine).point; } } diff --git a/packages/g6/src/exports.ts b/packages/g6/src/exports.ts index ee2cd8c66b6..060681a67ba 100644 --- a/packages/g6/src/exports.ts +++ b/packages/g6/src/exports.ts @@ -27,7 +27,16 @@ export { NodeEvent, } from './constants'; export { BaseCombo, CircleCombo, RectCombo } from './elements/combos'; -export { BaseEdge, Cubic, CubicHorizontal, CubicVertical, Line, Polyline, Quadratic } from './elements/edges'; +export { + BaseEdge, + Cubic, + CubicHorizontal, + CubicRadial, + CubicVertical, + Line, + Polyline, + Quadratic, +} from './elements/edges'; export { effect } from './elements/effect'; export { BaseNode, @@ -48,20 +57,20 @@ export { BaseLayout, CircularLayout, ComboCombinedLayout, + compactBox as CompactBoxLayout, ConcentricLayout, D3ForceLayout, DagreLayout, + dendrogram as DendrogramLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, GridLayout, + indented as IndentedLayout, MDSLayout, + mindmap as MindmapLayout, RadialLayout, RandomLayout, - compactBox, - dendrogram, - indented, - mindmap, } from './layouts'; export { BasePlugin, @@ -89,6 +98,7 @@ export { BaseTransform } from './transforms'; export { isCollapsed } from './utils/collapsibility'; export { idOf } from './utils/id'; export { invokeLayoutMethod } from './utils/layout'; +export { positionOf } from './utils/position'; export { omitStyleProps, subStyleProps } from './utils/prefix'; export { Shortcut } from './utils/shortcut'; export { parseSize } from './utils/size'; @@ -136,6 +146,7 @@ export type { BaseComboStyleProps, CircleComboStyleProps, RectComboStyleProps } export type { BaseEdgeStyleProps, CubicHorizontalStyleProps, + CubicRadialStyleProps, CubicStyleProps, CubicVerticalStyleProps, LineStyleProps, diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index 8c0aa31bc85..3197e525432 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -32,6 +32,7 @@ import { CircleCombo, Cubic, CubicHorizontal, + CubicRadial, CubicVertical, Diamond, Donut, @@ -92,6 +93,7 @@ import { CollapseExpandCombo, CollapseExpandNode, GetEdgeActualEnds, + PositionRadialLabels, ProcessParallelEdges, UpdateRelatedEdge, } from '../transforms'; @@ -138,6 +140,7 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = { polyline: Polyline, quadratic: Quadratic, 'cubic-horizontal': CubicHorizontal, + 'cubic-radial': CubicRadial, 'cubic-vertical': CubicVertical, }, layout: { @@ -207,6 +210,7 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = { 'collapse-expand-node': CollapseExpandNode, 'process-parallel-edges': ProcessParallelEdges, 'get-edge-actual-ends': GetEdgeActualEnds, + 'position-radial-labels': PositionRadialLabels, }, shape: { circle: GCircle, diff --git a/packages/g6/src/runtime/data.ts b/packages/g6/src/runtime/data.ts index aa062399db9..1e41c0be3d3 100644 --- a/packages/g6/src/runtime/data.ts +++ b/packages/g6/src/runtime/data.ts @@ -166,6 +166,10 @@ export class DataController { }, [] as ComboData[]); } + public getRootsData(hierarchyKey: HierarchyKey = TREE_KEY) { + return this.model.getRoots(hierarchyKey).map(toG6Data); + } + public getAncestorsData(id: ID, hierarchyKey: HierarchyKey): NodeLikeData[] { const { model } = this; if (!model.hasNode(id) || !model.hasTreeStructure(hierarchyKey)) return []; diff --git a/packages/g6/src/runtime/layout.ts b/packages/g6/src/runtime/layout.ts index 26f9fdb4d2f..061d2312172 100644 --- a/packages/g6/src/runtime/layout.ts +++ b/packages/g6/src/runtime/layout.ts @@ -69,6 +69,13 @@ export class LayoutController { } } emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_LAYOUT)); + this.transformDataAfterLayout(); + } + + private transformDataAfterLayout() { + const transforms = this.context.transform.getTransformInstance(); + + Object.values(transforms).forEach((transform) => transform.afterLayout()); } /** diff --git a/packages/g6/src/transforms/base-transform.ts b/packages/g6/src/transforms/base-transform.ts index 5fa9f67ade7..15c7a998647 100644 --- a/packages/g6/src/transforms/base-transform.ts +++ b/packages/g6/src/transforms/base-transform.ts @@ -9,4 +9,6 @@ export abstract class BaseTransform 根据径向布局自动调整节点标签样式的配置项 + * + * Options for automatically adjusting the style of node labels according to the radial layout + */ +interface PositionRadialLabelsOptions extends BaseTransformOptions { + /** + * 偏移量 + * + * Offset + */ + offset?: number; +} + +/** + * 根据径向布局自动调整节点标签样式,包括位置和旋转角度 + * + * Automatically adjust the style of node labels according to the radial layout, including position and rotation angle + */ +export class PositionRadialLabels extends BaseTransform { + static defaultOptions: Partial = { + offset: 5, + }; + + constructor(context: RuntimeContext, options: PositionRadialLabelsOptions) { + super(context, Object.assign({}, PositionRadialLabels.defaultOptions, options)); + } + + private get ref(): NodeData { + return this.context.model.getRootsData()[0]; + } + + public afterLayout() { + const refPoint = positionOf(this.ref); + + const { graph, model } = this.context; + const data = model.getData(); + + data.nodes?.forEach((datum) => { + if (idOf(datum) === idOf(this.ref)) return; + + const radian = rad(subtract(positionOf(datum), refPoint)); + const isLeft = Math.abs(radian) > Math.PI / 2; + + const isLeaf = !datum.children || datum.children.length === 0; + const nodeHalfWidth = parseSize(graph.getElementRenderStyle(idOf(datum)).size)[0] / 2; + const offset = (isLeaf ? 1 : -1) * (nodeHalfWidth + this.options.offset); + + const translate = `translate(${offset * Math.cos(radian)},${offset * Math.sin(radian)})`; + const rotate = `rotate(${isLeft ? rad2deg(radian) + 180 : rad2deg(radian)}deg)`; + + model.updateNodeData([ + { + id: idOf(datum), + style: { + labelTextAlign: isLeft === isLeaf ? 'right' : 'left', + labelTextBaseline: 'middle', + labelTransform: `${translate} ${rotate}`, + }, + }, + ]); + }); + + graph.draw(); + } +} diff --git a/packages/g6/src/types/element.ts b/packages/g6/src/types/element.ts index ad826e301d7..5c9713aff1a 100644 --- a/packages/g6/src/types/element.ts +++ b/packages/g6/src/types/element.ts @@ -29,13 +29,14 @@ export interface Node extends DisplayObject, ElementHooks, ElementMethods { * * Get the intersection point * @param point - 外部位置 | external position + * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点位置 | intersection point * @remarks * 给定一个外部位置,返回当前节点与该位置的连边与节点的交点位置 * * Given an external position, return the intersection point of the edge between the current node and the position and the node */ - getIntersectPoint(point: Point): Point; + getIntersectPoint(point: Point, useExtendedLine?: boolean): Point; } /** diff --git a/packages/g6/src/utils/point.ts b/packages/g6/src/utils/point.ts index 9095ed3a8f2..2848c762ff3 100644 --- a/packages/g6/src/utils/point.ts +++ b/packages/g6/src/utils/point.ts @@ -132,6 +132,18 @@ export function isCollinear(p1: Point, p2: Point, p3: Point): boolean { return isLinesParallel([p1, p2], [p2, p3]); } +/** + * 计算一个点相对于另一个点的中心对称点 + * + * Calculate the center symmetric point of a point relative to another point + * @param p - 要计算的点 | the point to calculate + * @param center - 中心点 | the center point + * @returns 中心对称点 | the center symmetric point + */ +export function getSymmetricPoint(p: Point, center: Point): Point { + return [2 * center[0] - p[0], 2 * center[1] - p[1]]; +} + /** * 获取从多边形中心到给定点的连线与多边形边缘的交点 * @@ -140,6 +152,7 @@ export function isCollinear(p1: Point, p2: Point, p3: Point): boolean { * @param center - 多边形中心 | the center of the polygon * @param points - 多边形顶点 | the vertices of the polygon * @param isRelativePos - 顶点坐标是否相对中心点 | whether the vertex coordinates are relative to the center point + * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点与相交线段 | intersection and intersecting line segment */ export function getPolygonIntersectPoint( @@ -147,6 +160,7 @@ export function getPolygonIntersectPoint( center: Point, points: Point[], isRelativePos = true, + useExtendedLine = false, ): { point: Point; line?: LineSegment } { for (let i = 0; i < points.length; i++) { let start = points[i]; @@ -157,7 +171,8 @@ export function getPolygonIntersectPoint( end = add(center, end); } - const intersect = getLinesIntersection([center, p], [start, end]); + const refP = useExtendedLine ? getSymmetricPoint(p, center) : p; + const intersect = getLinesIntersection([center, refP], [start, end]); if (intersect) { return { point: intersect, @@ -200,14 +215,15 @@ export function isPointInPolygon(point: Point, points: Point[], start?: number, } /** - * 获取从矩形中心到给定点的连线与矩形边缘的交点 + * 获取给定点到矩形中心的连线与矩形边缘的交点 * * Gets the intersection point between the line from the center of a rectangle to a given point and the rectangle's edge * @param p - 从矩形中心到矩形边缘的连线的外部点 | The point outside the rectangle from which the line to the rectangle's center is drawn * @param bbox - 矩形包围盒 | the bounding box of the rectangle + * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点 | intersection */ -export function getRectIntersectPoint(p: Point, bbox: AABB): Point { +export function getRectIntersectPoint(p: Point, bbox: AABB, useExtendedLine = false): Point { const center = getXYByPlacement(bbox, 'center'); const corners = [ getXYByPlacement(bbox, 'left-top'), @@ -215,21 +231,23 @@ export function getRectIntersectPoint(p: Point, bbox: AABB): Point { getXYByPlacement(bbox, 'right-bottom'), getXYByPlacement(bbox, 'left-bottom'), ]; - return getPolygonIntersectPoint(p, center, corners, false).point; + return getPolygonIntersectPoint(p, center, corners, false, useExtendedLine).point; } /** - * 获取从椭圆中心到给定点的连线与椭圆边缘的交点 + * 获取给定点到椭圆中心的连线与椭圆边缘的交点 * * Gets the intersection point between the line from the center of an ellipse to a given point and the ellipse's edge * @param p - 从椭圆中心到椭圆边缘的连线的外部点 | The point outside the ellipse from which the line to the ellipse's center is drawn * The point outside the ellipse from which the line to the ellipse's center is drawn. * @param bbox - 椭圆包围盒 | the bounding box of the ellipse + * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点 | intersection */ -export function getEllipseIntersectPoint(p: Point, bbox: AABB): Point { +export function getEllipseIntersectPoint(p: Point, bbox: AABB, useExtendedLine = false): Point { const center = bbox.center; - const vec = subtract(p, bbox.center); + const refP = useExtendedLine ? getSymmetricPoint(p, center) : p; + const vec = subtract(refP, bbox.center); const angle = Math.atan2(vec[1], vec[0]); if (isNaN(angle)) return center; diff --git a/packages/g6/src/utils/relation.ts b/packages/g6/src/utils/relation.ts index 6c8142e76c7..4bc8ea2b6a0 100644 --- a/packages/g6/src/utils/relation.ts +++ b/packages/g6/src/utils/relation.ts @@ -1,5 +1,5 @@ import type { Graph } from '../runtime/graph'; -import type { ElementType, ID } from '../types'; +import type { EdgeDirection, ElementType, ID } from '../types'; import { idOf } from './id'; import { bfs } from './traverse'; @@ -17,18 +17,25 @@ import { bfs } from './traverse'; * @param elementType - 元素类型 | element type * @param elementId - 起始元素的 ID | start element ID * @param degree - 指定的度数 | the specified degree + * @param direction - 边的方向 | edge direction * @returns - 返回节点和边的 ID 数组 | Returns an array of node and edge IDs */ -export function getElementNthDegreeIds(graph: Graph, elementType: ElementType, elementId: ID, degree: number): ID[] { +export function getElementNthDegreeIds( + graph: Graph, + elementType: ElementType, + elementId: ID, + degree: number, + direction: EdgeDirection, +): ID[] { if (elementType === 'combo' || elementType === 'node') { - return getNodeNthDegreeIds(graph, elementId, degree); + return getNodeNthDegreeIds(graph, elementId, degree, direction); } const edgeData = graph.getEdgeData(elementId); if (!edgeData) return []; - const sourceRelations = getNodeNthDegreeIds(graph, edgeData.source, degree - 1); - const targetRelations = getNodeNthDegreeIds(graph, edgeData.target, degree - 1); + const sourceRelations = getNodeNthDegreeIds(graph, edgeData.source, degree - 1, direction); + const targetRelations = getNodeNthDegreeIds(graph, edgeData.target, degree - 1, direction); return Array.from(new Set([...sourceRelations, ...targetRelations, elementId])); } @@ -39,14 +46,14 @@ export function getElementNthDegreeIds(graph: Graph, elementType: ElementType, e * Get all elements IDs within n-degree relationship of the specified node * @remarks * 节点的 0 度关系是节点本身,1 度关系是节点的直接相邻节点和边,以此类推 - * + * @param direction * 0-degree relationship of a node is the node itself; 1-degree relationship is the node's neighboring nodes and related edges, etc * @param graph - 图实例 | graph instance * @param startNodeId - 起始节点的 ID | The ID of the starting node * @param degree - 指定的度数 | The specified degree * @returns - 返回节点和边的 ID 数组 | Returns an array of node and edge IDs */ -export function getNodeNthDegreeIds(graph: Graph, startNodeId: ID, degree: number): ID[] { +export function getNodeNthDegreeIds(graph: Graph, startNodeId: ID, degree: number, direction: EdgeDirection): ID[] { const visitedNodes = new Set(); const visitedEdges = new Set(); const relations = new Set(); @@ -57,7 +64,7 @@ export function getNodeNthDegreeIds(graph: Graph, startNodeId: ID, degree: numbe if (depth > degree) return; relations.add(nodeId); - graph.getRelatedEdgesData(nodeId).forEach((edge) => { + graph.getRelatedEdgesData(nodeId, direction).forEach((edge) => { const edgeId = idOf(edge); if (!visitedEdges.has(edgeId) && depth < degree) { relations.add(edgeId); @@ -67,7 +74,7 @@ export function getNodeNthDegreeIds(graph: Graph, startNodeId: ID, degree: numbe }, (nodeId: ID) => { return graph - .getRelatedEdgesData(nodeId) + .getRelatedEdgesData(nodeId, direction) .map((edge) => (edge.source === nodeId ? edge.target : edge.source)) .filter((neighborNodeId) => { if (!visitedNodes.has(neighborNodeId)) { diff --git a/packages/g6/src/utils/vector.ts b/packages/g6/src/utils/vector.ts index a0543619176..112588b355c 100644 --- a/packages/g6/src/utils/vector.ts +++ b/packages/g6/src/utils/vector.ts @@ -206,3 +206,16 @@ export function toVector2(a: Vector2 | Vector3): Vector2 { export function toVector3(a: Vector2 | Vector3): Vector3 { return isVector2(a) ? [a[0], a[1], 0] : a; } + +/** + * 计算向量与 x 轴正方向的夹角(弧度制) + * + * The angle between the vector and the positive direction of the x-axis (radians) + * @param a - 向量 | The vector + * @returns 弧度值 | The angle in radians + */ +export function rad(a: Vector2 | Vector3): number { + const [x, y] = a; + if (!x && !y) return 0; + return Math.atan2(y, x); +}