create-eziwiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. package/template/vitest.config.ts +30 -0
@@ -0,0 +1,214 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+ import type { Root, Link, Text } from 'mdast';
6
+ import { getContentRegistry, type ContentDoc } from '../content/registry';
7
+ import { resolveTarget } from '../content/resolver';
8
+ import { findWikiLinks } from '../markdown/wikilink';
9
+ import { getSite } from '../site';
10
+ import { cached } from '../cache';
11
+
12
+ /**
13
+ * The document link graph.
14
+ *
15
+ * Built by scanning every page for links that point at another page, whether
16
+ * written as `[[wiki links]]` or as ordinary Markdown links. The result drives
17
+ * the backlinks panel and the graph view, and surfaces dangling references that
18
+ * would otherwise go unnoticed until a reader clicked one.
19
+ *
20
+ * Server-only.
21
+ */
22
+
23
+ /** A link from one document to another. */
24
+ export interface GraphEdge {
25
+ /** Path of the document containing the link */
26
+ from: string;
27
+ /** Path of the document being linked to */
28
+ to: string;
29
+ }
30
+
31
+ /** A link that could not be resolved to any document. */
32
+ export interface BrokenLink {
33
+ /** Path of the document containing the link */
34
+ from: string;
35
+ /** The target as written */
36
+ target: string;
37
+ /** Why it failed: nothing matched, or the shorthand matched several pages */
38
+ reason: 'missing' | 'ambiguous';
39
+ /** Matching paths, when the target was ambiguous */
40
+ candidates?: string[];
41
+ }
42
+
43
+ /** A node in the rendered graph. */
44
+ export interface GraphNode {
45
+ /** Content path, used as the node id */
46
+ path: string;
47
+ /** Display title */
48
+ title: string;
49
+ /** Href for navigation */
50
+ url: string;
51
+ /** Number of links in and out, used to size the node */
52
+ degree: number;
53
+ }
54
+
55
+ /** The full graph. */
56
+ export interface LinkGraph {
57
+ /** Every visible document */
58
+ nodes: GraphNode[];
59
+ /** Every resolved link between documents */
60
+ edges: GraphEdge[];
61
+ /** Documents linking *to* a given path */
62
+ backlinks: Map<string, string[]>;
63
+ /** Documents linked *from* a given path */
64
+ outbound: Map<string, string[]>;
65
+ /** Links that resolved to nothing */
66
+ broken: BrokenLink[];
67
+ }
68
+
69
+ /** Parser used for scanning; no rendering plugins, so it stays fast. */
70
+ const parser = unified().use(remarkParse).use(remarkGfm);
71
+
72
+ /**
73
+ * Extracts the document path an ordinary Markdown link points at.
74
+ *
75
+ * External links, anchors, and links to files that are not pages all yield
76
+ * null, so only real page-to-page references become edges.
77
+ *
78
+ * @param url - The link's href as authored
79
+ * @returns The referenced content path, or null
80
+ */
81
+ function resolveMarkdownLink(url: string): string | null {
82
+ if (!url || url.startsWith('#')) return null;
83
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//')) return null;
84
+
85
+ const path = url.split(/[#?]/)[0].replace(/^\/+/, '').replace(/\.md$/i, '').replace(/\/+$/, '');
86
+
87
+ if (!path) return null;
88
+
89
+ return getContentRegistry().byPath.has(path) ? path : null;
90
+ }
91
+
92
+ /**
93
+ * Collects every outbound reference in one document.
94
+ *
95
+ * Text nodes are scanned for wiki links and link nodes for Markdown links,
96
+ * which means references written inside code are excluded — documentation
97
+ * showing the syntax should not register as a link.
98
+ *
99
+ * @param doc - Document to scan
100
+ * @returns Resolved targets and any broken references
101
+ */
102
+ function scanDoc(doc: ContentDoc): { targets: Set<string>; broken: BrokenLink[] } {
103
+ const tree = parser.parse(doc.content) as Root;
104
+ const targets = new Set<string>();
105
+ const broken: BrokenLink[] = [];
106
+
107
+ visit(tree, (node) => {
108
+ if (node.type === 'link') {
109
+ const path = resolveMarkdownLink((node as Link).url);
110
+ if (path && path !== doc.path) targets.add(path);
111
+ return;
112
+ }
113
+
114
+ if (node.type !== 'text') return;
115
+
116
+ for (const link of findWikiLinks((node as Text).value)) {
117
+ // An anchor-only link stays within the page and is not an edge.
118
+ if (!link.target) continue;
119
+
120
+ const resolution = resolveTarget(link.target);
121
+
122
+ if (resolution.doc) {
123
+ if (resolution.doc.path !== doc.path) targets.add(resolution.doc.path);
124
+ continue;
125
+ }
126
+
127
+ broken.push({
128
+ from: doc.path,
129
+ target: link.target,
130
+ reason: resolution.kind === 'ambiguous' ? 'ambiguous' : 'missing',
131
+ candidates: resolution.candidates,
132
+ });
133
+ }
134
+ });
135
+
136
+ return { targets, broken };
137
+ }
138
+
139
+ let memo: LinkGraph | null = null;
140
+
141
+ /**
142
+ * Builds the link graph across all visible documents, memoised per process.
143
+ *
144
+ * Hidden pages are left out entirely: they do not appear as nodes, and links
145
+ * to them do not become edges, so an unlisted page cannot be discovered by
146
+ * reading the graph.
147
+ *
148
+ * @returns The graph
149
+ */
150
+ export function getLinkGraph(): LinkGraph {
151
+ const hit = cached(memo);
152
+ if (hit) return hit;
153
+
154
+ const { docs } = getContentRegistry();
155
+ const { urlMap, hiddenPaths } = getSite();
156
+
157
+ const visible = docs.filter((doc) => !hiddenPaths.has(doc.path));
158
+ const visiblePaths = new Set(visible.map((doc) => doc.path));
159
+
160
+ const edges: GraphEdge[] = [];
161
+ const broken: BrokenLink[] = [];
162
+ const backlinks = new Map<string, string[]>();
163
+ const outbound = new Map<string, string[]>();
164
+
165
+ for (const doc of visible) {
166
+ const scan = scanDoc(doc);
167
+ broken.push(...scan.broken);
168
+
169
+ const targets = [...scan.targets].filter((target) => visiblePaths.has(target)).sort();
170
+ outbound.set(doc.path, targets);
171
+
172
+ for (const target of targets) {
173
+ edges.push({ from: doc.path, to: target });
174
+
175
+ const existing = backlinks.get(target);
176
+ if (existing) existing.push(doc.path);
177
+ else backlinks.set(target, [doc.path]);
178
+ }
179
+ }
180
+
181
+ for (const list of backlinks.values()) list.sort();
182
+
183
+ const degree = new Map<string, number>();
184
+ for (const edge of edges) {
185
+ degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
186
+ degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
187
+ }
188
+
189
+ const nodes: GraphNode[] = visible.map((doc) => ({
190
+ path: doc.path,
191
+ title: doc.title,
192
+ url: `/${urlMap.toUrl[doc.path] ?? doc.path}`,
193
+ degree: degree.get(doc.path) ?? 0,
194
+ }));
195
+
196
+ memo = { nodes, edges, backlinks, outbound, broken };
197
+ return memo;
198
+ }
199
+
200
+ /**
201
+ * Returns the documents that link to a given page.
202
+ *
203
+ * @param path - Content path of the page
204
+ * @returns Nodes linking to it, sorted by title
205
+ */
206
+ export function getBacklinks(path: string): GraphNode[] {
207
+ const graph = getLinkGraph();
208
+ const byPath = new Map(graph.nodes.map((node) => [node.path, node]));
209
+
210
+ return (graph.backlinks.get(path) ?? [])
211
+ .map((from) => byPath.get(from))
212
+ .filter((node): node is GraphNode => node !== undefined)
213
+ .sort((a, b) => a.title.localeCompare(b.title));
214
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { bounds, layout, seedPositions, step, type LayoutEdge } from './layout';
3
+
4
+ const AREA = { width: 800, height: 600 };
5
+
6
+ describe('seedPositions', () => {
7
+ it('places every node inside the area', () => {
8
+ const nodes = seedPositions(['a', 'b', 'c'], AREA);
9
+
10
+ expect(nodes).toHaveLength(3);
11
+ for (const node of nodes) {
12
+ expect(node.x).toBeGreaterThan(0);
13
+ expect(node.x).toBeLessThan(AREA.width);
14
+ expect(node.y).toBeGreaterThan(0);
15
+ expect(node.y).toBeLessThan(AREA.height);
16
+ }
17
+ });
18
+
19
+ it('gives distinct starting positions', () => {
20
+ const nodes = seedPositions(['a', 'b', 'c', 'd'], AREA);
21
+ const keys = new Set(nodes.map((node) => `${node.x.toFixed(3)},${node.y.toFixed(3)}`));
22
+
23
+ expect(keys.size).toBe(4);
24
+ });
25
+
26
+ it('is deterministic', () => {
27
+ const a = seedPositions(['x', 'y'], AREA);
28
+ const b = seedPositions(['x', 'y'], AREA);
29
+
30
+ expect(a).toEqual(b);
31
+ });
32
+
33
+ it('handles a single node', () => {
34
+ expect(seedPositions(['only'], AREA)).toHaveLength(1);
35
+ });
36
+
37
+ it('handles no nodes', () => {
38
+ expect(seedPositions([], AREA)).toEqual([]);
39
+ });
40
+ });
41
+
42
+ describe('step', () => {
43
+ it('does nothing with no nodes', () => {
44
+ expect(() => step([], [], AREA, 1)).not.toThrow();
45
+ });
46
+
47
+ it('pushes unconnected nodes apart', () => {
48
+ const nodes = [
49
+ { id: 'a', x: 400, y: 300, vx: 0, vy: 0 },
50
+ { id: 'b', x: 410, y: 300, vx: 0, vy: 0 },
51
+ ];
52
+ const before = Math.hypot(nodes[0].x - nodes[1].x, nodes[0].y - nodes[1].y);
53
+
54
+ step(nodes, [], AREA, 1);
55
+
56
+ const after = Math.hypot(nodes[0].x - nodes[1].x, nodes[0].y - nodes[1].y);
57
+ expect(after).toBeGreaterThan(before);
58
+ });
59
+
60
+ it('pulls connected nodes together when they are far apart', () => {
61
+ const nodes = [
62
+ { id: 'a', x: 50, y: 300, vx: 0, vy: 0 },
63
+ { id: 'b', x: 750, y: 300, vx: 0, vy: 0 },
64
+ ];
65
+ const edges: LayoutEdge[] = [{ from: 'a', to: 'b' }];
66
+ const before = Math.abs(nodes[0].x - nodes[1].x);
67
+
68
+ for (let i = 0; i < 20; i++) step(nodes, edges, AREA, 1);
69
+
70
+ expect(Math.abs(nodes[0].x - nodes[1].x)).toBeLessThan(before);
71
+ });
72
+
73
+ it('separates coincident nodes instead of producing NaN', () => {
74
+ const nodes = [
75
+ { id: 'a', x: 400, y: 300, vx: 0, vy: 0 },
76
+ { id: 'b', x: 400, y: 300, vx: 0, vy: 0 },
77
+ ];
78
+
79
+ step(nodes, [], AREA, 1);
80
+
81
+ for (const node of nodes) {
82
+ expect(Number.isFinite(node.x)).toBe(true);
83
+ expect(Number.isFinite(node.y)).toBe(true);
84
+ }
85
+ expect(nodes[0].x !== nodes[1].x || nodes[0].y !== nodes[1].y).toBe(true);
86
+ });
87
+
88
+ it('leaves fixed nodes in place', () => {
89
+ const nodes = [
90
+ { id: 'a', x: 100, y: 100, vx: 0, vy: 0, fixed: true },
91
+ { id: 'b', x: 110, y: 100, vx: 0, vy: 0 },
92
+ ];
93
+
94
+ step(nodes, [], AREA, 1);
95
+
96
+ expect(nodes[0].x).toBe(100);
97
+ expect(nodes[0].y).toBe(100);
98
+ });
99
+
100
+ it('ignores edges referencing unknown nodes', () => {
101
+ const nodes = [{ id: 'a', x: 400, y: 300, vx: 0, vy: 0 }];
102
+
103
+ expect(() => step(nodes, [{ from: 'a', to: 'ghost' }], AREA, 1)).not.toThrow();
104
+ expect(Number.isFinite(nodes[0].x)).toBe(true);
105
+ });
106
+
107
+ it('ignores self-links', () => {
108
+ const nodes = [{ id: 'a', x: 400, y: 300, vx: 0, vy: 0 }];
109
+
110
+ step(nodes, [{ from: 'a', to: 'a' }], AREA, 1);
111
+
112
+ expect(Number.isFinite(nodes[0].x)).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe('layout', () => {
117
+ it('produces finite positions for a connected graph', () => {
118
+ const ids = ['a', 'b', 'c', 'd', 'e'];
119
+ const edges: LayoutEdge[] = [
120
+ { from: 'a', to: 'b' },
121
+ { from: 'b', to: 'c' },
122
+ { from: 'c', to: 'd' },
123
+ { from: 'd', to: 'e' },
124
+ ];
125
+
126
+ for (const node of layout(ids, edges, AREA)) {
127
+ expect(Number.isFinite(node.x)).toBe(true);
128
+ expect(Number.isFinite(node.y)).toBe(true);
129
+ }
130
+ });
131
+
132
+ it('is deterministic across runs', () => {
133
+ const ids = ['a', 'b', 'c'];
134
+ const edges: LayoutEdge[] = [{ from: 'a', to: 'b' }];
135
+
136
+ expect(layout(ids, edges, AREA)).toEqual(layout(ids, edges, AREA));
137
+ });
138
+
139
+ it('places linked nodes closer than unlinked ones', () => {
140
+ const nodes = layout(['a', 'b', 'far'], [{ from: 'a', to: 'b' }], AREA);
141
+ const byId = new Map(nodes.map((node) => [node.id, node]));
142
+
143
+ const linked = Math.hypot(
144
+ byId.get('a')!.x - byId.get('b')!.x,
145
+ byId.get('a')!.y - byId.get('b')!.y,
146
+ );
147
+ const unlinked = Math.hypot(
148
+ byId.get('a')!.x - byId.get('far')!.x,
149
+ byId.get('a')!.y - byId.get('far')!.y,
150
+ );
151
+
152
+ expect(linked).toBeLessThan(unlinked);
153
+ });
154
+
155
+ it('handles a graph with no edges', () => {
156
+ expect(layout(['a', 'b'], [], AREA)).toHaveLength(2);
157
+ });
158
+ });
159
+
160
+ describe('bounds', () => {
161
+ it('covers every node with padding', () => {
162
+ const box = bounds(
163
+ [
164
+ { id: 'a', x: 100, y: 100, vx: 0, vy: 0 },
165
+ { id: 'b', x: 300, y: 200, vx: 0, vy: 0 },
166
+ ],
167
+ 10,
168
+ );
169
+
170
+ expect(box.x).toBe(90);
171
+ expect(box.y).toBe(90);
172
+ expect(box.width).toBe(220);
173
+ expect(box.height).toBe(120);
174
+ });
175
+
176
+ it('never returns a zero-size box for a single node', () => {
177
+ const box = bounds([{ id: 'a', x: 5, y: 5, vx: 0, vy: 0 }], 0);
178
+
179
+ expect(box.width).toBeGreaterThan(0);
180
+ expect(box.height).toBeGreaterThan(0);
181
+ });
182
+
183
+ it('returns a usable box for no nodes', () => {
184
+ const box = bounds([]);
185
+
186
+ expect(box.width).toBeGreaterThan(0);
187
+ expect(box.height).toBeGreaterThan(0);
188
+ });
189
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Force-directed graph layout.
3
+ *
4
+ * A small Fruchterman–Reingold implementation rather than a charting library:
5
+ * published pages must be self-contained, and a graph of this size does not
6
+ * justify shipping a general-purpose layout engine to every reader.
7
+ *
8
+ * Pure and isomorphic — no DOM access — so it can be unit-tested directly.
9
+ */
10
+
11
+ /** A node being laid out. */
12
+ export interface LayoutNode {
13
+ /** Stable identifier, matching edge endpoints */
14
+ id: string;
15
+ /** Current position, mutated in place as the simulation runs */
16
+ x: number;
17
+ y: number;
18
+ /** Velocity carried between steps */
19
+ vx: number;
20
+ vy: number;
21
+ /** Pinned nodes are not moved by forces */
22
+ fixed?: boolean;
23
+ }
24
+
25
+ /** An edge between two node ids. */
26
+ export interface LayoutEdge {
27
+ from: string;
28
+ to: string;
29
+ }
30
+
31
+ /** Tunable simulation parameters. */
32
+ export interface LayoutOptions {
33
+ /** Width of the layout area */
34
+ width: number;
35
+ /** Height of the layout area */
36
+ height: number;
37
+ /** Multiplier on the natural spring length; higher spreads nodes further */
38
+ spacing?: number;
39
+ }
40
+
41
+ /** Fraction of velocity retained each step. */
42
+ const DAMPING = 0.9;
43
+
44
+ /** Pull toward the centre, keeping disconnected nodes from drifting away. */
45
+ const GRAVITY = 0.03;
46
+
47
+ /**
48
+ * Largest distance a node may move in one step, as a fraction of `k`.
49
+ *
50
+ * Connected nodes settle at roughly `k` apart, so a step anywhere near that
51
+ * distance overshoots the equilibrium and the pair oscillates instead of
52
+ * converging. Keeping it well below `k` is what makes the layout settle.
53
+ */
54
+ const MAX_STEP = 0.1;
55
+
56
+ /** Minimum separation used in force calculations, avoiding division by zero. */
57
+ const MIN_DISTANCE = 0.01;
58
+
59
+ /**
60
+ * Deterministically spreads nodes around a circle as a starting position.
61
+ *
62
+ * A random start would relayout differently on every render — and would not be
63
+ * reproducible in tests — so positions are seeded from each node's index.
64
+ *
65
+ * @param ids - Node ids, in a stable order
66
+ * @param options - Layout area
67
+ * @returns Nodes positioned on a circle around the centre
68
+ */
69
+ export function seedPositions(ids: string[], options: LayoutOptions): LayoutNode[] {
70
+ const { width, height } = options;
71
+ const radius = Math.min(width, height) * 0.35;
72
+
73
+ return ids.map((id, index) => {
74
+ const angle = (index / Math.max(1, ids.length)) * Math.PI * 2;
75
+
76
+ return {
77
+ id,
78
+ x: width / 2 + Math.cos(angle) * radius,
79
+ y: height / 2 + Math.sin(angle) * radius,
80
+ vx: 0,
81
+ vy: 0,
82
+ };
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Advances the simulation by one step.
88
+ *
89
+ * Every pair of nodes repels; connected pairs also attract. `alpha` scales the
90
+ * whole step and is decayed by the caller, which is what lets the layout settle
91
+ * instead of oscillating forever.
92
+ *
93
+ * @param nodes - Nodes to move, mutated in place
94
+ * @param edges - Edges providing attraction
95
+ * @param options - Layout area
96
+ * @param alpha - Step scale, from 1 down toward 0
97
+ */
98
+ export function step(
99
+ nodes: LayoutNode[],
100
+ edges: LayoutEdge[],
101
+ options: LayoutOptions,
102
+ alpha: number,
103
+ ): void {
104
+ const { width, height, spacing = 1 } = options;
105
+ if (nodes.length === 0) return;
106
+
107
+ // Natural distance between connected nodes for the available area.
108
+ const k = spacing * Math.sqrt((width * height) / nodes.length);
109
+ const byId = new Map(nodes.map((node) => [node.id, node]));
110
+
111
+ for (const node of nodes) {
112
+ node.vx *= DAMPING;
113
+ node.vy *= DAMPING;
114
+ }
115
+
116
+ // Repulsion: every pair pushes apart, strongest when close.
117
+ for (let i = 0; i < nodes.length; i++) {
118
+ for (let j = i + 1; j < nodes.length; j++) {
119
+ const a = nodes[i];
120
+ const b = nodes[j];
121
+
122
+ let dx = a.x - b.x;
123
+ let dy = a.y - b.y;
124
+ let distance = Math.hypot(dx, dy);
125
+
126
+ if (distance < MIN_DISTANCE) {
127
+ // Coincident nodes have no direction to separate along; nudge them
128
+ // apart deterministically using their index so the layout stays stable.
129
+ dx = (i - j) * MIN_DISTANCE;
130
+ dy = MIN_DISTANCE;
131
+ distance = Math.hypot(dx, dy);
132
+ }
133
+
134
+ const force = (k * k) / distance;
135
+ const fx = (dx / distance) * force;
136
+ const fy = (dy / distance) * force;
137
+
138
+ a.vx += fx;
139
+ a.vy += fy;
140
+ b.vx -= fx;
141
+ b.vy -= fy;
142
+ }
143
+ }
144
+
145
+ // Attraction along edges.
146
+ for (const edge of edges) {
147
+ const a = byId.get(edge.from);
148
+ const b = byId.get(edge.to);
149
+ if (!a || !b || a === b) continue;
150
+
151
+ const dx = a.x - b.x;
152
+ const dy = a.y - b.y;
153
+ const distance = Math.max(MIN_DISTANCE, Math.hypot(dx, dy));
154
+
155
+ const force = (distance * distance) / k;
156
+ const fx = (dx / distance) * force;
157
+ const fy = (dy / distance) * force;
158
+
159
+ a.vx -= fx;
160
+ a.vy -= fy;
161
+ b.vx += fx;
162
+ b.vy += fy;
163
+ }
164
+
165
+ const maxStep = k * MAX_STEP;
166
+
167
+ for (const node of nodes) {
168
+ if (node.fixed) {
169
+ node.vx = 0;
170
+ node.vy = 0;
171
+ continue;
172
+ }
173
+
174
+ // Displacement-based pull toward the centre.
175
+ node.vx += (width / 2 - node.x) * GRAVITY;
176
+ node.vy += (height / 2 - node.y) * GRAVITY;
177
+
178
+ const speed = Math.hypot(node.vx, node.vy);
179
+ const scale = speed > maxStep ? maxStep / speed : 1;
180
+
181
+ node.x += node.vx * scale * alpha;
182
+ node.y += node.vy * scale * alpha;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Runs the simulation to completion and returns the settled positions.
188
+ *
189
+ * @param ids - Node ids
190
+ * @param edges - Edges between them
191
+ * @param options - Layout area
192
+ * @param iterations - Number of steps to run
193
+ * @returns Settled node positions
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * const nodes = layout(['a', 'b'], [{ from: 'a', to: 'b' }], { width: 800, height: 600 });
198
+ * ```
199
+ */
200
+ export function layout(
201
+ ids: string[],
202
+ edges: LayoutEdge[],
203
+ options: LayoutOptions,
204
+ iterations = 300,
205
+ ): LayoutNode[] {
206
+ const nodes = seedPositions(ids, options);
207
+
208
+ for (let i = 0; i < iterations; i++) {
209
+ step(nodes, edges, options, 1 - i / iterations);
210
+ }
211
+
212
+ return nodes;
213
+ }
214
+
215
+ /**
216
+ * Computes the bounding box of laid-out nodes.
217
+ *
218
+ * Used to fit the result into the viewport regardless of how far the simulation
219
+ * spread things.
220
+ *
221
+ * @param nodes - Positioned nodes
222
+ * @param padding - Margin added on every side
223
+ * @returns The bounding box
224
+ */
225
+ export function bounds(
226
+ nodes: LayoutNode[],
227
+ padding = 40,
228
+ ): { x: number; y: number; width: number; height: number } {
229
+ if (nodes.length === 0) return { x: 0, y: 0, width: 1, height: 1 };
230
+
231
+ const xs = nodes.map((node) => node.x);
232
+ const ys = nodes.map((node) => node.y);
233
+
234
+ const minX = Math.min(...xs) - padding;
235
+ const minY = Math.min(...ys) - padding;
236
+ const maxX = Math.max(...xs) + padding;
237
+ const maxY = Math.max(...ys) + padding;
238
+
239
+ return {
240
+ x: minX,
241
+ y: minY,
242
+ // A single node produces a zero-size box, which would make the SVG
243
+ // viewBox degenerate.
244
+ width: Math.max(1, maxX - minX),
245
+ height: Math.max(1, maxY - minY),
246
+ };
247
+ }