@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,596 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mstEdges } from './mst'
3
+ import { cappedReplay } from './replay'
4
+ import { createClusterRuntime } from './runtime'
5
+ import { contract, finalize } from './schedule'
6
+ import { ClusterNode, ClusterTable, LeafInput, MergeEvent } from './types'
7
+
8
+ // --- Synthetic builders --------------------------------------------------------
9
+
10
+ function node(ids: string[], x = 0, y = 0): ClusterNode {
11
+ const members = ids.slice().sort()
12
+ return {
13
+ id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
14
+ centroid: { x, y },
15
+ count: members.length,
16
+ members,
17
+ }
18
+ }
19
+
20
+ function mev(
21
+ zMerge: number,
22
+ zSplit: number,
23
+ children: ClusterNode[],
24
+ result: ClusterNode
25
+ ): MergeEvent {
26
+ return { zMerge, zSplit, children, result }
27
+ }
28
+
29
+ function visibleIds(runtime: { getVisible(): ReadonlyMap<string, ClusterNode> }): string[] {
30
+ return [...runtime.getVisible().keys()].sort()
31
+ }
32
+
33
+ /** Reference derivation: start from all leaves, apply events[0..k) in order. */
34
+ function referenceVisible(table: ClusterTable, k: number): Map<string, ClusterNode> {
35
+ const map = new Map(table.leaves.map((l) => [l.id, l]))
36
+ for (let i = 0; i < k; i++) {
37
+ const ev = table.events[i]
38
+ for (const child of ev.children) map.delete(child.id)
39
+ map.set(ev.result.id, ev.result)
40
+ }
41
+ return map
42
+ }
43
+
44
+ /** Contract clause 3 postconditions, asserted after every seed/onCamera. */
45
+ function expectPostconditions(
46
+ runtime: { readonly k: number; getVisible(): ReadonlyMap<string, ClusterNode> },
47
+ table: ClusterTable,
48
+ zoom: number
49
+ ) {
50
+ const k = runtime.k
51
+ expect(k).toBeGreaterThanOrEqual(0)
52
+ expect(k).toBeLessThanOrEqual(table.events.length)
53
+ if (k < table.events.length) expect(zoom).toBeGreaterThan(table.events[k].zMerge)
54
+ if (k > 0) expect(zoom).toBeLessThan(table.events[k - 1].zSplit)
55
+ const expected = referenceVisible(table, k)
56
+ expect(visibleIds(runtime)).toEqual([...expected.keys()].sort())
57
+ for (const [id, nodeRef] of expected) {
58
+ // deep equality: a restored leaf may be the event's child object rather
59
+ // than the leaves-array twin — both are table objects (no-cloning is
60
+ // asserted by identity in the fixture tests, where objects are shared)
61
+ expect(runtime.getVisible().get(id)).toEqual(nodeRef)
62
+ }
63
+ }
64
+
65
+ // The micro-trace table of CLUSTERING.md §8.5:
66
+ // E0 { zMerge 4.0, zSplit 6.0, A+B+C → P }
67
+ // E1 { zMerge 1.0, zSplit 1.5, P+D → Q }
68
+ const A = node(['a'], 0, 0)
69
+ const B = node(['b'], 10, 0)
70
+ const C = node(['c'], 20, 0)
71
+ const D = node(['d'], 100, 0)
72
+ const P = node(['a', 'b', 'c'], 10, 0)
73
+ const Q = node(['a', 'b', 'c', 'd'], 32.5, 0)
74
+
75
+ function microTraceTable(): ClusterTable {
76
+ return {
77
+ events: [mev(4, 6, [A, B, C], P), mev(1, 1.5, [P, D], Q)],
78
+ leaves: [A, B, C, D],
79
+ }
80
+ }
81
+
82
+ // --- Fixtures -------------------------------------------------------------------
83
+
84
+ describe('createClusterRuntime micro-trace (CLUSTERING.md §8.5)', () => {
85
+ it('walks the exact documented trace', () => {
86
+ const table = microTraceTable()
87
+ const rt = createClusterRuntime(table)
88
+
89
+ // mount at zoom 5.0 → seed: √(4·6) ≈ 4.9 < 5.0 → k = 0
90
+ rt.seed(5.0)
91
+ expect(rt.k).toBe(0)
92
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
93
+ expectPostconditions(rt, table, 5.0)
94
+
95
+ // zoom out to 3.5 → fire E0
96
+ rt.onCamera(3.5)
97
+ expect(rt.k).toBe(1)
98
+ expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
99
+ expectPostconditions(rt, table, 3.5)
100
+
101
+ // zoom back in to 5.0 → inside E0's band (4 < 5 < 6): hold — no flicker
102
+ rt.onCamera(5.0)
103
+ expect(rt.k).toBe(1)
104
+ expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
105
+ expectPostconditions(rt, table, 5.0)
106
+
107
+ // zoom in to 6.2 → split E0
108
+ rt.onCamera(6.2)
109
+ expect(rt.k).toBe(0)
110
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
111
+ expectPostconditions(rt, table, 6.2)
112
+
113
+ // zoom-to-fit at 0.8 → fires E0 AND E1 in one call
114
+ rt.onCamera(0.8)
115
+ expect(rt.k).toBe(2)
116
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
117
+ expectPostconditions(rt, table, 0.8)
118
+
119
+ // pan (same zoom) → no-op
120
+ rt.onCamera(0.8)
121
+ expect(rt.k).toBe(2)
122
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
123
+ })
124
+
125
+ it('splits multiple events in one zoom-in jump', () => {
126
+ const table = microTraceTable()
127
+ const rt = createClusterRuntime(table)
128
+ rt.seed(0.8) // √(4·6) ≈ 4.9 ≥ 0.8 and √(1·1.5) ≈ 1.22 ≥ 0.8 → k = 2
129
+ expect(rt.k).toBe(2)
130
+ rt.onCamera(7) // ≥ both zSplits (1.5 and 6) → unwinds to k = 0
131
+ expect(rt.k).toBe(0)
132
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
133
+ expectPostconditions(rt, table, 7)
134
+ })
135
+ })
136
+
137
+ describe('createClusterRuntime seeding', () => {
138
+ it('seeds each side of the geometric band midpoint', () => {
139
+ const table = microTraceTable()
140
+ const rt = createClusterRuntime(table)
141
+ const mid0 = Math.sqrt(4 * 6) // ≈ 4.898979
142
+ const mid1 = Math.sqrt(1 * 1.5) // ≈ 1.224745
143
+
144
+ rt.seed(mid0 + 0.001) // just above E0's midpoint → unmerged
145
+ expect(rt.k).toBe(0)
146
+
147
+ rt.seed(mid0) // exactly at the midpoint: zoom <= mid counts as merged
148
+ expect(rt.k).toBe(1)
149
+
150
+ rt.seed(mid1 + 0.001) // between the two midpoints → only E0 active
151
+ expect(rt.k).toBe(1)
152
+
153
+ rt.seed(mid1) // at/below E1's midpoint → both active
154
+ expect(rt.k).toBe(2)
155
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
156
+ })
157
+
158
+ it('re-seeding resets state from scratch', () => {
159
+ const table = microTraceTable()
160
+ const rt = createClusterRuntime(table)
161
+ rt.seed(0.8)
162
+ expect(rt.k).toBe(2)
163
+ rt.seed(5.0) // rebuild-style reset: history is discarded
164
+ expect(rt.k).toBe(0)
165
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
166
+ })
167
+
168
+ it('throws on non-positive or non-finite seed zoom', () => {
169
+ const rt = createClusterRuntime(microTraceTable())
170
+ expect(() => rt.seed(0)).toThrow()
171
+ expect(() => rt.seed(-1)).toThrow()
172
+ expect(() => rt.seed(NaN)).toThrow()
173
+ expect(() => rt.seed(Infinity)).toThrow()
174
+ })
175
+
176
+ it('throws if onCamera is called before any seed', () => {
177
+ const rt = createClusterRuntime(microTraceTable())
178
+ expect(() => rt.onCamera(3)).toThrow()
179
+ })
180
+
181
+ it('seed followed by onCamera at the same zoom is a no-op', () => {
182
+ const table = microTraceTable()
183
+ for (const zoom of [0.5, 1.1, 2, 4.5, 4.9, 5.5, 8]) {
184
+ const rt = createClusterRuntime(table)
185
+ rt.seed(zoom)
186
+ const k = rt.k
187
+ const ids = visibleIds(rt)
188
+ rt.onCamera(zoom)
189
+ expect(rt.k).toBe(k)
190
+ expect(visibleIds(rt)).toEqual(ids)
191
+ expectPostconditions(rt, table, zoom)
192
+ }
193
+ })
194
+ })
195
+
196
+ describe('createClusterRuntime edge cases', () => {
197
+ it('handles an empty table: all leaves visible at any zoom', () => {
198
+ const table: ClusterTable = { events: [], leaves: [A, B] }
199
+ const rt = createClusterRuntime(table)
200
+ rt.seed(3)
201
+ expect(rt.k).toBe(0)
202
+ expect(visibleIds(rt)).toEqual(['a', 'b'])
203
+ rt.onCamera(0.01)
204
+ rt.onCamera(100)
205
+ expect(visibleIds(rt)).toEqual(['a', 'b'])
206
+ })
207
+
208
+ it('keeps Infinity events permanently merged at any reachable zoom', () => {
209
+ const R = node(['a', 'b'])
210
+ const table: ClusterTable = { events: [mev(Infinity, Infinity, [A, B], R)], leaves: [A, B] }
211
+ const rt = createClusterRuntime(table)
212
+ rt.seed(8) // √(∞·∞) = ∞ ≥ any zoom → seeded merged
213
+ expect(rt.k).toBe(1)
214
+ expect(visibleIds(rt)).toEqual(['cluster:2:a'])
215
+ rt.onCamera(1e9) // no finite zoom reaches zSplit = ∞
216
+ expect(rt.k).toBe(1)
217
+ expect(visibleIds(rt)).toEqual(['cluster:2:a'])
218
+ })
219
+
220
+ it('exposes the table node objects by reference, without cloning', () => {
221
+ const table = microTraceTable()
222
+ const rt = createClusterRuntime(table)
223
+ rt.seed(5)
224
+ expect(rt.getVisible().get('a')).toBe(A)
225
+ rt.onCamera(3.5)
226
+ expect(rt.getVisible().get('cluster:3:a')).toBe(P)
227
+ expect(rt.getVisible().get('d')).toBe(D)
228
+ })
229
+
230
+ it('never mutates the table', () => {
231
+ const table = microTraceTable()
232
+ const snapshot = JSON.parse(JSON.stringify(table))
233
+ const rt = createClusterRuntime(table)
234
+ rt.seed(5)
235
+ rt.onCamera(0.8)
236
+ rt.onCamera(7)
237
+ rt.seed(2)
238
+ expect(JSON.parse(JSON.stringify(table))).toEqual(snapshot)
239
+ })
240
+ })
241
+
242
+ // --- Property tests against real pipeline tables --------------------------------
243
+
244
+ function leaf(id: string, x: number, y: number): LeafInput {
245
+ return { id, point: { x, y } }
246
+ }
247
+
248
+ function mulberry32(seed: number): () => number {
249
+ let a = seed >>> 0
250
+ return () => {
251
+ a = (a + 0x6d2b79f5) >>> 0
252
+ let t = a
253
+ t = Math.imul(t ^ (t >>> 15), t | 1)
254
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
255
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
256
+ }
257
+ }
258
+
259
+ const PIPE_OPTS = {
260
+ Tc: 40,
261
+ Tu: 60,
262
+ eps: 0.12,
263
+ Dmax: 120,
264
+ minZoom: 0.05,
265
+ maxZoom: 8,
266
+ maxSplitZoom: 1e9,
267
+ }
268
+
269
+ function buildTable(leaves: LeafInput[]): ClusterTable {
270
+ const rawEvents = cappedReplay(leaves, mstEdges(leaves), {
271
+ Tc: PIPE_OPTS.Tc,
272
+ Dmax: PIPE_OPTS.Dmax,
273
+ })
274
+ const events = finalize(contract(rawEvents, PIPE_OPTS.eps), {
275
+ Tc: PIPE_OPTS.Tc,
276
+ Tu: PIPE_OPTS.Tu,
277
+ minZoom: PIPE_OPTS.minZoom,
278
+ maxZoom: PIPE_OPTS.maxZoom,
279
+ maxSplitZoom: PIPE_OPTS.maxSplitZoom,
280
+ })
281
+ const leafNodes: ClusterNode[] = leaves.map((l) => ({
282
+ id: l.id,
283
+ centroid: { x: l.point.x, y: l.point.y },
284
+ count: 1,
285
+ members: [l.id],
286
+ }))
287
+ return { events, leaves: leafNodes }
288
+ }
289
+
290
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
291
+ const rand = mulberry32(seed)
292
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
293
+ }
294
+
295
+ /** A zoom walk within camera bounds: mostly small multiplicative steps, some jumps. */
296
+ function zoomWalk(steps: number, seed: number): number[] {
297
+ const rand = mulberry32(seed)
298
+ const lnMin = Math.log(PIPE_OPTS.minZoom)
299
+ const lnMax = Math.log(PIPE_OPTS.maxZoom)
300
+ let ln = (lnMin + lnMax) / 2
301
+ const walk: number[] = []
302
+ for (let i = 0; i < steps; i++) {
303
+ const r = rand()
304
+ if (r < 0.15) {
305
+ // jump anywhere (zoom-to-fit, keyboard shortcuts)
306
+ ln = lnMin + rand() * (lnMax - lnMin)
307
+ } else if (r < 0.25) {
308
+ // hold (pan-only frames)
309
+ } else {
310
+ // scrub: small multiplicative step, clamped to bounds
311
+ ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
312
+ }
313
+ walk.push(Math.exp(ln))
314
+ }
315
+ return walk
316
+ }
317
+
318
+ /**
319
+ * Independent per-event hysteresis simulator: each event keeps its own bit with
320
+ * the two-threshold update rule. The prefix property says the bit vector is
321
+ * always exactly the prefix mask [0..k) — this cross-checks the cursor against
322
+ * the definition it compresses.
323
+ */
324
+ class BitSimulator {
325
+ private bits: boolean[]
326
+ constructor(
327
+ private events: readonly MergeEvent[],
328
+ seedZoom: number
329
+ ) {
330
+ this.bits = events.map((e) => seedZoom <= Math.sqrt(e.zMerge * e.zSplit))
331
+ }
332
+ update(zoom: number) {
333
+ this.bits = this.events.map((e, i) => {
334
+ if (zoom <= e.zMerge) return true
335
+ if (zoom >= e.zSplit) return false
336
+ return this.bits[i]
337
+ })
338
+ }
339
+ expectPrefixOfLength(k: number) {
340
+ for (let i = 0; i < this.bits.length; i++) {
341
+ expect(this.bits[i]).toBe(i < k)
342
+ }
343
+ }
344
+ }
345
+
346
+ describe('createClusterRuntime properties (random tables, random zoom walks)', () => {
347
+ it('holds all postconditions and matches the per-event bit simulator', () => {
348
+ for (const [n, seed] of [
349
+ [2, 1],
350
+ [15, 42],
351
+ [60, 7],
352
+ ] as const) {
353
+ const leaves = randomLeaves(n, seed * 13 + n)
354
+ const table = buildTable(leaves)
355
+ const rt = createClusterRuntime(table)
356
+ const walk = zoomWalk(300, seed + 100)
357
+
358
+ const seedZoom = walk[0]
359
+ rt.seed(seedZoom)
360
+ const sim = new BitSimulator(table.events, seedZoom)
361
+ expectPostconditions(rt, table, seedZoom)
362
+ sim.expectPrefixOfLength(rt.k)
363
+
364
+ for (const zoom of walk.slice(1)) {
365
+ rt.onCamera(zoom)
366
+ sim.update(zoom)
367
+ expectPostconditions(rt, table, zoom)
368
+ sim.expectPrefixOfLength(rt.k)
369
+ // conservation: visible clusters always partition all n leaves
370
+ let total = 0
371
+ for (const cluster of rt.getVisible().values()) total += cluster.count
372
+ expect(total).toBe(n)
373
+ }
374
+ }
375
+ })
376
+
377
+ it('is monotone: k never decreases while zooming out, never increases zooming in', () => {
378
+ const leaves = randomLeaves(40, 33)
379
+ const table = buildTable(leaves)
380
+ const rt = createClusterRuntime(table)
381
+
382
+ rt.seed(PIPE_OPTS.maxZoom)
383
+ let prevK = rt.k
384
+ for (let zoom = PIPE_OPTS.maxZoom; zoom >= PIPE_OPTS.minZoom; zoom *= 0.97) {
385
+ rt.onCamera(zoom)
386
+ expect(rt.k).toBeGreaterThanOrEqual(prevK)
387
+ prevK = rt.k
388
+ }
389
+ expect(rt.k).toBe(table.events.length) // fully zoomed out → everything merged
390
+
391
+ for (let zoom = PIPE_OPTS.minZoom; zoom <= PIPE_OPTS.maxZoom; zoom *= 1.03) {
392
+ rt.onCamera(zoom)
393
+ expect(rt.k).toBeLessThanOrEqual(prevK)
394
+ prevK = rt.k
395
+ }
396
+ })
397
+
398
+ it('produces zero transitions while scrubbing inside a hysteresis band', () => {
399
+ const leaves = randomLeaves(30, 55)
400
+ const table = buildTable(leaves)
401
+ // pick a finite event with a usable band inside camera bounds
402
+ const ev = table.events.find(
403
+ (e) =>
404
+ Number.isFinite(e.zMerge) &&
405
+ e.zMerge > PIPE_OPTS.minZoom * 2 &&
406
+ e.zSplit < PIPE_OPTS.maxZoom &&
407
+ e.zSplit / e.zMerge > 1.01
408
+ )
409
+ expect(ev).toBeDefined()
410
+ const lo = ev!.zMerge * 1.001
411
+ const hi = ev!.zSplit * 0.999
412
+
413
+ const rt = createClusterRuntime(table)
414
+ // approach from below: merged state, then scrub within (zMerge, zSplit)
415
+ rt.seed(ev!.zMerge * 0.9)
416
+ rt.onCamera(lo)
417
+ const k = rt.k
418
+ const ids = visibleIds(rt)
419
+ const rand = mulberry32(9)
420
+ for (let i = 0; i < 50; i++) {
421
+ rt.onCamera(lo + rand() * (hi - lo))
422
+ expect(rt.k).toBe(k)
423
+ expect(visibleIds(rt)).toEqual(ids)
424
+ }
425
+ })
426
+
427
+ it('agrees with a fresh seed after any walk that ends outside all bands', () => {
428
+ // Outside every band, state is history-independent: a runtime that walked
429
+ // there must equal a runtime seeded there directly.
430
+ const leaves = randomLeaves(30, 77)
431
+ const table = buildTable(leaves)
432
+ const walked = createClusterRuntime(table)
433
+ walked.seed(2)
434
+ for (const zoom of zoomWalk(120, 5)) walked.onCamera(zoom)
435
+
436
+ // find a zoom outside all bands near the end state
437
+ const finalZoom = PIPE_OPTS.maxZoom
438
+ walked.onCamera(finalZoom)
439
+ const inBand = table.events.some((e) => finalZoom > e.zMerge && finalZoom < e.zSplit)
440
+ if (!inBand) {
441
+ const seeded = createClusterRuntime(table)
442
+ seeded.seed(finalZoom)
443
+ expect(seeded.k).toBe(walked.k)
444
+ expect(visibleIds(seeded)).toEqual(visibleIds(walked))
445
+ }
446
+ })
447
+ })
448
+
449
+ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
450
+ it('band event merged in the previous partition stays merged', () => {
451
+ // zoom 5 is inside E0's band and above the geometric midpoint (≈4.899):
452
+ // a fresh seed would leave A/B/C split. History says merged — carryover keeps it.
453
+ const table = microTraceTable()
454
+ const rt = createClusterRuntime(table)
455
+ rt.seedFrom(
456
+ 5,
457
+ new Map([
458
+ [P.id, P],
459
+ [D.id, D],
460
+ ])
461
+ )
462
+ expect(rt.k).toBe(1)
463
+ expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
464
+ })
465
+
466
+ it('band event unmerged in the previous partition stays unmerged (no snap-together)', () => {
467
+ // zoom 4.5 is inside E0's band and below the geometric midpoint: a fresh seed
468
+ // would merge A/B/C even though nothing changed for them. History says split.
469
+ const table = microTraceTable()
470
+ const rt = createClusterRuntime(table)
471
+ rt.seedFrom(
472
+ 4.5,
473
+ new Map([
474
+ [A.id, A],
475
+ [B.id, B],
476
+ [C.id, C],
477
+ [D.id, D],
478
+ ])
479
+ )
480
+ expect(rt.k).toBe(0)
481
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
482
+ })
483
+
484
+ it('thresholds override history in both directions', () => {
485
+ const table = microTraceTable()
486
+ const rt = createClusterRuntime(table)
487
+ // zoom <= zMerge: mandatory merge, even though the previous partition was split
488
+ rt.seedFrom(
489
+ 3,
490
+ new Map([
491
+ [A.id, A],
492
+ [B.id, B],
493
+ [C.id, C],
494
+ [D.id, D],
495
+ ])
496
+ )
497
+ expect(rt.k).toBe(1)
498
+ // zoom >= zSplit: mandatory split, even though the previous partition was merged
499
+ rt.seedFrom(
500
+ 7,
501
+ new Map([
502
+ [P.id, P],
503
+ [D.id, D],
504
+ ])
505
+ )
506
+ expect(rt.k).toBe(0)
507
+ })
508
+
509
+ it('a band event with no counterpart in the previous partition stays inactive', () => {
510
+ // Empty history (or partial membership) → the rebuild introduced this merge; it
511
+ // defers to the next zoom-out rather than firing under a static camera.
512
+ const table = microTraceTable()
513
+ const rt = createClusterRuntime(table)
514
+ rt.seedFrom(5, new Map())
515
+ expect(rt.k).toBe(0)
516
+ const partial = node(['a', 'b'], 5, 0)
517
+ rt.seedFrom(
518
+ 5,
519
+ new Map([
520
+ [partial.id, partial],
521
+ [C.id, C],
522
+ [D.id, D],
523
+ ])
524
+ )
525
+ expect(rt.k).toBe(0)
526
+ })
527
+
528
+ it('membership in a superset cluster counts as merged', () => {
529
+ // The previous partition holds all four in Q; E1 (band at zoom 1.2) inherits merged.
530
+ const table = microTraceTable()
531
+ const rt = createClusterRuntime(table)
532
+ rt.seedFrom(1.2, new Map([[Q.id, Q]]))
533
+ expect(rt.k).toBe(2)
534
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
535
+ })
536
+
537
+ it('classification stops at the first inactive event (prefix cut is conservative)', () => {
538
+ // Two independent band events at zoom 4.5. The first is split in history, the
539
+ // second merged — but active events must be a prefix, so the second resolves
540
+ // to split (conservative: never merges anything history did not sanction).
541
+ const AB = node(['a', 'b'], 5, 0)
542
+ const CD = node(['c', 'd'], 105, 0)
543
+ const table: ClusterTable = {
544
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
545
+ leaves: [A, B, C, D],
546
+ }
547
+ const rt = createClusterRuntime(table)
548
+ rt.seedFrom(
549
+ 4.5,
550
+ new Map([
551
+ [A.id, A],
552
+ [B.id, B],
553
+ [CD.id, CD],
554
+ ])
555
+ )
556
+ expect(rt.k).toBe(0)
557
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
558
+ })
559
+
560
+ it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
561
+ const table = microTraceTable()
562
+ const rt = createClusterRuntime(table)
563
+ rt.seedFrom(
564
+ 5,
565
+ new Map([
566
+ [P.id, P],
567
+ [D.id, D],
568
+ ])
569
+ )
570
+ const k = rt.k
571
+ const ids = visibleIds(rt)
572
+ rt.onCamera(5)
573
+ expect(rt.k).toBe(k)
574
+ expect(visibleIds(rt)).toEqual(ids)
575
+ expectPostconditions(rt, table, 5)
576
+ })
577
+
578
+ it('matches a fresh seed when the previous partition mirrors the geometric tiebreak', () => {
579
+ // Sanity: outside bands and with agreeing history, seedFrom degenerates to seed.
580
+ const table = microTraceTable()
581
+ for (const zoom of [0.5, 2, 8]) {
582
+ const fresh = createClusterRuntime(table)
583
+ fresh.seed(zoom)
584
+ const carried = createClusterRuntime(table)
585
+ carried.seedFrom(zoom, fresh.getVisible())
586
+ expect(carried.k).toBe(fresh.k)
587
+ expect(visibleIds(carried)).toEqual(visibleIds(fresh))
588
+ }
589
+ })
590
+
591
+ it('throws on non-positive or non-finite zoom', () => {
592
+ const rt = createClusterRuntime(microTraceTable())
593
+ expect(() => rt.seedFrom(0, new Map())).toThrow()
594
+ expect(() => rt.seedFrom(NaN, new Map())).toThrow()
595
+ })
596
+ })