@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.
- package/README.md +11 -0
- package/commenting.css +832 -0
- package/dist-cjs/canvas/cluster-input.js +40 -0
- package/dist-cjs/canvas/cluster-input.js.map +7 -0
- package/dist-cjs/canvas/comment-body.js +31 -0
- package/dist-cjs/canvas/comment-body.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +72 -0
- package/dist-cjs/canvas/comment-render.js.map +7 -0
- package/dist-cjs/canvas/comment-store.js +48 -0
- package/dist-cjs/canvas/comment-store.js.map +7 -0
- package/dist-cjs/canvas/comment-tool.js +160 -0
- package/dist-cjs/canvas/comment-tool.js.map +7 -0
- package/dist-cjs/canvas/comments-filter-menu.js +109 -0
- package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-menu-item.js +42 -0
- package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
- package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
- package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-overlay.js +1081 -0
- package/dist-cjs/canvas/comments-overlay.js.map +7 -0
- package/dist-cjs/canvas/comments-sidebar.js +134 -0
- package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +45 -0
- package/dist-cjs/canvas/hooks.js.map +7 -0
- package/dist-cjs/canvas/license.js +28 -0
- package/dist-cjs/canvas/license.js.map +7 -0
- package/dist-cjs/canvas/options.js +45 -0
- package/dist-cjs/canvas/options.js.map +7 -0
- package/dist-cjs/canvas/region-options.js +49 -0
- package/dist-cjs/canvas/region-options.js.map +7 -0
- package/dist-cjs/canvas/rich-text.js +28 -0
- package/dist-cjs/canvas/rich-text.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +30 -0
- package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
- package/dist-cjs/canvas/state.js +76 -0
- package/dist-cjs/canvas/state.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +81 -0
- package/dist-cjs/canvas/thread-state.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +83 -0
- package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
- package/dist-cjs/clustering/mst.js +117 -0
- package/dist-cjs/clustering/mst.js.map +7 -0
- package/dist-cjs/clustering/replay.js +275 -0
- package/dist-cjs/clustering/replay.js.map +7 -0
- package/dist-cjs/clustering/runtime.js +134 -0
- package/dist-cjs/clustering/runtime.js.map +7 -0
- package/dist-cjs/clustering/schedule.js +170 -0
- package/dist-cjs/clustering/schedule.js.map +7 -0
- package/dist-cjs/clustering/types.js +17 -0
- package/dist-cjs/clustering/types.js.map +7 -0
- package/dist-cjs/index.d.ts +868 -0
- package/dist-cjs/index.js +126 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/ui/avatar.js +43 -0
- package/dist-cjs/ui/avatar.js.map +7 -0
- package/dist-cjs/ui/byline.js +35 -0
- package/dist-cjs/ui/byline.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +37 -0
- package/dist-cjs/ui/comment-card.js.map +7 -0
- package/dist-cjs/ui/comment-composer.js +136 -0
- package/dist-cjs/ui/comment-composer.js.map +7 -0
- package/dist-cjs/ui/comment-extensions.js +37 -0
- package/dist-cjs/ui/comment-extensions.js.map +7 -0
- package/dist-cjs/ui/comment-mention.js +42 -0
- package/dist-cjs/ui/comment-mention.js.map +7 -0
- package/dist-cjs/ui/comment-pin.js +29 -0
- package/dist-cjs/ui/comment-pin.js.map +7 -0
- package/dist-cjs/ui/comment-text.js +29 -0
- package/dist-cjs/ui/comment-text.js.map +7 -0
- package/dist-cjs/ui/comment-thread.js +45 -0
- package/dist-cjs/ui/comment-thread.js.map +7 -0
- package/dist-cjs/ui/comments-list.js +106 -0
- package/dist-cjs/ui/comments-list.js.map +7 -0
- package/dist-cjs/ui/count-badge.js +28 -0
- package/dist-cjs/ui/count-badge.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +31 -0
- package/dist-cjs/ui/empty-state.js.map +7 -0
- package/dist-cjs/ui/format-time.js +46 -0
- package/dist-cjs/ui/format-time.js.map +7 -0
- package/dist-cjs/ui/mention-list.js +70 -0
- package/dist-cjs/ui/mention-list.js.map +7 -0
- package/dist-cjs/ui/mention-suggestion.js +206 -0
- package/dist-cjs/ui/mention-suggestion.js.map +7 -0
- package/dist-cjs/ui/mention.js +31 -0
- package/dist-cjs/ui/mention.js.map +7 -0
- package/dist-cjs/ui/reaction.js +31 -0
- package/dist-cjs/ui/reaction.js.map +7 -0
- package/dist-cjs/ui/reactions.js +34 -0
- package/dist-cjs/ui/reactions.js.map +7 -0
- package/dist-cjs/ui/render-markdown.js +63 -0
- package/dist-cjs/ui/render-markdown.js.map +7 -0
- package/dist-cjs/ui/send-button.js +28 -0
- package/dist-cjs/ui/send-button.js.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +20 -0
- package/dist-esm/canvas/cluster-input.mjs.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +11 -0
- package/dist-esm/canvas/comment-body.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +52 -0
- package/dist-esm/canvas/comment-render.mjs.map +7 -0
- package/dist-esm/canvas/comment-store.mjs +28 -0
- package/dist-esm/canvas/comment-store.mjs.map +7 -0
- package/dist-esm/canvas/comment-tool.mjs +142 -0
- package/dist-esm/canvas/comment-tool.mjs.map +7 -0
- package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
- package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-menu-item.mjs +22 -0
- package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-overlay.mjs +1094 -0
- package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
- package/dist-esm/canvas/comments-sidebar.mjs +120 -0
- package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +25 -0
- package/dist-esm/canvas/hooks.mjs.map +7 -0
- package/dist-esm/canvas/license.mjs +8 -0
- package/dist-esm/canvas/license.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +27 -0
- package/dist-esm/canvas/options.mjs.map +7 -0
- package/dist-esm/canvas/region-options.mjs +29 -0
- package/dist-esm/canvas/region-options.mjs.map +7 -0
- package/dist-esm/canvas/rich-text.mjs +8 -0
- package/dist-esm/canvas/rich-text.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +10 -0
- package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs +60 -0
- package/dist-esm/canvas/state.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +61 -0
- package/dist-esm/canvas/thread-state.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +63 -0
- package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
- package/dist-esm/clustering/mst.mjs +97 -0
- package/dist-esm/clustering/mst.mjs.map +7 -0
- package/dist-esm/clustering/replay.mjs +255 -0
- package/dist-esm/clustering/replay.mjs.map +7 -0
- package/dist-esm/clustering/runtime.mjs +114 -0
- package/dist-esm/clustering/runtime.mjs.map +7 -0
- package/dist-esm/clustering/schedule.mjs +150 -0
- package/dist-esm/clustering/schedule.mjs.map +7 -0
- package/dist-esm/clustering/types.mjs +1 -0
- package/dist-esm/clustering/types.mjs.map +7 -0
- package/dist-esm/index.d.mts +868 -0
- package/dist-esm/index.mjs +139 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/ui/avatar.mjs +23 -0
- package/dist-esm/ui/avatar.mjs.map +7 -0
- package/dist-esm/ui/byline.mjs +15 -0
- package/dist-esm/ui/byline.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +17 -0
- package/dist-esm/ui/comment-card.mjs.map +7 -0
- package/dist-esm/ui/comment-composer.mjs +116 -0
- package/dist-esm/ui/comment-composer.mjs.map +7 -0
- package/dist-esm/ui/comment-extensions.mjs +17 -0
- package/dist-esm/ui/comment-extensions.mjs.map +7 -0
- package/dist-esm/ui/comment-mention.mjs +22 -0
- package/dist-esm/ui/comment-mention.mjs.map +7 -0
- package/dist-esm/ui/comment-pin.mjs +9 -0
- package/dist-esm/ui/comment-pin.mjs.map +7 -0
- package/dist-esm/ui/comment-text.mjs +9 -0
- package/dist-esm/ui/comment-text.mjs.map +7 -0
- package/dist-esm/ui/comment-thread.mjs +25 -0
- package/dist-esm/ui/comment-thread.mjs.map +7 -0
- package/dist-esm/ui/comments-list.mjs +86 -0
- package/dist-esm/ui/comments-list.mjs.map +7 -0
- package/dist-esm/ui/count-badge.mjs +8 -0
- package/dist-esm/ui/count-badge.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +11 -0
- package/dist-esm/ui/empty-state.mjs.map +7 -0
- package/dist-esm/ui/format-time.mjs +26 -0
- package/dist-esm/ui/format-time.mjs.map +7 -0
- package/dist-esm/ui/mention-list.mjs +50 -0
- package/dist-esm/ui/mention-list.mjs.map +7 -0
- package/dist-esm/ui/mention-suggestion.mjs +186 -0
- package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
- package/dist-esm/ui/mention.mjs +11 -0
- package/dist-esm/ui/mention.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +11 -0
- package/dist-esm/ui/reaction.mjs.map +7 -0
- package/dist-esm/ui/reactions.mjs +14 -0
- package/dist-esm/ui/reactions.mjs.map +7 -0
- package/dist-esm/ui/render-markdown.mjs +45 -0
- package/dist-esm/ui/render-markdown.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +8 -0
- package/dist-esm/ui/send-button.mjs.map +7 -0
- package/package.json +71 -0
- package/src/canvas/canvas.css +148 -0
- package/src/canvas/cluster-input.test.ts +223 -0
- package/src/canvas/cluster-input.ts +29 -0
- package/src/canvas/comment-body.tsx +21 -0
- package/src/canvas/comment-render.test.ts +90 -0
- package/src/canvas/comment-render.ts +92 -0
- package/src/canvas/comment-store.ts +55 -0
- package/src/canvas/comment-tool.tsx +200 -0
- package/src/canvas/comments-filter-menu.tsx +104 -0
- package/src/canvas/comments-menu-item.tsx +24 -0
- package/src/canvas/comments-overflow-menu.tsx +62 -0
- package/src/canvas/comments-overlay.tsx +1460 -0
- package/src/canvas/comments-sidebar.tsx +184 -0
- package/src/canvas/hooks.ts +28 -0
- package/src/canvas/license.ts +15 -0
- package/src/canvas/options.test.ts +127 -0
- package/src/canvas/options.ts +111 -0
- package/src/canvas/region-options.ts +57 -0
- package/src/canvas/rich-text.ts +15 -0
- package/src/canvas/sidebar-filters.ts +25 -0
- package/src/canvas/state.ts +102 -0
- package/src/canvas/thread-state.ts +91 -0
- package/src/clustering/computeClusterTable.test.ts +299 -0
- package/src/clustering/computeClusterTable.ts +82 -0
- package/src/clustering/locality.test.ts +189 -0
- package/src/clustering/mst.test.ts +383 -0
- package/src/clustering/mst.ts +134 -0
- package/src/clustering/replay.test.ts +547 -0
- package/src/clustering/replay.ts +326 -0
- package/src/clustering/runtime.test.ts +596 -0
- package/src/clustering/runtime.ts +153 -0
- package/src/clustering/schedule.test.ts +514 -0
- package/src/clustering/schedule.ts +184 -0
- package/src/clustering/types.ts +102 -0
- package/src/index.ts +96 -0
- package/src/ui/avatar.tsx +31 -0
- package/src/ui/byline.tsx +23 -0
- package/src/ui/comment-card.tsx +32 -0
- package/src/ui/comment-composer.tsx +179 -0
- package/src/ui/comment-extensions.ts +25 -0
- package/src/ui/comment-mention.ts +47 -0
- package/src/ui/comment-pin.tsx +21 -0
- package/src/ui/comment-text.tsx +12 -0
- package/src/ui/comment-thread.tsx +56 -0
- package/src/ui/comments-list.tsx +139 -0
- package/src/ui/comments.css +678 -0
- package/src/ui/count-badge.tsx +9 -0
- package/src/ui/empty-state.tsx +16 -0
- package/src/ui/format-time.ts +29 -0
- package/src/ui/mention-list.tsx +106 -0
- package/src/ui/mention-suggestion.test.ts +18 -0
- package/src/ui/mention-suggestion.tsx +285 -0
- package/src/ui/mention.tsx +9 -0
- package/src/ui/reaction.tsx +16 -0
- package/src/ui/reactions.tsx +15 -0
- package/src/ui/render-markdown.tsx +72 -0
- 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
|
+
}
|