diff --git a/README.md b/README.md index c9f1013..6cc9413 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ CDN usage: | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `diffJsonAdvanced`, `applyJsonDiff`, `applyJsonDiffSelective`, `flatten`, `unflatten`, `diffTree`, `applyTreeDiff` | `data/*.ts` | `examples/jsonDiff.ts`, `examples/treeDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | -| Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts` | +| Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier`, `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`, `mixRgbColors`, `computeForceDirectedLayout` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/color.ts`, `examples/forceDirected.ts` | ## Scripts ```bash diff --git a/ROADMAP.md b/ROADMAP.md index 44fb107..3ca5888 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,7 +88,7 @@ - [x] Advanced diff tooling (tree diff, selective patches) - **Visual & simulation tools** - [x] Color manipulation helpers (RGB/HSL conversion, blending) - - [ ] Force-directed graph layout + - [x] Force-directed graph layout - [ ] Marching squares contour extraction - [ ] Marching cubes isosurface generation - **Graph algorithms** diff --git a/docs/index.d.ts b/docs/index.d.ts index 8356696..b86f48a 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -157,6 +157,7 @@ export const examples: { readonly rgbToHsl: 'examples/color.ts'; readonly hslToRgb: 'examples/color.ts'; readonly mixRgbColors: 'examples/color.ts'; + readonly computeForceDirectedLayout: 'examples/forceDirected.ts'; }; }; @@ -3011,6 +3012,76 @@ export function hslToRgb(color: HSLColor): RGBColor; */ export function mixRgbColors(a: RGBColor, b: RGBColor, options?: MixColorOptions): RGBColor; +/** + * Node definition for force-directed layout input. + * Use for: positioning graph vertices in 2D space. + * Import: visual/forceDirected.ts + */ +export interface ForceDirectedNodeInput { + id: string; + x?: number; + y?: number; + fixed?: boolean; +} + +/** + * Resulting node with resolved coordinates after layout. + * Use for: rendering graphs, network diagrams, simulations. + * Import: visual/forceDirected.ts + */ +export interface ForceDirectedNode extends ForceDirectedNodeInput { + x: number; + y: number; +} + +/** + * Edge definition for force-directed layout. + * Use for: describing relationships between nodes. + * Import: visual/forceDirected.ts + */ +export interface ForceDirectedEdge { + source: string; + target: string; + weight?: number; +} + +/** + * Configuration options for the force-directed layout. + * Use for: tuning iteration count, repulsion, gravity, and bounds. + * Import: visual/forceDirected.ts + */ +export interface ForceDirectedLayoutOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; + width?: number; + height?: number; + iterations?: number; + repulsion?: number; + attraction?: number; + damping?: number; + gravity?: number; + initialTemperature?: number; + random?: () => number; +} + +/** + * Result payload from the force-directed layout computation. + * Use for: feeding coordinates into rendering pipelines. + * Import: visual/forceDirected.ts + */ +export interface ForceDirectedLayoutResult { + nodes: ForceDirectedNode[]; +} + +/** + * Computes 2D coordinates for a graph using a force-directed method. + * Use for: network visualisation, relationship mapping, graph diagrams. + * Import: visual/forceDirected.ts + */ +export function computeForceDirectedLayout( + options: ForceDirectedLayoutOptions +): ForceDirectedLayoutResult; + // ============================================================================ // 🤖 STEERING BEHAVIOURS // ============================================================================ diff --git a/examples/forceDirected.ts b/examples/forceDirected.ts new file mode 100644 index 0000000..cbc8148 --- /dev/null +++ b/examples/forceDirected.ts @@ -0,0 +1,28 @@ +import { computeForceDirectedLayout } from '../src/index.js'; + +const nodes = [ + { id: 'A' }, + { id: 'B' }, + { id: 'C' }, + { id: 'D' }, + { id: 'E' }, +]; + +const edges = [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'D' }, + { source: 'D', target: 'E' }, + { source: 'E', target: 'A' }, + { source: 'A', target: 'C' }, +]; + +const layout = computeForceDirectedLayout({ + nodes, + edges, + width: 400, + height: 400, + iterations: 200, +}); + +console.log('Computed layout:', layout.nodes); diff --git a/src/index.ts b/src/index.ts index c34456f..2a58751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,6 +156,7 @@ export const examples = { rgbToHsl: 'examples/color.ts', hslToRgb: 'examples/color.ts', mixRgbColors: 'examples/color.ts', + computeForceDirectedLayout: 'examples/forceDirected.ts', }, } as const; @@ -1039,6 +1040,19 @@ export { hexToRgb, rgbToHex, rgbToHsl, hslToRgb, mixRgbColors } from './visual/c export type { RGBColor, HSLColor, MixColorOptions } from './visual/color.js'; +/** + * Force-directed graph layout helper. + */ +export { computeForceDirectedLayout } from './visual/forceDirected.js'; + +export type { + ForceDirectedLayoutOptions, + ForceDirectedLayoutResult, + ForceDirectedEdge, + ForceDirectedNode, + ForceDirectedNodeInput, +} from './visual/forceDirected.js'; + // ============================================================================ // 🤖 AI & BEHAVIOUR // ============================================================================ diff --git a/src/visual/forceDirected.ts b/src/visual/forceDirected.ts new file mode 100644 index 0000000..1cb6e38 --- /dev/null +++ b/src/visual/forceDirected.ts @@ -0,0 +1,203 @@ +const EPSILON = 1e-6; + +export interface ForceDirectedNodeInput { + id: string; + x?: number; + y?: number; + fixed?: boolean; +} + +export interface ForceDirectedNode extends ForceDirectedNodeInput { + x: number; + y: number; +} + +export interface ForceDirectedEdge { + source: string; + target: string; + weight?: number; +} + +export interface ForceDirectedLayoutOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; + width?: number; + height?: number; + iterations?: number; + repulsion?: number; + attraction?: number; + damping?: number; + gravity?: number; + initialTemperature?: number; + random?: () => number; +} + +export interface ForceDirectedLayoutResult { + nodes: ForceDirectedNode[]; +} + +export function computeForceDirectedLayout(options: ForceDirectedLayoutOptions): ForceDirectedLayoutResult { + validateOptions(options); + const random = options.random ?? Math.random; + const width = options.width ?? 1; + const height = options.height ?? 1; + const iterations = Math.max(1, Math.floor(options.iterations ?? 100)); + const repulsion = options.repulsion ?? 600; + const attraction = options.attraction ?? 0.1; + const damping = clamp(options.damping ?? 0.9, 0.01, 1); + const gravity = options.gravity ?? 0.1; + const temperatureStart = options.initialTemperature ?? Math.min(width, height) / 10; + + const nodeCount = options.nodes.length; + const area = Math.max(width * height, EPSILON); + const optimalDistance = Math.sqrt(area / Math.max(nodeCount, 1)); + + const nodes: ForceDirectedNode[] = options.nodes.map((node) => ({ + id: node.id, + fixed: node.fixed, + x: node.x ?? random() * width, + y: node.y ?? random() * height, + })); + + const nodeIndex = new Map(); + nodes.forEach((node, index) => { + if (nodeIndex.has(node.id)) { + throw new Error(`Duplicate node id: ${node.id}`); + } + nodeIndex.set(node.id, index); + }); + + const edges = options.edges.map((edge) => { + if (!nodeIndex.has(edge.source)) { + throw new Error(`Edge references unknown node: ${edge.source}`); + } + if (!nodeIndex.has(edge.target)) { + throw new Error(`Edge references unknown node: ${edge.target}`); + } + return { + source: nodeIndex.get(edge.source)!, + target: nodeIndex.get(edge.target)!, + weight: edge.weight ?? 1, + }; + }); + + const displacements = new Array(nodeCount).fill(0).map(() => ({ x: 0, y: 0 })); + let temperature = temperatureStart; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (let i = 0; i < nodeCount; i += 1) { + displacements[i].x = 0; + displacements[i].y = 0; + } + + // Repulsive forces + for (let i = 0; i < nodeCount; i += 1) { + for (let j = i + 1; j < nodeCount; j += 1) { + const dx = nodes[i].x - nodes[j].x; + const dy = nodes[i].y - nodes[j].y; + const distance = Math.sqrt(dx * dx + dy * dy) + EPSILON; + const force = (repulsion * repulsion) / distance; + const fx = (dx / distance) * force; + const fy = (dy / distance) * force; + displacements[i].x += fx; + displacements[i].y += fy; + displacements[j].x -= fx; + displacements[j].y -= fy; + } + } + + // Attractive forces along edges + for (const edge of edges) { + const source = nodes[edge.source]; + const target = nodes[edge.target]; + const dx = source.x - target.x; + const dy = source.y - target.y; + const distance = Math.sqrt(dx * dx + dy * dy) + EPSILON; + const force = ((distance * distance) / optimalDistance) * (edge.weight ?? 1) * attraction; + const fx = (dx / distance) * force; + const fy = (dy / distance) * force; + displacements[edge.source].x -= fx; + displacements[edge.source].y -= fy; + displacements[edge.target].x += fx; + displacements[edge.target].y += fy; + } + + // Gravity towards center + const centerX = width / 2; + const centerY = height / 2; + for (let i = 0; i < nodeCount; i += 1) { + const node = nodes[i]; + const dx = node.x - centerX; + const dy = node.y - centerY; + displacements[i].x -= dx * gravity; + displacements[i].y -= dy * gravity; + } + + // Update positions + for (let i = 0; i < nodeCount; i += 1) { + const node = nodes[i]; + if (node.fixed) { + continue; + } + const disp = displacements[i]; + const dispLength = Math.sqrt(disp.x * disp.x + disp.y * disp.y); + if (dispLength > EPSILON) { + const limited = Math.min(dispLength, temperature); + node.x += (disp.x / dispLength) * limited; + node.y += (disp.y / dispLength) * limited; + } + node.x = clamp(node.x, 0, width); + node.y = clamp(node.y, 0, height); + } + + temperature *= damping; + if (temperature < EPSILON) { + break; + } + } + + return { nodes }; +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +function validateOptions(options: ForceDirectedLayoutOptions): void { + if (!Array.isArray(options.nodes) || options.nodes.length === 0) { + throw new Error('nodes must contain at least one entry.'); + } + if (!Array.isArray(options.edges)) { + throw new Error('edges must be an array.'); + } + if (options.width !== undefined && (!Number.isFinite(options.width) || options.width <= 0)) { + throw new Error('width must be a positive number when provided.'); + } + if (options.height !== undefined && (!Number.isFinite(options.height) || options.height <= 0)) { + throw new Error('height must be a positive number when provided.'); + } + if (options.iterations !== undefined && (!Number.isFinite(options.iterations) || options.iterations <= 0)) { + throw new Error('iterations must be a positive number when provided.'); + } + if (options.repulsion !== undefined && options.repulsion <= 0) { + throw new Error('repulsion must be positive when provided.'); + } + if (options.attraction !== undefined && options.attraction <= 0) { + throw new Error('attraction must be positive when provided.'); + } + if (options.damping !== undefined && (options.damping <= 0 || options.damping > 1)) { + throw new Error('damping must be between 0 and 1 when provided.'); + } + if (options.gravity !== undefined && options.gravity < 0) { + throw new Error('gravity must be non-negative when provided.'); + } + if (options.initialTemperature !== undefined && options.initialTemperature <= 0) { + throw new Error('initialTemperature must be positive when provided.'); + } +} diff --git a/tests/forceDirected.test.ts b/tests/forceDirected.test.ts new file mode 100644 index 0000000..c23155a --- /dev/null +++ b/tests/forceDirected.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { computeForceDirectedLayout } from '../src/index.js'; + +describe('computeForceDirectedLayout', () => { + it('positions connected nodes within the bounding box', () => { + const nodes = [ + { id: 'a', x: 0, y: 0 }, + { id: 'b', x: 200, y: 0 }, + { id: 'c', x: 100, y: 200 }, + { id: 'd', x: 100, y: 100 }, + ]; + const edges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'a' }, + { source: 'a', target: 'd' }, + { source: 'b', target: 'd' }, + { source: 'c', target: 'd' }, + ]; + + const result = computeForceDirectedLayout({ + nodes, + edges, + width: 300, + height: 300, + iterations: 150, + repulsion: 400, + attraction: 0.08, + damping: 0.9, + gravity: 0.05, + }); + + expect(result.nodes).toHaveLength(nodes.length); + for (const node of result.nodes) { + expect(node.x).toBeGreaterThanOrEqual(0); + expect(node.x).toBeLessThanOrEqual(300); + expect(node.y).toBeGreaterThanOrEqual(0); + expect(node.y).toBeLessThanOrEqual(300); + } + + const distances = extractEdgeLengths(result.nodes, edges); + const avg = distances.reduce((sum, value) => sum + value, 0) / distances.length; + expect(avg).toBeGreaterThan(20); + expect(avg).toBeLessThan(500); + + const centerX = result.nodes.reduce((sum, node) => sum + node.x, 0) / result.nodes.length; + const centerY = result.nodes.reduce((sum, node) => sum + node.y, 0) / result.nodes.length; + expect(centerX).toBeGreaterThan(60); + expect(centerX).toBeLessThan(240); + expect(centerY).toBeGreaterThan(60); + expect(centerY).toBeLessThan(240); + }); + + it('supports deterministic layouts when providing a random source', () => { + const nodes = Array.from({ length: 4 }, (_, index) => ({ id: String.fromCharCode(97 + index) })); + const edges = [ + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + { source: 'c', target: 'd' }, + { source: 'd', target: 'a' }, + ]; + + const random = createDeterministicRandom(0.42); + const result = computeForceDirectedLayout({ + nodes, + edges, + width: 200, + height: 200, + iterations: 120, + random, + repulsion: 300, + attraction: 0.07, + damping: 0.92, + }); + + expect(result.nodes.map((node) => ({ id: node.id, x: round(node.x), y: round(node.y) }))).toEqual([ + { id: 'a', x: 0, y: 43.76 }, + { id: 'b', x: 29.93, y: 200 }, + { id: 'c', x: 200, y: 85 }, + { id: 'd', x: 118.96, y: 0 }, + ]); + }); + + it('respects fixed nodes during layout', () => { + const result = computeForceDirectedLayout({ + nodes: [ + { id: 'a', x: 0, y: 0, fixed: true }, + { id: 'b', x: 100, y: 0 }, + ], + edges: [{ source: 'a', target: 'b' }], + width: 200, + height: 200, + iterations: 50, + }); + + const nodeA = result.nodes.find((node) => node.id === 'a'); + expect(nodeA).toBeDefined(); + expect(nodeA?.x).toBe(0); + expect(nodeA?.y).toBe(0); + }); + + it('validates options', () => { + expect(() => + computeForceDirectedLayout({ + nodes: [], + edges: [], + }) + ).toThrow('nodes must contain at least one entry.'); + + expect(() => + computeForceDirectedLayout({ + nodes: [{ id: 'a' }], + edges: [{ source: 'a', target: 'missing' }], + }) + ).toThrow('Edge references unknown node: missing'); + }); +}); + +function extractEdgeLengths(nodes: { id: string; x: number; y: number }[], edges: { source: string; target: string }[]) { + const index = new Map(nodes.map((node, idx) => [node.id, idx] as const)); + return edges.map((edge) => { + const a = nodes[index.get(edge.source)!]; + const b = nodes[index.get(edge.target)!]; + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); + }); +} + +function createDeterministicRandom(seed: number): () => number { + let state = seed % 1; + return () => { + state = (state * 9301 + 49297) % 233280; + return state / 233280; + }; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 29e7fe7..44b5f69 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -32,6 +32,7 @@ describe('package entry point', () => { expect(examples.visual.rgbToHsl).toBe('examples/color.ts'); expect(examples.visual.hslToRgb).toBe('examples/color.ts'); expect(examples.visual.mixRgbColors).toBe('examples/color.ts'); + expect(examples.visual.computeForceDirectedLayout).toBe('examples/forceDirected.ts'); expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); @@ -182,6 +183,7 @@ describe('package entry point', () => { | 'rgbToHsl' | 'hslToRgb' | 'mixRgbColors' + | 'computeForceDirectedLayout' >(); }); });