@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

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 (238) hide show
  1. package/commenting.css +63 -71
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +0 -1
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +77 -870
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +134 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +323 -202
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +0 -1
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +54 -878
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +126 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +323 -202
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +5 -5
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. package/src/ui/tooltip-button.tsx +0 -20
@@ -51,7 +51,6 @@ function eventShapes(events: RawMergeEvent[]) {
51
51
  return events.map(eventShape)
52
52
  }
53
53
 
54
- // --- Reference oracle -------------------------------------------------------
55
54
  // Direct transcription of contract clause 4 (CLUSTERING-STEPS.md step 2): at
56
55
  // each of the n−1 steps, evaluate every unfired MST edge's zEff against the
57
56
  // CURRENT clusters and fire the highest, tie-broken by the edge's normalized
@@ -181,8 +180,6 @@ function referenceReplay(
181
180
  return events
182
181
  }
183
182
 
184
- // --- Shared invariant assertions --------------------------------------------
185
-
186
183
  function expectNonIncreasingZ(events: RawMergeEvent[]) {
187
184
  for (let i = 1; i < events.length; i++) {
188
185
  expect(events[i].z).toBeLessThanOrEqual(events[i - 1].z)
@@ -260,8 +257,6 @@ function expectCentroids(events: RawMergeEvent[], leaves: readonly LeafInput[])
260
257
  }
261
258
  }
262
259
 
263
- // --- Fixtures ----------------------------------------------------------------
264
-
265
260
  describe('cappedReplay fixtures', () => {
266
261
  it('exports the pinned D_FLOOR constant', () => {
267
262
  expect(D_FLOOR).toBe(1e-9)
@@ -409,8 +404,6 @@ describe('cappedReplay fixtures', () => {
409
404
  })
410
405
  })
411
406
 
412
- // --- Properties against the reference oracle ---------------------------------
413
-
414
407
  describe('cappedReplay vs reference simulator', () => {
415
408
  const OPTION_SETS = [
416
409
  { Tc: 40, Dmax: 120 }, // default shape
@@ -1,17 +1,20 @@
1
- import type { ClusterNode, LeafInput, MstEdge, RawMergeEvent } from './types'
1
+ import type { ClusterNode, LeafInput, LeafScreenOffsets, MstEdge, RawMergeEvent } from './types'
2
2
 
3
3
  export const D_FLOOR = 1e-9
4
4
 
5
5
  export function cappedReplay(
6
6
  leaves: readonly LeafInput[],
7
7
  edges: readonly MstEdge[],
8
- opts: { Tc: number; Dmax: number }
8
+ opts: { Tc: number; Dmax: number },
9
+ // Render offsets for markers that draw off their anchor (imprecise pins). Omitted or empty,
10
+ // every code path below is the offset-unaware original — pricing, events, and floats alike.
11
+ screenOffsets?: LeafScreenOffsets
9
12
  ): RawMergeEvent[] {
10
13
  validateOptions(opts)
11
14
 
12
15
  if (edges.length === 0) return []
13
16
 
14
- const clusters = new ClusterState(leaves)
17
+ const clusters = new ClusterState(leaves, screenOffsets)
15
18
  const heap = new EdgeMaxHeap(edges, leaves)
16
19
  // Edges incident to each current cluster root, for eager repricing: a merge
17
20
  // moves the result's centroid, which can RAISE an incident edge's key (the
@@ -95,19 +98,64 @@ function zForRoots(
95
98
  clusters: ClusterState,
96
99
  opts: { Tc: number; Dmax: number }
97
100
  ): number {
98
- if (edge.d < D_FLOOR) return Number.POSITIVE_INFINITY
99
- // Badge-anchored gap pricing: clusters render as badges at their centroids,
100
- // so the merge is priced by the distance between the rendered centers, not
101
- // the nearest members. For leaves the two are identical; for clusters the
102
- // centroid distance is larger, so groups merge later than their closest
103
- // members would suggest matching what the user actually sees.
104
- // (Coincident CENTROIDS with non-coincident members leave the gap term
105
- // Infinity and the fit term finite the min stays finite, no special case.)
106
- const gap = opts.Tc / clusters.centroidDistance(aRoot, bRoot)
101
+ // Null unless offset pricing is active AND this pair's mean render offsets differ. Every
102
+ // other pair including every pair when no offsets were passed — takes the branch below,
103
+ // which is the offset-unaware pricing verbatim.
104
+ const off = clusters.offsetDelta(aRoot, bRoot)
105
+ if (off === null) {
106
+ if (edge.d < D_FLOOR) return Number.POSITIVE_INFINITY
107
+ // Badge-anchored gap pricing: clusters render as badges at their centroids,
108
+ // so the merge is priced by the distance between the rendered centers, not
109
+ // the nearest members. For leaves the two are identical; for clusters the
110
+ // centroid distance is larger, so groups merge later than their closest
111
+ // members would suggest — matching what the user actually sees.
112
+ // (Coincident CENTROIDS with non-coincident members leave the gap term
113
+ // Infinity and the fit term finite — the min stays finite, no special case.)
114
+ const gap = opts.Tc / clusters.centroidDistance(aRoot, bRoot)
115
+ const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
116
+ return Math.min(gap, fit)
117
+ }
118
+
119
+ // Offset-aware gap pricing: these markers render at `z·centroid + meanOffset`, so their
120
+ // visual distance at zoom z is |z·ΔC + Δō| and the merge prices at its Tc crossing. When
121
+ // that distance never reaches Tc (the constant offsets hold the visuals apart harder than
122
+ // the anchors close), the pair prices at 0 — visually never mergeable, pruned by finalize's
123
+ // minZoom cut. No D_FLOOR fast path here: coincident anchors with differing offsets are a
124
+ // constant |Δō| apart on screen, which is exactly the ΔC = 0 case below.
125
+ const dc = clusters.centroidDelta(aRoot, bRoot)
126
+ if (dc.x === 0 && dc.y === 0) {
127
+ return Math.hypot(off.x, off.y) < opts.Tc ? Number.POSITIVE_INFINITY : 0
128
+ }
129
+ const gap = largestVisualCrossing(dc.x, dc.y, off.x, off.y, opts.Tc)
130
+ if (gap === null) return 0
107
131
  const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
108
132
  return Math.min(gap, fit)
109
133
  }
110
134
 
135
+ /**
136
+ * The largest positive root of `|z·ΔC + Δō| = level`, i.e. of
137
+ * `|ΔC|²·z² + 2(ΔC·Δō)·z + (|Δō|² − level²) = 0` — the zoom at which two offset markers are
138
+ * exactly `level` screen px apart, with the distance below the level for every smaller zoom
139
+ * (matching the "merged at z ≤ threshold" model; a lower second crossing, where opposed offsets
140
+ * push the visuals back above the level near z = 0, is deliberately collapsed). Null when the
141
+ * visual distance never reaches the level. Callers handle ΔC = 0 (constant distance) themselves.
142
+ */
143
+ function largestVisualCrossing(
144
+ dcx: number,
145
+ dcy: number,
146
+ dox: number,
147
+ doy: number,
148
+ level: number
149
+ ): number | null {
150
+ const a = dcx * dcx + dcy * dcy
151
+ const b = 2 * (dcx * dox + dcy * doy)
152
+ const c = dox * dox + doy * doy - level * level
153
+ const disc = b * b - 4 * a * c
154
+ if (disc < 0) return null
155
+ const z = (-b + Math.sqrt(disc)) / (2 * a)
156
+ return z > 0 ? z : null
157
+ }
158
+
111
159
  class ClusterState {
112
160
  private readonly parent: Int32Array
113
161
  private readonly minX: Float64Array
@@ -120,8 +168,13 @@ class ClusterState {
120
168
  private readonly nodes: ClusterNode[]
121
169
  private readonly memberLists: string[][]
122
170
  private readonly minMemberIds: string[]
171
+ // Per-cluster sums of member render offsets (screen px), maintained like the centroid sums.
172
+ // Null when no offsets were passed — offsetDelta() then answers null unconditionally, which
173
+ // routes every pricing call down the offset-unaware path.
174
+ private readonly offsetX: Float64Array | null
175
+ private readonly offsetY: Float64Array | null
123
176
 
124
- constructor(leaves: readonly LeafInput[]) {
177
+ constructor(leaves: readonly LeafInput[], screenOffsets?: LeafScreenOffsets) {
125
178
  const n = leaves.length
126
179
  this.parent = new Int32Array(n)
127
180
  this.minX = new Float64Array(n)
@@ -135,6 +188,21 @@ class ClusterState {
135
188
  this.memberLists = new Array(n)
136
189
  this.minMemberIds = new Array(n)
137
190
 
191
+ if (screenOffsets !== undefined && screenOffsets.size > 0) {
192
+ this.offsetX = new Float64Array(n)
193
+ this.offsetY = new Float64Array(n)
194
+ for (let i = 0; i < n; i++) {
195
+ const offset = screenOffsets.get(leaves[i].id)
196
+ if (offset) {
197
+ this.offsetX[i] = offset.x
198
+ this.offsetY[i] = offset.y
199
+ }
200
+ }
201
+ } else {
202
+ this.offsetX = null
203
+ this.offsetY = null
204
+ }
205
+
138
206
  for (let i = 0; i < n; i++) {
139
207
  const leaf = leaves[i]
140
208
  this.parent[i] = i
@@ -176,6 +244,24 @@ class ClusterState {
176
244
  )
177
245
  }
178
246
 
247
+ centroidDelta(aRoot: number, bRoot: number): { x: number; y: number } {
248
+ return {
249
+ x: this.centroidX[aRoot] - this.centroidX[bRoot],
250
+ y: this.centroidY[aRoot] - this.centroidY[bRoot],
251
+ }
252
+ }
253
+
254
+ /** The difference of the two clusters' mean render offsets (screen px), or null when it's
255
+ * zero — including always when no offsets were passed. Null routes pricing down the
256
+ * offset-unaware path. */
257
+ offsetDelta(aRoot: number, bRoot: number): { x: number; y: number } | null {
258
+ if (this.offsetX === null || this.offsetY === null) return null
259
+ const x = this.offsetX[aRoot] / this.counts[aRoot] - this.offsetX[bRoot] / this.counts[bRoot]
260
+ const y = this.offsetY[aRoot] / this.counts[aRoot] - this.offsetY[bRoot] / this.counts[bRoot]
261
+ if (x === 0 && y === 0) return null
262
+ return { x, y }
263
+ }
264
+
179
265
  unionBboxDiag(aRoot: number, bRoot: number): number {
180
266
  const minX = Math.min(this.minX[aRoot], this.minX[bRoot])
181
267
  const minY = Math.min(this.minY[aRoot], this.minY[bRoot])
@@ -224,6 +310,10 @@ class ClusterState {
224
310
  this.nodes[leftRoot] = result
225
311
  this.memberLists[leftRoot] = members
226
312
  this.minMemberIds[leftRoot] = members[0]
313
+ if (this.offsetX !== null && this.offsetY !== null) {
314
+ this.offsetX[leftRoot] += this.offsetX[rightRoot]
315
+ this.offsetY[leftRoot] += this.offsetY[rightRoot]
316
+ }
227
317
 
228
318
  return { z, children: [left, right], result }
229
319
  }
@@ -253,11 +343,29 @@ interface HeapEntry {
253
343
 
254
344
  class EdgeMaxHeap {
255
345
  private readonly items: HeapEntry[] = []
256
-
257
- constructor(
258
- private readonly edges: readonly MstEdge[],
259
- private readonly leaves: readonly LeafInput[]
260
- ) {}
346
+ // Per-edge normalized (lo, hi) id pair for the z tie-break, precomputed once so comparisons
347
+ // allocate nothing. With coincident anchors every edge prices to the same z (+Infinity), so
348
+ // the tie-break runs on nearly every comparison of a rebuild — allocating the pair there
349
+ // churned millions of short-lived tuples.
350
+ private readonly loIds: string[]
351
+ private readonly hiIds: string[]
352
+
353
+ constructor(edges: readonly MstEdge[], leaves: readonly LeafInput[]) {
354
+ const n = edges.length
355
+ this.loIds = new Array(n)
356
+ this.hiIds = new Array(n)
357
+ for (let i = 0; i < n; i++) {
358
+ const aId = leaves[edges[i].a].id
359
+ const bId = leaves[edges[i].b].id
360
+ if (aId < bId) {
361
+ this.loIds[i] = aId
362
+ this.hiIds[i] = bId
363
+ } else {
364
+ this.loIds[i] = bId
365
+ this.hiIds[i] = aId
366
+ }
367
+ }
368
+ }
261
369
 
262
370
  peek(): HeapEntry | undefined {
263
371
  return this.items[0]
@@ -282,7 +390,11 @@ class EdgeMaxHeap {
282
390
  higherPriority(a: HeapEntry, b: HeapEntry): boolean {
283
391
  if (a.z > b.z) return true
284
392
  if (a.z < b.z) return false
285
- return edgeIdPairLess(this.edges[a.edgeIndex], this.edges[b.edgeIndex], this.leaves)
393
+ // Tie-break on the normalized leaf-id pair, ascending.
394
+ const aLo = this.loIds[a.edgeIndex]
395
+ const bLo = this.loIds[b.edgeIndex]
396
+ if (aLo !== bLo) return aLo < bLo
397
+ return this.hiIds[a.edgeIndex] < this.hiIds[b.edgeIndex]
286
398
  }
287
399
 
288
400
  private siftUp(index: number) {
@@ -311,16 +423,3 @@ class EdgeMaxHeap {
311
423
  }
312
424
  }
313
425
  }
314
-
315
- function edgeIdPairLess(a: MstEdge, b: MstEdge, leaves: readonly LeafInput[]): boolean {
316
- const [aLo, aHi] = normalizedEdgeIds(a, leaves)
317
- const [bLo, bHi] = normalizedEdgeIds(b, leaves)
318
- if (aLo !== bLo) return aLo < bLo
319
- return aHi < bHi
320
- }
321
-
322
- function normalizedEdgeIds(edge: MstEdge, leaves: readonly LeafInput[]): [string, string] {
323
- const aId = leaves[edge.a].id
324
- const bId = leaves[edge.b].id
325
- return aId < bId ? [aId, bId] : [bId, aId]
326
- }
@@ -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
  }
@@ -793,6 +787,56 @@ describe('createClusterRuntime detachLeaf (local partition edits)', () => {
793
787
  expect(rt.getVisible().get(P.id)).toEqual(P)
794
788
  })
795
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
+
796
840
  it('getVisible returns a stable reference until the partition changes', () => {
797
841
  const table = microTraceTable()
798
842
  const rt = createClusterRuntime(table)
@@ -16,28 +16,29 @@ export interface ClusterRuntime {
16
16
  /** Reset state from scratch for the given zoom (cold start / after rebuild). Clears detaches. */
17
17
  seed(zoom: number): void
18
18
  /**
19
- * Reset state for the given zoom, carrying hysteresis state over from a previous partition
20
- * (the visible map of the model being replaced). Threshold-forced events ignore history:
21
- * zoom \<= zMerge is always merged, zoom \>= zSplit always split. An event inside its band
22
- * keeps its previous state: merged iff its members were merged together in `previous`; a band
23
- * event that was unmerged (or has no history, e.g. introduced by the rebuild) stays unmerged
24
- * inside the cursor but suppressed — until a zoom-out crosses its own zMerge, exactly like any
25
- * other pending merge. Carryover is exact: no group changes state because of the swap alone.
26
- * Clears detaches.
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.
27
24
  */
28
25
  seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
29
26
  /** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
30
27
  onCamera(zoom: number): void
31
28
  /**
32
29
  * Remove one leaf from the displayed partition without touching the event table. Local by
33
- * construction: only the nodes containing the leaf change a badge shrinks in place (count
34
- * and centroid recomputed from its remaining members), a pair collapses to its surviving
35
- * leaf, and the leaf on its own disappears. Everything else is untouched, so a deletion,
36
- * pop-out, or thread-open never re-flows the rest of the document. The table's thresholds
37
- * around the detached leaf go stale; the caller is expected to hold a corrected rebuild and
38
- * adopt it (with seedFrom) at the next zoom-out. Unknown or already-detached ids are no-ops.
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.
39
34
  */
40
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
41
42
  /** The displayed partition: cluster id → node, with detaches applied. Do not mutate. */
42
43
  getVisible(): ReadonlyMap<string, ClusterNode>
43
44
  }
@@ -56,10 +57,9 @@ class ClusterRuntimeImpl implements ClusterRuntime {
56
57
  // time in getVisible().
57
58
  private visible = new Map<string, ClusterNode>()
58
59
  private seeded = false
59
- // Indices (< k) of band events held unmerged by seedFrom carryover. The single cursor can
60
- // only express "merged up to here", but a carried-over partition can be "merged except these"
61
- // — the exceptions live here. Self-draining: an entry leaves via onCamera when the zoom
62
- // crosses its own zMerge (merges) or its zSplit (the split walk retreats past it).
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
63
  private suppressed = new Set<number>()
64
64
  // Leaves removed from the displayed partition (deleted / popped out / opened). Patches map
65
65
  // each structural node containing a detached leaf to its displayed replacement (or null to
@@ -121,10 +121,8 @@ class ClusterRuntimeImpl implements ClusterRuntime {
121
121
  for (let i = 0; i < k; i++) {
122
122
  const event = events[i]
123
123
  if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
124
- // In its band and previously unmerged: keep it unmerged, as an exception inside
125
- // the cursor. Dependency-safe: an applied event can never consume a suppressed
126
- // result — merged members imply merged (subset) children, and threshold-forced
127
- // events force their children too (zMerge is non-increasing down the table).
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.
128
126
  this.suppressed.add(i)
129
127
  } else {
130
128
  applyEvent(this.visible, event)
@@ -141,17 +139,13 @@ class ClusterRuntimeImpl implements ClusterRuntime {
141
139
  }
142
140
 
143
141
  let changed = false
144
- // Heal suppressed events at their own merge threshold BEFORE the merge walk: a zoom-out
145
- // past zMerge merges a held-out band event exactly as if it had still been ahead of the
146
- // cursor. Healing must precede the walk because a single zoom jump can cross both a
147
- // suppressed event and an event that consumes its result; applying the consumer first
148
- // would leave the unapplied producer's children in `visible` (its delete is a no-op) and
149
- // then re-add the producer on top, double-counting those leaves. Threshold monotonicity
150
- // guarantees a suppressed producer's zMerge is >= its consumer's, so anything the walk
151
- // needs has already healed. Set iteration is insertion order (ascending index), so a
152
- // healed event's suppressed children (larger zMerge, smaller index) heal before it.
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.
153
147
  if (this.suppressed.size > 0) {
154
- for (const i of [...this.suppressed]) {
148
+ for (const i of this.suppressed) {
155
149
  if (zoom <= this.table.events[i].zMerge) {
156
150
  this.suppressed.delete(i)
157
151
  applyEvent(this.visible, this.table.events[i])
@@ -176,9 +170,19 @@ class ClusterRuntimeImpl implements ClusterRuntime {
176
170
  }
177
171
 
178
172
  detachLeaf(leafId: string): void {
179
- if (this.detached.has(leafId)) return
180
- if (!this.getLeafById().has(leafId)) return
181
- this.detached.add(leafId)
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
184
+ }
185
+ if (!changed) return
182
186
  this.rebuildPatches()
183
187
  this.version++
184
188
  }
@@ -208,10 +212,9 @@ class ClusterRuntimeImpl implements ClusterRuntime {
208
212
  return this.leafById
209
213
  }
210
214
 
211
- /** Recompute the patch map from the detached set. A node is patched iff it contains a
212
- * detached member; the patch drops those members and recomputes count/centroid, collapsing
213
- * to the surviving leaf node at count 1 and to nothing at count 0. Patched nodes keep their
214
- * structural id, so cursor events keep addressing them. */
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. */
215
218
  private rebuildPatches() {
216
219
  this.patched.clear()
217
220
  const leafById = this.getLeafById()
@@ -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