feat: add hull to support using contour to include some items

This commit is contained in:
chenluli 2020-07-14 19:50:30 +08:00 committed by Moyee
parent 071ce303b7
commit 2cbf36ddf6
10 changed files with 1472 additions and 5 deletions

View File

@ -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"

View File

@ -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
View 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
View 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;
}

View 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);
}

View File

@ -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;
}

View File

@ -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);
}

View 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

View 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 />
// ))

View 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)
})
})