@tldraw/commenting 0.0.0-bootstrap

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 (242) hide show
  1. package/README.md +11 -0
  2. package/commenting.css +832 -0
  3. package/dist-cjs/canvas/cluster-input.js +40 -0
  4. package/dist-cjs/canvas/cluster-input.js.map +7 -0
  5. package/dist-cjs/canvas/comment-body.js +31 -0
  6. package/dist-cjs/canvas/comment-body.js.map +7 -0
  7. package/dist-cjs/canvas/comment-render.js +72 -0
  8. package/dist-cjs/canvas/comment-render.js.map +7 -0
  9. package/dist-cjs/canvas/comment-store.js +48 -0
  10. package/dist-cjs/canvas/comment-store.js.map +7 -0
  11. package/dist-cjs/canvas/comment-tool.js +160 -0
  12. package/dist-cjs/canvas/comment-tool.js.map +7 -0
  13. package/dist-cjs/canvas/comments-filter-menu.js +109 -0
  14. package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
  15. package/dist-cjs/canvas/comments-menu-item.js +42 -0
  16. package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
  17. package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
  18. package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
  19. package/dist-cjs/canvas/comments-overlay.js +1081 -0
  20. package/dist-cjs/canvas/comments-overlay.js.map +7 -0
  21. package/dist-cjs/canvas/comments-sidebar.js +134 -0
  22. package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
  23. package/dist-cjs/canvas/hooks.js +45 -0
  24. package/dist-cjs/canvas/hooks.js.map +7 -0
  25. package/dist-cjs/canvas/license.js +28 -0
  26. package/dist-cjs/canvas/license.js.map +7 -0
  27. package/dist-cjs/canvas/options.js +45 -0
  28. package/dist-cjs/canvas/options.js.map +7 -0
  29. package/dist-cjs/canvas/region-options.js +49 -0
  30. package/dist-cjs/canvas/region-options.js.map +7 -0
  31. package/dist-cjs/canvas/rich-text.js +28 -0
  32. package/dist-cjs/canvas/rich-text.js.map +7 -0
  33. package/dist-cjs/canvas/sidebar-filters.js +30 -0
  34. package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
  35. package/dist-cjs/canvas/state.js +76 -0
  36. package/dist-cjs/canvas/state.js.map +7 -0
  37. package/dist-cjs/canvas/thread-state.js +81 -0
  38. package/dist-cjs/canvas/thread-state.js.map +7 -0
  39. package/dist-cjs/clustering/computeClusterTable.js +83 -0
  40. package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
  41. package/dist-cjs/clustering/mst.js +117 -0
  42. package/dist-cjs/clustering/mst.js.map +7 -0
  43. package/dist-cjs/clustering/replay.js +275 -0
  44. package/dist-cjs/clustering/replay.js.map +7 -0
  45. package/dist-cjs/clustering/runtime.js +134 -0
  46. package/dist-cjs/clustering/runtime.js.map +7 -0
  47. package/dist-cjs/clustering/schedule.js +170 -0
  48. package/dist-cjs/clustering/schedule.js.map +7 -0
  49. package/dist-cjs/clustering/types.js +17 -0
  50. package/dist-cjs/clustering/types.js.map +7 -0
  51. package/dist-cjs/index.d.ts +868 -0
  52. package/dist-cjs/index.js +126 -0
  53. package/dist-cjs/index.js.map +7 -0
  54. package/dist-cjs/ui/avatar.js +43 -0
  55. package/dist-cjs/ui/avatar.js.map +7 -0
  56. package/dist-cjs/ui/byline.js +35 -0
  57. package/dist-cjs/ui/byline.js.map +7 -0
  58. package/dist-cjs/ui/comment-card.js +37 -0
  59. package/dist-cjs/ui/comment-card.js.map +7 -0
  60. package/dist-cjs/ui/comment-composer.js +136 -0
  61. package/dist-cjs/ui/comment-composer.js.map +7 -0
  62. package/dist-cjs/ui/comment-extensions.js +37 -0
  63. package/dist-cjs/ui/comment-extensions.js.map +7 -0
  64. package/dist-cjs/ui/comment-mention.js +42 -0
  65. package/dist-cjs/ui/comment-mention.js.map +7 -0
  66. package/dist-cjs/ui/comment-pin.js +29 -0
  67. package/dist-cjs/ui/comment-pin.js.map +7 -0
  68. package/dist-cjs/ui/comment-text.js +29 -0
  69. package/dist-cjs/ui/comment-text.js.map +7 -0
  70. package/dist-cjs/ui/comment-thread.js +45 -0
  71. package/dist-cjs/ui/comment-thread.js.map +7 -0
  72. package/dist-cjs/ui/comments-list.js +106 -0
  73. package/dist-cjs/ui/comments-list.js.map +7 -0
  74. package/dist-cjs/ui/count-badge.js +28 -0
  75. package/dist-cjs/ui/count-badge.js.map +7 -0
  76. package/dist-cjs/ui/empty-state.js +31 -0
  77. package/dist-cjs/ui/empty-state.js.map +7 -0
  78. package/dist-cjs/ui/format-time.js +46 -0
  79. package/dist-cjs/ui/format-time.js.map +7 -0
  80. package/dist-cjs/ui/mention-list.js +70 -0
  81. package/dist-cjs/ui/mention-list.js.map +7 -0
  82. package/dist-cjs/ui/mention-suggestion.js +206 -0
  83. package/dist-cjs/ui/mention-suggestion.js.map +7 -0
  84. package/dist-cjs/ui/mention.js +31 -0
  85. package/dist-cjs/ui/mention.js.map +7 -0
  86. package/dist-cjs/ui/reaction.js +31 -0
  87. package/dist-cjs/ui/reaction.js.map +7 -0
  88. package/dist-cjs/ui/reactions.js +34 -0
  89. package/dist-cjs/ui/reactions.js.map +7 -0
  90. package/dist-cjs/ui/render-markdown.js +63 -0
  91. package/dist-cjs/ui/render-markdown.js.map +7 -0
  92. package/dist-cjs/ui/send-button.js +28 -0
  93. package/dist-cjs/ui/send-button.js.map +7 -0
  94. package/dist-esm/canvas/cluster-input.mjs +20 -0
  95. package/dist-esm/canvas/cluster-input.mjs.map +7 -0
  96. package/dist-esm/canvas/comment-body.mjs +11 -0
  97. package/dist-esm/canvas/comment-body.mjs.map +7 -0
  98. package/dist-esm/canvas/comment-render.mjs +52 -0
  99. package/dist-esm/canvas/comment-render.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-store.mjs +28 -0
  101. package/dist-esm/canvas/comment-store.mjs.map +7 -0
  102. package/dist-esm/canvas/comment-tool.mjs +142 -0
  103. package/dist-esm/canvas/comment-tool.mjs.map +7 -0
  104. package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
  105. package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
  106. package/dist-esm/canvas/comments-menu-item.mjs +22 -0
  107. package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
  108. package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
  109. package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
  110. package/dist-esm/canvas/comments-overlay.mjs +1094 -0
  111. package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
  112. package/dist-esm/canvas/comments-sidebar.mjs +120 -0
  113. package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
  114. package/dist-esm/canvas/hooks.mjs +25 -0
  115. package/dist-esm/canvas/hooks.mjs.map +7 -0
  116. package/dist-esm/canvas/license.mjs +8 -0
  117. package/dist-esm/canvas/license.mjs.map +7 -0
  118. package/dist-esm/canvas/options.mjs +27 -0
  119. package/dist-esm/canvas/options.mjs.map +7 -0
  120. package/dist-esm/canvas/region-options.mjs +29 -0
  121. package/dist-esm/canvas/region-options.mjs.map +7 -0
  122. package/dist-esm/canvas/rich-text.mjs +8 -0
  123. package/dist-esm/canvas/rich-text.mjs.map +7 -0
  124. package/dist-esm/canvas/sidebar-filters.mjs +10 -0
  125. package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
  126. package/dist-esm/canvas/state.mjs +60 -0
  127. package/dist-esm/canvas/state.mjs.map +7 -0
  128. package/dist-esm/canvas/thread-state.mjs +61 -0
  129. package/dist-esm/canvas/thread-state.mjs.map +7 -0
  130. package/dist-esm/clustering/computeClusterTable.mjs +63 -0
  131. package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
  132. package/dist-esm/clustering/mst.mjs +97 -0
  133. package/dist-esm/clustering/mst.mjs.map +7 -0
  134. package/dist-esm/clustering/replay.mjs +255 -0
  135. package/dist-esm/clustering/replay.mjs.map +7 -0
  136. package/dist-esm/clustering/runtime.mjs +114 -0
  137. package/dist-esm/clustering/runtime.mjs.map +7 -0
  138. package/dist-esm/clustering/schedule.mjs +150 -0
  139. package/dist-esm/clustering/schedule.mjs.map +7 -0
  140. package/dist-esm/clustering/types.mjs +1 -0
  141. package/dist-esm/clustering/types.mjs.map +7 -0
  142. package/dist-esm/index.d.mts +868 -0
  143. package/dist-esm/index.mjs +139 -0
  144. package/dist-esm/index.mjs.map +7 -0
  145. package/dist-esm/ui/avatar.mjs +23 -0
  146. package/dist-esm/ui/avatar.mjs.map +7 -0
  147. package/dist-esm/ui/byline.mjs +15 -0
  148. package/dist-esm/ui/byline.mjs.map +7 -0
  149. package/dist-esm/ui/comment-card.mjs +17 -0
  150. package/dist-esm/ui/comment-card.mjs.map +7 -0
  151. package/dist-esm/ui/comment-composer.mjs +116 -0
  152. package/dist-esm/ui/comment-composer.mjs.map +7 -0
  153. package/dist-esm/ui/comment-extensions.mjs +17 -0
  154. package/dist-esm/ui/comment-extensions.mjs.map +7 -0
  155. package/dist-esm/ui/comment-mention.mjs +22 -0
  156. package/dist-esm/ui/comment-mention.mjs.map +7 -0
  157. package/dist-esm/ui/comment-pin.mjs +9 -0
  158. package/dist-esm/ui/comment-pin.mjs.map +7 -0
  159. package/dist-esm/ui/comment-text.mjs +9 -0
  160. package/dist-esm/ui/comment-text.mjs.map +7 -0
  161. package/dist-esm/ui/comment-thread.mjs +25 -0
  162. package/dist-esm/ui/comment-thread.mjs.map +7 -0
  163. package/dist-esm/ui/comments-list.mjs +86 -0
  164. package/dist-esm/ui/comments-list.mjs.map +7 -0
  165. package/dist-esm/ui/count-badge.mjs +8 -0
  166. package/dist-esm/ui/count-badge.mjs.map +7 -0
  167. package/dist-esm/ui/empty-state.mjs +11 -0
  168. package/dist-esm/ui/empty-state.mjs.map +7 -0
  169. package/dist-esm/ui/format-time.mjs +26 -0
  170. package/dist-esm/ui/format-time.mjs.map +7 -0
  171. package/dist-esm/ui/mention-list.mjs +50 -0
  172. package/dist-esm/ui/mention-list.mjs.map +7 -0
  173. package/dist-esm/ui/mention-suggestion.mjs +186 -0
  174. package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
  175. package/dist-esm/ui/mention.mjs +11 -0
  176. package/dist-esm/ui/mention.mjs.map +7 -0
  177. package/dist-esm/ui/reaction.mjs +11 -0
  178. package/dist-esm/ui/reaction.mjs.map +7 -0
  179. package/dist-esm/ui/reactions.mjs +14 -0
  180. package/dist-esm/ui/reactions.mjs.map +7 -0
  181. package/dist-esm/ui/render-markdown.mjs +45 -0
  182. package/dist-esm/ui/render-markdown.mjs.map +7 -0
  183. package/dist-esm/ui/send-button.mjs +8 -0
  184. package/dist-esm/ui/send-button.mjs.map +7 -0
  185. package/package.json +71 -0
  186. package/src/canvas/canvas.css +148 -0
  187. package/src/canvas/cluster-input.test.ts +223 -0
  188. package/src/canvas/cluster-input.ts +29 -0
  189. package/src/canvas/comment-body.tsx +21 -0
  190. package/src/canvas/comment-render.test.ts +90 -0
  191. package/src/canvas/comment-render.ts +92 -0
  192. package/src/canvas/comment-store.ts +55 -0
  193. package/src/canvas/comment-tool.tsx +200 -0
  194. package/src/canvas/comments-filter-menu.tsx +104 -0
  195. package/src/canvas/comments-menu-item.tsx +24 -0
  196. package/src/canvas/comments-overflow-menu.tsx +62 -0
  197. package/src/canvas/comments-overlay.tsx +1460 -0
  198. package/src/canvas/comments-sidebar.tsx +184 -0
  199. package/src/canvas/hooks.ts +28 -0
  200. package/src/canvas/license.ts +15 -0
  201. package/src/canvas/options.test.ts +127 -0
  202. package/src/canvas/options.ts +111 -0
  203. package/src/canvas/region-options.ts +57 -0
  204. package/src/canvas/rich-text.ts +15 -0
  205. package/src/canvas/sidebar-filters.ts +25 -0
  206. package/src/canvas/state.ts +102 -0
  207. package/src/canvas/thread-state.ts +91 -0
  208. package/src/clustering/computeClusterTable.test.ts +299 -0
  209. package/src/clustering/computeClusterTable.ts +82 -0
  210. package/src/clustering/locality.test.ts +189 -0
  211. package/src/clustering/mst.test.ts +383 -0
  212. package/src/clustering/mst.ts +134 -0
  213. package/src/clustering/replay.test.ts +547 -0
  214. package/src/clustering/replay.ts +326 -0
  215. package/src/clustering/runtime.test.ts +596 -0
  216. package/src/clustering/runtime.ts +153 -0
  217. package/src/clustering/schedule.test.ts +514 -0
  218. package/src/clustering/schedule.ts +184 -0
  219. package/src/clustering/types.ts +102 -0
  220. package/src/index.ts +96 -0
  221. package/src/ui/avatar.tsx +31 -0
  222. package/src/ui/byline.tsx +23 -0
  223. package/src/ui/comment-card.tsx +32 -0
  224. package/src/ui/comment-composer.tsx +179 -0
  225. package/src/ui/comment-extensions.ts +25 -0
  226. package/src/ui/comment-mention.ts +47 -0
  227. package/src/ui/comment-pin.tsx +21 -0
  228. package/src/ui/comment-text.tsx +12 -0
  229. package/src/ui/comment-thread.tsx +56 -0
  230. package/src/ui/comments-list.tsx +139 -0
  231. package/src/ui/comments.css +678 -0
  232. package/src/ui/count-badge.tsx +9 -0
  233. package/src/ui/empty-state.tsx +16 -0
  234. package/src/ui/format-time.ts +29 -0
  235. package/src/ui/mention-list.tsx +106 -0
  236. package/src/ui/mention-suggestion.test.ts +18 -0
  237. package/src/ui/mention-suggestion.tsx +285 -0
  238. package/src/ui/mention.tsx +9 -0
  239. package/src/ui/reaction.tsx +16 -0
  240. package/src/ui/reactions.tsx +15 -0
  241. package/src/ui/render-markdown.tsx +72 -0
  242. package/src/ui/send-button.tsx +15 -0
@@ -0,0 +1,383 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mstEdges } from './mst'
3
+ import { LeafInput, MstEdge } from './types'
4
+
5
+ function leaf(id: string, x: number, y: number): LeafInput {
6
+ return { id, point: { x, y } }
7
+ }
8
+
9
+ /** An edge as a canonical `idLo|idHi` string (output edges are already id-normalized). */
10
+ function pairKey(e: MstEdge, leaves: readonly LeafInput[]): string {
11
+ return `${leaves[e.a].id}|${leaves[e.b].id}`
12
+ }
13
+
14
+ function pairKeySet(edges: MstEdge[], leaves: readonly LeafInput[]): Set<string> {
15
+ return new Set(edges.map((e) => pairKey(e, leaves)))
16
+ }
17
+
18
+ /** Map of `idLo|idHi` → distance, for order-independent comparisons across shuffles. */
19
+ function pairDistanceMap(edges: MstEdge[], leaves: readonly LeafInput[]): Map<string, number> {
20
+ return new Map(edges.map((e) => [pairKey(e, leaves), e.d]))
21
+ }
22
+
23
+ /** Deterministic PRNG so random tests are reproducible. */
24
+ function mulberry32(seed: number): () => number {
25
+ let a = seed >>> 0
26
+ return () => {
27
+ a = (a + 0x6d2b79f5) >>> 0
28
+ let t = a
29
+ t = Math.imul(t ^ (t >>> 15), t | 1)
30
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
31
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
32
+ }
33
+ }
34
+
35
+ function shuffled<T>(items: readonly T[], rand: () => number): T[] {
36
+ const out = items.slice()
37
+ for (let i = out.length - 1; i > 0; i--) {
38
+ const j = Math.floor(rand() * (i + 1))
39
+ ;[out[i], out[j]] = [out[j], out[i]]
40
+ }
41
+ return out
42
+ }
43
+
44
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
45
+ const rand = mulberry32(seed)
46
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
47
+ }
48
+
49
+ class UnionFind {
50
+ private parent: number[]
51
+ constructor(n: number) {
52
+ this.parent = Array.from({ length: n }, (_, i) => i)
53
+ }
54
+ find(x: number): number {
55
+ while (this.parent[x] !== x) {
56
+ this.parent[x] = this.parent[this.parent[x]]
57
+ x = this.parent[x]
58
+ }
59
+ return x
60
+ }
61
+ union(a: number, b: number): boolean {
62
+ const ra = this.find(a)
63
+ const rb = this.find(b)
64
+ if (ra === rb) return false
65
+ this.parent[ra] = rb
66
+ return true
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Reference oracle: Kruskal over the complete graph under the contract's strict edge
72
+ * order — (distance, idLo, idHi). Distances are compared as squares, which orders
73
+ * identically to true distances and avoids sqrt rounding. This is the definition the
74
+ * implementation must reproduce exactly, ties and all.
75
+ */
76
+ function referenceKruskal(leaves: readonly LeafInput[]): Set<string> {
77
+ const n = leaves.length
78
+ if (n < 2) return new Set()
79
+ const all: { i: number; j: number; d2: number; idLo: string; idHi: string }[] = []
80
+ for (let i = 0; i < n; i++) {
81
+ for (let j = i + 1; j < n; j++) {
82
+ const dx = leaves[i].point.x - leaves[j].point.x
83
+ const dy = leaves[i].point.y - leaves[j].point.y
84
+ const [idLo, idHi] =
85
+ leaves[i].id < leaves[j].id ? [leaves[i].id, leaves[j].id] : [leaves[j].id, leaves[i].id]
86
+ all.push({ i, j, d2: dx * dx + dy * dy, idLo, idHi })
87
+ }
88
+ }
89
+ all.sort((a, b) => {
90
+ if (a.d2 !== b.d2) return a.d2 - b.d2
91
+ if (a.idLo !== b.idLo) return a.idLo < b.idLo ? -1 : 1
92
+ return a.idHi < b.idHi ? -1 : 1
93
+ })
94
+ const uf = new UnionFind(n)
95
+ const accepted = new Set<string>()
96
+ for (const e of all) {
97
+ if (uf.union(e.i, e.j)) {
98
+ accepted.add(`${e.idLo}|${e.idHi}`)
99
+ if (accepted.size === n - 1) break
100
+ }
101
+ }
102
+ return accepted
103
+ }
104
+
105
+ /** Assert the structural contract: n−1 edges, spanning, no duplicate pairs, valid endpoints. */
106
+ function expectSpanningTree(edges: MstEdge[], leaves: readonly LeafInput[]) {
107
+ const n = leaves.length
108
+ expect(edges).toHaveLength(Math.max(0, n - 1))
109
+ const uf = new UnionFind(n)
110
+ const seen = new Set<string>()
111
+ for (const e of edges) {
112
+ expect(Number.isInteger(e.a)).toBe(true)
113
+ expect(Number.isInteger(e.b)).toBe(true)
114
+ expect(e.a).toBeGreaterThanOrEqual(0)
115
+ expect(e.b).toBeGreaterThanOrEqual(0)
116
+ expect(e.a).toBeLessThan(n)
117
+ expect(e.b).toBeLessThan(n)
118
+ expect(e.a).not.toBe(e.b)
119
+ const key = pairKey(e, leaves)
120
+ expect(seen.has(key)).toBe(false)
121
+ seen.add(key)
122
+ // n−1 accepted unions with no rejected ones ⇒ connected and acyclic
123
+ expect(uf.union(e.a, e.b)).toBe(true)
124
+ }
125
+ }
126
+
127
+ /** Assert output ordering (d, then idLo, then idHi) and endpoint id-normalization. */
128
+ function expectSortedAndNormalized(edges: MstEdge[], leaves: readonly LeafInput[]) {
129
+ for (const e of edges) {
130
+ expect(leaves[e.a].id < leaves[e.b].id).toBe(true)
131
+ }
132
+ for (let i = 1; i < edges.length; i++) {
133
+ const prev = edges[i - 1]
134
+ const curr = edges[i]
135
+ if (prev.d !== curr.d) {
136
+ expect(prev.d).toBeLessThan(curr.d)
137
+ continue
138
+ }
139
+ const prevLo = leaves[prev.a].id
140
+ const currLo = leaves[curr.a].id
141
+ if (prevLo !== currLo) {
142
+ expect(prevLo < currLo).toBe(true)
143
+ continue
144
+ }
145
+ expect(leaves[prev.b].id < leaves[curr.b].id).toBe(true)
146
+ }
147
+ }
148
+
149
+ /** Assert each edge's distance is the exact Math.hypot of its endpoints. */
150
+ function expectExactDistances(edges: MstEdge[], leaves: readonly LeafInput[]) {
151
+ for (const e of edges) {
152
+ const expected = Math.hypot(
153
+ leaves[e.a].point.x - leaves[e.b].point.x,
154
+ leaves[e.a].point.y - leaves[e.b].point.y
155
+ )
156
+ expect(e.d).toBe(expected)
157
+ }
158
+ }
159
+
160
+ describe('mstEdges fixtures', () => {
161
+ it('returns [] for zero and one leaves', () => {
162
+ expect(mstEdges([])).toEqual([])
163
+ expect(mstEdges([leaf('only', 5, 5)])).toEqual([])
164
+ })
165
+
166
+ it('connects two points with one exact-distance edge', () => {
167
+ const leaves = [leaf('a', 0, 0), leaf('b', 3, 4)]
168
+ expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 5 }])
169
+ })
170
+
171
+ it('normalizes endpoints by id, not input order', () => {
172
+ // 'b' comes first in the array; the edge must still point a→'a', b→'b'
173
+ const leaves = [leaf('b', 3, 4), leaf('a', 0, 0)]
174
+ expect(mstEdges(leaves)).toEqual([{ a: 1, b: 0, d: 5 }])
175
+ })
176
+
177
+ it('handles coincident points with a d = 0 edge', () => {
178
+ const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
179
+ expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 0 }])
180
+ })
181
+
182
+ it('builds the path MST for collinear points with distinct gaps, sorted ascending', () => {
183
+ // x positions 0, 1, 3, 7 → consecutive gaps 1, 2, 4; no skip edge can beat them
184
+ const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 3, 0), leaf('d', 7, 0)]
185
+ expect(mstEdges(leaves)).toEqual([
186
+ { a: 0, b: 1, d: 1 },
187
+ { a: 1, b: 2, d: 2 },
188
+ { a: 2, b: 3, d: 4 },
189
+ ])
190
+ })
191
+
192
+ it('breaks four-way side ties of the unit square by id order', () => {
193
+ // All four sides have d = 1. Kruskal under (d, idLo, idHi) accepts
194
+ // (a,b), then (a,d), then (b,c); (c,d) would close the cycle.
195
+ const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 1, 1), leaf('d', 0, 1)]
196
+ expect(mstEdges(leaves)).toEqual([
197
+ { a: 0, b: 1, d: 1 },
198
+ { a: 0, b: 3, d: 1 },
199
+ { a: 1, b: 2, d: 1 },
200
+ ])
201
+ })
202
+
203
+ it('builds the path MST for the uniform chain (the clustering worst case)', () => {
204
+ // 8 pins spaced 10 apart — CLUSTERING.md appendix A.2. All 7 edges d = 10.
205
+ const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
206
+ const edges = mstEdges(leaves)
207
+ expect(edges.map((e) => pairKey(e, leaves))).toEqual([
208
+ 'p1|p2',
209
+ 'p2|p3',
210
+ 'p3|p4',
211
+ 'p4|p5',
212
+ 'p5|p6',
213
+ 'p6|p7',
214
+ 'p7|p8',
215
+ ])
216
+ for (const e of edges) expect(e.d).toBe(10)
217
+ })
218
+
219
+ it('crosses a dumbbell gap exactly once, via the closest pair (cut property)', () => {
220
+ // Two tight clusters far apart; l2/r0 is the unique closest crossing pair.
221
+ const leaves = [
222
+ leaf('l0', 0, 0),
223
+ leaf('l1', 2, 1),
224
+ leaf('l2', 4, 0),
225
+ leaf('r0', 100, 0),
226
+ leaf('r1', 102, 1),
227
+ leaf('r2', 104, 0),
228
+ ]
229
+ const edges = mstEdges(leaves)
230
+ const crossings = edges.filter(
231
+ (e) => leaves[e.a].id.startsWith('l') !== leaves[e.b].id.startsWith('l')
232
+ )
233
+ expect(crossings).toHaveLength(1)
234
+ expect(pairKey(crossings[0], leaves)).toBe('l2|r0')
235
+ expect(crossings[0].d).toBe(96)
236
+ })
237
+
238
+ it('chains coincident points with d = 0 edges plus one bridge', () => {
239
+ const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 5, 5), leaf('far', 105, 5)]
240
+ const edges = mstEdges(leaves)
241
+ const zeros = edges.filter((e) => e.d === 0)
242
+ const bridges = edges.filter((e) => e.d !== 0)
243
+ expect(zeros).toHaveLength(2)
244
+ expect(bridges).toHaveLength(1)
245
+ expect(bridges[0].d).toBe(100)
246
+ // zero edges come first in the ascending sort, tie-broken by id: (a,b) then (a,c)
247
+ expect(edges.slice(0, 2).map((e) => pairKey(e, leaves))).toEqual(['a|b', 'a|c'])
248
+ })
249
+ })
250
+
251
+ describe('mstEdges vs reference Kruskal oracle', () => {
252
+ it.each([2, 3, 5, 10, 50, 120])('matches the oracle on %i random points', (n) => {
253
+ for (const seed of [1, 42, 1234]) {
254
+ const leaves = randomLeaves(n, seed * 31 + n)
255
+ const edges = mstEdges(leaves)
256
+ expectSpanningTree(edges, leaves)
257
+ expectSortedAndNormalized(edges, leaves)
258
+ expectExactDistances(edges, leaves)
259
+ expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
260
+ }
261
+ })
262
+
263
+ it('matches the oracle on a 5×5 integer grid (maximal distance ties)', () => {
264
+ const leaves: LeafInput[] = []
265
+ for (let y = 0; y < 5; y++) {
266
+ for (let x = 0; x < 5; x++) {
267
+ leaves.push(leaf(`g-${x}-${y}`, x, y))
268
+ }
269
+ }
270
+ const edges = mstEdges(leaves)
271
+ expectSpanningTree(edges, leaves)
272
+ expectSortedAndNormalized(edges, leaves)
273
+ // every grid MST edge is an axis-aligned unit step
274
+ for (const e of edges) expect(e.d).toBe(1)
275
+ expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
276
+ })
277
+
278
+ it('matches the oracle across realistic canvas coordinate regimes', () => {
279
+ // Canvas pages routinely have negative coordinates (panning left/up of the
280
+ // origin), large offsets, and tightly packed points — none of which the
281
+ // uniform [0, 1000]² sets exercise.
282
+ const regimes: { name: string; make(rand: () => number, i: number): LeafInput }[] = [
283
+ {
284
+ name: 'mixed-sign',
285
+ make: (rand, i) => leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000),
286
+ },
287
+ {
288
+ name: 'far-offset',
289
+ make: (rand, i) => leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000),
290
+ },
291
+ {
292
+ name: 'tiny-scale',
293
+ make: (rand, i) => leaf(`t-${i}`, rand() * 0.001, rand() * 0.001),
294
+ },
295
+ ]
296
+ for (const regime of regimes) {
297
+ const rand = mulberry32(101)
298
+ const leaves = Array.from({ length: 60 }, (_, i) => regime.make(rand, i))
299
+ const edges = mstEdges(leaves)
300
+ expectSpanningTree(edges, leaves)
301
+ expectSortedAndNormalized(edges, leaves)
302
+ expectExactDistances(edges, leaves)
303
+ expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
304
+ }
305
+ })
306
+
307
+ it('matches the oracle on random lattice points (ties + coincident points)', () => {
308
+ const rand = mulberry32(7)
309
+ const leaves = Array.from({ length: 60 }, (_, i) =>
310
+ leaf(`lat-${i}`, Math.floor(rand() * 10), Math.floor(rand() * 10))
311
+ )
312
+ const edges = mstEdges(leaves)
313
+ expectSpanningTree(edges, leaves)
314
+ expectSortedAndNormalized(edges, leaves)
315
+ expectExactDistances(edges, leaves)
316
+ expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
317
+ })
318
+ })
319
+
320
+ describe('mstEdges determinism', () => {
321
+ it('is invariant under input permutation (as an id-pair → distance map)', () => {
322
+ const base = randomLeaves(80, 99)
323
+ const expected = pairDistanceMap(mstEdges(base), base)
324
+ for (const shuffleSeed of [5, 17, 23]) {
325
+ const perm = shuffled(base, mulberry32(shuffleSeed))
326
+ expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
327
+ }
328
+ })
329
+
330
+ it('is invariant under permutation even with heavy ties', () => {
331
+ // Uniform chains have all-equal edge lengths — tie-breaking must be id-based,
332
+ // never index-based, or a shuffled rebuild would produce a different tree.
333
+ const base = Array.from({ length: 12 }, (_, i) =>
334
+ leaf(`c${String(i).padStart(2, '0')}`, i * 10, 0)
335
+ )
336
+ const expected = pairDistanceMap(mstEdges(base), base)
337
+ for (const shuffleSeed of [3, 11]) {
338
+ const perm = shuffled(base, mulberry32(shuffleSeed))
339
+ expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
340
+ }
341
+ })
342
+
343
+ it('returns identical output for repeated calls on the same input', () => {
344
+ const leaves = randomLeaves(40, 7)
345
+ expect(mstEdges(leaves)).toEqual(mstEdges(leaves))
346
+ })
347
+
348
+ it('does not mutate its input', () => {
349
+ const leaves = randomLeaves(10, 3)
350
+ const snapshot = JSON.parse(JSON.stringify(leaves))
351
+ mstEdges(leaves)
352
+ expect(leaves).toEqual(snapshot)
353
+ })
354
+ })
355
+
356
+ describe('mstEdges validation', () => {
357
+ it('throws on duplicate ids, naming the duplicate', () => {
358
+ const leaves = [leaf('a', 0, 0), leaf('dup', 1, 0), leaf('dup', 2, 0)]
359
+ expect(() => mstEdges(leaves)).toThrow(/dup/)
360
+ })
361
+
362
+ it('throws on non-finite coordinates, naming the leaf', () => {
363
+ expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-x', NaN, 0)])).toThrow(/bad-x/)
364
+ expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-y', 0, Infinity)])).toThrow(/bad-y/)
365
+ expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-neg', 0, -Infinity)])).toThrow(/bad-neg/)
366
+ })
367
+
368
+ it('fails fast on invalid input even when n < 2', () => {
369
+ expect(() => mstEdges([leaf('bad', NaN, NaN)])).toThrow(/bad/)
370
+ })
371
+ })
372
+
373
+ describe('mstEdges performance', () => {
374
+ it('handles 4,000 points well within budget', () => {
375
+ const leaves = randomLeaves(4000, 2026)
376
+ const start = performance.now()
377
+ const edges = mstEdges(leaves)
378
+ const elapsed = performance.now() - start
379
+ expect(edges).toHaveLength(3999)
380
+ // generous bound to stay non-flaky on slow CI; typical runs are ~100× faster
381
+ expect(elapsed).toBeLessThan(2000)
382
+ })
383
+ })
@@ -0,0 +1,134 @@
1
+ import type { LeafInput, MstEdge } from './types'
2
+
3
+ /**
4
+ * Return the Euclidean MST over the leaves, ordered by the canonical edge key:
5
+ * distance, then normalized endpoint ids.
6
+ */
7
+ export function mstEdges(leaves: readonly LeafInput[]): MstEdge[] {
8
+ const n = leaves.length
9
+ const ids = new Array<string>(n)
10
+ const xs = new Float64Array(n)
11
+ const ys = new Float64Array(n)
12
+ const seen = new Set<string>()
13
+
14
+ let root = 0
15
+ for (let i = 0; i < n; i++) {
16
+ const leaf = leaves[i]
17
+ if (seen.has(leaf.id)) {
18
+ throw new Error(`Duplicate leaf id: ${leaf.id}`)
19
+ }
20
+ seen.add(leaf.id)
21
+ if (!Number.isFinite(leaf.point.x) || !Number.isFinite(leaf.point.y)) {
22
+ throw new Error(`Non-finite coordinate for leaf id: ${leaf.id}`)
23
+ }
24
+ ids[i] = leaf.id
25
+ xs[i] = leaf.point.x
26
+ ys[i] = leaf.point.y
27
+ if (i > 0 && leaf.id < ids[root]) root = i
28
+ }
29
+
30
+ if (n < 2) return []
31
+
32
+ const inTree = new Uint8Array(n)
33
+ const bestD2 = new Float64Array(n)
34
+ const bestFrom = new Int32Array(n)
35
+
36
+ inTree[root] = 1
37
+ bestD2.fill(Number.POSITIVE_INFINITY)
38
+ bestFrom.fill(-1)
39
+
40
+ for (let i = 0; i < n; i++) {
41
+ if (i === root) continue
42
+ bestD2[i] = squaredDistance(xs, ys, root, i)
43
+ bestFrom[i] = root
44
+ }
45
+
46
+ const edges: MstEdge[] = []
47
+
48
+ for (let edgeCount = 0; edgeCount < n - 1; edgeCount++) {
49
+ let next = -1
50
+ for (let i = 0; i < n; i++) {
51
+ if (inTree[i]) continue
52
+ if (
53
+ next === -1 ||
54
+ edgeKeyLess(bestD2[i], bestFrom[i], i, bestD2[next], bestFrom[next], next, ids)
55
+ ) {
56
+ next = i
57
+ }
58
+ }
59
+
60
+ const from = bestFrom[next]
61
+ edges.push(createOutputEdge(xs, ys, ids, from, next))
62
+ inTree[next] = 1
63
+
64
+ for (let i = 0; i < n; i++) {
65
+ if (inTree[i]) continue
66
+ const d2 = squaredDistance(xs, ys, next, i)
67
+ if (edgeKeyLess(d2, next, i, bestD2[i], bestFrom[i], i, ids)) {
68
+ bestD2[i] = d2
69
+ bestFrom[i] = next
70
+ }
71
+ }
72
+ }
73
+
74
+ edges.sort((a, b) => outputEdgeCompare(a, b, leaves))
75
+ return edges
76
+ }
77
+
78
+ function squaredDistance(xs: Float64Array, ys: Float64Array, a: number, b: number): number {
79
+ const dx = xs[a] - xs[b]
80
+ const dy = ys[a] - ys[b]
81
+ return dx * dx + dy * dy
82
+ }
83
+
84
+ function edgeKeyLess(
85
+ d2A: number,
86
+ a0: number,
87
+ a1: number,
88
+ d2B: number,
89
+ b0: number,
90
+ b1: number,
91
+ ids: readonly string[]
92
+ ): boolean {
93
+ if (d2A !== d2B) return d2A < d2B
94
+
95
+ const aLo = ids[a0] < ids[a1] ? ids[a0] : ids[a1]
96
+ const aHi = ids[a0] < ids[a1] ? ids[a1] : ids[a0]
97
+ const bLo = ids[b0] < ids[b1] ? ids[b0] : ids[b1]
98
+ const bHi = ids[b0] < ids[b1] ? ids[b1] : ids[b0]
99
+
100
+ if (aLo !== bLo) return aLo < bLo
101
+ return aHi < bHi
102
+ }
103
+
104
+ function createOutputEdge(
105
+ xs: Float64Array,
106
+ ys: Float64Array,
107
+ ids: readonly string[],
108
+ i: number,
109
+ j: number
110
+ ): MstEdge {
111
+ const a = ids[i] < ids[j] ? i : j
112
+ const b = ids[i] < ids[j] ? j : i
113
+ return {
114
+ a,
115
+ b,
116
+ d: Math.hypot(xs[a] - xs[b], ys[a] - ys[b]),
117
+ }
118
+ }
119
+
120
+ function outputEdgeCompare(a: MstEdge, b: MstEdge, leaves: readonly LeafInput[]): number {
121
+ if (a.d < b.d) return -1
122
+ if (a.d > b.d) return 1
123
+
124
+ const aLo = leaves[a.a].id
125
+ const aHi = leaves[a.b].id
126
+ const bLo = leaves[b.a].id
127
+ const bHi = leaves[b.b].id
128
+
129
+ if (aLo < bLo) return -1
130
+ if (aLo > bLo) return 1
131
+ if (aHi < bHi) return -1
132
+ if (aHi > bHi) return 1
133
+ return 0
134
+ }