mirror of
https://gitee.com/antv/g6.git
synced 2024-12-02 19:58:46 +08:00
feat: add hull to support using contour to include some items
This commit is contained in:
parent
071ce303b7
commit
2cbf36ddf6
@ -50,7 +50,7 @@
|
||||
"site:deploy": "npm run site:build && gh-pages -d public",
|
||||
"start": "npm run site:develop",
|
||||
"test": "jest",
|
||||
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/plugins/minimap-spec",
|
||||
"test-live": "DEBUG_MODE=1 jest --watch ./tests/unit/graph/graph-hull-spec.ts",
|
||||
"lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx",
|
||||
"watch": "father build -w",
|
||||
"cdn": "antv-bin upload -n @antv/g6"
|
||||
|
@ -25,7 +25,8 @@ import {
|
||||
ModeOption,
|
||||
ModeType,
|
||||
States,
|
||||
ComboTree
|
||||
ComboTree,
|
||||
HullCfg
|
||||
} from '../types';
|
||||
import { getAllNodeInGroups } from '../util/group';
|
||||
import { move } from '../util/math';
|
||||
@ -47,6 +48,8 @@ import Stack from '../algorithm/structs/stack'
|
||||
import adjMatrix from '../algorithm/adjacent-matrix';
|
||||
import floydWarshall from '../algorithm/floydWarshall'
|
||||
|
||||
import Hull from '../item/hull'
|
||||
|
||||
const NODE = 'node';
|
||||
const SVG = 'svg';
|
||||
|
||||
@ -1210,7 +1213,7 @@ export default class Graph extends EventEmitter implements IGraph {
|
||||
self.add('edge', edge, false);
|
||||
});
|
||||
|
||||
let animate = self.get('animate');
|
||||
const animate = self.get('animate');
|
||||
if (self.get('fitView') || self.get('fitCenter')) {
|
||||
self.set('animate', false);
|
||||
}
|
||||
@ -3021,4 +3024,50 @@ export default class Graph extends EventEmitter implements IGraph {
|
||||
this.redoStack = null
|
||||
this.undoStack = null
|
||||
}
|
||||
|
||||
public addHull(cfg: HullCfg) {
|
||||
let parent = this.get('hullGroup');
|
||||
let hullMap = this.get('hullMap')
|
||||
if (!hullMap) {
|
||||
hullMap = {}
|
||||
this.set('hullMap', hullMap)
|
||||
}
|
||||
if (!parent) {
|
||||
parent = this.get('group').addGroup({
|
||||
id: 'hullGroup'
|
||||
})
|
||||
parent.toBack()
|
||||
this.set('hullGroup', parent)
|
||||
}
|
||||
const group = parent.addGroup({
|
||||
id: `${cfg.id}-container`
|
||||
});
|
||||
const hull = new Hull(this, {
|
||||
...cfg,
|
||||
group
|
||||
})
|
||||
const hullId = hull.id
|
||||
hullMap[hullId] = hull
|
||||
return hull
|
||||
}
|
||||
|
||||
public getHulls() {
|
||||
return this.get('hullMap')
|
||||
}
|
||||
|
||||
public getHullById(hullId: string) {
|
||||
return this.get('hullMap')[hullId]
|
||||
}
|
||||
|
||||
public removeHull(hull: Hull | string) {
|
||||
let hullInstance: Hull;
|
||||
if (isString(hull)) {
|
||||
hullInstance = this.getHullById(hull)
|
||||
} else {
|
||||
hullInstance = hull
|
||||
}
|
||||
let hullMap = this.get('hullMap')
|
||||
delete hullMap[hullInstance.id];
|
||||
hullInstance.destroy()
|
||||
}
|
||||
}
|
||||
|
219
src/item/hull.ts
Normal file
219
src/item/hull.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { IGroup } from '@antv/g-base';
|
||||
import { deepMix, isString } from '@antv/util';
|
||||
import { Item, BubblesetCfg, HullCfg } from '../types';
|
||||
import { getSpline, pathToPoints } from '../util/path';
|
||||
import { isPolygonsIntersect } from '../util/math';
|
||||
import { IGraph } from '../interface/graph';
|
||||
|
||||
import { genConvexHull } from '../shape/hull/convexHull';
|
||||
import { genBubbleSet } from '../shape/hull/bubbleset';
|
||||
|
||||
export default class Hull {
|
||||
id: string;
|
||||
graph: IGraph;
|
||||
cfg: any;
|
||||
path: any[][];
|
||||
group: IGroup;
|
||||
members: Item[];
|
||||
nonMembers: Item[];
|
||||
bubbleCfg: BubblesetCfg;
|
||||
|
||||
constructor(graph: IGraph, cfg: HullCfg) {
|
||||
this.cfg = deepMix(this.getDefaultCfg(), cfg)
|
||||
this.graph = graph
|
||||
this.id = this.cfg.id
|
||||
this.group = this.cfg.group
|
||||
this.members = cfg.members || []
|
||||
this.nonMembers = cfg.nonMembers || []
|
||||
!isNaN(this.cfg.padding) && (this.cfg.bubbleCfg = {
|
||||
nodeR0: this.cfg.padding,
|
||||
nodeR1: this.cfg.padding * 2,
|
||||
morphBuffer: this.cfg.padding
|
||||
})
|
||||
this.path = this.calcPath(this.members, this.nonMembers)
|
||||
this.render()
|
||||
|
||||
if (this.cfg.autoUpdate) {
|
||||
graph.on('afterremoveitem', e => {
|
||||
this.removeMember(e.item)
|
||||
this.removeNonMember(e.item)
|
||||
})
|
||||
graph.on('afterupdateitem', e => {
|
||||
if (e.item.getContainer().get('capture') && this.members.indexOf(e.item) > -1 || this.nonMembers.indexOf(e.item) > -1) {
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
}
|
||||
})
|
||||
graph.on('node:dragend', e => {
|
||||
const item = e.item;
|
||||
const memberIdx = this.members.indexOf(item)
|
||||
if (memberIdx > -1) {
|
||||
// 如果移出原hull范围,则去掉
|
||||
if (!this.contain(item)) {
|
||||
this.removeMember(item)
|
||||
}
|
||||
else {
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
}
|
||||
} else {
|
||||
if (this.contain(item)) this.addMember(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public getDefaultCfg(): HullCfg {
|
||||
return {
|
||||
id: 'g6-hull',
|
||||
mode: 'bubble', // 'convex' or 'bubble'
|
||||
name: 'g6-hull',
|
||||
members: [],
|
||||
nonMembers: [],
|
||||
style: {
|
||||
fill: 'lightblue',
|
||||
stroke: 'blue',
|
||||
opacity: 0.2,
|
||||
},
|
||||
autoUpdate: false,
|
||||
bubbleCfg: {
|
||||
nodeR0: 5,
|
||||
nodeR1: 10,
|
||||
morphBuffer: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calcPath(members: Item[], nonMembers: Item[],) {
|
||||
let contour = []
|
||||
if (this.cfg.mode === 'convex') {
|
||||
contour = genConvexHull(members)
|
||||
} else if (this.cfg.mode === 'bubble') {
|
||||
contour = genBubbleSet(members, nonMembers, this.cfg.bubbleCfg)
|
||||
} else {
|
||||
console.warn('Set mode to convex or bubble.')
|
||||
}
|
||||
const path = contour.length >= 2 && getSpline(contour)
|
||||
return path
|
||||
}
|
||||
|
||||
render() {
|
||||
this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.path,
|
||||
...this.cfg.style
|
||||
},
|
||||
id: this.id,
|
||||
name: this.cfg.name,
|
||||
})
|
||||
this.group.toBack()
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加hull的成员,同时如果该成员原先在nonMembers中,则从nonMembers中去掉
|
||||
* @param item 节点实例
|
||||
* @return boolean 添加成功返回 true,否则返回 false
|
||||
*/
|
||||
public addMember(item: Item): boolean {
|
||||
if (!item) return;
|
||||
this.members.push(item)
|
||||
const index = this.nonMembers.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.nonMembers.splice(index, 1);
|
||||
}
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加hull需要排除的节点,同时如果该成员原先在members中,则从members中去掉
|
||||
* @param item 节点实例
|
||||
* @return boolean 添加成功返回 true,否则返回 false
|
||||
*/
|
||||
public addNonMember(item: Item): boolean {
|
||||
if (!item) return;
|
||||
this.nonMembers.push(item)
|
||||
const index = this.members.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.members.splice(index, 1);
|
||||
}
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除hull中的成员,且添加到nonMembers中
|
||||
* @param node 节点实例
|
||||
* @return boolean 移除成功返回 true,否则返回 false
|
||||
*/
|
||||
public removeMember(item: Item): boolean {
|
||||
if (!item) return;
|
||||
const index = this.members.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.members.splice(index, 1);
|
||||
this.nonMembers.push(item)
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param node 节点实例
|
||||
* @return boolean 移除成功返回 true,否则返回 false
|
||||
*/
|
||||
public removeNonMember(item: Item): boolean {
|
||||
if (!item) return;
|
||||
const index = this.nonMembers.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.nonMembers.splice(index, 1);
|
||||
this.updateData(this.members, this.nonMembers)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateData(members: Item[], nonMembers: Item[]) {
|
||||
this.group.findById(this.id).remove()
|
||||
this.members = members
|
||||
this.nonMembers = nonMembers
|
||||
this.path = this.calcPath(members, nonMembers)
|
||||
this.render()
|
||||
}
|
||||
|
||||
public updateStyle(cfg: HullCfg["style"]) {
|
||||
const path = this.group.findById(this.id)
|
||||
path.attr({
|
||||
...cfg
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否在hull内部
|
||||
* @param item
|
||||
*/
|
||||
public contain(item: Item | string): boolean {
|
||||
let nodeItem: Item;
|
||||
if (isString(item)) {
|
||||
nodeItem = this.graph.findById(item)
|
||||
} else {
|
||||
nodeItem = item;
|
||||
}
|
||||
let shapePoints;
|
||||
const shape = nodeItem.getKeyShape()
|
||||
if (nodeItem.get('type') === 'path') {
|
||||
shapePoints = pathToPoints(shape.attr('path'))
|
||||
} else {
|
||||
const shapeBBox = shape.getCanvasBBox();
|
||||
shapePoints = [[shapeBBox.minX, shapeBBox.minY], [shapeBBox.maxX, shapeBBox.minY], [shapeBBox.maxX, shapeBBox.maxY], [shapeBBox.minX, shapeBBox.maxY]];
|
||||
}
|
||||
shapePoints = shapePoints.map(canvasPoint => {
|
||||
const point = this.graph.getPointByCanvas(canvasPoint[0], canvasPoint[1])
|
||||
return [point.x, point.y]
|
||||
})
|
||||
return isPolygonsIntersect(shapePoints, pathToPoints(this.path))
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.group.remove()
|
||||
this.cfg = null;
|
||||
}
|
||||
}
|
564
src/shape/hull/bubbleset.ts
Normal file
564
src/shape/hull/bubbleset.ts
Normal file
@ -0,0 +1,564 @@
|
||||
import { IPoint, IBBox, Item, BubblesetCfg } from '../../types'
|
||||
import { squareDist, pointLineDist, itemIntersectByLine, getPointsCenter, fractionToLine, isPointsOverlap, getRectDistSq, Line, isPointInPolygon } from '../../util/math'
|
||||
|
||||
const defauleOps = {
|
||||
maxRoutingIterations: 100, // number of times to run the algorithm to refine the path finding in difficult areas
|
||||
maxMarchingIterations: 100, // number of times to refine the boundary
|
||||
pixelGroupSize: 4, // the resolution of the algorithm in square pixels
|
||||
edgeR0: 5, // the distance from edges at which energy is 1 (full influence)
|
||||
edgeR1: 10, // the distance from edges at which energy is 0 (no influence)
|
||||
nodeR0: 5, // the distance from nodes which energy is 1 (full influence)
|
||||
nodeR1: 10, // the distance from nodes at which energy is 0 (no influence)
|
||||
morphBuffer: 5,// DEFAULT_NODE_R0; the amount of space to move the virtual edge when wrapping around obstacles
|
||||
threshold: 0.001,
|
||||
skip: 8,
|
||||
nodeInfluenceFactor: 1,
|
||||
edgeInfluenceFactor: 1,
|
||||
negativeNodeInfluenceFactor: -0.5,
|
||||
}
|
||||
|
||||
/**
|
||||
* Marching square algorithm for traching the contour of a pixel group
|
||||
* https://www.emanueleferonato.com/2013/03/01/using-marching-squares-algorithm-to-trace-the-contour-of-an-image/
|
||||
* @param potentialArea
|
||||
* @param threshold
|
||||
*/
|
||||
function MarchingSquares(contour, potentialArea, threshold) {
|
||||
let marched = false;
|
||||
const getVal = (x: number, y: number) => {
|
||||
return potentialArea.cells[x + y * potentialArea.width]
|
||||
}
|
||||
|
||||
const getState = (x: number, y: number) => {
|
||||
let squareVal = 0
|
||||
if (getVal(x - 1, y - 1) >= threshold) {
|
||||
squareVal += 1;
|
||||
}
|
||||
if (getVal(x, y - 1) > threshold) {
|
||||
squareVal += 2;
|
||||
}
|
||||
if (getVal(x - 1, y) > threshold) {
|
||||
squareVal += 4;
|
||||
}
|
||||
if (getVal(x, y) > threshold) {
|
||||
squareVal += 8;
|
||||
}
|
||||
return squareVal;
|
||||
}
|
||||
|
||||
const doMarch = (xPos: number, yPos: number) => {
|
||||
let x = xPos;
|
||||
let y = yPos;
|
||||
let prevX;
|
||||
let prevY;
|
||||
|
||||
for (let i = 0; i < potentialArea.width * potentialArea.height; i++) {
|
||||
prevX = x
|
||||
prevY = y
|
||||
if (contour.findIndex(item => item.x === x && item.y === y) > -1) {
|
||||
if (contour[0].x !== x || contour[0].y !== y) {
|
||||
// encountered a loop but haven't returned to start: change direction using conditionals and continue back to start
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
contour.push({ x, y });
|
||||
}
|
||||
|
||||
const state = getState(x, y);
|
||||
// assign the move direction according to state of the square
|
||||
switch (state) {
|
||||
case -1:
|
||||
console.warn('Marched out of bounds')
|
||||
return true;
|
||||
case 0:
|
||||
case 3:
|
||||
case 2:
|
||||
case 7:
|
||||
x++; // go right
|
||||
break;
|
||||
case 12:
|
||||
case 14:
|
||||
case 4:
|
||||
x--; // go left
|
||||
break;
|
||||
case 6: // go left if come from up else go right
|
||||
(prevX === 0 && prevY === -1) ? x-- : x++
|
||||
break;
|
||||
case 1:
|
||||
case 13:
|
||||
case 5:
|
||||
y--; // go up
|
||||
break;
|
||||
case 9: // go up if come from right else go down
|
||||
(prevX === 1 && prevY === 0) ? y-- : y++
|
||||
break;
|
||||
case 10:
|
||||
case 8:
|
||||
case 11:
|
||||
y++; // go down
|
||||
break;
|
||||
default:
|
||||
console.warn(`Marching squares invalid state: ${state}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.march = function () {
|
||||
for (let x = 0; x < potentialArea.width && !marched; x += 1) {
|
||||
for (let y = 0; y < potentialArea.height && !marched; y += 1) {
|
||||
if (getVal(x, y) > threshold && getState(x, y) !== 15) {
|
||||
marched = doMarch(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
return marched
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Space partition & assign value to each cell
|
||||
* @param points
|
||||
*/
|
||||
const initGridCells = (width: number, height: number, pixelGroupSize: number) => {
|
||||
const scaleWidth = Math.ceil(width / pixelGroupSize);
|
||||
const scaleHeight = Math.ceil(height / pixelGroupSize);
|
||||
const gridCells = new Float32Array(Math.max(0, scaleWidth * scaleHeight)).fill(0)
|
||||
return {
|
||||
cells: gridCells,
|
||||
width: scaleWidth,
|
||||
height: scaleHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the optimal already visited member to item;
|
||||
Optimal: minimize cost(j) = distance(i,j) ∗ countObstacles(i,j)
|
||||
* @param item
|
||||
* @param visited
|
||||
*/
|
||||
const pickBestNeighbor = (item: Item, visited: Item[], nonMembers: Item[]): Item | null => {
|
||||
let closestNeighbour = null;
|
||||
let minCost = Number.POSITIVE_INFINITY;
|
||||
|
||||
visited.forEach(neighbourItem => {
|
||||
const itemP = { x: item.getModel().x, y: item.getModel().y }
|
||||
const neighbourItemP = { x: neighbourItem.getModel().x, y: neighbourItem.getModel().y }
|
||||
const dist = squareDist(itemP, neighbourItemP);
|
||||
const directLine = new Line(itemP.x, itemP.y, neighbourItemP.x, neighbourItemP.y);
|
||||
const numberObstacles = nonMembers.reduce((count, _item) => {
|
||||
if (fractionToLine(_item, directLine) > 0) {
|
||||
return count + 1;
|
||||
}
|
||||
return count;
|
||||
}, 0);
|
||||
// console.log(item.getID(), neighbourItem.getID(), numberObstacles)
|
||||
if (dist * ((numberObstacles + 1) ** 2) < minCost) {
|
||||
closestNeighbour = neighbourItem;
|
||||
minCost = dist * ((numberObstacles + 1) ** 2);
|
||||
}
|
||||
});
|
||||
return closestNeighbour
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回和线相交的item中,离边的起点最近的item
|
||||
* @param items
|
||||
* @param line
|
||||
*/
|
||||
const getIntersectItem = (items: Item[], line: Line): Item | null => {
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
let closestItem = null;
|
||||
|
||||
items.forEach(item => {
|
||||
const distance = fractionToLine(item, line);
|
||||
// find closest intersection
|
||||
if (distance >= 0 && distance < minDistance) {
|
||||
closestItem = item;
|
||||
minDistance = distance;
|
||||
}
|
||||
});
|
||||
return closestItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the directLine and Route virtual edges around obstacles
|
||||
*/
|
||||
const computeRoute = (directLine: Line, nonMembers: Item[], maxRoutingIterations: number, morphBuffer: number): Line[] => {
|
||||
const checkedLines: Line[] = []
|
||||
const linesToCheck: Line[] = []
|
||||
linesToCheck.push(directLine);
|
||||
|
||||
let hasIntersection = true;
|
||||
let iterations = 0;
|
||||
|
||||
const pointExists = (point: IPoint, lines: Line[]) => {
|
||||
let flag = false
|
||||
lines.forEach((line) => {
|
||||
if (flag) return;
|
||||
if (isPointsOverlap(point, { x: line.x1, y: line.y1 }) || isPointsOverlap(point, { x: line.x2, y: line.y2 })) {
|
||||
flag = true;
|
||||
}
|
||||
});
|
||||
return flag
|
||||
}
|
||||
const isPointInNonMembers = (point: IPoint, _nonMembers: Item[]) => {
|
||||
for (const item of _nonMembers) {
|
||||
const bbox = item.getBBox()
|
||||
const itemContour = [[bbox.x, bbox.y], [bbox.x + bbox.width, bbox.y],
|
||||
[bbox.x, bbox.y + bbox.height], [bbox.x + bbox.width, bbox.y + bbox.height]]
|
||||
if (isPointInPolygon(itemContour, point.x, point.y)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// outer loop end when no more intersections or out of iterations
|
||||
while (hasIntersection && iterations < maxRoutingIterations) {
|
||||
hasIntersection = false;
|
||||
// inner loop end when out of lines or found an intersection
|
||||
while (!hasIntersection && linesToCheck.length) {
|
||||
const line = linesToCheck.pop();
|
||||
const closestItem = getIntersectItem(nonMembers, line);
|
||||
if (closestItem) {
|
||||
const [intersections, countIntersections] = itemIntersectByLine(closestItem, line)
|
||||
// if line passes through item
|
||||
if (countIntersections === 2) {
|
||||
const testReroute = (isFirst: boolean) => {
|
||||
let tempMorphBuffer = morphBuffer;
|
||||
let virtualNode = rerouteLine(closestItem, tempMorphBuffer, intersections, isFirst);
|
||||
// test the virtualNode already exists
|
||||
let exist = pointExists(virtualNode, linesToCheck) || pointExists(virtualNode, checkedLines);
|
||||
let pointInside = isPointInNonMembers(virtualNode, nonMembers);
|
||||
|
||||
while (!exist && pointInside && (tempMorphBuffer >= 1)) {
|
||||
// try a smaller buffer
|
||||
tempMorphBuffer /= 1.5;
|
||||
virtualNode = rerouteLine(closestItem, tempMorphBuffer, intersections, isFirst);
|
||||
exist = pointExists(virtualNode, linesToCheck) || pointExists(virtualNode, checkedLines);
|
||||
pointInside = isPointInNonMembers(virtualNode, nonMembers);
|
||||
}
|
||||
|
||||
// 第二次route时不要求pointInside
|
||||
if (virtualNode && (!exist) && (!isFirst || !pointInside)) {
|
||||
// add 2 rerouted lines to check
|
||||
linesToCheck.push(new Line(line.x1, line.y1, virtualNode.x, virtualNode.y));
|
||||
linesToCheck.push(new Line(virtualNode.x, virtualNode.y, line.x2, line.y2));
|
||||
hasIntersection = true;
|
||||
}
|
||||
}
|
||||
|
||||
testReroute(true)
|
||||
if (!hasIntersection) { // if we didn't find a valid point around the first corner, try the second
|
||||
testReroute(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no intersection found, mark this line as completed
|
||||
if (!hasIntersection) {
|
||||
checkedLines.push(line);
|
||||
}
|
||||
iterations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 加入剩余的线
|
||||
while (linesToCheck.length) {
|
||||
checkedLines.push(linesToCheck.pop());
|
||||
}
|
||||
return checkedLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect item with visited members using direct line or virtual edges
|
||||
*/
|
||||
function getRoute(item: Item, nonMembers: Item[], visited: Item[], maxRoutingIterations: number, morphBuffer: number) {
|
||||
const optimalNeighbor = pickBestNeighbor(item, visited, nonMembers)
|
||||
if (optimalNeighbor === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
// merge the consecutive lines
|
||||
const mergeLines = (checkedLines: Line[]): Line[] => {
|
||||
const finalRoute: Line[] = [];
|
||||
while (checkedLines.length > 0) {
|
||||
const line1 = checkedLines.pop()!;
|
||||
if (checkedLines.length === 0) {
|
||||
finalRoute.push(line1);
|
||||
break;
|
||||
}
|
||||
const line2 = checkedLines.pop()!;
|
||||
const mergeLine = new Line(line1.x1, line1.y1, line2.x2, line2.y2);
|
||||
const closestItem = getIntersectItem(nonMembers, mergeLine);
|
||||
// merge most recent line and previous line
|
||||
if (!closestItem) {
|
||||
checkedLines.push(mergeLine);
|
||||
} else {
|
||||
finalRoute.push(line1);
|
||||
checkedLines.push(line2);
|
||||
}
|
||||
}
|
||||
return finalRoute;
|
||||
}
|
||||
const directLine = new Line(item.getModel().x, item.getModel().y, optimalNeighbor.getModel().x, optimalNeighbor.getModel().y);
|
||||
const checkedLines = computeRoute(directLine, nonMembers, maxRoutingIterations, morphBuffer);
|
||||
const finalRoute = mergeLines(checkedLines);
|
||||
return finalRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the countor that includes the selected items and exclues the non-selected items
|
||||
* @param graph
|
||||
* @param members
|
||||
* @param nonMembers
|
||||
* @param options
|
||||
*/
|
||||
export const genBubbleSet = (members: Item[], nonMembers: Item[], ops?: BubblesetCfg) => {
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const options = Object.assign(defauleOps, ops)
|
||||
const centroid = getPointsCenter(members.map(item => ({ x: item.getModel().x, y: item.getModel().y })));
|
||||
// 按照到中心距离远近排序
|
||||
members = members.sort((a, b) => (squareDist({ x: a.getModel().x, y: a.getModel().y }, centroid) - squareDist({ x: b.getModel().x, y: b.getModel().y }, centroid)))
|
||||
const visited: Item[] = [];
|
||||
const virtualEdges: Line[] = [];
|
||||
members.forEach(item => {
|
||||
const lines = getRoute(item, nonMembers, visited, options.maxRoutingIterations, options.morphBuffer);
|
||||
lines.forEach(l => {
|
||||
virtualEdges.push(l);
|
||||
});
|
||||
visited.push(item);
|
||||
});
|
||||
// 由于edge也可以作为member和nonMember传入,暂时不考虑把edges作为参数传入genBubbleSet
|
||||
// edges && edges.forEach(e => {
|
||||
// virtualEdges.push(new Line(e.getSource().getModel().x, e.getSource().getModel().y, e.getTarget().getModel().x, e.getTarget().getModel().y));
|
||||
// });
|
||||
|
||||
const activeRegion = getActiveRregion(members, virtualEdges, options.nodeR0);
|
||||
const potentialArea = initGridCells(activeRegion.width, activeRegion.height, options.pixelGroupSize)
|
||||
|
||||
// Use march squares to generate contour
|
||||
let contour = [];
|
||||
let hull = [];
|
||||
for (let iterations = 0; iterations < options.maxMarchingIterations; iterations++) {
|
||||
fillPotentialArea(members, nonMembers, virtualEdges, activeRegion, potentialArea, options);
|
||||
contour = []
|
||||
hull = []
|
||||
if (!new MarchingSquares(contour, potentialArea, options.threshold).march()) continue;
|
||||
const marchedPath = contour.map(point => ({ x: Math.round(point.x * options.pixelGroupSize + activeRegion.minX), y: Math.round(point.y * options.pixelGroupSize + activeRegion.minY) }))
|
||||
// const marchedPath = marchingSquares(potentialArea, options.threshold).map(point => ({ x: Math.round(point.x * options.pixelGroupSize + activeRegion.minX), y: Math.round(point.y * options.pixelGroupSize + activeRegion.minY) }))
|
||||
if (marchedPath) {
|
||||
let size = marchedPath.length;
|
||||
if (options.skip > 1) {
|
||||
size = Math.floor(marchedPath.length / options.skip);
|
||||
// if we reduced too much (fewer than three points in reduced surface) reduce skip and try again
|
||||
while ((size < 3) && (options.skip > 1)) {
|
||||
options.skip -= 1;
|
||||
size = Math.floor(marchedPath.length / options.skip);
|
||||
}
|
||||
}
|
||||
// copy hull values
|
||||
for (let i = 0, j = 0; j < size; j += 1, i += options.skip) {
|
||||
hull.push({ x: marchedPath[i].x, y: marchedPath[i].y });
|
||||
}
|
||||
hull.push(hull[0])
|
||||
}
|
||||
|
||||
const isContourValid = () => {
|
||||
for (const item of members) {
|
||||
const hullPoints = hull.map(point => [point.x, point.y])
|
||||
if (!isPointInPolygon(hullPoints, item.getBBox().centerX, item.getBBox().centerY)) return false
|
||||
}
|
||||
// 不强制要求所有nonMembers都没有包含在内
|
||||
// for (const item of nonMembers) {
|
||||
// if (isPointInPolygon({ x: item.getBBox().centerX, y: item.getBBox().centerY }, contour)) return false
|
||||
// }
|
||||
return true
|
||||
}
|
||||
|
||||
if (hull && isContourValid()) {
|
||||
return hull
|
||||
}
|
||||
|
||||
// update parameters for next iteraction
|
||||
options.threshold *= 0.9;
|
||||
if (iterations <= options.maxMarchingIterations * 0.5) {
|
||||
options.memberInfluenceFactor *= 1.2;
|
||||
options.edgeInfluenceFactor *= 1.2;
|
||||
} else if (options.nonMemberInfluenceFactor !== 0 && nonMembers.length > 0) {
|
||||
// after half the iterations, start increasing positive energy and lowering the threshold
|
||||
options.nonMemberInfluenceFactor *= 0.8;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return hull
|
||||
}
|
||||
|
||||
/**
|
||||
* unionboundingbox
|
||||
* @param members
|
||||
* @param edges
|
||||
*/
|
||||
function getActiveRregion(members: Item[], edges: Line[], offset: number): IBBox {
|
||||
const activeRegion = {
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minY: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxY: Number.NEGATIVE_INFINITY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
const bboxes = []
|
||||
|
||||
members.forEach(item => {
|
||||
bboxes.push(item.getBBox())
|
||||
})
|
||||
edges.forEach(l => {
|
||||
bboxes.push(l.getBBox());
|
||||
});
|
||||
|
||||
for (const bbox of bboxes) {
|
||||
activeRegion.minX = (bbox.minX < activeRegion.minX ? bbox.minX : activeRegion.minX) - offset;
|
||||
activeRegion.minY = (bbox.minY < activeRegion.minY ? bbox.minY : activeRegion.minY) - offset;
|
||||
activeRegion.maxX = (bbox.maxX > activeRegion.maxX ? bbox.maxX : activeRegion.maxX) + offset;
|
||||
activeRegion.maxY = (bbox.maxY > activeRegion.maxY ? bbox.maxY : activeRegion.maxY) + offset;
|
||||
}
|
||||
activeRegion.width = activeRegion.maxX - activeRegion.minX
|
||||
activeRegion.height = activeRegion.maxY - activeRegion.minY
|
||||
activeRegion.x = activeRegion.minX
|
||||
activeRegion.y = activeRegion.minY
|
||||
return activeRegion
|
||||
}
|
||||
|
||||
function fillPotentialArea(members: Item[], nonMembers: Item[], edges: Line[], activeRegion: IBBox, potentialArea, options: BubblesetCfg) {
|
||||
function pos2GridIx(x, offset) {
|
||||
const gridIx = Math.floor((x - offset) / options.pixelGroupSize)
|
||||
return gridIx < 0 ? 0 : gridIx
|
||||
}
|
||||
|
||||
function gridIx2Pos(x, offset) {
|
||||
return x * options.pixelGroupSize + offset
|
||||
}
|
||||
|
||||
// using inverse a for numerical stability
|
||||
const nodeInfA = (options.nodeR0 - options.nodeR1) * (options.nodeR0 - options.nodeR1);
|
||||
const edgeInfA = (options.edgeR0 - options.edgeR1) * (options.edgeR0 - options.edgeR1);
|
||||
|
||||
const getAffectedRegion = (bbox, thresholdR) => {
|
||||
const startX = Math.min(pos2GridIx(bbox.minX, thresholdR + activeRegion.minX), potentialArea.width);
|
||||
const startY = Math.min(pos2GridIx(bbox.minY, thresholdR + activeRegion.minY), potentialArea.height);
|
||||
const endX = Math.min(pos2GridIx(bbox.maxX, -thresholdR + activeRegion.minX), potentialArea.width);
|
||||
const endY = Math.min(pos2GridIx(bbox.maxY, -thresholdR + activeRegion.minY), potentialArea.height);
|
||||
return [startX, startY, endX, endY]
|
||||
}
|
||||
const addItemInfluence = (item: Item, influenceFactor: number) => {
|
||||
const bbox = item.getBBox();
|
||||
const [startX, startY, endX, endY] = getAffectedRegion(bbox, options.nodeR1)
|
||||
// console.log(startX, startY, endX, endY)
|
||||
// calculate item influence for each cell
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
for (let x = startX; x < endX; x += 1) {
|
||||
if (influenceFactor < 0 && potentialArea[x + y * potentialArea.width] <= 0) {
|
||||
continue;
|
||||
}
|
||||
const tempX = gridIx2Pos(x, activeRegion.minX);
|
||||
const tempY = gridIx2Pos(y, activeRegion.minY);
|
||||
const distanceSq = getRectDistSq(item, tempX, tempY);
|
||||
// console.log(distanceSq)
|
||||
// only influence if less than r1
|
||||
if (distanceSq < options.nodeR1 ** 2) {
|
||||
const dr = Math.sqrt(distanceSq) - options.nodeR1;
|
||||
potentialArea.cells[x + y * potentialArea.width] += influenceFactor * dr * dr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addEdgeInfluence = (line: Line, influenceFactor: number) => {
|
||||
const bbox = line.getBBox();
|
||||
const [startX, startY, endX, endY] = getAffectedRegion(bbox, options.edgeR1)
|
||||
// for every point in active part of potentialArea, calculate distance to nearest point on line and add influence
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
for (let x = startX; x < endX; x += 1) {
|
||||
if (influenceFactor < 0 && potentialArea.cells[x + y * potentialArea.width] <= 0) {
|
||||
continue;
|
||||
}
|
||||
const tempX = gridIx2Pos(x, activeRegion.minX);
|
||||
const tempY = gridIx2Pos(y, activeRegion.minY);
|
||||
const minDistanceSq = pointLineDist({ x: tempX, y: tempY }, line);
|
||||
// only influence if less than r1
|
||||
if (minDistanceSq < options.edgeR1 ** 2) {
|
||||
const mdr = Math.sqrt(minDistanceSq) - options.edgeR1;
|
||||
potentialArea.cells[x + y * potentialArea.width] += influenceFactor * mdr * mdr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.nodeInfluenceFactor) {
|
||||
members.forEach(item => {
|
||||
addItemInfluence(item, options.nodeInfluenceFactor / nodeInfA)
|
||||
});
|
||||
}
|
||||
|
||||
if (options.edgeInfluenceFactor) {
|
||||
edges.forEach(edge => {
|
||||
addEdgeInfluence(edge, options.edgeInfluenceFactor / edgeInfA)
|
||||
})
|
||||
}
|
||||
if (options.negativeNodeInfluenceFactor) {
|
||||
nonMembers.forEach(item => {
|
||||
addItemInfluence(item, options.negativeNodeInfluenceFactor / nodeInfA)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rerouteLine(item, buffer: number, intersections: IPoint[], wrapNormal: boolean): IPoint {
|
||||
const bbox = item.getBBox();
|
||||
const [topIntersect, leftIntersect, bottomIntersect, rightIntersect] = intersections;
|
||||
const cornerPos = {
|
||||
'topLeft': { x: bbox.minX - buffer, y: bbox.minY - buffer },
|
||||
'topRight': { x: bbox.maxX + buffer, y: bbox.minY - buffer },
|
||||
'bottomLeft': { x: bbox.minX - buffer, y: bbox.maxY + buffer },
|
||||
'bottomRight': { x: bbox.maxX + buffer, y: bbox.maxY + buffer }
|
||||
}
|
||||
|
||||
const totalArea = bbox.height * bbox.width;
|
||||
function calcHalfArea(intersect1, intersect2) {
|
||||
return bbox.width * (((intersect1.y - bbox.minY) + (intersect2.y - bbox.minY)) * 0.5)
|
||||
}
|
||||
|
||||
// 根据线和boundingbox相交的情况,确定control point的位置
|
||||
if (leftIntersect) {
|
||||
// 相交区域有三角形
|
||||
if (topIntersect) return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
|
||||
if (bottomIntersect) return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
|
||||
// 相交区域分成上下两个梯形,比较面积
|
||||
const topArea = calcHalfArea(leftIntersect, rightIntersect);
|
||||
if (topArea < totalArea * 0.5) {
|
||||
if (leftIntersect.y > rightIntersect.y) return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
|
||||
return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
|
||||
}
|
||||
if (leftIntersect.y < rightIntersect.y) return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
|
||||
return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
|
||||
}
|
||||
|
||||
if (rightIntersect) {
|
||||
if (topIntersect) return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
|
||||
if (bottomIntersect) return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
|
||||
}
|
||||
|
||||
// 相交区域分成左右两个梯形
|
||||
const leftArea = calcHalfArea(topIntersect, bottomIntersect);;
|
||||
if (leftArea < totalArea * 0.5) {
|
||||
if (topIntersect.x > bottomIntersect.x) return wrapNormal ? cornerPos.topLeft : cornerPos.bottomRight;
|
||||
return wrapNormal ? cornerPos.bottomLeft : cornerPos.topRight;
|
||||
}
|
||||
if (topIntersect.x < bottomIntersect.x) return wrapNormal ? cornerPos.topRight : cornerPos.bottomLeft;
|
||||
return wrapNormal ? cornerPos.bottomRight : cornerPos.topLeft;
|
||||
}
|
41
src/shape/hull/convexHull.ts
Normal file
41
src/shape/hull/convexHull.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { IPoint, Item } from '../../types'
|
||||
|
||||
/**
|
||||
* Use cross product to judge the direction of the turn.
|
||||
* Returns a positive value, if OAB makes a clockwise turn,
|
||||
* negative for counter-clockwise turn, and zero if the points are collinear.
|
||||
*/
|
||||
export const cross = (a: IPoint, b: IPoint, o: IPoint) => {
|
||||
return (a.y - o.y) * (b.x - o.x) - (a.x - o.x) * (b.y - o.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a convex hull of given points. Andrew's monotone chain algorithm.
|
||||
* @param points An array of [x, y] representing the coordinates of points.
|
||||
* @return a list of vertices of the convex hull in counter-clockwise order,
|
||||
*/
|
||||
export const genConvexHull = (items: Item[]) => {
|
||||
const points: IPoint[] = items.map(item => ({ x: item.getModel().x, y: item.getModel().y }))
|
||||
points.sort((a, b) => {
|
||||
return a.x === b.x ? a.y - b.y : a.x - b.x;
|
||||
});
|
||||
|
||||
// build the lower hull
|
||||
const lower = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], points[i]) <= 0) {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(points[i]);
|
||||
}
|
||||
|
||||
// build the upper hull
|
||||
const upper = [];
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], points[i]) <= 0) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(points[i]);
|
||||
}
|
||||
return lower.concat(upper);
|
||||
}
|
@ -6,6 +6,7 @@ import Node from '../item/node';
|
||||
import { IGraph } from '../interface/graph';
|
||||
import { IEdge, INode, ICombo } from '../interface/item';
|
||||
import { ILabelConfig } from '../interface/shape';
|
||||
import Group from '@antv/g-canvas/lib/group';
|
||||
|
||||
// Math types
|
||||
export interface IPoint {
|
||||
@ -666,3 +667,37 @@ export interface StackData {
|
||||
action: string;
|
||||
data: GraphData;
|
||||
}
|
||||
|
||||
export interface HullCfg {
|
||||
id: string,
|
||||
members?: Item[],
|
||||
nonMembers?: Item[],
|
||||
group?: Group,
|
||||
mode?: string, // 'convex' or 'bubble'
|
||||
name?: string,
|
||||
padding?: number,
|
||||
style?: {
|
||||
fill?: string,
|
||||
stroke?: string,
|
||||
opacity?: number
|
||||
},
|
||||
autoUpdate?: boolean, // 是否支持拖动和更新节点时,对hull进行动态更新
|
||||
bubbleCfg?: BubblesetCfg
|
||||
}
|
||||
|
||||
export interface BubblesetCfg {
|
||||
morphBuffer?: number;
|
||||
threshold?: number;
|
||||
pixelGroupSize?: number;
|
||||
maxMarchingIterations?: number;
|
||||
maxRoutingIterations?: number;
|
||||
nodeR0?: number;
|
||||
nodeR1?: number;
|
||||
edgeR0?: number;
|
||||
edgeR1?: number;
|
||||
nodeInfluenceFactor?: number;
|
||||
edgeInfluenceFactor?: number;
|
||||
negativeNodeInfluenceFactor?: number;
|
||||
memberInfluenceFactor?: number;
|
||||
nonMemberInfluenceFactor?: number;
|
||||
}
|
154
src/util/math.ts
154
src/util/math.ts
@ -2,9 +2,10 @@ import { Point } from '@antv/g-base/lib/types';
|
||||
import { IGroup } from '@antv/g-canvas/lib/interfaces';
|
||||
import { mat3, transform, vec3 } from '@antv/matrix-util';
|
||||
import isArray from '@antv/util/lib/is-array';
|
||||
import { GraphData, ICircle, IEllipse, IRect, Matrix, EdgeConfig, NodeIdxMap, IBBox } from '../types';
|
||||
import { GraphData, ICircle, IEllipse, IRect, Matrix, EdgeConfig, NodeIdxMap, IBBox, Item, IPoint } from '../types';
|
||||
import { each } from '@antv/util';
|
||||
|
||||
|
||||
/**
|
||||
* 是否在区间内
|
||||
* @param {number} value 值
|
||||
@ -22,7 +23,7 @@ const isBetween = (value: number, min: number, max: number) => value >= min && v
|
||||
* @param {Point} p3 第二条线段终点
|
||||
* @return {Point} 交点
|
||||
*/
|
||||
const getLineIntersect = (p0: Point, p1: Point, p2: Point, p3: Point): Point | null => {
|
||||
export const getLineIntersect = (p0: Point, p1: Point, p2: Point, p3: Point): Point | null => {
|
||||
const tolerance = 0.001;
|
||||
|
||||
const E: Point = {
|
||||
@ -570,3 +571,152 @@ export const isPolygonsIntersect = (points1: number[][], points2: number[][]): b
|
||||
});
|
||||
return isIntersect;
|
||||
}
|
||||
|
||||
export class Line {
|
||||
public x1: number;
|
||||
public y1: number;
|
||||
public x2: number;
|
||||
public y2: number;
|
||||
constructor(x1, y1, x2, y2) {
|
||||
this.x1 = x1;
|
||||
this.y1 = y1;
|
||||
this.x2 = x2;
|
||||
this.y2 = y2;
|
||||
}
|
||||
public getBBox() {
|
||||
const minX = Math.min(this.x1, this.x2);
|
||||
const minY = Math.min(this.y1, this.y2);
|
||||
const maxX = Math.max(this.x1, this.x2);
|
||||
const maxY = Math.max(this.y1, this.y2);
|
||||
const res = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const getBBoxBoundLine = (bbox: IBBox, direction: string) => {
|
||||
const bounds = {
|
||||
'top': [bbox.minX, bbox.minY, bbox.maxX, bbox.minY],
|
||||
'left': [bbox.minX, bbox.minY, bbox.minX, bbox.maxY],
|
||||
'bottom': [bbox.minX, bbox.maxY, bbox.maxX, bbox.maxY],
|
||||
'right': [bbox.maxX, bbox.minY, bbox.maxX, bbox.maxY]
|
||||
}
|
||||
return bounds[direction]
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两条线段相交时,相交点对第一条线段上的分割比例
|
||||
*/
|
||||
const fractionAlongLineA = (la: Line, lb: Line) => {
|
||||
const uaT = (lb.x2 - lb.x1) * (la.y1 - lb.y1) - (lb.y2 - lb.y1) * (la.x1 - lb.x1);
|
||||
const ubT = (la.x2 - la.x1) * (la.y1 - lb.y1) - (la.y2 - la.y1) * (la.x1 - lb.x1);
|
||||
const uB = (lb.y2 - lb.y1) * (la.x2 - la.x1) - (lb.x2 - lb.x1) * (la.y2 - la.y1);
|
||||
if (uB) {
|
||||
const ua = uaT / uB;
|
||||
const ub = ubT / uB;
|
||||
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
|
||||
return ua;
|
||||
}
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export const itemIntersectByLine = (item: Item, line: Line): [IPoint[], number] => {
|
||||
const directions = ['top', 'left', 'bottom', 'right']
|
||||
const bbox = item.getBBox();
|
||||
let countIntersections = 0;
|
||||
const intersections = []
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [x1, y1, x2, y2] = getBBoxBoundLine(bbox, directions[i])
|
||||
intersections[i] = getLineIntersect({ x: line.x1, y: line.y1 }, { x: line.x2, y: line.y2 }, { x: x1, y: y1 }, { x: x2, y: y2 });
|
||||
if (intersections[i]) {
|
||||
countIntersections += 1;
|
||||
}
|
||||
}
|
||||
return [intersections, countIntersections]
|
||||
}
|
||||
|
||||
export const fractionToLine = (item: Item, line: Line) => {
|
||||
const directions = ['top', 'left', 'bottom', 'right']
|
||||
const bbox = item.getBBox();
|
||||
let minDistance = Number.POSITIVE_INFINITY;
|
||||
let countIntersections = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [x1, y1, x2, y2] = getBBoxBoundLine(bbox, directions[i])
|
||||
let testDistance = fractionAlongLineA(line, new Line(x1, y1, x2, y2));
|
||||
testDistance = Math.abs(testDistance - 0.5);
|
||||
if ((testDistance >= 0) && (testDistance <= 1)) {
|
||||
countIntersections += 1;
|
||||
minDistance = testDistance < minDistance ? testDistance : minDistance;
|
||||
}
|
||||
}
|
||||
|
||||
if (countIntersections === 0) return -1;
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
export const getPointsCenter = (points: IPoint[]): IPoint => {
|
||||
let centerX = 0;
|
||||
let centerY = 0;
|
||||
if (points.length > 0) {
|
||||
for (const point of points) {
|
||||
centerX += point.x
|
||||
centerY += point.y
|
||||
}
|
||||
centerX /= points.length
|
||||
centerY /= points.length
|
||||
}
|
||||
return { x: centerX, y: centerY }
|
||||
}
|
||||
|
||||
export const squareDist = (a: IPoint, b: IPoint): number => {
|
||||
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2
|
||||
}
|
||||
|
||||
export const pointLineDist = (point: IPoint, line: Line) => {
|
||||
const x1 = line.x1;
|
||||
const y1 = line.y1;
|
||||
const x2 = line.x2 - x1;
|
||||
const y2 = line.y2 - y1;
|
||||
let px = point.x - x1;
|
||||
let py = point.y - y1;
|
||||
let dotprod = px * x2 + py * y2;
|
||||
let projlenSq;
|
||||
if (dotprod <= 0) {
|
||||
projlenSq = 0;
|
||||
} else {
|
||||
px = x2 - px;
|
||||
py = y2 - py;
|
||||
dotprod = px * x2 + py * y2;
|
||||
if (dotprod <= 0) {
|
||||
projlenSq = 0;
|
||||
} else {
|
||||
projlenSq = dotprod * dotprod / (x2 * x2 + y2 * y2);
|
||||
}
|
||||
}
|
||||
let lenSq = px * px + py * py - projlenSq;
|
||||
if (lenSq < 0) {
|
||||
lenSq = 0;
|
||||
}
|
||||
return lenSq;
|
||||
}
|
||||
|
||||
export const isPointsOverlap = (p1, p2, e = 1e-3) => {
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2 < e ** 2
|
||||
}
|
||||
|
||||
export const getRectDistSq = (item: Item, x: number, y: number) => {
|
||||
const rect = item.getBBox()
|
||||
const dx = Math.max(rect.minX - x, 0, x - rect.maxX);
|
||||
const dy = Math.max(rect.minY - y, 0, y - rect.maxY);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
210
stories/Hull/component/hull.tsx
Normal file
210
stories/Hull/component/hull.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Graph } from '../../../src';
|
||||
|
||||
let graph = null;
|
||||
const data = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
label: '公司1',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: '公司2',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: '公司3',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
label: '公司4',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
label: '公司5',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
label: '公司6',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
label: '公司7',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
label: '公司8',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
label: '公司9',
|
||||
group: 2
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
source: '1',
|
||||
target: '1',
|
||||
type: 'loop'
|
||||
},
|
||||
{
|
||||
source: '2',
|
||||
target: '2',
|
||||
type: 'loop'
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '2',
|
||||
data: {
|
||||
type: 'A',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '3',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '2',
|
||||
target: '5',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '5',
|
||||
target: '6',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '3',
|
||||
target: '4',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '4',
|
||||
target: '7',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '8',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '9',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const HullDemo = () => {
|
||||
const container = React.useRef();
|
||||
useEffect(() => {
|
||||
if (!graph) {
|
||||
graph = new Graph({
|
||||
container: container.current as string | HTMLElement,
|
||||
width: 500,
|
||||
height: 500,
|
||||
modes: {
|
||||
default: ['drag-canvas', 'zoom-canvas', 'drag-node']
|
||||
},
|
||||
});
|
||||
graph.data(data);
|
||||
graph.render();
|
||||
|
||||
let members = graph.getNodes().filter(node => node.getModel().group === 2);
|
||||
let nonMembers = graph.getNodes().filter(node => node.getModel().group === 1);
|
||||
|
||||
const hull = graph.addHull({
|
||||
id: 'hull1',
|
||||
mode: 'convex',
|
||||
members: nonMembers,
|
||||
})
|
||||
const hull2 = graph.addHull({
|
||||
id: 'hull2',
|
||||
members,
|
||||
nonMembers,
|
||||
padding: 10,
|
||||
style: {
|
||||
fill: 'pink',
|
||||
stroke: 'red',
|
||||
},
|
||||
autoUpdate: true
|
||||
})
|
||||
|
||||
graph.on('canvas:click', ev => {
|
||||
const item = graph.addItem('node', {
|
||||
x: ev.x,
|
||||
y: ev.y,
|
||||
id: Math.random(),
|
||||
group: 1
|
||||
})
|
||||
nonMembers.push(item)
|
||||
hull.addMember(item)
|
||||
hull2.addNonMember(item)
|
||||
})
|
||||
graph.on('canvas:contextmenu', ev => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation();
|
||||
const item = graph.addItem('node', {
|
||||
x: ev.x,
|
||||
y: ev.y,
|
||||
id: Math.random(),
|
||||
group: 2
|
||||
})
|
||||
console.log(hull2.contain(item))
|
||||
members.push(item);
|
||||
hull.addNonMember(item)
|
||||
hull2.addMember(item)
|
||||
console.log(hull2.contain(item))
|
||||
})
|
||||
|
||||
graph.on('dblclick', () => {
|
||||
hull2.destroy()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return <div ref={container}></div>;
|
||||
}
|
||||
|
||||
export default HullDemo
|
14
stories/Hull/hull.stories.tsx
Normal file
14
stories/Hull/hull.stories.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import Hull from './component/hull';
|
||||
// import InteractiveHull from './component/interactiveHull';
|
||||
|
||||
export default { title: 'Hull' };
|
||||
|
||||
storiesOf('Hull', module)
|
||||
.add('Hull', () => (
|
||||
<Hull />
|
||||
))
|
||||
// .add('Interactive hull', () => (
|
||||
// <InteractiveHull />
|
||||
// ))
|
185
tests/unit/graph/graph-hull-spec.ts
Normal file
185
tests/unit/graph/graph-hull-spec.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { Graph } from '../../../src';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.id = 'hull-spec';
|
||||
document.body.appendChild(div);
|
||||
|
||||
const data = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
label: '公司1',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: '公司2',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: '公司3',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
label: '公司4',
|
||||
group: 1
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
label: '公司5',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
label: '公司6',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
label: '公司7',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
label: '公司8',
|
||||
group: 2
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
label: '公司9',
|
||||
group: 2
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
source: '1',
|
||||
target: '1',
|
||||
type: 'loop'
|
||||
},
|
||||
{
|
||||
source: '2',
|
||||
target: '2',
|
||||
type: 'loop'
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '2',
|
||||
data: {
|
||||
type: 'A',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '3',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '2',
|
||||
target: '5',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '5',
|
||||
target: '6',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '3',
|
||||
target: '4',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '4',
|
||||
target: '7',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '8',
|
||||
data: {
|
||||
type: 'B',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: '1',
|
||||
target: '9',
|
||||
data: {
|
||||
type: 'C',
|
||||
amount: '100,000 元',
|
||||
date: '2019-08-03',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('graph hull', () => {
|
||||
const graph = new Graph({
|
||||
container: div,
|
||||
width: 500,
|
||||
height: 500,
|
||||
modes: {
|
||||
default: ['drag-node', 'zoom-canvas', 'drag-canvas'],
|
||||
},
|
||||
});
|
||||
|
||||
graph.data(data)
|
||||
graph.render()
|
||||
const members = graph.getNodes().filter(node => node.getModel().group === 2);
|
||||
const nonMembers = graph.getNodes().filter(node => node.getModel().group === 1);
|
||||
|
||||
it('add a convex hull', () => {
|
||||
graph.addHull({
|
||||
id: 'hull1',
|
||||
members,
|
||||
mode: 'convex'
|
||||
})
|
||||
})
|
||||
it('add a bubble hull', () => {
|
||||
graph.addHull({
|
||||
id: 'hull2',
|
||||
nonMembers: members,
|
||||
members: nonMembers,
|
||||
padding: 10,
|
||||
style: {
|
||||
fill: 'pink',
|
||||
stroke: 'red',
|
||||
},
|
||||
})
|
||||
const hulls = Object.values(graph.getHulls())
|
||||
expect(hulls.length).toEqual(2)
|
||||
})
|
||||
it('remove hull', () => {
|
||||
let hullShapes = graph.get('hullGroup').get('children')
|
||||
expect(hullShapes.length).toEqual(2)
|
||||
|
||||
graph.removeHull('hull2')
|
||||
hullShapes = graph.get('hullGroup').get('children')
|
||||
expect(hullShapes.length).toEqual(1)
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user