@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,91 @@
1
+ import { BoxModel, Editor, TLCommentAnchor, TLCommentThread, TLShapeId, VecLike } from 'tldraw'
2
+ import { getRegionCommentOptions } from './region-options'
3
+ import { openThreadId } from './state'
4
+
5
+ /** Where an imprecise shape comment sits by default: the shape's top-right corner. Overridable. @public */
6
+ export const DEFAULT_IMPRECISE_SHAPE_ANCHOR = { x: 1, y: 0 }
7
+
8
+ /** The default corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right).
9
+ * Overridable per editor via region options; pin position, composer placement, region move, and
10
+ * which corner has no resize handle all derive from the chosen corner. */
11
+ export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
12
+
13
+ /** The page point of a region's pin corner. */
14
+ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
15
+ return {
16
+ x: region.x + corner.x * region.w,
17
+ y: region.y + corner.y * region.h,
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. For imprecise
23
+ * shape anchors the pin uses `impreciseShapeAnchor` (a normalized 0–1 spot, top-right by default)
24
+ * rather than the stored `x`/`y`.
25
+ * @public
26
+ */
27
+ export function anchorPagePoint(
28
+ editor: Editor,
29
+ anchor: TLCommentAnchor,
30
+ impreciseShapeAnchor: { x: number; y: number } = DEFAULT_IMPRECISE_SHAPE_ANCHOR
31
+ ): { x: number; y: number } | null {
32
+ switch (anchor.type) {
33
+ case 'shape': {
34
+ const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
35
+ if (!bounds) return null
36
+ // Precise pins sit at their stored x/y; imprecise ones at the consumer's default spot.
37
+ const { x, y } = anchor.isPrecise ? anchor : impreciseShapeAnchor
38
+ return { x: bounds.minX + x * bounds.w, y: bounds.minY + y * bounds.h }
39
+ }
40
+ case 'text-range': {
41
+ const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
42
+ if (!bounds) return null
43
+ return { x: bounds.maxX, y: bounds.minY }
44
+ }
45
+ case 'point':
46
+ return { x: anchor.x, y: anchor.y }
47
+ case 'region':
48
+ return regionPinPoint(anchor, getRegionCommentOptions(editor).pinCorner)
49
+ case 'page':
50
+ return null
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
56
+ * shape's page bounds, remembered either way. When `precise` (Alt held) the pin sits at exactly
57
+ * `x`/`y`; otherwise it sits at the consumer's imprecise default (top-right out of the box).
58
+ * @public
59
+ */
60
+ export function shapeAnchorAt(
61
+ editor: Editor,
62
+ shapeId: TLShapeId,
63
+ page: { x: number; y: number },
64
+ precise: boolean
65
+ ): TLCommentAnchor {
66
+ const bounds = editor.getShapePageBounds(shapeId)
67
+ if (!bounds || bounds.w === 0 || bounds.h === 0) {
68
+ return { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: precise }
69
+ }
70
+ return {
71
+ type: 'shape',
72
+ shapeId,
73
+ x: (page.x - bounds.minX) / bounds.w,
74
+ y: (page.y - bounds.minY) / bounds.h,
75
+ isPrecise: precise,
76
+ }
77
+ }
78
+
79
+ /** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
80
+ export function focusThread(
81
+ editor: Editor,
82
+ thread: TLCommentThread,
83
+ impreciseShapeAnchor?: { x: number; y: number }
84
+ ): void {
85
+ if (thread.pageId !== editor.getCurrentPageId()) {
86
+ editor.setCurrentPage(thread.pageId as any)
87
+ }
88
+ openThreadId.set(editor, thread.id)
89
+ const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
90
+ if (point) editor.centerOnPoint(point, { animation: { duration: 200 } })
91
+ }
@@ -0,0 +1,299 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeClusterTable } from './computeClusterTable'
3
+ import { mstEdges } from './mst'
4
+ import { cappedReplay } from './replay'
5
+ import { createClusterRuntime } from './runtime'
6
+ import { contract, finalize } from './schedule'
7
+ import { ClusterTable, LeafInput, MergeEvent } from './types'
8
+
9
+ function leaf(id: string, x: number, y: number): LeafInput {
10
+ return { id, point: { x, y } }
11
+ }
12
+
13
+ function mulberry32(seed: number): () => number {
14
+ let a = seed >>> 0
15
+ return () => {
16
+ a = (a + 0x6d2b79f5) >>> 0
17
+ let t = a
18
+ t = Math.imul(t ^ (t >>> 15), t | 1)
19
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
20
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
21
+ }
22
+ }
23
+
24
+ function shuffled<T>(items: readonly T[], rand: () => number): T[] {
25
+ const out = items.slice()
26
+ for (let i = out.length - 1; i > 0; i--) {
27
+ const j = Math.floor(rand() * (i + 1))
28
+ ;[out[i], out[j]] = [out[j], out[i]]
29
+ }
30
+ return out
31
+ }
32
+
33
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
34
+ const rand = mulberry32(seed)
35
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
36
+ }
37
+
38
+ /** The pipeline composed by hand — computeClusterTable must match it exactly. */
39
+ function manualPipeline(
40
+ leaves: readonly LeafInput[],
41
+ opts: {
42
+ Tc: number
43
+ Tu: number
44
+ eps: number
45
+ Dmax: number
46
+ minZoom: number
47
+ maxZoom: number
48
+ maxSplitZoom?: number
49
+ }
50
+ ): MergeEvent[] {
51
+ const raw = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
52
+ return finalize(contract(raw, opts.eps), {
53
+ Tc: opts.Tc,
54
+ Tu: opts.Tu,
55
+ minZoom: opts.minZoom,
56
+ maxZoom: opts.maxZoom,
57
+ maxSplitZoom: opts.maxSplitZoom ?? 6,
58
+ })
59
+ }
60
+
61
+ function eventShape(ev: MergeEvent) {
62
+ return {
63
+ zMerge: ev.zMerge,
64
+ zSplit: ev.zSplit,
65
+ children: ev.children.map((c) => c.id),
66
+ result: ev.result.id,
67
+ members: ev.result.members,
68
+ }
69
+ }
70
+
71
+ function tableShapes(table: ClusterTable) {
72
+ return table.events.map(eventShape)
73
+ }
74
+
75
+ const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
76
+
77
+ describe('computeClusterTable composition', () => {
78
+ it('equals the hand-composed pipeline with all options explicit', () => {
79
+ const opts = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, ...ZOOM_BOUNDS }
80
+ for (const seed of [1, 42]) {
81
+ for (const n of [2, 10, 60]) {
82
+ const leaves = randomLeaves(n, seed * 19 + n)
83
+ const table = computeClusterTable(leaves, opts)
84
+ expect(tableShapes(table)).toEqual(manualPipeline(leaves, opts).map(eventShape))
85
+ }
86
+ }
87
+ })
88
+
89
+ it('resolves all defaults from an empty option set (Tc 40, Tu 60, eps 0.12, Dmax 120)', () => {
90
+ const leaves = randomLeaves(30, 7)
91
+ const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
92
+ const expected = manualPipeline(leaves, {
93
+ Tc: 40,
94
+ Tu: 60,
95
+ eps: 0.12,
96
+ Dmax: 120,
97
+ ...ZOOM_BOUNDS,
98
+ })
99
+ expect(tableShapes(table)).toEqual(expected.map(eventShape))
100
+ })
101
+
102
+ it('derives Tu and Dmax from a caller-supplied Tc (Tu = 1.5·Tc, Dmax = 3·Tc)', () => {
103
+ const leaves = randomLeaves(30, 13)
104
+ const table = computeClusterTable(leaves, { Tc: 50, ...ZOOM_BOUNDS })
105
+ const expected = manualPipeline(leaves, {
106
+ Tc: 50,
107
+ Tu: 75,
108
+ eps: 0.12,
109
+ Dmax: 150,
110
+ ...ZOOM_BOUNDS,
111
+ })
112
+ expect(tableShapes(table)).toEqual(expected.map(eventShape))
113
+ })
114
+
115
+ it('caps coincident pairs at the default maxSplitZoom of 6 (600%)', () => {
116
+ // Two threads at the same point: raw merge zoom is Infinity (never split), but the
117
+ // default cap schedules them to merge at 4 and split at 6 like any ultra-tight pair.
118
+ const leaves = [leaf('a', 50, 50), leaf('b', 50, 50)]
119
+ const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
120
+ expect(table.events).toHaveLength(1)
121
+ expect(table.events[0].zMerge).toBe(4)
122
+ expect(table.events[0].zSplit).toBe(6)
123
+ })
124
+
125
+ it('honors partial overrides without disturbing the other defaults', () => {
126
+ const leaves = randomLeaves(30, 29)
127
+ const table = computeClusterTable(leaves, { Tu: 100, eps: 0, ...ZOOM_BOUNDS })
128
+ const expected = manualPipeline(leaves, {
129
+ Tc: 40,
130
+ Tu: 100,
131
+ eps: 0,
132
+ Dmax: 120,
133
+ ...ZOOM_BOUNDS,
134
+ })
135
+ expect(tableShapes(table)).toEqual(expected.map(eventShape))
136
+ })
137
+ })
138
+
139
+ describe('computeClusterTable leaves output', () => {
140
+ it('returns leaf nodes in input order with exact leaf shape', () => {
141
+ const leaves = [leaf('z-last', 5, 6), leaf('a-first', 1, 2), leaf('m-mid', 3, 4)]
142
+ const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
143
+ expect(table.leaves).toEqual([
144
+ { id: 'z-last', centroid: { x: 5, y: 6 }, count: 1, members: ['z-last'] },
145
+ { id: 'a-first', centroid: { x: 1, y: 2 }, count: 1, members: ['a-first'] },
146
+ { id: 'm-mid', centroid: { x: 3, y: 4 }, count: 1, members: ['m-mid'] },
147
+ ])
148
+ })
149
+
150
+ it('handles n = 0 and n = 1 (empty events, leaves still produced)', () => {
151
+ expect(computeClusterTable([], { ...ZOOM_BOUNDS })).toEqual({ events: [], leaves: [] })
152
+ const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
153
+ expect(table.events).toEqual([])
154
+ expect(table.leaves).toEqual([
155
+ { id: 'only', centroid: { x: 9, y: 9 }, count: 1, members: ['only'] },
156
+ ])
157
+ })
158
+
159
+ it('is usable directly by the runtime (n = 1 and n = 0 tables)', () => {
160
+ const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
161
+ const rt = createClusterRuntime(table)
162
+ rt.seed(1)
163
+ rt.onCamera(0.1)
164
+ expect([...rt.getVisible().keys()]).toEqual(['only'])
165
+ })
166
+ })
167
+
168
+ describe('computeClusterTable determinism', () => {
169
+ it('is invariant under input permutation, events as id-keyed structures', () => {
170
+ const base = randomLeaves(50, 23)
171
+ const expected = tableShapes(computeClusterTable(base, { ...ZOOM_BOUNDS }))
172
+ for (const shuffleSeed of [5, 17]) {
173
+ const perm = shuffled(base, mulberry32(shuffleSeed))
174
+ const table = computeClusterTable(perm, { ...ZOOM_BOUNDS })
175
+ expect(tableShapes(table)).toEqual(expected)
176
+ // leaves follow the (new) input order
177
+ expect(table.leaves.map((l) => l.id)).toEqual(perm.map((l) => l.id))
178
+ }
179
+ })
180
+
181
+ it('returns identical output for repeated calls', () => {
182
+ const leaves = randomLeaves(20, 3)
183
+ expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS })).toEqual(
184
+ computeClusterTable(leaves, { ...ZOOM_BOUNDS })
185
+ )
186
+ })
187
+ })
188
+
189
+ describe('computeClusterTable validation', () => {
190
+ const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
191
+
192
+ it('rejects every invalid option combination', () => {
193
+ expect(() => computeClusterTable(leaves, { Tc: 0, ...ZOOM_BOUNDS })).toThrow()
194
+ expect(() => computeClusterTable(leaves, { Tc: -5, ...ZOOM_BOUNDS })).toThrow()
195
+ expect(() => computeClusterTable(leaves, { Tc: 40, Tu: 40, ...ZOOM_BOUNDS })).toThrow()
196
+ expect(() => computeClusterTable(leaves, { Tu: 30, ...ZOOM_BOUNDS })).toThrow() // < default Tc 40
197
+ expect(() => computeClusterTable(leaves, { eps: -0.01, ...ZOOM_BOUNDS })).toThrow()
198
+ expect(() => computeClusterTable(leaves, { Dmax: 30, ...ZOOM_BOUNDS })).toThrow() // < default Tc 40
199
+ expect(() => computeClusterTable(leaves, { Tc: 50, Dmax: 40, ...ZOOM_BOUNDS })).toThrow()
200
+ expect(() => computeClusterTable(leaves, { minZoom: 0, maxZoom: 8 })).toThrow()
201
+ expect(() => computeClusterTable(leaves, { minZoom: 8, maxZoom: 8 })).toThrow()
202
+ expect(() => computeClusterTable(leaves, { minZoom: 9, maxZoom: 8 })).toThrow()
203
+ expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: NaN })).toThrow()
204
+ expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: Infinity })).toThrow()
205
+ expect(() => computeClusterTable(leaves, { eps: NaN, ...ZOOM_BOUNDS })).toThrow()
206
+ expect(() => computeClusterTable(leaves, { Tc: NaN, ...ZOOM_BOUNDS })).toThrow()
207
+ })
208
+
209
+ it('validates even when there is nothing to cluster', () => {
210
+ expect(() => computeClusterTable([], { minZoom: 0, maxZoom: 8 })).toThrow()
211
+ expect(() => computeClusterTable([leaf('x', 0, 0)], { Tc: -1, ...ZOOM_BOUNDS })).toThrow()
212
+ })
213
+
214
+ it('propagates input errors from the MST stage', () => {
215
+ expect(() =>
216
+ computeClusterTable([leaf('dup', 0, 0), leaf('dup', 1, 1)], { ...ZOOM_BOUNDS })
217
+ ).toThrow(/dup/)
218
+ expect(() =>
219
+ computeClusterTable([leaf('ok', 0, 0), leaf('bad', NaN, 0)], { ...ZOOM_BOUNDS })
220
+ ).toThrow(/bad/)
221
+ })
222
+ })
223
+
224
+ describe('computeClusterTable end-to-end scenarios', () => {
225
+ it('produces the appendix A.2 zoom bands for the 8-pin chain, via the runtime', () => {
226
+ const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
227
+ const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
228
+
229
+ // events (centroid pricing): four pair births at zMerge 4 (zSplit 6), two
230
+ // quads at zMerge 2 (zSplit 3), the bridge at zMerge 1 (zSplit 1.5)
231
+ const seedAt = (zoom: number) => {
232
+ const rt = createClusterRuntime(table)
233
+ rt.seed(zoom)
234
+ return [...rt.getVisible().keys()].sort()
235
+ }
236
+ // zoom 8: above every band midpoint → 8 separate pins
237
+ expect(seedAt(8)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
238
+ // zoom 3: below the pair midpoints (√24 ≈ 4.9), above the quad midpoint (√6 ≈ 2.45)
239
+ expect(seedAt(3)).toEqual(['cluster:2:p1', 'cluster:2:p3', 'cluster:2:p5', 'cluster:2:p7'])
240
+ // zoom 2: quads merged, above the bridge midpoint (√1.5 ≈ 1.22)
241
+ expect(seedAt(2)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
242
+ // zoom 1: below everything → one badge of 8
243
+ expect(seedAt(1)).toEqual(['cluster:8:p1'])
244
+ })
245
+
246
+ it('keeps far-apart groups exactly independent at eps = 0 (bridge pruned by minZoom)', () => {
247
+ // Two groups 10,000 page units apart: the bridging merge would fire at
248
+ // z ≈ 40/10000 = 0.004 < minZoom → pruned. With eps = 0 no contraction
249
+ // window can couple the groups, so the combined table is exactly the two
250
+ // solo tables' events merged in threshold order.
251
+ const groupA = randomLeaves(12, 5, 300)
252
+ const groupB = randomLeaves(12, 9, 300).map((l) => ({
253
+ id: `far-${l.id}`,
254
+ point: { x: l.point.x + 10000, y: l.point.y },
255
+ }))
256
+ const opts = { eps: 0, ...ZOOM_BOUNDS }
257
+
258
+ const combined = tableShapes(computeClusterTable([...groupA, ...groupB], opts))
259
+ const soloA = tableShapes(computeClusterTable(groupA, opts))
260
+ const soloB = tableShapes(computeClusterTable(groupB, opts))
261
+
262
+ const merged = [...soloA, ...soloB].sort((a, b) => b.zMerge - a.zMerge)
263
+ expect(combined.map((e) => e.result).sort()).toEqual(merged.map((e) => e.result).sort())
264
+ expect(combined).toHaveLength(soloA.length + soloB.length)
265
+
266
+ // no event ever mixes members across the gap
267
+ for (const ev of combined) {
268
+ const far = ev.members.filter((m) => m.startsWith('far-')).length
269
+ expect(far === 0 || far === ev.members.length).toBe(true)
270
+ }
271
+ })
272
+
273
+ it('keeps group memberships unmixed at default eps too (thresholds may synchronize)', () => {
274
+ // Global contraction windows may pull the two groups' thresholds together
275
+ // (synchronized firing is by design) but must never fuse their clusters.
276
+ const groupA = randomLeaves(10, 55, 300)
277
+ const groupB = randomLeaves(10, 66, 300).map((l) => ({
278
+ id: `far-${l.id}`,
279
+ point: { x: l.point.x + 10000, y: l.point.y },
280
+ }))
281
+ const combined = computeClusterTable([...groupA, ...groupB], { ...ZOOM_BOUNDS })
282
+ for (const ev of combined.events) {
283
+ const far = ev.result.members.filter((m) => m.startsWith('far-')).length
284
+ expect(far === 0 || far === ev.result.members.length).toBe(true)
285
+ }
286
+ })
287
+ })
288
+
289
+ describe('computeClusterTable performance', () => {
290
+ it('builds 2,000 leaves well within budget', () => {
291
+ const leaves = randomLeaves(2000, 2026)
292
+ const start = performance.now()
293
+ const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
294
+ const elapsed = performance.now() - start
295
+ expect(table.leaves).toHaveLength(2000)
296
+ // generous bound to stay non-flaky on slow CI
297
+ expect(elapsed).toBeLessThan(2500)
298
+ })
299
+ })
@@ -0,0 +1,82 @@
1
+ import { mstEdges } from './mst'
2
+ import { cappedReplay } from './replay'
3
+ import { contract, finalize } from './schedule'
4
+ import type { ClusterNode, ClusterOptions, ClusterTable, LeafInput } from './types'
5
+
6
+ interface ResolvedClusterOptions {
7
+ Tc: number
8
+ Tu: number
9
+ eps: number
10
+ Dmax: number
11
+ minZoom: number
12
+ maxZoom: number
13
+ maxSplitZoom: number
14
+ }
15
+
16
+ /** @public */
17
+ export function computeClusterTable(
18
+ leaves: readonly LeafInput[],
19
+ options: ClusterOptions
20
+ ): ClusterTable {
21
+ const opts = resolveOptions(options)
22
+ const leafNodes = leaves.map(leafToNode)
23
+ const edges = mstEdges(leaves)
24
+
25
+ if (leaves.length < 2) {
26
+ return { events: [], leaves: leafNodes }
27
+ }
28
+
29
+ const raw = cappedReplay(leaves, edges, { Tc: opts.Tc, Dmax: opts.Dmax })
30
+ const contracted = contract(raw, opts.eps)
31
+ const events = finalize(contracted, {
32
+ Tc: opts.Tc,
33
+ Tu: opts.Tu,
34
+ minZoom: opts.minZoom,
35
+ maxZoom: opts.maxZoom,
36
+ maxSplitZoom: opts.maxSplitZoom,
37
+ })
38
+
39
+ return { events, leaves: leafNodes }
40
+ }
41
+
42
+ function resolveOptions(options: ClusterOptions): ResolvedClusterOptions {
43
+ const Tc = options.Tc ?? 40
44
+ const Tu = options.Tu ?? 1.5 * Tc
45
+ const eps = options.eps ?? 0.12
46
+ const Dmax = options.Dmax ?? 3 * Tc
47
+ const maxSplitZoom = options.maxSplitZoom ?? 6
48
+ const { minZoom, maxZoom } = options
49
+
50
+ if (!Number.isFinite(Tc) || Tc <= 0) {
51
+ throw new Error('Tc must be finite and greater than 0')
52
+ }
53
+ if (!Number.isFinite(Tu) || Tu <= Tc) {
54
+ throw new Error('Tu must be finite and greater than Tc')
55
+ }
56
+ if (!Number.isFinite(eps) || eps < 0) {
57
+ throw new Error('eps must be finite and greater than or equal to 0')
58
+ }
59
+ if (!Number.isFinite(Dmax) || Dmax < Tc) {
60
+ throw new Error('Dmax must be finite and greater than or equal to Tc')
61
+ }
62
+ if (!Number.isFinite(minZoom) || minZoom <= 0) {
63
+ throw new Error('minZoom must be finite and greater than 0')
64
+ }
65
+ if (!Number.isFinite(maxZoom) || maxZoom <= minZoom) {
66
+ throw new Error('maxZoom must be finite and greater than minZoom')
67
+ }
68
+ if (!Number.isFinite(maxSplitZoom) || maxSplitZoom <= 0) {
69
+ throw new Error('maxSplitZoom must be finite and greater than 0')
70
+ }
71
+
72
+ return { Tc, Tu, eps, Dmax, minZoom, maxZoom, maxSplitZoom }
73
+ }
74
+
75
+ function leafToNode(leaf: LeafInput): ClusterNode {
76
+ return {
77
+ id: leaf.id,
78
+ centroid: { x: leaf.point.x, y: leaf.point.y },
79
+ count: 1,
80
+ members: [leaf.id],
81
+ }
82
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeClusterTable } from './computeClusterTable'
3
+ import { createClusterRuntime } from './runtime'
4
+ import { ClusterTable, LeafInput, MergeEvent } from './types'
5
+
6
+ // The locality contract the overlay's rendering relies on (CLUSTERING.md §8.4):
7
+ // a visible cluster's members never sit farther than (1+eps)·r·Dmax screen px
8
+ // from its centroid, so culling with that margin never hides a badge whose
9
+ // member anchors are on screen. The bound stacks all three mechanisms:
10
+ // cap — zEff·pageDiag ≤ Dmax at every raw merge
11
+ // contraction— fusing to the window anchor overshoots by ≤ (1+eps)
12
+ // hysteresis — a merged cluster survives up to zSplit = r·zMerge
13
+ // and uses the FULL extent, not half: a count-weighted centroid of a skewed
14
+ // cluster (many pins on one side, one far out) can sit a full extent from its
15
+ // farthest member.
16
+
17
+ function leaf(id: string, x: number, y: number): LeafInput {
18
+ return { id, point: { x, y } }
19
+ }
20
+
21
+ function mulberry32(seed: number): () => number {
22
+ let a = seed >>> 0
23
+ return () => {
24
+ a = (a + 0x6d2b79f5) >>> 0
25
+ let t = a
26
+ t = Math.imul(t ^ (t >>> 15), t | 1)
27
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
28
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29
+ }
30
+ }
31
+
32
+ function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
33
+ const rand = mulberry32(seed)
34
+ return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
35
+ }
36
+
37
+ const OPTS = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, minZoom: 0.05, maxZoom: 8, maxSplitZoom: 1e9 }
38
+ const R = OPTS.Tu / OPTS.Tc
39
+ const SLACK = 1 + 1e-9
40
+
41
+ function pointsOf(leaves: readonly LeafInput[]): Map<string, { x: number; y: number }> {
42
+ return new Map(leaves.map((l) => [l.id, l.point]))
43
+ }
44
+
45
+ /** Bbox diagonal of a member set, recomputed from the original leaf anchors. */
46
+ function memberDiag(
47
+ members: readonly string[],
48
+ pts: Map<string, { x: number; y: number }>
49
+ ): number {
50
+ let minX = Infinity
51
+ let minY = Infinity
52
+ let maxX = -Infinity
53
+ let maxY = -Infinity
54
+ for (const id of members) {
55
+ const p = pts.get(id)!
56
+ minX = Math.min(minX, p.x)
57
+ minY = Math.min(minY, p.y)
58
+ maxX = Math.max(maxX, p.x)
59
+ maxY = Math.max(maxY, p.y)
60
+ }
61
+ return Math.hypot(maxX - minX, maxY - minY)
62
+ }
63
+
64
+ function zoomWalk(steps: number, seed: number): number[] {
65
+ const rand = mulberry32(seed)
66
+ const lnMin = Math.log(OPTS.minZoom)
67
+ const lnMax = Math.log(OPTS.maxZoom)
68
+ let ln = (lnMin + lnMax) / 2
69
+ const walk: number[] = []
70
+ for (let i = 0; i < steps; i++) {
71
+ const r = rand()
72
+ if (r < 0.15) ln = lnMin + rand() * (lnMax - lnMin)
73
+ else ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
74
+ walk.push(Math.exp(ln))
75
+ }
76
+ return walk
77
+ }
78
+
79
+ describe('locality bounds (the culling contract)', () => {
80
+ it('bounds every finite event at birth: zMerge · pageDiag(result) ≤ (1+eps) · Dmax', () => {
81
+ for (const seed of [3, 42, 77]) {
82
+ const leaves = randomLeaves(50, seed)
83
+ const table = computeClusterTable(leaves, OPTS)
84
+ const pts = pointsOf(leaves)
85
+ for (const ev of table.events) {
86
+ if (!Number.isFinite(ev.zMerge)) continue
87
+ const diag = memberDiag(ev.result.members, pts)
88
+ expect(ev.zMerge * diag).toBeLessThanOrEqual((1 + OPTS.eps) * OPTS.Dmax * SLACK)
89
+ }
90
+ }
91
+ })
92
+
93
+ it('bounds every VISIBLE cluster during random zoom walks: member-to-centroid ≤ (1+eps)·r·Dmax screen px', () => {
94
+ const margin = (1 + OPTS.eps) * R * OPTS.Dmax * SLACK
95
+ for (const seed of [5, 21]) {
96
+ const leaves = randomLeaves(40, seed * 7)
97
+ const table = computeClusterTable(leaves, OPTS)
98
+ const pts = pointsOf(leaves)
99
+ const rt = createClusterRuntime(table)
100
+ const walk = zoomWalk(200, seed)
101
+ rt.seed(walk[0])
102
+ for (const zoom of walk) {
103
+ rt.onCamera(zoom)
104
+ for (const cluster of rt.getVisible().values()) {
105
+ if (cluster.count === 1) continue
106
+ for (const id of cluster.members) {
107
+ const p = pts.get(id)!
108
+ const dist = Math.hypot(p.x - cluster.centroid.x, p.y - cluster.centroid.y)
109
+ expect(zoom * dist).toBeLessThanOrEqual(margin)
110
+ }
111
+ }
112
+ }
113
+ }
114
+ })
115
+
116
+ it('exercises the skewed-centroid worst case (many pins one side, one far out)', () => {
117
+ // 9 pins tightly packed at the origin plus one at distance 35: the
118
+ // count-weighted centroid sits near the pack, so the far member is almost
119
+ // a FULL extent away — the case that breaks a Dmax/2-style margin.
120
+ const leaves = [
121
+ ...Array.from({ length: 9 }, (_, i) => leaf(`pack-${i}`, (i % 3) * 2, Math.floor(i / 3) * 2)),
122
+ leaf('far', 39, 2),
123
+ ]
124
+ const table = computeClusterTable(leaves, OPTS)
125
+ const pts = pointsOf(leaves)
126
+ const full = table.events.find((ev) => ev.result.count === 10)
127
+ expect(full).toBeDefined()
128
+ // the far member really is beyond half the extent from the centroid…
129
+ const diag = memberDiag(full!.result.members, pts)
130
+ const p = pts.get('far')!
131
+ const dist = Math.hypot(p.x - full!.result.centroid.x, p.y - full!.result.centroid.y)
132
+ expect(dist).toBeGreaterThan(diag / 2)
133
+ // …and still within the full-extent bound at any zoom the cluster survives
134
+ expect(full!.zSplit * dist).toBeLessThanOrEqual((1 + OPTS.eps) * R * OPTS.Dmax * SLACK)
135
+ })
136
+ })
137
+
138
+ describe('move lifecycle (pick up = delete, drop = insert — CLUSTERING.md §9.2)', () => {
139
+ function tableShapes(table: ClusterTable) {
140
+ return table.events.map((ev: MergeEvent) => ({
141
+ zMerge: ev.zMerge,
142
+ zSplit: ev.zSplit,
143
+ children: ev.children.map((c) => c.id),
144
+ result: ev.result.id,
145
+ }))
146
+ }
147
+
148
+ it('drop-then-rebuild equals a fresh build of the final positions', () => {
149
+ const before = randomLeaves(20, 11)
150
+ // pick up leaf-5 (delete)…
151
+ const during = before.filter((l) => l.id !== 'leaf-5')
152
+ // …drop it somewhere else (insert at the end — array order differs from a
153
+ // fresh build, which must not matter)
154
+ const after = [...during, leaf('leaf-5', 777, 333)]
155
+ const fresh = before.map((l) => (l.id === 'leaf-5' ? leaf('leaf-5', 777, 333) : l))
156
+
157
+ expect(tableShapes(computeClusterTable(after, OPTS))).toEqual(
158
+ tableShapes(computeClusterTable(fresh, OPTS))
159
+ )
160
+ })
161
+
162
+ it('the mid-drag table is simply the table without the picked-up comment', () => {
163
+ const before = randomLeaves(12, 13)
164
+ const during = before.filter((l) => l.id !== 'leaf-3')
165
+ const table = computeClusterTable(during, OPTS)
166
+ expect(table.leaves.some((l) => l.id === 'leaf-3')).toBe(false)
167
+ for (const ev of table.events) {
168
+ expect(ev.result.members).not.toContain('leaf-3')
169
+ }
170
+ })
171
+
172
+ it('re-seeding after the rebuild matches seeding the fresh table directly', () => {
173
+ const before = randomLeaves(20, 17)
174
+ const after = [...before.filter((l) => l.id !== 'leaf-5'), leaf('leaf-5', 777, 333)]
175
+ const rebuilt = createClusterRuntime(computeClusterTable(after, OPTS))
176
+ const freshRt = createClusterRuntime(createFreshTableSameShape(after))
177
+ for (const zoom of [0.1, 0.5, 1, 2, 4, 7.9]) {
178
+ rebuilt.seed(zoom)
179
+ freshRt.seed(zoom)
180
+ expect([...rebuilt.getVisible().keys()].sort()).toEqual(
181
+ [...freshRt.getVisible().keys()].sort()
182
+ )
183
+ }
184
+
185
+ function createFreshTableSameShape(leaves: LeafInput[]): ClusterTable {
186
+ return computeClusterTable(leaves.slice().reverse(), OPTS)
187
+ }
188
+ })
189
+ })