diff --git a/docs/index.d.ts b/docs/index.d.ts index 7b35926..ba76c62 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -175,6 +175,7 @@ export const examples: { readonly lineIntersection: 'examples/geometry.ts'; readonly pointInPolygon: 'examples/geometry.ts'; readonly bresenhamLine: 'examples/bresenham.ts'; + readonly closestPair: 'examples/closestPair.ts'; }; readonly visual: { readonly easing: 'examples/visual.ts'; @@ -3275,6 +3276,15 @@ export function pointInPolygon(point: Point, polygon: Point[]): boolean; */ export function bresenhamLine(start: Point, end: Point): Point[]; +/** + * Closest pair of points in 2D space. + * Use for: nearest neighbour queries, clustering seeds, geometric stats. + * Performance: O(n log n). + * Import: geometry/closestPair.ts + */ +export interface ClosestPairResult { distance: number; pair: [Point, Point] | null } +export function closestPair(points: ReadonlyArray): ClosestPairResult; + /** * Common easing curves for animation. * Use for: UI transitions, motion design, data viz. diff --git a/examples/closestPair.ts b/examples/closestPair.ts new file mode 100644 index 0000000..b60647a --- /dev/null +++ b/examples/closestPair.ts @@ -0,0 +1,12 @@ +import { closestPair } from '../src/index.js'; + +const points = [ + { x: 0, y: 0 }, + { x: 5, y: 4 }, + { x: 1.25, y: 1.25 }, + { x: 2, y: 2 }, +]; + +const result = closestPair(points); +console.log(result.distance); +console.log(result.pair); diff --git a/package.json b/package.json index 73b8c0e..f9d18df 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ { "name": "bundle", "path": "dist/index.js", - "limit": "44 KB" + "limit": "46 KB" } ] } diff --git a/src/geometry/closestPair.ts b/src/geometry/closestPair.ts new file mode 100644 index 0000000..e473a5d --- /dev/null +++ b/src/geometry/closestPair.ts @@ -0,0 +1,178 @@ +import type { Point } from '../types.js'; + +export interface ClosestPairResult { + distance: number; + pair: [Point, Point] | null; +} + +/** + * Computes the closest pair of points in O(n log n) using a divide-and-conquer strategy. + * Returns the distance between the closest points along with the pair itself. + */ +export function closestPair(points: ReadonlyArray): ClosestPairResult { + if (!Array.isArray(points) || points.length < 2) { + return { distance: Number.POSITIVE_INFINITY, pair: null }; + } + + const normalized: Array = points.map((point: Point) => { + if ( + typeof point?.x !== 'number' || + typeof point?.y !== 'number' || + Number.isNaN(point.x) || + Number.isNaN(point.y) + ) { + throw new TypeError('points must contain numeric x and y values.'); + } + return { x: point.x, y: point.y }; + }); + + normalized.sort((a, b) => (a.x - b.x) || (a.y - b.y)); + const scratch: Array = normalized.map((point) => ({ ...point })); + + const result = divideAndConquer(normalized, scratch, 0, normalized.length); + return { distance: result.distance, pair: result.pair }; +} + +interface DivideResult { + distance: number; + pair: [Point, Point] | null; +} + +function divideAndConquer( + points: Array, + scratch: Array, + start: number, + end: number +): DivideResult { + const count = end - start; + if (count <= 1) { + return { distance: Number.POSITIVE_INFINITY, pair: null }; + } + + if (count <= 3) { + let bestDistance = Number.POSITIVE_INFINITY; + let bestPair: [Point, Point] | null = null; + for (let i = start; i < end; i += 1) { + const pointI = points[i]; + if (!pointI) continue; + for (let j = i + 1; j < end; j += 1) { + const pointJ = points[j]; + if (!pointJ) continue; + const distance = euclidean(pointI, pointJ); + if (distance < bestDistance) { + bestDistance = distance; + bestPair = [pointI, pointJ]; + } + } + } + const sorted = points.slice(start, end).sort((a, b) => (a.y - b.y) || (a.x - b.x)); + for (let i = 0; i < sorted.length; i += 1) { + points[start + i] = sorted[i]!; + } + return { distance: bestDistance, pair: bestPair }; + } + + const mid = start + Math.floor(count / 2); + const midPoint = points[mid]; + if (!midPoint) { + return { distance: Number.POSITIVE_INFINITY, pair: null }; + } + const midX = midPoint.x; + + const left = divideAndConquer(points, scratch, start, mid); + const right = divideAndConquer(points, scratch, mid, end); + + let bestDistance = left.distance; + let bestPair = left.pair; + if (right.distance < bestDistance) { + bestDistance = right.distance; + bestPair = right.pair; + } + + mergeByY(points, scratch, start, mid, end); + + const strip: Point[] = []; + for (let i = start; i < end; i += 1) { + const candidate = points[i]; + if (candidate && Math.abs(candidate.x - midX) < bestDistance) { + strip.push(candidate); + } + } + + for (let i = 0; i < strip.length; i += 1) { + const pointI = strip[i]; + if (!pointI) continue; + for (let j = i + 1; j < strip.length; j += 1) { + const pointJ = strip[j]; + if (!pointJ) continue; + const deltaY = pointJ.y - pointI.y; + if (deltaY >= bestDistance) break; + const distance = euclidean(pointI, pointJ); + if (distance < bestDistance) { + bestDistance = distance; + bestPair = [pointI, pointJ]; + } + } + } + + return { distance: bestDistance, pair: bestPair }; +} + +function mergeByY( + points: Array, + scratch: Array, + start: number, + mid: number, + end: number +): void { + let i = start; + let j = mid; + let k = start; + + while (i < mid && j < end) { + const pointI = points[i]; + const pointJ = points[j]; + if (pointI === undefined || pointJ === undefined) { + throw new Error('Unexpected undefined point while merging by Y.'); + } + if ((pointI.y < pointJ.y) || (pointI.y === pointJ.y && pointI.x <= pointJ.x)) { + scratch[k] = pointI; + i += 1; + } else { + scratch[k] = pointJ; + j += 1; + } + k += 1; + } + while (i < mid) { + const pointI = points[i]; + if (pointI === undefined) { + throw new Error('Unexpected undefined point while merging left partition.'); + } + scratch[k] = pointI; + i += 1; + k += 1; + } + while (j < end) { + const pointJ = points[j]; + if (pointJ === undefined) { + throw new Error('Unexpected undefined point while merging right partition.'); + } + scratch[k] = pointJ; + j += 1; + k += 1; + } + for (let idx = start; idx < end; idx += 1) { + const value = scratch[idx]; + if (value === undefined) { + throw new Error('Unexpected undefined scratch point after merge.'); + } + points[idx] = value; + } +} + +function euclidean(a: Point, b: Point): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.hypot(dx, dy); +} diff --git a/src/index.ts b/src/index.ts index 428acf2..33ce7a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,6 +173,7 @@ export const examples = { lineIntersection: 'examples/geometry.ts', pointInPolygon: 'examples/geometry.ts', bresenhamLine: 'examples/bresenham.ts', + closestPair: 'examples/closestPair.ts', }, visual: { easing: 'examples/visual.ts', @@ -1171,6 +1172,13 @@ export { pointInPolygon } from './geometry/pointInPolygon.js'; * Example file: examples/bresenham.ts */ export { bresenhamLine } from './geometry/bresenham.js'; +/** + * Closest pair of points in the plane via divide-and-conquer. + * + * Example file: examples/closestPair.ts + */ +export { closestPair } from './geometry/closestPair.js'; +export type { ClosestPairResult } from './geometry/closestPair.js'; // ============================================================================ // 🎨 VISUAL & ANIMATION diff --git a/tests/closestPair.test.ts b/tests/closestPair.test.ts new file mode 100644 index 0000000..44c033b --- /dev/null +++ b/tests/closestPair.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { closestPair } from '../src/geometry/closestPair.js'; + +describe('closestPair', () => { + it('finds the minimal distance among scattered points', () => { + const result = closestPair([ + { x: 0, y: 0 }, + { x: 5, y: 4 }, + { x: 1, y: 1 }, + { x: 10, y: 10 }, + { x: 1.5, y: 1.5 }, + ]); + + expect(result.distance).toBeCloseTo(Math.hypot(0.5, 0.5), 6); + expect(result.pair).not.toBeNull(); + expect(result.pair?.map((p) => `${p.x}:${p.y}`)).toContain('1:1'); + expect(result.pair?.map((p) => `${p.x}:${p.y}`)).toContain('1.5:1.5'); + }); + + it('handles duplicate points returning zero distance', () => { + const result = closestPair([ + { x: 2, y: 3 }, + { x: 5, y: 7 }, + { x: 2, y: 3 }, + ]); + + expect(result.distance).toBe(0); + expect(result.pair).not.toBeNull(); + }); + + it('returns infinity when less than two points provided', () => { + expect(closestPair([])).toEqual({ distance: Infinity, pair: null }); + expect(closestPair([{ x: 1, y: 2 }])).toEqual({ distance: Infinity, pair: null }); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index e170ffc..c64928b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -52,6 +52,7 @@ describe('package entry point', () => { expect(examples.gameplay.computeFieldOfView).toBe('examples/shadowcasting.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); + expect(examples.geometry.closestPair).toBe('examples/closestPair.ts'); expect(examples.spatial.Octree).toBe('examples/octree.ts'); expect(examples.spatial.buildBvh).toBe('examples/bvh.ts'); expect(examples.spatial.queryBvh).toBe('examples/bvh.ts'); @@ -186,6 +187,14 @@ describe('package entry point', () => { | 'createScreenTransition' >(); + expectTypeOf>().toEqualTypeOf< + | 'convexHull' + | 'lineIntersection' + | 'pointInPolygon' + | 'bresenhamLine' + | 'closestPair' + >(); + expectTypeOf>().toEqualTypeOf< | 'seek' | 'flee'