feat(pkg): Add pathfinding edge pkg to packages dir
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import { Component, CSSProperties, DefineComponent } from 'vue'
|
||||
import { getEdgeCenter, BezierEdge, EdgeText, Position, EdgeProps, EdgeTextProps, GraphNode } from '@braks/vue-flow'
|
||||
import { createGrid, gridRatio } from './createGrid'
|
||||
import { drawSmoothLinePath } from './drawSvgPath'
|
||||
import { generatePath } from './generatePath'
|
||||
import { getBoundingBoxes } from './getBoundingBoxes'
|
||||
import { gridToGraphPoint } from './pointConversion'
|
||||
|
||||
interface PathFindingEdgeProps extends EdgeProps {
|
||||
nodes: GraphNode[]
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceX: number
|
||||
sourceY: number
|
||||
targetX: number
|
||||
targetY: number
|
||||
selected?: boolean
|
||||
animated?: boolean
|
||||
sourcePosition: Position
|
||||
targetPosition: Position
|
||||
label?:
|
||||
| string
|
||||
| {
|
||||
component: Component<EdgeTextProps> | DefineComponent<EdgeTextProps>
|
||||
props?: EdgeTextProps
|
||||
}
|
||||
labelStyle?: any
|
||||
labelShowBg?: boolean
|
||||
labelBgStyle?: any
|
||||
labelBgPadding?: [number, number]
|
||||
labelBgBorderRadius?: number
|
||||
style?: CSSProperties
|
||||
markerEnd?: string
|
||||
markerStart?: string
|
||||
data?: any
|
||||
sourceHandleId?: string
|
||||
targetHandleId?: string
|
||||
}
|
||||
|
||||
const nodePadding = 10
|
||||
const graphPadding = 20
|
||||
const roundCoordinatesTo = gridRatio
|
||||
|
||||
const props = withDefaults(defineProps<PathFindingEdgeProps>(), {
|
||||
selected: false,
|
||||
sourcePosition: Position.Bottom,
|
||||
targetPosition: Position.Top,
|
||||
labelStyle: () => ({}),
|
||||
labelShowBg: true,
|
||||
labelBgStyle: () => ({}),
|
||||
})
|
||||
|
||||
const centered = computed(() =>
|
||||
getEdgeCenter({
|
||||
...props,
|
||||
}),
|
||||
)
|
||||
const source = computed(() => ({
|
||||
x: props.sourceX,
|
||||
y: props.sourceY,
|
||||
position: props.sourcePosition,
|
||||
}))
|
||||
const target = computed(() => ({
|
||||
x: props.targetX,
|
||||
y: props.targetY,
|
||||
position: props.targetPosition,
|
||||
}))
|
||||
|
||||
// We use the nodes information to generate bounding boxes for them
|
||||
// and the graph
|
||||
const bb = computed(() => getBoundingBoxes(props.nodes, nodePadding, graphPadding, roundCoordinatesTo))
|
||||
|
||||
const gridPath = computed(() => {
|
||||
let path: number[][] = []
|
||||
|
||||
if (target.value.x && source.value.x && props.nodes.length) {
|
||||
// We then can use the grid representation to do pathfinding
|
||||
// With this information, we can create a 2D grid representation of
|
||||
// our graph, that tells us where in the graph there is a "free" space or not
|
||||
const { grid, start, end } = createGrid(bb.value.graph, bb.value.nodes, source.value, target.value)
|
||||
path = generatePath(grid, start, end)
|
||||
}
|
||||
|
||||
return path
|
||||
})
|
||||
|
||||
const path = computed(() => {
|
||||
let svgPath = ''
|
||||
if (gridPath.value?.length) {
|
||||
// Here we convert the grid path to a sequence of graph coordinates.
|
||||
const graphPath = gridPath.value.map((gridPoint) => {
|
||||
const [x, y] = gridPoint
|
||||
const graphPoint = gridToGraphPoint({ x, y }, bb.value.graph.xMin, bb.value.graph.yMin)
|
||||
return [graphPoint.x, graphPoint.y]
|
||||
})
|
||||
|
||||
// Finally, we can use the graph path to draw the edge
|
||||
svgPath = drawSmoothLinePath(source.value, target.value, graphPath)
|
||||
}
|
||||
|
||||
return svgPath
|
||||
})
|
||||
const attrs: any = useAttrs()
|
||||
</script>
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'PathFindingEdge',
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<BezierEdge v-if="gridPath && gridPath.length <= 2" v-bind="{ ...props, ...attrs }" />
|
||||
<template v-else>
|
||||
<path
|
||||
:style="{ ...props.style, ...attrs.style }"
|
||||
class="vue-flow__edge-path"
|
||||
:d="path"
|
||||
:marker-end="props.markerEnd"
|
||||
:marker-start="props.markerStart"
|
||||
/>
|
||||
<EdgeText
|
||||
v-if="props.label"
|
||||
:x="centered[0]"
|
||||
:y="centered[1]"
|
||||
:label="props.label"
|
||||
:label-style="props.labelStyle"
|
||||
:label-show-bg="props.labelShowBg"
|
||||
:label-bg-style="props.labelBgStyle"
|
||||
:label-bg-padding="props.labelBgPadding"
|
||||
:label-bg-border-radius="props.labelBgBorderRadius"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Grid } from 'pathfinding'
|
||||
import { Position } from '@braks/vue-flow'
|
||||
import { guaranteeWalkablePath, getNextPointFromPosition } from './guaranteeWalkablePath'
|
||||
import { graphToGridPoint } from './pointConversion'
|
||||
import { round } from './utils'
|
||||
import type { NodeBoundingBox, GraphBoundingBox } from './getBoundingBoxes'
|
||||
|
||||
export const gridRatio = 10
|
||||
|
||||
export type PointInfo = {
|
||||
x: number
|
||||
y: number
|
||||
position: Position
|
||||
}
|
||||
|
||||
export const createGrid = (graph: GraphBoundingBox, nodes: NodeBoundingBox[], source: PointInfo, target: PointInfo) => {
|
||||
const { xMin, yMin, width, height } = graph
|
||||
|
||||
// Create a grid representation of the graph box, where each cell is
|
||||
// equivalent to 10x10 pixels on the graph. We'll use this simplified grid
|
||||
// to do pathfinding.
|
||||
const mapColumns = width / gridRatio
|
||||
const mapRows = height / gridRatio
|
||||
const grid = new Grid(mapColumns, mapRows)
|
||||
|
||||
// Update the grid representation with the space the nodes take up
|
||||
nodes.forEach((node) => {
|
||||
const nodeStart = graphToGridPoint(node.topLeft, xMin, yMin)
|
||||
const nodeEnd = graphToGridPoint(node.bottomRight, xMin, yMin)
|
||||
|
||||
for (let x = nodeStart.x; x < nodeEnd.x; x++) {
|
||||
for (let y = nodeStart.y; y < nodeEnd.y; y++) {
|
||||
grid.setWalkableAt(x, y, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Convert the starting and ending graph points to grid points
|
||||
const startGrid = graphToGridPoint(
|
||||
{
|
||||
x: round(source.x, gridRatio),
|
||||
y: round(source.y, gridRatio),
|
||||
},
|
||||
xMin,
|
||||
yMin,
|
||||
)
|
||||
|
||||
const endGrid = graphToGridPoint(
|
||||
{
|
||||
x: round(target.x, gridRatio),
|
||||
y: round(target.y, gridRatio),
|
||||
},
|
||||
xMin,
|
||||
yMin,
|
||||
)
|
||||
|
||||
// Guarantee a walkable path between the start and end points, even if the
|
||||
// source or target where covered by another node or by padding
|
||||
const startingNode = grid.getNodeAt(startGrid.x, startGrid.y)
|
||||
guaranteeWalkablePath(grid, startingNode, source.position)
|
||||
const endingNode = grid.getNodeAt(endGrid.x, endGrid.y)
|
||||
guaranteeWalkablePath(grid, endingNode, target.position)
|
||||
|
||||
// Use the next closest points as the start and end points, so
|
||||
// pathfinding does not start too close to the nodes
|
||||
const start = getNextPointFromPosition(startingNode, source.position)
|
||||
const end = getNextPointFromPosition(endingNode, target.position)
|
||||
|
||||
return { grid, start, end }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Draws a SVG path from a list of points, using straight lines.
|
||||
*/
|
||||
import { XYPosition } from '@braks/vue-flow'
|
||||
|
||||
const getMidPoint = (Ax: number, Ay: number, Bx: number, By: number) => {
|
||||
const Zx = (Ax - Bx) / 2 + Bx
|
||||
const Zy = (Ay - By) / 2 + By
|
||||
return [Zx, Zy]
|
||||
}
|
||||
|
||||
export const drawStraightLinePath = (source: XYPosition, target: XYPosition, path: number[][]) => {
|
||||
let svgPathString = `M ${source.x}, ${source.y} `
|
||||
|
||||
path.forEach((point) => {
|
||||
const [x, y] = point
|
||||
svgPathString += `L ${x}, ${y} `
|
||||
})
|
||||
|
||||
svgPathString += `L ${target.x}, ${target.y} `
|
||||
|
||||
return svgPathString
|
||||
}
|
||||
|
||||
const quadraticBezierCurve = (points: number[][]) => {
|
||||
const X = 0
|
||||
const Y = 1
|
||||
let point = points[0]
|
||||
|
||||
const first = points[0]
|
||||
let svgPath = `M${first[X]},${first[Y]}M`
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const next = points[i]
|
||||
const midPoint = getMidPoint(point[X], point[Y], next[X], next[Y])
|
||||
|
||||
svgPath += ` ${midPoint[X]},${midPoint[Y]}`
|
||||
svgPath += `Q${next[X]},${next[Y]}`
|
||||
point = next
|
||||
}
|
||||
|
||||
const last = points[points.length - 1]
|
||||
svgPath += ` ${last[0]},${last[1]}`
|
||||
|
||||
return svgPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a SVG path from a list of points, using rounded lines.
|
||||
*/
|
||||
export const drawSmoothLinePath = (source: XYPosition, target: XYPosition, path: number[][]) => {
|
||||
const points = [[source.x, source.y], ...path, [target.x, target.y]]
|
||||
return quadraticBezierCurve(points)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AStarFinder, Util, DiagonalMovement, Heuristic } from 'pathfinding'
|
||||
import type { Grid } from 'pathfinding'
|
||||
import { XYPosition } from '@braks/vue-flow'
|
||||
|
||||
// https://www.npmjs.com/package/pathfinding#advanced-usage
|
||||
declare module 'pathfinding' {
|
||||
interface FinderOptions extends Heuristic {
|
||||
diagonalMovement?: DiagonalMovement
|
||||
weight?: number
|
||||
allowDiagonal?: boolean
|
||||
dontCrossCorners?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePath = (grid: Grid, start: XYPosition, end: XYPosition): number[][] => {
|
||||
const finder = new AStarFinder({
|
||||
diagonalMovement: DiagonalMovement.Always,
|
||||
allowDiagonal: true,
|
||||
dontCrossCorners: true,
|
||||
})
|
||||
|
||||
let path: number[][] = []
|
||||
|
||||
try {
|
||||
path = finder.findPath(start.x, start.y, end.x, end.y, grid)
|
||||
path = Util.smoothenPath(grid, path)
|
||||
} catch {
|
||||
// No path was found. This can happen if the end point is "surrounded"
|
||||
// by other nodes, or if the starting and ending nodes are on top of
|
||||
// each other.
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { XYPosition, GraphNode } from '@braks/vue-flow'
|
||||
import { roundUp, roundDown } from './utils'
|
||||
|
||||
export type NodeBoundingBox = {
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
topLeft: XYPosition
|
||||
bottomLeft: XYPosition
|
||||
topRight: XYPosition
|
||||
bottomRight: XYPosition
|
||||
}
|
||||
|
||||
export type GraphBoundingBox = {
|
||||
width: number
|
||||
height: number
|
||||
topLeft: XYPosition
|
||||
bottomLeft: XYPosition
|
||||
topRight: XYPosition
|
||||
bottomRight: XYPosition
|
||||
xMax: number
|
||||
yMax: number
|
||||
xMin: number
|
||||
yMin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounding box of all nodes and the graph itself, as X/Y coordinates
|
||||
* of all corner points.
|
||||
*
|
||||
* @param storeNodes The node list
|
||||
* @param nodePadding Optional padding to add to the node's bounding boxes
|
||||
* @param graphPadding Optional padding to add to the graph's bounding box
|
||||
* @param roundTo If the coordinates should be rounded to this nearest integer
|
||||
* @returns Graph and nodes bounding boxes.
|
||||
*/
|
||||
export const getBoundingBoxes = (storeNodes: GraphNode[], nodePadding = 0, graphPadding = 0, roundTo = 0) => {
|
||||
// Guarantee that the given parameters are positive integers
|
||||
nodePadding = Math.max(Math.round(nodePadding), 0)
|
||||
graphPadding = Math.max(Math.round(graphPadding), 0)
|
||||
roundTo = Math.max(Math.round(roundTo), 0)
|
||||
|
||||
nodePadding = Number.isInteger(nodePadding) ? nodePadding : 0
|
||||
graphPadding = Number.isInteger(graphPadding) ? graphPadding : 0
|
||||
roundTo = Number.isInteger(roundTo) ? roundTo : 0
|
||||
|
||||
let xMax = Number.MIN_SAFE_INTEGER
|
||||
let yMax = Number.MIN_SAFE_INTEGER
|
||||
let xMin = Number.MAX_SAFE_INTEGER
|
||||
let yMin = Number.MAX_SAFE_INTEGER
|
||||
|
||||
const nodes: NodeBoundingBox[] = storeNodes.map((node) => {
|
||||
const {
|
||||
computedPosition: { x, y },
|
||||
dimensions,
|
||||
} = node
|
||||
const width = Math.max(dimensions.width || 0, 1)
|
||||
const height = Math.max(dimensions.height || 0, 1)
|
||||
|
||||
const position: XYPosition = {
|
||||
x: x || 0,
|
||||
y: y || 0,
|
||||
}
|
||||
const topLeft: XYPosition = {
|
||||
x: position.x - nodePadding,
|
||||
y: position.y - nodePadding,
|
||||
}
|
||||
const bottomLeft: XYPosition = {
|
||||
x: position.x - nodePadding,
|
||||
y: position.y + height + nodePadding,
|
||||
}
|
||||
const topRight: XYPosition = {
|
||||
x: position.x + width + nodePadding,
|
||||
y: position.y - nodePadding,
|
||||
}
|
||||
const bottomRight: XYPosition = {
|
||||
x: position.x + width + nodePadding,
|
||||
y: position.y + height + nodePadding,
|
||||
}
|
||||
|
||||
if (roundTo > 0) {
|
||||
topLeft.x = roundDown(topLeft.x, roundTo)
|
||||
topLeft.y = roundDown(topLeft.y, roundTo)
|
||||
bottomLeft.x = roundDown(bottomLeft.x, roundTo)
|
||||
bottomLeft.y = roundUp(bottomLeft.y, roundTo)
|
||||
topRight.x = roundUp(topRight.x, roundTo)
|
||||
topRight.y = roundDown(topRight.y, roundTo)
|
||||
bottomRight.x = roundUp(bottomRight.x, roundTo)
|
||||
bottomRight.y = roundUp(bottomRight.y, roundTo)
|
||||
}
|
||||
|
||||
if (topLeft.y < yMin) yMin = topLeft.y
|
||||
if (topLeft.x < xMin) xMin = topLeft.x
|
||||
if (bottomRight.y > yMax) yMax = bottomRight.y
|
||||
if (bottomRight.x > xMax) xMax = bottomRight.x
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
width,
|
||||
height,
|
||||
topLeft,
|
||||
bottomLeft,
|
||||
topRight,
|
||||
bottomRight,
|
||||
}
|
||||
})
|
||||
|
||||
xMax = xMax + graphPadding
|
||||
yMax = yMax + graphPadding
|
||||
xMin = xMin - graphPadding
|
||||
yMin = yMin - graphPadding
|
||||
|
||||
const topLeft: XYPosition = {
|
||||
x: xMin,
|
||||
y: yMin,
|
||||
}
|
||||
|
||||
const bottomLeft: XYPosition = {
|
||||
x: xMin,
|
||||
y: yMax,
|
||||
}
|
||||
|
||||
const topRight: XYPosition = {
|
||||
x: xMax,
|
||||
y: yMin,
|
||||
}
|
||||
|
||||
const bottomRight: XYPosition = {
|
||||
x: xMax,
|
||||
y: yMax,
|
||||
}
|
||||
|
||||
const width = Math.abs(topLeft.x - topRight.x)
|
||||
const height = Math.abs(topLeft.y - bottomLeft.y)
|
||||
|
||||
const graph: GraphBoundingBox = {
|
||||
topLeft,
|
||||
bottomLeft,
|
||||
topRight,
|
||||
bottomRight,
|
||||
width,
|
||||
height,
|
||||
xMax,
|
||||
yMax,
|
||||
xMin,
|
||||
yMin,
|
||||
}
|
||||
|
||||
return { nodes, graph }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Grid } from 'pathfinding'
|
||||
import { Position, XYPosition } from '@braks/vue-flow'
|
||||
|
||||
type Direction = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export const getNextPointFromPosition = (point: XYPosition, position: Direction): XYPosition => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return { x: point.x, y: point.y - 1 }
|
||||
case 'bottom':
|
||||
return { x: point.x, y: point.y + 1 }
|
||||
case 'left':
|
||||
return { x: point.x - 1, y: point.y }
|
||||
case 'right':
|
||||
return { x: point.x + 1, y: point.y }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee that the path is walkable, even if the point is inside a non
|
||||
* walkable area, by adding a walkable path in the direction of the point's
|
||||
* Position.
|
||||
*/
|
||||
export const guaranteeWalkablePath = (grid: Grid, point: XYPosition, position: Position) => {
|
||||
let node = grid.getNodeAt(point.x, point.y)
|
||||
while (!node.walkable) {
|
||||
grid.setWalkableAt(node.x, node.y, true)
|
||||
const next = getNextPointFromPosition(node, position)
|
||||
node = grid.getNodeAt(next.x, next.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { XYPosition } from '@braks/vue-flow'
|
||||
|
||||
const gridRatio = 10
|
||||
|
||||
/**
|
||||
* Each bounding box is a collection of X/Y points in a graph, and we
|
||||
* need to convert them to "occupied" cells in a 2D grid representation.
|
||||
*
|
||||
* The top most position of the grid (grid[0][0]) needs to be equivalent
|
||||
* to the top most point in the graph (the graph.topLeft point).
|
||||
*
|
||||
* Since the top most point can have X/Y values different than zero,
|
||||
* and each cell in a grid represents a 10x10 pixel area in the grid,
|
||||
* there's need to be a conversion between a point in a graph to a point
|
||||
* in the grid.
|
||||
*
|
||||
* We do this conversion by dividing a graph point X/Y values by 10, and
|
||||
* "shifting" their values up or down, depending on the values of the top most
|
||||
* point in the graph. The top most point in the graph will have the smallest
|
||||
* values for X and Y.
|
||||
*
|
||||
* We avoid setting nodes in the border of the grid (x=0 or y=0), so there's
|
||||
* always a "walkable" area around the grid.
|
||||
*/
|
||||
export const graphToGridPoint = (graphPoint: XYPosition, smallestX: number, smallestY: number): XYPosition => {
|
||||
let x = graphPoint.x / gridRatio
|
||||
let y = graphPoint.y / gridRatio
|
||||
|
||||
let referenceX = smallestX / gridRatio
|
||||
let referenceY = smallestY / gridRatio
|
||||
|
||||
if (referenceX < 1) {
|
||||
while (referenceX !== 1) {
|
||||
referenceX++
|
||||
x++
|
||||
}
|
||||
} else if (referenceX > 1) {
|
||||
while (referenceX !== 1) {
|
||||
referenceX--
|
||||
x--
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceY < 1) {
|
||||
while (referenceY !== 1) {
|
||||
referenceY++
|
||||
y++
|
||||
}
|
||||
} else if (referenceY > 1) {
|
||||
while (referenceY !== 1) {
|
||||
referenceY--
|
||||
y--
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a grid point back to a graph point, using the reverse logic of
|
||||
* graphToGridPoint.
|
||||
*/
|
||||
export const gridToGraphPoint = (gridPoint: XYPosition, smallestX: number, smallestY: number): XYPosition => {
|
||||
let x = gridPoint.x * gridRatio
|
||||
let y = gridPoint.y * gridRatio
|
||||
|
||||
let referenceX = smallestX
|
||||
let referenceY = smallestY
|
||||
|
||||
if (referenceX < gridRatio) {
|
||||
while (referenceX !== gridRatio) {
|
||||
referenceX = referenceX + gridRatio
|
||||
x = x - gridRatio
|
||||
}
|
||||
} else if (referenceX > gridRatio) {
|
||||
while (referenceX !== gridRatio) {
|
||||
referenceX = referenceX - gridRatio
|
||||
x = x + gridRatio
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceY < gridRatio) {
|
||||
while (referenceY !== gridRatio) {
|
||||
referenceY = referenceY + gridRatio
|
||||
y = y - gridRatio
|
||||
}
|
||||
} else if (referenceY > gridRatio) {
|
||||
while (referenceY !== gridRatio) {
|
||||
referenceY = referenceY - gridRatio
|
||||
y = y + gridRatio
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const round = (x: number, multiple = 10) => Math.round(x / multiple) * multiple
|
||||
|
||||
export const roundDown = (x: number, multiple = 10) => Math.floor(x / multiple) * multiple
|
||||
|
||||
export const roundUp = (x: number, multiple = 10) => Math.ceil(x / multiple) * multiple
|
||||
Reference in New Issue
Block a user