Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 64 additions & 20 deletions src/common/Graph.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PriorityQueue } from "./PriorityQueue.js"
import { PriorityQueue } from "./PriorityQueue"
import Victor from "victor"

export const mix = (v1, v2, s) => {
Expand Down Expand Up @@ -37,7 +37,9 @@ export const edgeKey = (node1, node2) => {
return [node1Key, node2Key].sort().toString()
}

// note: requires string-based nodes to work properly
// Note: requires string-based nodes to work properly.
// Path cache is not invalidated on addNode/addEdge for performance.
// If modifying graph after computing paths, call clearCachedPaths() manually.
export default class Graph {
constructor() {
this.nodeMap = {}
Expand All @@ -58,7 +60,6 @@ export default class Graph {
this.nodeKeys.add(key)
this.nodeMap[key] = node
this.adjacencyList[key] = []
this.clearCachedPaths()
}
}

Expand All @@ -72,7 +73,6 @@ export default class Graph {
this.adjacencyList[node2Key].push({ node: node1, weight })
this.edgeKeys.add(edge12Key)
this.edgeMap[edge12Key] = [node1.toString(), node2.toString()]
this.clearCachedPaths()
}
}

Expand All @@ -93,18 +93,61 @@ export default class Graph {
return Object.values(this.nodeMap).find(fn)
}

// BFS-based shortest path - optimal for uniform edge weights
bfsShortestPath(startNode, endNode) {
let shortest = this.getCachedShortestPath(startNode, endNode)

if (shortest === undefined) {
const backtrace = {}
const visited = new Set()
const queue = [startNode]

visited.add(startNode)

while (queue.length > 0) {
const currentNode = queue.shift()

if (currentNode === endNode) {
break
}

for (const neighbor of this.adjacencyList[currentNode]) {
const neighborKey = neighbor.node.toString()

if (!visited.has(neighborKey)) {
visited.add(neighborKey)
backtrace[neighborKey] = currentNode
queue.push(neighborKey)
}
}
}

let path = [endNode.toString()]
let lastStep = endNode

while (lastStep !== startNode) {
path.unshift(backtrace[lastStep].toString())
lastStep = backtrace[lastStep]
}

shortest = path.map((node) => this.nodeMap[node])
this.cacheShortestPath(startNode, endNode, shortest)
}

return shortest
}

// Dijkstra's algorithm with binary heap - O((V+E) log V)
dijkstraShortestPath(startNode, endNode) {
let shortest = this.getCachedShortestPath(startNode, endNode)

if (shortest === undefined) {
let times = {}
let backtrace = {}
let pq = new PriorityQueue()
let nodes = this.nodeKeys
const times = {}
const backtrace = {}
const pq = new PriorityQueue()

times[startNode] = 0

nodes.forEach((node) => {
this.nodeKeys.forEach((node) => {
if (node !== startNode) {
times[node] = Infinity
}
Expand All @@ -113,17 +156,18 @@ export default class Graph {
pq.enqueue([startNode, 0])

while (!pq.isEmpty()) {
let shortestStep = pq.dequeue()
let currentNode = shortestStep[0]
this.adjacencyList[currentNode.toString()].forEach((neighbor) => {
let time = times[currentNode] + neighbor.weight

if (time < times[neighbor.node]) {
times[neighbor.node] = time
backtrace[neighbor.node] = currentNode
pq.enqueue([neighbor.node, time])
const [currentNode] = pq.dequeue()

for (const neighbor of this.adjacencyList[currentNode]) {
const neighborKey = neighbor.node.toString()
const time = times[currentNode] + neighbor.weight

if (time < times[neighborKey]) {
times[neighborKey] = time
backtrace[neighborKey] = currentNode
pq.enqueue([neighborKey, time])
}
})
}
}

let path = [endNode.toString()]
Expand Down
79 changes: 59 additions & 20 deletions src/common/PriorityQueue.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,72 @@
// Binary min-heap priority queue
// Elements are [value, priority] tuples; lower priority = dequeued first
export class PriorityQueue {
constructor() {
this.collection = []
this.heap = []
}

enqueue(element) {
if (this.isEmpty()) {
this.collection.push(element)
} else {
let added = false
for (let i = 1; i <= this.collection.length; i++) {
if (element[1] < this.collection[i - 1][1]) {
this.collection.splice(i - 1, 0, element)
added = true
break
}
}

if (!added) {
this.collection.push(element)
}
}
this.heap.push(element)
this.bubbleUp(this.heap.length - 1)
}

dequeue() {
let value = this.collection.shift()
return value
if (this.heap.length === 0) return undefined
if (this.heap.length === 1) return this.heap.pop()

const min = this.heap[0]

this.heap[0] = this.heap.pop()
this.bubbleDown(0)

return min
}

isEmpty() {
return this.collection.length === 0
return this.heap.length === 0
}

bubbleUp(index) {
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2)

if (this.heap[parentIndex][1] <= this.heap[index][1]) break
this.swap(parentIndex, index)
index = parentIndex
}
}

bubbleDown(index) {
const length = this.heap.length

while (true) {
const leftChild = 2 * index + 1
const rightChild = 2 * index + 2
let smallest = index

if (
leftChild < length &&
this.heap[leftChild][1] < this.heap[smallest][1]
) {
smallest = leftChild
}
if (
rightChild < length &&
this.heap[rightChild][1] < this.heap[smallest][1]
) {
smallest = rightChild
}

if (smallest === index) break
this.swap(index, smallest)
index = smallest
}
}

swap(i, j) {
const temp = this.heap[i]

this.heap[i] = this.heap[j]
this.heap[j] = temp
}
}
38 changes: 38 additions & 0 deletions src/common/PriorityQueue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { PriorityQueue } from "./PriorityQueue"

describe("PriorityQueue", () => {
it("dequeues elements in priority order", () => {
const pq = new PriorityQueue()
pq.enqueue(["c", 3])
pq.enqueue(["a", 1])
pq.enqueue(["b", 2])

expect(pq.dequeue()).toEqual(["a", 1])
expect(pq.dequeue()).toEqual(["b", 2])
expect(pq.dequeue()).toEqual(["c", 3])
})

it("handles empty queue", () => {
const pq = new PriorityQueue()
expect(pq.isEmpty()).toBe(true)
expect(pq.dequeue()).toBeUndefined()
})

it("handles single element", () => {
const pq = new PriorityQueue()
pq.enqueue(["only", 1])
expect(pq.isEmpty()).toBe(false)
expect(pq.dequeue()).toEqual(["only", 1])
expect(pq.isEmpty()).toBe(true)
})

it("handles duplicate priorities", () => {
const pq = new PriorityQueue()
pq.enqueue(["first", 1])
pq.enqueue(["second", 1])

const results = [pq.dequeue(), pq.dequeue()]
expect(results).toContainEqual(["first", 1])
expect(results).toContainEqual(["second", 1])
})
})
4 changes: 2 additions & 2 deletions src/common/lindenmayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const shortestPath = (nodes) => {
if (visited[edge12Key]) {
const result = nearestUnvisitedNode(i + 1, nodes, visited, graph)

if (result) {
const shortestSubPath = graph.dijkstraShortestPath(
if (result != null) {
const shortestSubPath = graph.bfsShortestPath(
node1Key,
result.node.toString(),
)
Expand Down
74 changes: 68 additions & 6 deletions src/common/voronoi.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import seedrandom from "seedrandom"
import { Delaunay } from "d3-delaunay"
import PoissonDiskSampling from "poisson-disk-sampling"
import Victor from "victor"
import KDBush from "kdbush"

function linearWeight(i, seed, numPoints, options) {
return 1 + (2 * i) / (numPoints * options.voronoiZoom)
Expand Down Expand Up @@ -117,17 +118,33 @@ function wavePatternWeight(points, width, height, options) {
}

function densityWeight(points, width, height, options) {
return points.map((point, i) => {
const [x, y] = point
// Build spatial index O(n log n)
const index = new KDBush(points.length)
points.forEach(([x, y]) => index.add(x, y))
index.finish()

// Estimate initial search radius based on average spacing
const area = width * height
const avgSpacing = Math.sqrt(area / points.length)
let searchRadius = avgSpacing * 2

return points.map(([x, y], i) => {
let nearestDistance = Infinity

points.forEach((otherPoint, j) => {
// Find nearest neighbor using spatial index O(log n) average
let neighbors = index.within(x, y, searchRadius)
while (neighbors.length < 2 && searchRadius < Math.max(width, height)) {
searchRadius *= 2
neighbors = index.within(x, y, searchRadius)
}

for (const j of neighbors) {
if (i !== j) {
const [x2, y2] = otherPoint
const [x2, y2] = points[j]
const distance = Math.sqrt((x - x2) ** 2 + (y - y2) ** 2)
nearestDistance = Math.min(nearestDistance, distance)
}
})
}

const weight = nearestDistance // larger distances (sparser areas) get higher weight

Expand Down Expand Up @@ -210,7 +227,7 @@ export class VoronoiMixin {

if (!visited) {
if (prev) {
const path = this.graph.dijkstraShortestPath(
const path = this.graph.bfsShortestPath(
prev.toString(),
neighbor.toString(),
)
Expand All @@ -234,6 +251,7 @@ export class VoronoiMixin {
voronoiMinDistance,
voronoiMaxDistance,
voronoiZoom,
voronoiUniformity = 0,
} = options
const width =
voronoiPlacement == "poisson disk sampling"
Expand Down Expand Up @@ -270,6 +288,10 @@ export class VoronoiMixin {
})
}

if (voronoiUniformity > 0) {
points = this.relaxPoints(points, width, height, voronoiUniformity)
}

return points
}

Expand Down Expand Up @@ -337,4 +359,44 @@ export class VoronoiMixin {

return graph.findNode(onEdge) || closestNode
}

// Lloyd relaxation: move each point to the centroid of its Voronoi cell
relaxPoints(points, width, height, iterations) {
let relaxedPoints = points

for (let i = 0; i < iterations; i++) {
const delaunay = Delaunay.from(relaxedPoints)
const voronoi = delaunay.voronoi([0, 0, width, height])

relaxedPoints = relaxedPoints.map((point, idx) => {
const cell = voronoi.cellPolygon(idx)
if (!cell || cell.length < 3) return point

// Calculate centroid of the cell polygon
let cx = 0
let cy = 0
let area = 0

for (let j = 0; j < cell.length - 1; j++) {
const [x0, y0] = cell[j]
const [x1, y1] = cell[j + 1]
const cross = x0 * y1 - x1 * y0

area += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
}

area /= 2
if (Math.abs(area) < 1e-10) return point

cx /= 6 * area
cy /= 6 * area

return [cx, cy]
})
}

return relaxedPoints
}
}
Loading