@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d

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 (338) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1152 -350
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/{ui/avatar.js → canvas/canvas-events.js} +14 -22
  6. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  7. package/dist-cjs/canvas/cluster-badge.js +121 -0
  8. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  9. package/dist-cjs/canvas/cluster-fade.js +85 -0
  10. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  11. package/dist-cjs/canvas/cluster-input.js +53 -3
  12. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  13. package/dist-cjs/canvas/cluster-model.js +252 -0
  14. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  15. package/dist-cjs/canvas/comment-body.js +1 -1
  16. package/dist-cjs/canvas/comment-body.js.map +2 -2
  17. package/dist-cjs/canvas/comment-drafts.js +54 -0
  18. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  19. package/dist-cjs/canvas/comment-mutations.js +152 -0
  20. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  21. package/dist-cjs/canvas/comment-reactions.js +160 -0
  22. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  23. package/dist-cjs/canvas/comment-render.js +9 -6
  24. package/dist-cjs/canvas/comment-render.js.map +2 -2
  25. package/dist-cjs/canvas/comment-store.js +19 -9
  26. package/dist-cjs/canvas/comment-store.js.map +2 -2
  27. package/dist-cjs/canvas/comment-tool.js +63 -14
  28. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  29. package/dist-cjs/canvas/comments-filter-menu.js +17 -37
  30. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  31. package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
  32. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  33. package/dist-cjs/canvas/comments-overlay.js +151 -989
  34. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  35. package/dist-cjs/canvas/comments-sidebar.js +49 -41
  36. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  37. package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
  38. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  39. package/dist-cjs/canvas/context.js +17 -0
  40. package/dist-cjs/canvas/context.js.map +7 -0
  41. package/dist-cjs/canvas/hooks.js +28 -3
  42. package/dist-cjs/canvas/hooks.js.map +2 -2
  43. package/dist-cjs/canvas/license.js.map +1 -1
  44. package/dist-cjs/canvas/mobile-placement.js +91 -0
  45. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  46. package/dist-cjs/canvas/options.js +57 -2
  47. package/dist-cjs/canvas/options.js.map +2 -2
  48. package/dist-cjs/canvas/pending-composer.js +134 -0
  49. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  50. package/dist-cjs/canvas/pin-stacking.js +81 -0
  51. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  52. package/dist-cjs/canvas/region-box.js +113 -0
  53. package/dist-cjs/canvas/region-box.js.map +7 -0
  54. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  55. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  56. package/dist-cjs/canvas/state.js +28 -14
  57. package/dist-cjs/canvas/state.js.map +2 -2
  58. package/dist-cjs/canvas/thread-pin.js +304 -0
  59. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  60. package/dist-cjs/canvas/thread-preview.js +177 -0
  61. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  62. package/dist-cjs/canvas/thread-stack.js +199 -0
  63. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  64. package/dist-cjs/canvas/thread-state.js +82 -21
  65. package/dist-cjs/canvas/thread-state.js.map +2 -2
  66. package/dist-cjs/canvas/thread-view.js +450 -0
  67. package/dist-cjs/canvas/thread-view.js.map +7 -0
  68. package/dist-cjs/clustering/computeClusterTable.js +6 -6
  69. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  70. package/dist-cjs/clustering/replay.js +89 -22
  71. package/dist-cjs/clustering/replay.js.map +3 -3
  72. package/dist-cjs/clustering/runtime.js +126 -7
  73. package/dist-cjs/clustering/runtime.js.map +2 -2
  74. package/dist-cjs/clustering/types.js.map +1 -1
  75. package/dist-cjs/index.d.ts +904 -353
  76. package/dist-cjs/index.js +52 -28
  77. package/dist-cjs/index.js.map +2 -2
  78. package/dist-cjs/ui/byline.js +10 -6
  79. package/dist-cjs/ui/byline.js.map +2 -2
  80. package/dist-cjs/ui/comment-card.js +15 -8
  81. package/dist-cjs/ui/comment-card.js.map +2 -2
  82. package/dist-cjs/ui/comment-composer.js +124 -39
  83. package/dist-cjs/ui/comment-composer.js.map +2 -2
  84. package/dist-cjs/ui/comment-pin.js +11 -2
  85. package/dist-cjs/ui/comment-pin.js.map +2 -2
  86. package/dist-cjs/ui/comment-thread.js +9 -7
  87. package/dist-cjs/ui/comment-thread.js.map +2 -2
  88. package/dist-cjs/ui/comments-list.js +39 -40
  89. package/dist-cjs/ui/comments-list.js.map +2 -2
  90. package/dist-cjs/ui/count-badge.js +3 -2
  91. package/dist-cjs/ui/count-badge.js.map +2 -2
  92. package/dist-cjs/ui/emoji-picker.js +54 -0
  93. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  94. package/dist-cjs/ui/empty-state.js +3 -3
  95. package/dist-cjs/ui/empty-state.js.map +2 -2
  96. package/dist-cjs/ui/format-time.js +16 -1
  97. package/dist-cjs/ui/format-time.js.map +2 -2
  98. package/dist-cjs/ui/icons.js +115 -0
  99. package/dist-cjs/ui/icons.js.map +7 -0
  100. package/dist-cjs/ui/reaction-picker.js +71 -0
  101. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  102. package/dist-cjs/ui/reaction.js +58 -6
  103. package/dist-cjs/ui/reaction.js.map +2 -2
  104. package/dist-cjs/ui/reactions.js +20 -7
  105. package/dist-cjs/ui/reactions.js.map +2 -2
  106. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  107. package/dist-cjs/ui/reply-count.js.map +7 -0
  108. package/dist-cjs/ui/send-button.js +13 -1
  109. package/dist-cjs/ui/send-button.js.map +2 -2
  110. package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
  111. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  112. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  113. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  114. package/dist-esm/canvas/canvas-events.mjs +15 -0
  115. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  116. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  117. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  118. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  119. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  120. package/dist-esm/canvas/cluster-input.mjs +54 -4
  121. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  122. package/dist-esm/canvas/cluster-model.mjs +236 -0
  123. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  124. package/dist-esm/canvas/comment-body.mjs +1 -1
  125. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  126. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  127. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  128. package/dist-esm/canvas/comment-mutations.mjs +132 -0
  129. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  130. package/dist-esm/canvas/comment-reactions.mjs +145 -0
  131. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  132. package/dist-esm/canvas/comment-render.mjs +10 -7
  133. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  134. package/dist-esm/canvas/comment-store.mjs +19 -9
  135. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  136. package/dist-esm/canvas/comment-tool.mjs +66 -17
  137. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  138. package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
  139. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  140. package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
  141. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  142. package/dist-esm/canvas/comments-overlay.mjs +157 -1010
  143. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  144. package/dist-esm/canvas/comments-sidebar.mjs +53 -46
  145. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  146. package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
  147. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  148. package/dist-esm/canvas/context.mjs +1 -0
  149. package/dist-esm/canvas/context.mjs.map +7 -0
  150. package/dist-esm/canvas/hooks.mjs +35 -5
  151. package/dist-esm/canvas/hooks.mjs.map +2 -2
  152. package/dist-esm/canvas/license.mjs.map +1 -1
  153. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  154. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  155. package/dist-esm/canvas/options.mjs +59 -3
  156. package/dist-esm/canvas/options.mjs.map +2 -2
  157. package/dist-esm/canvas/pending-composer.mjs +126 -0
  158. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  159. package/dist-esm/canvas/pin-stacking.mjs +61 -0
  160. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  161. package/dist-esm/canvas/region-box.mjs +93 -0
  162. package/dist-esm/canvas/region-box.mjs.map +7 -0
  163. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  164. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  165. package/dist-esm/canvas/state.mjs +29 -19
  166. package/dist-esm/canvas/state.mjs.map +2 -2
  167. package/dist-esm/canvas/thread-pin.mjs +310 -0
  168. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  169. package/dist-esm/canvas/thread-preview.mjs +163 -0
  170. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  171. package/dist-esm/canvas/thread-stack.mjs +189 -0
  172. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  173. package/dist-esm/canvas/thread-state.mjs +86 -22
  174. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  175. package/dist-esm/canvas/thread-view.mjs +461 -0
  176. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  177. package/dist-esm/clustering/computeClusterTable.mjs +6 -6
  178. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  179. package/dist-esm/clustering/replay.mjs +89 -22
  180. package/dist-esm/clustering/replay.mjs.map +3 -3
  181. package/dist-esm/clustering/runtime.mjs +126 -7
  182. package/dist-esm/clustering/runtime.mjs.map +2 -2
  183. package/dist-esm/index.d.mts +904 -353
  184. package/dist-esm/index.mjs +102 -43
  185. package/dist-esm/index.mjs.map +2 -2
  186. package/dist-esm/ui/byline.mjs +11 -7
  187. package/dist-esm/ui/byline.mjs.map +2 -2
  188. package/dist-esm/ui/comment-card.mjs +15 -8
  189. package/dist-esm/ui/comment-card.mjs.map +2 -2
  190. package/dist-esm/ui/comment-composer.mjs +135 -39
  191. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  192. package/dist-esm/ui/comment-pin.mjs +11 -2
  193. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  194. package/dist-esm/ui/comment-thread.mjs +9 -7
  195. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  196. package/dist-esm/ui/comments-list.mjs +51 -50
  197. package/dist-esm/ui/comments-list.mjs.map +2 -2
  198. package/dist-esm/ui/count-badge.mjs +3 -2
  199. package/dist-esm/ui/count-badge.mjs.map +2 -2
  200. package/dist-esm/ui/emoji-picker.mjs +34 -0
  201. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  202. package/dist-esm/ui/empty-state.mjs +3 -3
  203. package/dist-esm/ui/empty-state.mjs.map +2 -2
  204. package/dist-esm/ui/format-time.mjs +16 -1
  205. package/dist-esm/ui/format-time.mjs.map +2 -2
  206. package/dist-esm/ui/icons.mjs +95 -0
  207. package/dist-esm/ui/icons.mjs.map +7 -0
  208. package/dist-esm/ui/reaction-picker.mjs +59 -0
  209. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  210. package/dist-esm/ui/reaction.mjs +59 -7
  211. package/dist-esm/ui/reaction.mjs.map +2 -2
  212. package/dist-esm/ui/reactions.mjs +21 -8
  213. package/dist-esm/ui/reactions.mjs.map +2 -2
  214. package/dist-esm/ui/reply-count.mjs +9 -0
  215. package/dist-esm/ui/reply-count.mjs.map +7 -0
  216. package/dist-esm/ui/send-button.mjs +13 -1
  217. package/dist-esm/ui/send-button.mjs.map +2 -2
  218. package/dist-esm/ui/visual-viewport.mjs +12 -0
  219. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  220. package/package.json +6 -6
  221. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  222. package/src/canvas/anchor-lifecycle.ts +191 -0
  223. package/src/canvas/canvas-events.ts +18 -0
  224. package/src/canvas/canvas.css +464 -55
  225. package/src/canvas/cluster-badge.tsx +133 -0
  226. package/src/canvas/cluster-fade.ts +102 -0
  227. package/src/canvas/cluster-input.test.ts +178 -36
  228. package/src/canvas/cluster-input.ts +95 -9
  229. package/src/canvas/cluster-model.ts +378 -0
  230. package/src/canvas/comment-body.tsx +1 -1
  231. package/src/canvas/comment-drafts.test.ts +55 -0
  232. package/src/canvas/comment-drafts.ts +46 -0
  233. package/src/canvas/comment-mutations.test.ts +452 -0
  234. package/src/canvas/comment-mutations.ts +299 -0
  235. package/src/canvas/comment-reactions.test.ts +154 -0
  236. package/src/canvas/comment-reactions.tsx +246 -0
  237. package/src/canvas/comment-render.test.ts +1 -1
  238. package/src/canvas/comment-render.ts +17 -13
  239. package/src/canvas/comment-store.ts +68 -33
  240. package/src/canvas/comment-tool.test.ts +162 -0
  241. package/src/canvas/comment-tool.tsx +103 -25
  242. package/src/canvas/comments-filter-menu.tsx +20 -36
  243. package/src/canvas/comments-overflow-menu.tsx +17 -19
  244. package/src/canvas/comments-overlay.tsx +252 -1369
  245. package/src/canvas/comments-sidebar.test.ts +45 -0
  246. package/src/canvas/comments-sidebar.tsx +112 -73
  247. package/src/canvas/comments-visibility-toggle.tsx +25 -0
  248. package/src/canvas/context.ts +48 -0
  249. package/src/canvas/hooks.test.ts +94 -0
  250. package/src/canvas/hooks.ts +66 -11
  251. package/src/canvas/license.ts +1 -1
  252. package/src/canvas/mobile-placement.ts +115 -0
  253. package/src/canvas/options.test.ts +176 -4
  254. package/src/canvas/options.ts +276 -18
  255. package/src/canvas/pending-composer.tsx +157 -0
  256. package/src/canvas/pin-stacking.test.ts +224 -0
  257. package/src/canvas/pin-stacking.ts +103 -0
  258. package/src/canvas/region-box.tsx +124 -0
  259. package/src/canvas/sidebar-filters.ts +4 -3
  260. package/src/canvas/state.ts +88 -39
  261. package/src/canvas/thread-pin.tsx +418 -0
  262. package/src/canvas/thread-preview.test.ts +91 -0
  263. package/src/canvas/thread-preview.tsx +294 -0
  264. package/src/canvas/thread-stack.tsx +242 -0
  265. package/src/canvas/thread-state.test.ts +260 -0
  266. package/src/canvas/thread-state.ts +171 -37
  267. package/src/canvas/thread-view.test.ts +72 -0
  268. package/src/canvas/thread-view.tsx +614 -0
  269. package/src/clustering/computeClusterTable.test.ts +24 -23
  270. package/src/clustering/computeClusterTable.ts +17 -8
  271. package/src/clustering/replay.test.ts +0 -7
  272. package/src/clustering/replay.ts +131 -32
  273. package/src/clustering/runtime.test.ts +267 -13
  274. package/src/clustering/runtime.ts +176 -19
  275. package/src/clustering/schedule.test.ts +0 -6
  276. package/src/clustering/screen-offsets.test.ts +171 -0
  277. package/src/clustering/types.ts +19 -9
  278. package/src/index.ts +91 -42
  279. package/src/ui/byline.tsx +21 -9
  280. package/src/ui/comment-card.tsx +20 -10
  281. package/src/ui/comment-composer.tsx +178 -56
  282. package/src/ui/comment-pin.tsx +13 -2
  283. package/src/ui/comment-thread.tsx +13 -6
  284. package/src/ui/comments-list.test.ts +31 -0
  285. package/src/ui/comments-list.tsx +82 -47
  286. package/src/ui/comments.css +577 -294
  287. package/src/ui/count-badge.tsx +9 -2
  288. package/src/ui/emoji-picker.test.ts +25 -0
  289. package/src/ui/emoji-picker.tsx +76 -0
  290. package/src/ui/empty-state.tsx +3 -3
  291. package/src/ui/format-time.test.ts +69 -0
  292. package/src/ui/format-time.ts +27 -2
  293. package/src/ui/icons.tsx +116 -0
  294. package/src/ui/reaction-picker.tsx +102 -0
  295. package/src/ui/reaction.tsx +125 -6
  296. package/src/ui/reactions.tsx +69 -10
  297. package/src/ui/reply-count.ts +16 -0
  298. package/src/ui/send-button.tsx +13 -3
  299. package/src/ui/visual-viewport.test.ts +36 -0
  300. package/src/ui/visual-viewport.ts +27 -0
  301. package/dist-cjs/canvas/region-options.js +0 -49
  302. package/dist-cjs/canvas/region-options.js.map +0 -7
  303. package/dist-cjs/ui/avatar.js.map +0 -7
  304. package/dist-cjs/ui/comment-mention.js +0 -42
  305. package/dist-cjs/ui/comment-mention.js.map +0 -7
  306. package/dist-cjs/ui/comment-text.js.map +0 -7
  307. package/dist-cjs/ui/mention-list.js +0 -70
  308. package/dist-cjs/ui/mention-list.js.map +0 -7
  309. package/dist-cjs/ui/mention-suggestion.js +0 -206
  310. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  311. package/dist-cjs/ui/mention.js.map +0 -7
  312. package/dist-cjs/ui/render-markdown.js +0 -63
  313. package/dist-cjs/ui/render-markdown.js.map +0 -7
  314. package/dist-esm/canvas/region-options.mjs +0 -29
  315. package/dist-esm/canvas/region-options.mjs.map +0 -7
  316. package/dist-esm/ui/avatar.mjs +0 -23
  317. package/dist-esm/ui/avatar.mjs.map +0 -7
  318. package/dist-esm/ui/comment-mention.mjs +0 -22
  319. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  320. package/dist-esm/ui/comment-text.mjs +0 -9
  321. package/dist-esm/ui/comment-text.mjs.map +0 -7
  322. package/dist-esm/ui/mention-list.mjs +0 -50
  323. package/dist-esm/ui/mention-list.mjs.map +0 -7
  324. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  325. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  326. package/dist-esm/ui/mention.mjs +0 -11
  327. package/dist-esm/ui/mention.mjs.map +0 -7
  328. package/dist-esm/ui/render-markdown.mjs +0 -45
  329. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  330. package/src/canvas/region-options.ts +0 -57
  331. package/src/ui/avatar.tsx +0 -31
  332. package/src/ui/comment-mention.ts +0 -47
  333. package/src/ui/comment-text.tsx +0 -12
  334. package/src/ui/mention-list.tsx +0 -106
  335. package/src/ui/mention-suggestion.test.ts +0 -18
  336. package/src/ui/mention-suggestion.tsx +0 -285
  337. package/src/ui/mention.tsx +0 -9
  338. package/src/ui/render-markdown.tsx +0 -72
@@ -5,8 +5,6 @@ import { createClusterRuntime } from './runtime'
5
5
  import { contract, finalize } from './schedule'
6
6
  import { ClusterNode, ClusterTable, LeafInput, MergeEvent } from './types'
7
7
 
8
- // --- Synthetic builders --------------------------------------------------------
9
-
10
8
  function node(ids: string[], x = 0, y = 0): ClusterNode {
11
9
  const members = ids.slice().sort()
12
10
  return {
@@ -79,8 +77,6 @@ function microTraceTable(): ClusterTable {
79
77
  }
80
78
  }
81
79
 
82
- // --- Fixtures -------------------------------------------------------------------
83
-
84
80
  describe('createClusterRuntime micro-trace (CLUSTERING.md §8.5)', () => {
85
81
  it('walks the exact documented trace', () => {
86
82
  const table = microTraceTable()
@@ -239,8 +235,6 @@ describe('createClusterRuntime edge cases', () => {
239
235
  })
240
236
  })
241
237
 
242
- // --- Property tests against real pipeline tables --------------------------------
243
-
244
238
  function leaf(id: string, x: number, y: number): LeafInput {
245
239
  return { id, point: { x, y } }
246
240
  }
@@ -460,6 +454,7 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
460
454
  ])
461
455
  )
462
456
  expect(rt.k).toBe(1)
457
+ expect(rt.getSuppressedCount()).toBe(0)
463
458
  expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
464
459
  })
465
460
 
@@ -477,7 +472,8 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
477
472
  [D.id, D],
478
473
  ])
479
474
  )
480
- expect(rt.k).toBe(0)
475
+ expect(rt.k).toBe(1)
476
+ expect(rt.getSuppressedCount()).toBe(1)
481
477
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
482
478
  })
483
479
 
@@ -512,7 +508,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
512
508
  const table = microTraceTable()
513
509
  const rt = createClusterRuntime(table)
514
510
  rt.seedFrom(5, new Map())
515
- expect(rt.k).toBe(0)
511
+ expect(rt.k).toBe(1)
512
+ expect(rt.getSuppressedCount()).toBe(1)
513
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
516
514
  const partial = node(['a', 'b'], 5, 0)
517
515
  rt.seedFrom(
518
516
  5,
@@ -522,7 +520,9 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
522
520
  [D.id, D],
523
521
  ])
524
522
  )
525
- expect(rt.k).toBe(0)
523
+ expect(rt.k).toBe(1)
524
+ expect(rt.getSuppressedCount()).toBe(1)
525
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
526
526
  })
527
527
 
528
528
  it('membership in a superset cluster counts as merged', () => {
@@ -534,10 +534,58 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
534
534
  expect(visibleIds(rt)).toEqual(['cluster:4:a'])
535
535
  })
536
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).
537
+ it('carries over mixed band states exactly (split before merged in table order)', () => {
538
+ // Two independent band events at zoom 4.5: the earlier-sorted one split in history,
539
+ // the later one merged. The old prefix cut forced the second to split (the mass-split
540
+ // bug); with suppression the carryover is exact each keeps its own state.
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(2)
557
+ expect(rt.getSuppressedCount()).toBe(1)
558
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'cluster:2:c'])
559
+ })
560
+
561
+ it('a suppressed event merges (heals) when a zoom-out crosses its own zMerge', () => {
562
+ const AB = node(['a', 'b'], 5, 0)
563
+ const CD = node(['c', 'd'], 105, 0)
564
+ const table: ClusterTable = {
565
+ events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
566
+ leaves: [A, B, C, D],
567
+ }
568
+ const rt = createClusterRuntime(table)
569
+ rt.seedFrom(
570
+ 4.5,
571
+ new Map([
572
+ [A.id, A],
573
+ [B.id, B],
574
+ [CD.id, CD],
575
+ ])
576
+ )
577
+ const versionBefore = rt.version
578
+ rt.onCamera(4.4) // still inside AB's band: nothing happens
579
+ expect(rt.version).toBe(versionBefore)
580
+ rt.onCamera(3.9) // below AB's zMerge (4): the held-out merge fires at its own threshold
581
+ expect(rt.version).toBeGreaterThan(versionBefore)
582
+ expect(rt.k).toBe(2)
583
+ expect(rt.getSuppressedCount()).toBe(0)
584
+ expect(visibleIds(rt)).toEqual(['cluster:2:a', 'cluster:2:c'])
585
+ expectPostconditions(rt, table, 3.9)
586
+ })
587
+
588
+ it('the split walk retreats past a suppressed event without corrupting the partition', () => {
541
589
  const AB = node(['a', 'b'], 5, 0)
542
590
  const CD = node(['c', 'd'], 105, 0)
543
591
  const table: ClusterTable = {
@@ -553,8 +601,52 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
553
601
  [CD.id, CD],
554
602
  ])
555
603
  )
604
+ rt.onCamera(5.5) // past CD's zSplit (5): CD splits; suppressed AB stays put
605
+ expect(rt.k).toBe(1)
606
+ expect(rt.getSuppressedCount()).toBe(1)
607
+ expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
608
+ rt.onCamera(6.5) // past AB's zSplit (6): retreat past the suppressed (never-applied) event
556
609
  expect(rt.k).toBe(0)
610
+ expect(rt.getSuppressedCount()).toBe(0)
557
611
  expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
612
+ rt.onCamera(3.9) // zoom back out: normal cursor walk from a clean state
613
+ expect(rt.k).toBe(1)
614
+ expect(visibleIds(rt)).toEqual(['c', 'cluster:2:a', 'd'])
615
+ expectPostconditions(rt, table, 3.9)
616
+ })
617
+
618
+ it('heals a suppressed event whose result a later event consumes, in one zoom-out jump', () => {
619
+ // E1 consumes E0's result, so a single jump past both thresholds must apply E0 first.
620
+ // Applying E1 first would no-op its delete of the never-applied ABC and then re-add ABC
621
+ // on top of ABCD, covering a, b and c twice.
622
+ const ABC = node(['a', 'b', 'c'], 10, 0)
623
+ const ABCD = node(['a', 'b', 'c', 'd'], 32.5, 0)
624
+ const table: ClusterTable = {
625
+ events: [mev(4, 6, [A, B, C], ABC), mev(1, 1.5, [ABC, D], ABCD)],
626
+ leaves: [A, B, C, D],
627
+ }
628
+ const rt = createClusterRuntime(table)
629
+ // previous partition: everything split apart, so E0 seeds suppressed inside its band
630
+ rt.seedFrom(
631
+ 5,
632
+ new Map([
633
+ [A.id, A],
634
+ [B.id, B],
635
+ [C.id, C],
636
+ [D.id, D],
637
+ ])
638
+ )
639
+ expect(rt.getSuppressedCount()).toBe(1)
640
+
641
+ rt.onCamera(0.9) // one jump past E0's zMerge (4) and E1's zMerge (1)
642
+
643
+ expect(rt.getSuppressedCount()).toBe(0)
644
+ expect(rt.k).toBe(2)
645
+ expect(visibleIds(rt)).toEqual(['cluster:4:a'])
646
+ // conservation: every leaf is covered exactly once
647
+ const members = [...rt.getVisible().values()].flatMap((n) => n.members)
648
+ expect(members.slice().sort()).toEqual(['a', 'b', 'c', 'd'])
649
+ expectPostconditions(rt, table, 0.9)
558
650
  })
559
651
 
560
652
  it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
@@ -594,3 +686,165 @@ describe('createClusterRuntime seedFrom (carryover seeding)', () => {
594
686
  expect(() => rt.seedFrom(NaN, new Map())).toThrow()
595
687
  })
596
688
  })
689
+
690
+ describe('createClusterRuntime detachLeaf (local partition edits)', () => {
691
+ it('shrinks the containing badge in place: members, count, centroid', () => {
692
+ const table = microTraceTable()
693
+ const rt = createClusterRuntime(table)
694
+ rt.seed(4) // P = a+b+c visible, d separate
695
+ const versionBefore = rt.version
696
+ rt.detachLeaf('a')
697
+ expect(rt.version).toBeGreaterThan(versionBefore)
698
+ expect(rt.getDetachedCount()).toBe(1)
699
+ const patchedP = rt.getVisible().get(P.id)!
700
+ expect(patchedP.members).toEqual(['b', 'c'])
701
+ expect(patchedP.count).toBe(2)
702
+ // centroid recomputed from the remaining leaves: B (10,0), C (20,0)
703
+ expect(patchedP.centroid).toEqual({ x: 15, y: 0 })
704
+ // keeps the structural id so cursor events keep addressing it
705
+ expect(patchedP.id).toBe(P.id)
706
+ // everything else untouched
707
+ expect(rt.getVisible().get('d')).toEqual(D)
708
+ expect(rt.getVisible().size).toBe(2)
709
+ })
710
+
711
+ it('is idempotent and ignores unknown ids', () => {
712
+ const table = microTraceTable()
713
+ const rt = createClusterRuntime(table)
714
+ rt.seed(4)
715
+ rt.detachLeaf('a')
716
+ const version = rt.version
717
+ rt.detachLeaf('a')
718
+ rt.detachLeaf('nonexistent')
719
+ expect(rt.version).toBe(version)
720
+ expect(rt.getDetachedCount()).toBe(1)
721
+ })
722
+
723
+ it('collapses a badge to its surviving leaf, and to nothing', () => {
724
+ const table = microTraceTable()
725
+ const rt = createClusterRuntime(table)
726
+ rt.seed(4)
727
+ rt.detachLeaf('a')
728
+ rt.detachLeaf('b')
729
+ // P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
730
+ expect(rt.getVisible().get('c')).toEqual(C)
731
+ expect(rt.getVisible().has(P.id)).toBe(false)
732
+ rt.detachLeaf('c')
733
+ expect(visibleIds(rt)).toEqual(['d'])
734
+ })
735
+
736
+ it('removes a leaf that is visible as its own pin', () => {
737
+ const table = microTraceTable()
738
+ const rt = createClusterRuntime(table)
739
+ rt.seed(8) // everything split
740
+ rt.detachLeaf('a')
741
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
742
+ })
743
+
744
+ it('zoom walks stay correct while patches are active (split)', () => {
745
+ const table = microTraceTable()
746
+ const rt = createClusterRuntime(table)
747
+ rt.seed(4)
748
+ rt.detachLeaf('a')
749
+ rt.onCamera(6.5) // past P's zSplit (6): structural split of a+b+c
750
+ expect(rt.k).toBe(0)
751
+ // resolved view: a stays gone, b/c/d as pins — the split itself changed nothing for a
752
+ expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
753
+ })
754
+
755
+ it('zoom walks stay correct while patches are active (merge)', () => {
756
+ const table = microTraceTable()
757
+ const rt = createClusterRuntime(table)
758
+ rt.seed(4)
759
+ rt.detachLeaf('a')
760
+ rt.onCamera(0.9) // below E1's zMerge (1): P+D merge into Q
761
+ expect(rt.k).toBe(2)
762
+ const patchedQ = rt.getVisible().get(Q.id)!
763
+ expect(patchedQ.members).toEqual(['b', 'c', 'd'])
764
+ expect(patchedQ.count).toBe(3)
765
+ // mean of B (10,0), C (20,0), D (100,0)
766
+ expect(patchedQ.centroid.x).toBeCloseTo(130 / 3)
767
+ expect(rt.getVisible().size).toBe(1)
768
+ })
769
+
770
+ it('seed and seedFrom clear detaches', () => {
771
+ const table = microTraceTable()
772
+ const rt = createClusterRuntime(table)
773
+ rt.seed(4)
774
+ rt.detachLeaf('a')
775
+ rt.seed(4)
776
+ expect(rt.getDetachedCount()).toBe(0)
777
+ expect(rt.getVisible().get(P.id)).toEqual(P)
778
+ rt.detachLeaf('a')
779
+ rt.seedFrom(
780
+ 4,
781
+ new Map([
782
+ [P.id, P],
783
+ [D.id, D],
784
+ ])
785
+ )
786
+ expect(rt.getDetachedCount()).toBe(0)
787
+ expect(rt.getVisible().get(P.id)).toEqual(P)
788
+ })
789
+
790
+ it('detachLeaves batches a whole set into one version bump', () => {
791
+ const table = microTraceTable()
792
+ const rt = createClusterRuntime(table)
793
+ rt.seed(4) // P = a+b+c visible, d separate
794
+ const versionBefore = rt.version
795
+ rt.detachLeaves(['a', 'b'])
796
+ expect(rt.version).toBe(versionBefore + 1)
797
+ expect(rt.getDetachedCount()).toBe(2)
798
+ // P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
799
+ expect(rt.getVisible().get('c')).toEqual(C)
800
+ expect(rt.getVisible().has(P.id)).toBe(false)
801
+ expect(visibleIds(rt)).toEqual(['c', 'd'])
802
+ })
803
+
804
+ it('detachLeaves matches the equivalent one-at-a-time detaches', () => {
805
+ const makeDetached = (detach: (rt: ReturnType<typeof createClusterRuntime>) => void) => {
806
+ const rt = createClusterRuntime(microTraceTable())
807
+ rt.seed(4)
808
+ detach(rt)
809
+ return rt
810
+ }
811
+ const batched = makeDetached((rt) => rt.detachLeaves(['a', 'd']))
812
+ const oneByOne = makeDetached((rt) => {
813
+ rt.detachLeaf('a')
814
+ rt.detachLeaf('d')
815
+ })
816
+ expect(visibleIds(batched)).toEqual(visibleIds(oneByOne))
817
+ for (const id of visibleIds(batched)) {
818
+ expect(batched.getVisible().get(id)).toEqual(oneByOne.getVisible().get(id))
819
+ }
820
+ })
821
+
822
+ it('detachLeaves skips unknown and already-detached ids, and no-ops on an empty batch', () => {
823
+ const table = microTraceTable()
824
+ const rt = createClusterRuntime(table)
825
+ rt.seed(4)
826
+ rt.detachLeaf('a')
827
+ const version = rt.version
828
+ const visible = rt.getVisible()
829
+ rt.detachLeaves([])
830
+ rt.detachLeaves(['a', 'nonexistent'])
831
+ expect(rt.version).toBe(version)
832
+ expect(rt.getDetachedCount()).toBe(1)
833
+ expect(rt.getVisible()).toBe(visible)
834
+ // a mixed batch still applies the new id in a single bump
835
+ rt.detachLeaves(['a', 'b', 'nonexistent'])
836
+ expect(rt.version).toBe(version + 1)
837
+ expect(rt.getDetachedCount()).toBe(2)
838
+ })
839
+
840
+ it('getVisible returns a stable reference until the partition changes', () => {
841
+ const table = microTraceTable()
842
+ const rt = createClusterRuntime(table)
843
+ rt.seed(4)
844
+ rt.detachLeaf('a')
845
+ const first = rt.getVisible()
846
+ expect(rt.getVisible()).toBe(first)
847
+ rt.detachLeaf('b')
848
+ expect(rt.getVisible()).not.toBe(first)
849
+ })
850
+ })
@@ -1,28 +1,49 @@
1
1
  import type { ClusterNode, ClusterTable, MergeEvent } from './types'
2
2
 
3
- /** @public */
3
+ /** @internal */
4
4
  export interface ClusterRuntime {
5
- /** Events[0..k) are active. Exposed for tests and debugging. */
5
+ /** Events[0..k) are in the cursor (applied unless suppressed). Exposed for tests/debugging. */
6
6
  readonly k: number
7
- /** Reset state from scratch for the given zoom (cold start / after rebuild). */
7
+ /**
8
+ * Increments whenever the displayed partition changes. Subscribe to this, not `k`: detaches
9
+ * and suppressed-event healing change the partition without moving the cursor.
10
+ */
11
+ readonly version: number
12
+ /** Number of suppressed band events currently inside the cursor. Exposed for tests. */
13
+ getSuppressedCount(): number
14
+ /** Number of leaves currently detached from the displayed partition. Exposed for tests. */
15
+ getDetachedCount(): number
16
+ /** Reset state from scratch for the given zoom (cold start / after rebuild). Clears detaches. */
8
17
  seed(zoom: number): void
9
18
  /**
10
- * Reset state for the given zoom, carrying hysteresis state over from a previous partition
11
- * (the visible map of the model being replaced). Threshold-forced events ignore history:
12
- * zoom \<= zMerge is always active, zoom \>= zSplit always inactive. An event inside its band
13
- * is active iff its members were merged together in `previous`; a band event with no history
14
- * (e.g. introduced by the rebuild) stays inactive until the next zoom-out crosses its zMerge.
15
- * Active events must form a prefix of the table, so classification stops at the first
16
- * inactive event; later band-active events conservatively resolve to split.
19
+ * Reset state for the given zoom, carrying hysteresis state over from a previous partition.
20
+ * Threshold-forced events ignore history: zoom \<= zMerge is always merged, zoom \>= zSplit always
21
+ * split. An event inside its band keeps its previous state; one that was unmerged (or has no
22
+ * history) stays suppressed inside the cursor until a zoom-out crosses its own zMerge. Carryover is
23
+ * exact no group changes state because of the swap alone. Clears detaches.
17
24
  */
18
25
  seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
19
26
  /** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
20
27
  onCamera(zoom: number): void
21
- /** The current partition: cluster id → node. Do not mutate. */
28
+ /**
29
+ * Remove one leaf from the displayed partition without touching the event table. Local by
30
+ * construction: only the nodes containing the leaf change, so a deletion, pop-out, or thread-open
31
+ * never re-flows the rest of the document. The table's thresholds around the detached leaf go
32
+ * stale; the caller is expected to adopt a corrected rebuild (with seedFrom) at the next zoom-out.
33
+ * Unknown or already-detached ids are no-ops.
34
+ */
35
+ detachLeaf(leafId: string): void
36
+ /**
37
+ * Batch form of {@link ClusterRuntime.detachLeaf}: detach every given leaf with a single
38
+ * patch rebuild and a single version bump. Ids that are unknown or already detached are
39
+ * skipped; if nothing new detaches, nothing changes.
40
+ */
41
+ detachLeaves(leafIds: Iterable<string>): void
42
+ /** The displayed partition: cluster id → node, with detaches applied. Do not mutate. */
22
43
  getVisible(): ReadonlyMap<string, ClusterNode>
23
44
  }
24
45
 
25
- /** @public */
46
+ /** @internal */
26
47
  export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
27
48
  return new ClusterRuntimeImpl(table)
28
49
  }
@@ -30,21 +51,48 @@ export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
30
51
  class ClusterRuntimeImpl implements ClusterRuntime {
31
52
  // mutable internally; readonly through the ClusterRuntime interface
32
53
  k = 0
54
+ version = 0
55
+ // Structural partition: exactly leaves + applied events, never patched. The cursor invariants
56
+ // live here, untouched by detaches — resolution to the displayed partition happens at read
57
+ // time in getVisible().
33
58
  private visible = new Map<string, ClusterNode>()
34
59
  private seeded = false
60
+ // Indices (< k) of band events held unmerged by seedFrom carryover. The single cursor can only
61
+ // express "merged up to here", but a carried-over partition can be "merged except these". Self-
62
+ // draining: an entry leaves via onCamera when the zoom crosses its zMerge or its zSplit.
63
+ private suppressed = new Set<number>()
64
+ // Leaves removed from the displayed partition (deleted / popped out / opened). Patches map
65
+ // each structural node containing a detached leaf to its displayed replacement (or null to
66
+ // drop it). Rebuilt from the detached set; cleared by seed/seedFrom.
67
+ private detached = new Set<string>()
68
+ private patched = new Map<string, ClusterNode | null>()
69
+ private leafById: Map<string, ClusterNode> | null = null
70
+ private resolvedCache: { version: number; map: Map<string, ClusterNode> } | null = null
35
71
 
36
72
  constructor(private readonly table: ClusterTable) {
37
73
  this.resetVisible()
38
74
  }
39
75
 
76
+ getSuppressedCount(): number {
77
+ return this.suppressed.size
78
+ }
79
+
80
+ getDetachedCount(): number {
81
+ return this.detached.size
82
+ }
83
+
40
84
  seed(zoom: number): void {
41
85
  validateZoom(zoom)
42
86
  this.k = seedCount(this.table.events, zoom)
87
+ this.suppressed.clear()
88
+ this.detached.clear()
89
+ this.patched.clear()
43
90
  this.resetVisible()
44
91
  for (let i = 0; i < this.k; i++) {
45
92
  applyEvent(this.visible, this.table.events[i])
46
93
  }
47
94
  this.seeded = true
95
+ this.version++
48
96
  }
49
97
 
50
98
  seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
@@ -56,21 +104,32 @@ class ClusterRuntimeImpl implements ClusterRuntime {
56
104
  }
57
105
  }
58
106
 
107
+ // Cut the cursor purely on split thresholds. zSplit is non-increasing down the table, so
108
+ // everything past the first mandatory split (zoom >= zSplit) is also mandatorily split —
109
+ // this cut can never conflict with an event that should be merged.
59
110
  const events = this.table.events
60
111
  let k = 0
61
- while (k < events.length) {
62
- const event = events[k]
63
- if (zoom >= event.zSplit) break
64
- if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) break
112
+ while (k < events.length && zoom < events[k].zSplit) {
65
113
  k++
66
114
  }
67
115
 
68
116
  this.k = k
117
+ this.suppressed.clear()
118
+ this.detached.clear()
119
+ this.patched.clear()
69
120
  this.resetVisible()
70
121
  for (let i = 0; i < k; i++) {
71
- applyEvent(this.visible, events[i])
122
+ const event = events[i]
123
+ if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
124
+ // In its band and previously unmerged: keep it unmerged, as an exception inside the cursor.
125
+ // Dependency-safe, since an applied event can never consume a suppressed result.
126
+ this.suppressed.add(i)
127
+ } else {
128
+ applyEvent(this.visible, event)
129
+ }
72
130
  }
73
131
  this.seeded = true
132
+ this.version++
74
133
  }
75
134
 
76
135
  onCamera(zoom: number): void {
@@ -79,18 +138,116 @@ class ClusterRuntimeImpl implements ClusterRuntime {
79
138
  throw new Error('Cluster runtime must be seeded before onCamera')
80
139
  }
81
140
 
141
+ let changed = false
142
+ // Heal suppressed events at their own merge threshold BEFORE the merge walk, so a zoom-out past
143
+ // zMerge merges a held-out band event as if it had still been ahead of the cursor. Healing must come
144
+ // first: one zoom jump can cross both a suppressed event and an event consuming its result, and
145
+ // applying the consumer first would double-count the producer's leaves. Iterated live — deleting only
146
+ // the entry being visited is safe, and Set iteration still yields the rest in insertion order.
147
+ if (this.suppressed.size > 0) {
148
+ for (const i of this.suppressed) {
149
+ if (zoom <= this.table.events[i].zMerge) {
150
+ this.suppressed.delete(i)
151
+ applyEvent(this.visible, this.table.events[i])
152
+ changed = true
153
+ }
154
+ }
155
+ }
82
156
  while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
83
157
  applyEvent(this.visible, this.table.events[this.k])
84
158
  this.k++
159
+ changed = true
85
160
  }
86
161
  while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
87
162
  this.k--
88
- unapplyEvent(this.visible, this.table.events[this.k])
163
+ // A suppressed event was never applied — retreating past it is bookkeeping only.
164
+ if (!this.suppressed.delete(this.k)) {
165
+ unapplyEvent(this.visible, this.table.events[this.k])
166
+ changed = true
167
+ }
168
+ }
169
+ if (changed) this.version++
170
+ }
171
+
172
+ detachLeaf(leafId: string): void {
173
+ this.detachLeaves([leafId])
174
+ }
175
+
176
+ detachLeaves(leafIds: Iterable<string>): void {
177
+ const leafById = this.getLeafById()
178
+ let changed = false
179
+ for (const leafId of leafIds) {
180
+ if (this.detached.has(leafId)) continue
181
+ if (!leafById.has(leafId)) continue
182
+ this.detached.add(leafId)
183
+ changed = true
89
184
  }
185
+ if (!changed) return
186
+ this.rebuildPatches()
187
+ this.version++
90
188
  }
91
189
 
92
190
  getVisible(): ReadonlyMap<string, ClusterNode> {
93
- return this.visible
191
+ if (this.resolvedCache && this.resolvedCache.version === this.version) {
192
+ return this.resolvedCache.map
193
+ }
194
+ let map: Map<string, ClusterNode>
195
+ if (this.patched.size === 0) {
196
+ map = this.visible
197
+ } else {
198
+ map = new Map()
199
+ for (const node of this.visible.values()) {
200
+ const resolved = this.patched.has(node.id) ? this.patched.get(node.id)! : node
201
+ if (resolved) map.set(resolved.id, resolved)
202
+ }
203
+ }
204
+ this.resolvedCache = { version: this.version, map }
205
+ return map
206
+ }
207
+
208
+ private getLeafById(): Map<string, ClusterNode> {
209
+ if (!this.leafById) {
210
+ this.leafById = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
211
+ }
212
+ return this.leafById
213
+ }
214
+
215
+ /** Recompute the patch map from the detached set. A node is patched iff it contains a detached
216
+ * member; the patch drops those members and recomputes count/centroid, collapsing to the surviving
217
+ * leaf at count 1 and to nothing at count 0. Patched nodes keep their structural id. */
218
+ private rebuildPatches() {
219
+ this.patched.clear()
220
+ const leafById = this.getLeafById()
221
+ for (const id of this.detached) {
222
+ this.patched.set(id, null)
223
+ }
224
+ const patch = (node: ClusterNode) => {
225
+ if (node.members.length === 1 || !node.members.some((m) => this.detached.has(m))) return
226
+ const members = node.members.filter((m) => !this.detached.has(m))
227
+ if (members.length === 0) {
228
+ this.patched.set(node.id, null)
229
+ } else if (members.length === 1) {
230
+ this.patched.set(node.id, leafById.get(members[0])!)
231
+ } else {
232
+ let x = 0
233
+ let y = 0
234
+ for (const m of members) {
235
+ const leaf = leafById.get(m)!
236
+ x += leaf.centroid.x
237
+ y += leaf.centroid.y
238
+ }
239
+ this.patched.set(node.id, {
240
+ id: node.id,
241
+ centroid: { x: x / members.length, y: y / members.length },
242
+ count: members.length,
243
+ members,
244
+ })
245
+ }
246
+ }
247
+ for (const event of this.table.events) {
248
+ patch(event.result)
249
+ for (const child of event.children) patch(child)
250
+ }
94
251
  }
95
252
 
96
253
  private resetVisible() {
@@ -7,7 +7,6 @@ import { cappedReplay } from './replay'
7
7
  import { contract, finalize } from './schedule'
8
8
  import { ClusterNode, ContractedEvent, LeafInput, MergeEvent, RawMergeEvent } from './types'
9
9
 
10
- // --- Synthetic builders -------------------------------------------------------
11
10
  // `contract` is specified over plain RawMergeEvent[] input precisely so it can
12
11
  // be tested with hand-built events, without running the replay.
13
12
 
@@ -55,8 +54,6 @@ const F = node(['f'])
55
54
  const X = node(['x'])
56
55
  const Y = node(['y'])
57
56
 
58
- // --- contract: fixtures -------------------------------------------------------
59
-
60
57
  describe('contract fixtures', () => {
61
58
  it('returns [] for empty input', () => {
62
59
  expect(contract([], 0.12)).toEqual([])
@@ -210,8 +207,6 @@ describe('contract validation and purity', () => {
210
207
  })
211
208
  })
212
209
 
213
- // --- finalize: fixtures --------------------------------------------------------
214
-
215
210
  const FIN_OPTS = { Tc: 40, Tu: 60, minZoom: 0.1, maxZoom: 8, maxSplitZoom: 1e9 } // r = 1.5
216
211
 
217
212
  function contracted(zMerge: number, children: ClusterNode[], result: ClusterNode): ContractedEvent {
@@ -322,7 +317,6 @@ describe('finalize fixtures', () => {
322
317
  })
323
318
  })
324
319
 
325
- // --- Composed pipeline properties ---------------------------------------------
326
320
  // finalize(contract(cappedReplay(mstEdges(leaves)))) must satisfy every table
327
321
  // invariant of CLUSTERING.md §7.6.
328
322