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