import Alea from "alea"; import { color, quadtree } from "d3"; import Delaunator from "delaunator"; import { type Cells, type Point, type Vertices, Voronoi, } from "../modules/voronoi"; import type { PackedGraph } from "../types/PackedGraph"; import { createTypedArray } from "./arrayUtils"; import { rn } from "./numberUtils"; import { byId } from "./shorthands"; /** * Get boundary points on a regular square grid * @param {number} width - The width of the area * @param {number} height - The height of the area * @param {number} spacing - The spacing between points * @returns {Array} - An array of boundary points */ const getBoundaryPoints = ( width: number, height: number, spacing: number, ): Point[] => { const offset = rn(-1 * spacing); const bSpacing = spacing * 2; const w = width - offset * 2; const h = height - offset * 2; const numberX = Math.ceil(w / bSpacing) - 1; const numberY = Math.ceil(h / bSpacing) - 1; const points: Point[] = []; for (let i = 0.5; i < numberX; i++) { const x = Math.ceil((w * i) / numberX + offset); points.push([x, offset], [x, h + offset]); } for (let i = 0.5; i < numberY; i++) { const y = Math.ceil((h * i) / numberY + offset); points.push([offset, y], [w + offset, y]); } return points; }; /** * Get points on a jittered square grid * @param {number} width - The width of the area * @param {number} height - The height of the area * @param {number} spacing - The spacing between points * @returns {Array} - An array of jittered grid points */ const getJitteredGrid = ( width: number, height: number, spacing: number, ): Point[] => { const radius = spacing / 2; // square radius const jittering = radius * 0.9; // max deviation const doubleJittering = jittering * 2; const jitter = () => Math.random() * doubleJittering - jittering; const points: Point[] = []; for (let y = radius; y < height; y += spacing) { for (let x = radius; x < width; x += spacing) { const xj = Math.min(rn(x + jitter(), 2), width); const yj = Math.min(rn(y + jitter(), 2), height); points.push([xj, yj]); } } return points; }; /** * Places points on a jittered grid and calculates spacing and cell counts * @param {number} graphWidth - The width of the graph * @param {number} graphHeight - The height of the graph * @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY */ const placePoints = ( graphWidth: number, graphHeight: number, ): { spacing: number; cellsDesired: number; boundary: Point[]; points: Point[]; cellsX: number; cellsY: number; } => { TIME && console.time("placePoints"); const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0); const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing); const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction const cellCountY = Math.floor( (graphHeight + 0.5 * spacing - 1e-10) / spacing, ); // number of cells in y direction TIME && console.timeEnd("placePoints"); return { spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY, }; }; /** * Checks if the grid needs to be regenerated based on desired parameters * @param {Object} grid - The current grid object * @param {number} expectedSeed - The expected seed value * @param {number} graphWidth - The width of the graph * @param {number} graphHeight - The height of the graph * @returns {boolean} - True if the grid should be regenerated, false otherwise */ export const shouldRegenerateGrid = ( grid: any, expectedSeed: number, graphWidth: number, graphHeight: number, ) => { if (expectedSeed && expectedSeed !== grid.seed) return true; const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0); if (cellsDesired !== grid.cellsDesired) return true; const newSpacing = rn( Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2, ); const newCellsX = Math.floor( (graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing, ); const newCellsY = Math.floor( (graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing, ); return ( grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY ); }; interface Grid { spacing: number; cellsDesired: number; boundary: Point[]; points: Point[]; cellsX: number; cellsY: number; seed: string | number; cells: Cells; vertices: Vertices; } /** * Generates a Voronoi grid based on jittered grid points * @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed */ export const generateGrid = ( seed: string, graphWidth: number, graphHeight: number, ): Grid => { Math.random = Alea(seed); // reset PRNG const { spacing, cellsDesired, boundary, points, cellsX, cellsY } = placePoints(graphWidth, graphHeight); const { cells, vertices } = calculateVoronoi(points, boundary); return { spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed, }; }; /** * Calculates the Voronoi diagram from given points and boundary * @param {Array} points - The array of points for Voronoi calculation * @param {Array} boundary - The boundary points to clip the Voronoi cells * @returns {Object} - An object containing Voronoi cells and vertices */ export const calculateVoronoi = ( points: Point[], boundary: Point[], ): { cells: Cells; vertices: Vertices } => { TIME && console.time("calculateDelaunay"); const allPoints = points.concat(boundary); const delaunay = Delaunator.from(allPoints); TIME && console.timeEnd("calculateDelaunay"); TIME && console.time("calculateVoronoi"); const voronoi = new Voronoi(delaunay, allPoints, points.length); const cells = voronoi.cells; cells.i = createTypedArray({ maxValue: points.length, length: points.length, }).map((_, i) => i) as Uint32Array; // array of indexes const vertices = voronoi.vertices; TIME && console.timeEnd("calculateVoronoi"); return { cells, vertices }; }; /** * Returns a cell index on a regular square grid based on x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY * @returns {number} - The index of the cell in the grid */ export const findGridCell = (x: number, y: number, grid: any): number => { return ( Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1)) ); }; /** * return array of cell indexes in radius on a regular square grid * @param {number} x - The x coordinate * @param {number} y - The y coordinate * @param {number} radius - The search radius * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY * @returns {Array} - An array of cell indexes within the specified radius */ export const findGridAll = ( x: number, y: number, radius: number, grid: any, ): number[] => { const c = grid.cells.c; let r = Math.floor(radius / grid.spacing); let found = [findGridCell(x, y, grid)]; if (!r || radius === 1) return found; if (r > 0) found = found.concat(c[found[0]]); if (r > 1) { let frontier = c[found[0]]; while (r > 1) { const cycle = frontier.slice(); frontier = []; cycle.forEach((s: number) => { c[s].forEach((e: number) => { if (found.indexOf(e) !== -1) return; found.push(e); frontier.push(e); }); }); r--; } } return found; }; const quadtreeCache = new WeakMap< object, ReturnType> >(); /** * Returns the index of the packed cell containing the given x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate * @param {number} radius - The search radius (default is Infinity) * @returns {number|undefined} - The index of the found cell or undefined if not found */ export const findClosestCell = ( x: number, y: number, radius = Infinity, pack: { cells: { p: [number, number][] } }, ): number | undefined => { if (!pack.cells?.p) throw new Error("Pack cells not found"); let qTree = quadtreeCache.get(pack.cells.p); if (!qTree) { qTree = quadtree(pack.cells.p.map(([px, py], i) => [px, py, i])); if (!qTree) throw new Error("Failed to create quadtree"); quadtreeCache.set(pack.cells.p, qTree); } const found = qTree.find(x, y, radius); return found ? found[2] : undefined; }; /** * Searches a quadtree for all points within a given radius * Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e * @param {number} x - The x coordinate of the search center * @param {number} y - The y coordinate of the search center * @param {number} radius - The search radius * @param {Object} quadtree - The D3 quadtree to search * @returns {Array} - An array of found data points within the radius */ export const findAllInQuadtree = ( x: number, y: number, radius: number, quadtree: any, ) => { let dx: number, dy: number, d2: number; const radiusSearchInit = (t: any, radius: number) => { t.result = []; t.x0 = t.x - radius; t.y0 = t.y - radius; t.x3 = t.x + radius; t.y3 = t.y + radius; t.radius = radius * radius; }; const radiusSearchVisit = (t: any, d2: number) => { t.node.data.scanned = true; if (d2 < t.radius) { while (t.node) { t.result.push(t.node.data); t.node.data.selected = true; t.node = t.node.next; } } }; class Quad { node: any; x0: number; y0: number; x1: number; y1: number; constructor(node: any, x0: number, y0: number, x1: number, y1: number) { this.node = node; this.x0 = x0; this.y0 = y0; this.x1 = x1; this.y1 = y1; } } const t: any = { x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root, }; if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); radiusSearchInit(t, radius); var _i = 0; t.q = t.quads.pop(); while (t.q) { _i++; t.node = t.q.node; t.x1 = t.q.x0; t.y1 = t.q.y0; t.x2 = t.q.x1; t.y2 = t.q.y1; // Stop searching if this quadrant can't contain a closer node. if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) { t.q = t.quads.pop(); continue; } // Bisect the current quadrant. if (t.node.length) { t.node.explored = true; const xm: number = (t.x1 + t.x2) / 2, ym: number = (t.y1 + t.y2) / 2; t.quads.push( new Quad(t.node[3], xm, ym, t.x2, t.y2), new Quad(t.node[2], t.x1, ym, xm, t.y2), new Quad(t.node[1], xm, t.y1, t.x2, ym), new Quad(t.node[0], t.x1, t.y1, xm, ym), ); // Visit the closest quadrant first. t.i = (+(y >= ym) << 1) | +(x >= xm); if (t.i) { t.q = t.quads[t.quads.length - 1]; t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; t.quads[t.quads.length - 1 - t.i] = t.q; } } // Visit this point. (Visiting coincident points isn't necessary!) else { dx = x - +quadtree._x.call(null, t.node.data); dy = y - +quadtree._y.call(null, t.node.data); d2 = dx * dx + dy * dy; radiusSearchVisit(t, d2); } t.q = t.quads.pop(); } return t.result; }; /** * Returns an array of packed cell indexes within a specified radius from given x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate * @param {number} radius - The search radius * @param {Object} packedGraph - The packed graph containing cells with quadtree * @returns {number[]} - An array of cell indexes within the radius */ export const findAllCellsInRadius = ( x: number, y: number, radius: number, packedGraph: any, ): number[] => { const q = quadtree<[number, number, number]>( packedGraph.cells.p.map( ([px, py]: [number, number], i: number) => [px, py, i] as [number, number, number], ), ); const found = findAllInQuadtree(x, y, radius, q); return found.map((r: any) => r[2]); }; /** * Returns the polygon points for a packed cell given its index * @param {number} i - The index of the packed cell * @returns {Array} - An array of polygon points for the specified cell */ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { return packedGraph.cells.v[cellIndex].map( (v: number) => packedGraph.vertices.p[v], ); }; /** * Returns the polygon points for a grid cell given its index * @param {number} i - The index of the grid cell * @returns {Array} - An array of polygon points for the specified grid cell */ export const getGridPolygon = (i: number, grid: any) => { return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); }; /** * mbostock's poissonDiscSampler implementation * Generates points using Poisson-disc sampling within a specified rectangle * @param {number} x0 - The minimum x coordinate of the rectangle * @param {number} y0 - The minimum y coordinate of the rectangle * @param {number} x1 - The maximum x coordinate of the rectangle * @param {number} y1 - The maximum y coordinate of the rectangle * @param {number} r - The minimum distance between points * @param {number} k - The number of attempts before rejection (default is 3) * @yields {Array} - An array containing the x and y coordinates of a generated point */ export function* poissonDiscSampler( x0: number, y0: number, x1: number, y1: number, r: number, k = 3, ) { if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error(); const width = x1 - x0; const height = y1 - y0; const r2 = r * r; const r2_3 = 3 * r2; const cellSize = r * Math.SQRT1_2; const gridWidth = Math.ceil(width / cellSize); const gridHeight = Math.ceil(height / cellSize); const grid = new Array(gridWidth * gridHeight); const queue: [number, number][] = []; function far(x: number, y: number) { const i = (x / cellSize) | 0; const j = (y / cellSize) | 0; const i0 = Math.max(i - 2, 0); const j0 = Math.max(j - 2, 0); const i1 = Math.min(i + 3, gridWidth); const j1 = Math.min(j + 3, gridHeight); for (let j = j0; j < j1; ++j) { const o = j * gridWidth; for (let i = i0; i < i1; ++i) { const s = grid[o + i]; if (s) { const dx = s[0] - x; const dy = s[1] - y; if (dx * dx + dy * dy < r2) return false; } } } return true; } function sample(x: number, y: number): [number, number] { const point: [number, number] = [x, y]; grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point; queue.push(point); return [x + x0, y + y0]; } yield sample(width / 2, height / 2); pick: while (queue.length) { const i = (Math.random() * queue.length) | 0; const parent = queue[i]; for (let j = 0; j < k; ++j) { const a = 2 * Math.PI * Math.random(); const r = Math.sqrt(Math.random() * r2_3 + r2); const x = parent[0] + r * Math.cos(a); const y = parent[1] + r * Math.sin(a); if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) { yield sample(x, y); continue pick; } } const r = queue.pop(); if (r !== undefined && i < queue.length) queue[i] = r; } } /** * Checks if a packed cell is land based on its height * @param {number} i - The index of the packed cell * @returns {boolean} - True if the cell is land, false otherwise */ export const isLand = (i: number, packedGraph: PackedGraph) => { return packedGraph.cells.h[i] >= 20; }; /** * Checks if a packed cell is water based on its height * @param {number} i - The index of the packed cell * @returns {boolean} - True if the cell is water, false otherwise */ export const isWater = (i: number, packedGraph: PackedGraph) => { return packedGraph.cells.h[i] < 20; }; // draw raster heightmap preview (not used in main generation) /** * Draws a raster heightmap preview based on given heights and rendering options * @param {Object} options - The options for drawing the heightmap * @param {Array} options.heights - The array of height values * @param {number} options.width - The width of the heightmap * @param {number} options.height - The height of the heightmap * @param {Function} options.scheme - The color scheme function for rendering heights * @param {boolean} options.renderOcean - Whether to render ocean heights * @returns {string} - A data URL representing the drawn heightmap image */ export const drawHeights = ({ heights, width, height, scheme, renderOcean, }: { heights: number[]; width: number; height: number; scheme: (value: number) => string; renderOcean: boolean; }) => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d")!; const imageData = ctx.createImageData(width, height); const getHeight = (height: number) => height < 20 ? (renderOcean ? height : 0) : height; for (let i = 0; i < heights.length; i++) { const colorScheme = scheme(1 - getHeight(heights[i]) / 100); const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 }; const n = i * 4; imageData.data[n] = r; imageData.data[n + 1] = g; imageData.data[n + 2] = b; imageData.data[n + 3] = 255; } ctx.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); }; declare global { var TIME: boolean; interface Window { shouldRegenerateGrid: typeof shouldRegenerateGrid; generateGrid: typeof generateGrid; findCell: typeof findClosestCell; findGridCell: typeof findGridCell; findGridAll: typeof findGridAll; calculateVoronoi: typeof calculateVoronoi; findAll: typeof findAllCellsInRadius; getPackPolygon: typeof getPackPolygon; getGridPolygon: typeof getGridPolygon; poissonDiscSampler: typeof poissonDiscSampler; isLand: typeof isLand; isWater: typeof isWater; findAllInQuadtree: typeof findAllInQuadtree; drawHeights: typeof drawHeights; } }