@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,153 @@
|
|
|
1
|
+
import type { ClusterNode, ClusterTable, MergeEvent } from './types'
|
|
2
|
+
|
|
3
|
+
/** @public */
|
|
4
|
+
export interface ClusterRuntime {
|
|
5
|
+
/** Events[0..k) are active. Exposed for tests and debugging. */
|
|
6
|
+
readonly k: number
|
|
7
|
+
/** Reset state from scratch for the given zoom (cold start / after rebuild). */
|
|
8
|
+
seed(zoom: number): void
|
|
9
|
+
/**
|
|
10
|
+
* Reset state for the given zoom, carrying hysteresis state over from a previous partition
|
|
11
|
+
* (the visible map of the model being replaced). Threshold-forced events ignore history:
|
|
12
|
+
* zoom \<= zMerge is always active, zoom \>= zSplit always inactive. An event inside its band
|
|
13
|
+
* is active iff its members were merged together in `previous`; a band event with no history
|
|
14
|
+
* (e.g. introduced by the rebuild) stays inactive until the next zoom-out crosses its zMerge.
|
|
15
|
+
* Active events must form a prefix of the table, so classification stops at the first
|
|
16
|
+
* inactive event; later band-active events conservatively resolve to split.
|
|
17
|
+
*/
|
|
18
|
+
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
|
|
19
|
+
/** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
|
|
20
|
+
onCamera(zoom: number): void
|
|
21
|
+
/** The current partition: cluster id → node. Do not mutate. */
|
|
22
|
+
getVisible(): ReadonlyMap<string, ClusterNode>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @public */
|
|
26
|
+
export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
|
|
27
|
+
return new ClusterRuntimeImpl(table)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ClusterRuntimeImpl implements ClusterRuntime {
|
|
31
|
+
// mutable internally; readonly through the ClusterRuntime interface
|
|
32
|
+
k = 0
|
|
33
|
+
private visible = new Map<string, ClusterNode>()
|
|
34
|
+
private seeded = false
|
|
35
|
+
|
|
36
|
+
constructor(private readonly table: ClusterTable) {
|
|
37
|
+
this.resetVisible()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
seed(zoom: number): void {
|
|
41
|
+
validateZoom(zoom)
|
|
42
|
+
this.k = seedCount(this.table.events, zoom)
|
|
43
|
+
this.resetVisible()
|
|
44
|
+
for (let i = 0; i < this.k; i++) {
|
|
45
|
+
applyEvent(this.visible, this.table.events[i])
|
|
46
|
+
}
|
|
47
|
+
this.seeded = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
|
|
51
|
+
validateZoom(zoom)
|
|
52
|
+
const ownerByMember = new Map<string, string>()
|
|
53
|
+
for (const node of previous.values()) {
|
|
54
|
+
for (const member of node.members) {
|
|
55
|
+
ownerByMember.set(member, node.id)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const events = this.table.events
|
|
60
|
+
let k = 0
|
|
61
|
+
while (k < events.length) {
|
|
62
|
+
const event = events[k]
|
|
63
|
+
if (zoom >= event.zSplit) break
|
|
64
|
+
if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) break
|
|
65
|
+
k++
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.k = k
|
|
69
|
+
this.resetVisible()
|
|
70
|
+
for (let i = 0; i < k; i++) {
|
|
71
|
+
applyEvent(this.visible, events[i])
|
|
72
|
+
}
|
|
73
|
+
this.seeded = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onCamera(zoom: number): void {
|
|
77
|
+
validateZoom(zoom)
|
|
78
|
+
if (!this.seeded) {
|
|
79
|
+
throw new Error('Cluster runtime must be seeded before onCamera')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
|
|
83
|
+
applyEvent(this.visible, this.table.events[this.k])
|
|
84
|
+
this.k++
|
|
85
|
+
}
|
|
86
|
+
while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
|
|
87
|
+
this.k--
|
|
88
|
+
unapplyEvent(this.visible, this.table.events[this.k])
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getVisible(): ReadonlyMap<string, ClusterNode> {
|
|
93
|
+
return this.visible
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private resetVisible() {
|
|
97
|
+
this.visible = new Map()
|
|
98
|
+
for (const leaf of this.table.leaves) {
|
|
99
|
+
this.visible.set(leaf.id, leaf)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True iff every member id belonged to the same node of the previous partition. */
|
|
105
|
+
function wasMergedTogether(
|
|
106
|
+
members: readonly string[],
|
|
107
|
+
ownerByMember: ReadonlyMap<string, string>
|
|
108
|
+
): boolean {
|
|
109
|
+
const owner = ownerByMember.get(members[0])
|
|
110
|
+
if (owner === undefined) return false
|
|
111
|
+
for (let i = 1; i < members.length; i++) {
|
|
112
|
+
if (ownerByMember.get(members[i]) !== owner) return false
|
|
113
|
+
}
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function seedCount(events: readonly MergeEvent[], zoom: number): number {
|
|
118
|
+
let lo = 0
|
|
119
|
+
let hi = events.length
|
|
120
|
+
while (lo < hi) {
|
|
121
|
+
const mid = (lo + hi) >> 1
|
|
122
|
+
if (zoom <= seedThreshold(events[mid])) {
|
|
123
|
+
lo = mid + 1
|
|
124
|
+
} else {
|
|
125
|
+
hi = mid
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return lo
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function seedThreshold(event: MergeEvent): number {
|
|
132
|
+
return Math.sqrt(event.zMerge * event.zSplit)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function applyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
|
136
|
+
for (const child of event.children) {
|
|
137
|
+
visible.delete(child.id)
|
|
138
|
+
}
|
|
139
|
+
visible.set(event.result.id, event.result)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function unapplyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
|
143
|
+
visible.delete(event.result.id)
|
|
144
|
+
for (const child of event.children) {
|
|
145
|
+
visible.set(child.id, child)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateZoom(zoom: number) {
|
|
150
|
+
if (!Number.isFinite(zoom) || zoom <= 0) {
|
|
151
|
+
throw new Error('zoom must be finite and greater than 0')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mstEdges } from './mst'
|
|
3
|
+
import { cappedReplay } from './replay'
|
|
4
|
+
// These imports are red until step 3 is implemented — that is intentional.
|
|
5
|
+
// Implement `schedule.ts` (and the step 3 additions to `types.ts`) per
|
|
6
|
+
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
|
7
|
+
import { contract, finalize } from './schedule'
|
|
8
|
+
import { ClusterNode, ContractedEvent, LeafInput, MergeEvent, RawMergeEvent } from './types'
|
|
9
|
+
|
|
10
|
+
// --- Synthetic builders -------------------------------------------------------
|
|
11
|
+
// `contract` is specified over plain RawMergeEvent[] input precisely so it can
|
|
12
|
+
// be tested with hand-built events, without running the replay.
|
|
13
|
+
|
|
14
|
+
/** Build a ClusterNode from member ids (leaf node for one id, cluster node for more). */
|
|
15
|
+
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
|
16
|
+
const members = ids.slice().sort()
|
|
17
|
+
return {
|
|
18
|
+
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
|
19
|
+
centroid: { x, y },
|
|
20
|
+
count: members.length,
|
|
21
|
+
members,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build a RawMergeEvent merging two nodes, with contract-conformant ordering. */
|
|
26
|
+
function raw(z: number, a: ClusterNode, b: ClusterNode): RawMergeEvent {
|
|
27
|
+
const [first, second] = a.members[0] < b.members[0] ? [a, b] : [b, a]
|
|
28
|
+
return { z, children: [first, second], result: node([...a.members, ...b.members]) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every cluster id referenced anywhere in the output (children and results). */
|
|
32
|
+
function collectIds(events: readonly ContractedEvent[]): Set<string> {
|
|
33
|
+
const ids = new Set<string>()
|
|
34
|
+
for (const ev of events) {
|
|
35
|
+
for (const c of ev.children) ids.add(c.id)
|
|
36
|
+
ids.add(ev.result.id)
|
|
37
|
+
}
|
|
38
|
+
return ids
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contractedShape(ev: ContractedEvent) {
|
|
42
|
+
return {
|
|
43
|
+
zMerge: ev.zMerge,
|
|
44
|
+
children: ev.children.map((c) => c.id),
|
|
45
|
+
result: ev.result.id,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const A = node(['a'])
|
|
50
|
+
const B = node(['b'])
|
|
51
|
+
const C = node(['c'])
|
|
52
|
+
const D = node(['d'])
|
|
53
|
+
const E = node(['e'])
|
|
54
|
+
const F = node(['f'])
|
|
55
|
+
const X = node(['x'])
|
|
56
|
+
const Y = node(['y'])
|
|
57
|
+
|
|
58
|
+
// --- contract: fixtures -------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('contract fixtures', () => {
|
|
61
|
+
it('returns [] for empty input', () => {
|
|
62
|
+
expect(contract([], 0.12)).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('passes a single event through unchanged (apart from the type)', () => {
|
|
66
|
+
const e1 = raw(4, A, B)
|
|
67
|
+
expect(contract([e1], 0.12)).toEqual([{ zMerge: 4, children: [A, B], result: e1.result }])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('fuses a two-event chain within eps into one 3-way event at the anchor z', () => {
|
|
71
|
+
const e1 = raw(4, A, B) // result P = cluster:2:a
|
|
72
|
+
const e2 = raw(3.7, e1.result, C) // 3.7 >= 4/1.12 ≈ 3.571 → same window, chained via P
|
|
73
|
+
const out = contract([e1, e2], 0.12)
|
|
74
|
+
expect(out).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
|
75
|
+
// the intermediate P is erased — representable nowhere
|
|
76
|
+
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps the same chain separate when the second event falls outside eps', () => {
|
|
80
|
+
const e1 = raw(4, A, B)
|
|
81
|
+
const e2 = raw(3.5, e1.result, C) // 3.5 < 4/1.12 ≈ 3.571 → next window
|
|
82
|
+
const out = contract([e1, e2], 0.12)
|
|
83
|
+
expect(out.map(contractedShape)).toEqual([
|
|
84
|
+
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
|
85
|
+
{ zMerge: 3.5, children: [e1.result.id, 'c'], result: e2.result.id },
|
|
86
|
+
])
|
|
87
|
+
// the intermediate survives: it is genuinely visible between 3.5 and 4
|
|
88
|
+
expect(collectIds(out).has(e1.result.id)).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('synchronizes unrelated same-window merges without fusing them', () => {
|
|
92
|
+
const e1 = raw(4, A, B)
|
|
93
|
+
const e2 = raw(3.9, X, Y) // within eps of the anchor, but disjoint
|
|
94
|
+
const out = contract([e1, e2], 0.12)
|
|
95
|
+
// two events, BOTH at the anchor z, ordered by ascending min-member id of result
|
|
96
|
+
expect(out.map(contractedShape)).toEqual([
|
|
97
|
+
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
|
98
|
+
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('separates interleaved chains within one window by connectivity', () => {
|
|
103
|
+
const e1 = raw(4, A, B) // chain 1
|
|
104
|
+
const e2 = raw(3.95, X, Y) // chain 2, interleaved between chain 1's events
|
|
105
|
+
const e3 = raw(3.9, e1.result, C) // chain 1 again, via e1.result
|
|
106
|
+
const out = contract([e1, e2, e3], 0.12)
|
|
107
|
+
expect(out.map(contractedShape)).toEqual([
|
|
108
|
+
{ zMerge: 4, children: ['a', 'b', 'c'], result: e3.result.id },
|
|
109
|
+
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
|
110
|
+
])
|
|
111
|
+
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('fuses branched chains (two results consumed by one later event)', () => {
|
|
115
|
+
const e1 = raw(4, A, B) // → cluster:2:a
|
|
116
|
+
const e2 = raw(3.9, C, D) // → cluster:2:c
|
|
117
|
+
const e3 = raw(3.85, e1.result, e2.result) // consumes both → one connected chain
|
|
118
|
+
const out = contract([e1, e2, e3], 0.12)
|
|
119
|
+
expect(out).toEqual([{ zMerge: 4, children: [A, B, C, D], result: e3.result }])
|
|
120
|
+
const ids = collectIds(out)
|
|
121
|
+
expect(ids.has(e1.result.id)).toBe(false)
|
|
122
|
+
expect(ids.has(e2.result.id)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('anchors the window to its first event, never the previous one', () => {
|
|
126
|
+
// 3.8 is within eps of the 4.0 anchor; 3.55 is within eps of 3.8 but NOT of
|
|
127
|
+
// the anchor (3.55 < 4/1.12 ≈ 3.571) — a gradient must break into windows.
|
|
128
|
+
const e1 = raw(4, A, B)
|
|
129
|
+
const e2 = raw(3.8, X, Y)
|
|
130
|
+
const e3 = raw(3.55, C, D)
|
|
131
|
+
const out = contract([e1, e2, e3], 0.12)
|
|
132
|
+
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4, 3.55])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('includes an event exactly on the window boundary (>=, not >)', () => {
|
|
136
|
+
// eps = 0.25 → boundary at exactly 4/1.25 = 3.2
|
|
137
|
+
const e1 = raw(4, A, B)
|
|
138
|
+
const e2 = raw(3.2, X, Y)
|
|
139
|
+
const out = contract([e1, e2], 0.25)
|
|
140
|
+
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('fuses a chain that spans windows only up to the boundary, keeping the seam intact', () => {
|
|
144
|
+
// One chain across five events; eps = 0.12 splits it into windows
|
|
145
|
+
// {4.0, 3.8, 3.6} and {3.4, 3.2}. The second window's fused event must
|
|
146
|
+
// reference the first window's fused RESULT (cluster:4:a), not an erased
|
|
147
|
+
// intermediate.
|
|
148
|
+
const e1 = raw(4, A, B) // → cluster:2:a
|
|
149
|
+
const e2 = raw(3.8, e1.result, C) // → cluster:3:a
|
|
150
|
+
const e3 = raw(3.6, e2.result, D) // → cluster:4:a
|
|
151
|
+
const e4 = raw(3.4, e3.result, E) // → cluster:5:a
|
|
152
|
+
const e5 = raw(3.2, e4.result, F) // → cluster:6:a
|
|
153
|
+
const out = contract([e1, e2, e3, e4, e5], 0.12)
|
|
154
|
+
expect(out.map(contractedShape)).toEqual([
|
|
155
|
+
{ zMerge: 4, children: ['a', 'b', 'c', 'd'], result: 'cluster:4:a' },
|
|
156
|
+
{ zMerge: 3.4, children: ['cluster:4:a', 'e', 'f'], result: 'cluster:6:a' },
|
|
157
|
+
])
|
|
158
|
+
const ids = collectIds(out)
|
|
159
|
+
for (const erased of [e1.result.id, e2.result.id, e4.result.id]) {
|
|
160
|
+
expect(ids.has(erased)).toBe(false)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('groups exact ties with eps = 0, and nothing else', () => {
|
|
165
|
+
const e1 = raw(4, A, B)
|
|
166
|
+
const e2 = raw(4, e1.result, C) // exact tie, chained → fuses even at eps = 0
|
|
167
|
+
expect(contract([e1, e2], 0)).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
|
168
|
+
|
|
169
|
+
const e3 = raw(4, A, B)
|
|
170
|
+
const e4 = raw(3.9999999, e3.result, C) // any difference → separate windows
|
|
171
|
+
expect(contract([e3, e4], 0).map((ev) => ev.zMerge)).toEqual([4, 3.9999999])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('lets an Infinity anchor admit only other Infinity events', () => {
|
|
175
|
+
const e1 = raw(Infinity, A, B)
|
|
176
|
+
const e2 = raw(4, e1.result, C) // finite: must NOT join the Infinity window
|
|
177
|
+
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
|
178
|
+
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
|
179
|
+
{ zMerge: 4, children: [e1.result.id, 'c'], result: e2.result.id },
|
|
180
|
+
])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('keeps two coincident groups separate inside one Infinity window', () => {
|
|
184
|
+
const e1 = raw(Infinity, A, B)
|
|
185
|
+
const e2 = raw(Infinity, X, Y)
|
|
186
|
+
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
|
187
|
+
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
|
188
|
+
{ zMerge: Infinity, children: ['x', 'y'], result: e2.result.id },
|
|
189
|
+
])
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('contract validation and purity', () => {
|
|
194
|
+
it('throws on negative or non-finite eps', () => {
|
|
195
|
+
const events = [raw(4, A, B)]
|
|
196
|
+
expect(() => contract(events, -0.1)).toThrow()
|
|
197
|
+
expect(() => contract(events, NaN)).toThrow()
|
|
198
|
+
expect(() => contract(events, Infinity)).toThrow()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('is pure: identical repeat output, no input mutation', () => {
|
|
202
|
+
const e1 = raw(4, A, B)
|
|
203
|
+
const e2 = raw(3.7, e1.result, C)
|
|
204
|
+
const input = [e1, e2]
|
|
205
|
+
const snapshot = JSON.parse(JSON.stringify(input))
|
|
206
|
+
const first = contract(input, 0.12)
|
|
207
|
+
const second = contract(input, 0.12)
|
|
208
|
+
expect(first).toEqual(second)
|
|
209
|
+
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// --- finalize: fixtures --------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const FIN_OPTS = { Tc: 40, Tu: 60, minZoom: 0.1, maxZoom: 8, maxSplitZoom: 1e9 } // r = 1.5
|
|
216
|
+
|
|
217
|
+
function contracted(zMerge: number, children: ClusterNode[], result: ClusterNode): ContractedEvent {
|
|
218
|
+
return { zMerge, children, result }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
describe('finalize fixtures', () => {
|
|
222
|
+
it('returns [] for empty input', () => {
|
|
223
|
+
expect(finalize([], FIN_OPTS)).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('applies zSplit = r · zMerge and preserves everything else', () => {
|
|
227
|
+
const ev1 = contracted(4, [A, B], node(['a', 'b']))
|
|
228
|
+
const ev2 = contracted(1, [node(['a', 'b']), C], node(['a', 'b', 'c']))
|
|
229
|
+
expect(finalize([ev1, ev2], FIN_OPTS)).toEqual([
|
|
230
|
+
{ zMerge: 4, zSplit: 6, children: ev1.children, result: ev1.result },
|
|
231
|
+
{ zMerge: 1, zSplit: 1.5, children: ev2.children, result: ev2.result },
|
|
232
|
+
])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('clamps a band that straddles maxZoom (the stuck-merged trap)', () => {
|
|
236
|
+
// zMerge 6, r 1.5 → raw zSplit 9 > maxZoom 8: without the clamp this pair
|
|
237
|
+
// would merge on zoom-out and never split again within reachable zoom.
|
|
238
|
+
const ev = contracted(6, [A, B], node(['a', 'b']))
|
|
239
|
+
const [out] = finalize([ev], FIN_OPTS)
|
|
240
|
+
expect(out.zSplit).toBe(8)
|
|
241
|
+
expect(out.zMerge).toBe(6)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not clamp bands that fit under maxZoom', () => {
|
|
245
|
+
const ev = contracted(2, [A, B], node(['a', 'b']))
|
|
246
|
+
expect(finalize([ev], FIN_OPTS)[0].zSplit).toBe(3)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('leaves zMerge >= maxZoom events unclamped by maxZoom (band above camera range)', () => {
|
|
250
|
+
// zMerge exactly at maxZoom: the clamp condition is zMerge < maxZoom, strict
|
|
251
|
+
const atMax = contracted(8, [A, B], node(['a', 'b']))
|
|
252
|
+
expect(finalize([atMax], FIN_OPTS)[0].zSplit).toBe(12)
|
|
253
|
+
// coincident anchors are no longer permanently merged — maxSplitZoom caps them
|
|
254
|
+
const inf = contracted(Infinity, [X, Y], node(['x', 'y']))
|
|
255
|
+
expect(finalize([inf], FIN_OPTS)[0]).toMatchObject({ zMerge: 1e9 / 1.5, zSplit: 1e9 })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('caps every split at maxSplitZoom, folding the band below the cap', () => {
|
|
259
|
+
// r = 1.5, maxSplitZoom 6 -> zMerge cap 4: past zoom 6 nothing stays clustered,
|
|
260
|
+
// however close (or coincident) the members are.
|
|
261
|
+
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
|
262
|
+
const coincident = contracted(Infinity, [A, B], node(['a', 'b']))
|
|
263
|
+
const tight = contracted(10, [A, B], node(['a', 'b']))
|
|
264
|
+
const normal = contracted(2, [A, B], node(['a', 'b']))
|
|
265
|
+
expect(finalize([coincident], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
|
266
|
+
expect(finalize([tight], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
|
267
|
+
// events already below the cap are untouched
|
|
268
|
+
expect(finalize([normal], opts)[0]).toMatchObject({ zMerge: 2, zSplit: 3 })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('capping preserves sort order and the zSplit > zMerge invariant', () => {
|
|
272
|
+
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
|
273
|
+
const events = [
|
|
274
|
+
contracted(Infinity, [A, B], node(['a', 'b'])),
|
|
275
|
+
contracted(10, [C, X], node(['c', 'x'])),
|
|
276
|
+
contracted(2, [Y, node(['a', 'b'])], node(['a', 'b', 'y'])),
|
|
277
|
+
]
|
|
278
|
+
const out = finalize(events, opts)
|
|
279
|
+
expect(out.map((e) => e.zMerge)).toEqual([4, 4, 2])
|
|
280
|
+
for (let i = 0; i < out.length; i++) {
|
|
281
|
+
expect(out[i].zSplit).toBeGreaterThan(out[i].zMerge)
|
|
282
|
+
if (i > 0) expect(out[i].zSplit).toBeLessThanOrEqual(out[i - 1].zSplit)
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('throws on non-positive or non-finite maxSplitZoom', () => {
|
|
287
|
+
const events = [contracted(2, [A, B], node(['a', 'b']))]
|
|
288
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: 0 })).toThrow()
|
|
289
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: -1 })).toThrow()
|
|
290
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: NaN })).toThrow()
|
|
291
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: Infinity })).toThrow()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('prunes the trailing suffix below minZoom, keeping the boundary value', () => {
|
|
295
|
+
const keep = contracted(4, [A, B], node(['a', 'b']))
|
|
296
|
+
const boundary = contracted(0.1, [node(['a', 'b']), C], node(['a', 'b', 'c'])) // == minZoom: kept
|
|
297
|
+
const prune = contracted(0.0999, [node(['a', 'b', 'c']), D], node(['a', 'b', 'c', 'd']))
|
|
298
|
+
const out = finalize([keep, boundary, prune], FIN_OPTS)
|
|
299
|
+
expect(out.map((ev) => ev.zMerge)).toEqual([4, 0.1])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('throws on every invalid option combination', () => {
|
|
303
|
+
const events = [contracted(4, [A, B], node(['a', 'b']))]
|
|
304
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tc: 0 })).toThrow()
|
|
305
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tc: -1 })).toThrow()
|
|
306
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tu: 40 })).toThrow() // Tu must be > Tc
|
|
307
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tu: 30 })).toThrow()
|
|
308
|
+
expect(() => finalize(events, { ...FIN_OPTS, minZoom: 0 })).toThrow()
|
|
309
|
+
expect(() => finalize(events, { ...FIN_OPTS, minZoom: -1 })).toThrow()
|
|
310
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.1 })).toThrow() // must be > minZoom
|
|
311
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.05 })).toThrow()
|
|
312
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tc: NaN })).toThrow()
|
|
313
|
+
expect(() => finalize(events, { ...FIN_OPTS, Tu: Infinity })).toThrow()
|
|
314
|
+
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: Infinity })).toThrow()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('is pure: identical repeat output, no input mutation', () => {
|
|
318
|
+
const input = [contracted(4, [A, B], node(['a', 'b']))]
|
|
319
|
+
const snapshot = JSON.parse(JSON.stringify(input))
|
|
320
|
+
expect(finalize(input, FIN_OPTS)).toEqual(finalize(input, FIN_OPTS))
|
|
321
|
+
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// --- Composed pipeline properties ---------------------------------------------
|
|
326
|
+
// finalize(contract(cappedReplay(mstEdges(leaves)))) must satisfy every table
|
|
327
|
+
// invariant of CLUSTERING.md §7.6.
|
|
328
|
+
|
|
329
|
+
function leaf(id: string, x: number, y: number): LeafInput {
|
|
330
|
+
return { id, point: { x, y } }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mulberry32(seed: number): () => number {
|
|
334
|
+
let a = seed >>> 0
|
|
335
|
+
return () => {
|
|
336
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
337
|
+
let t = a
|
|
338
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
339
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
340
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
|
345
|
+
const out = items.slice()
|
|
346
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
347
|
+
const j = Math.floor(rand() * (i + 1))
|
|
348
|
+
;[out[i], out[j]] = [out[j], out[i]]
|
|
349
|
+
}
|
|
350
|
+
return out
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
|
354
|
+
const rand = mulberry32(seed)
|
|
355
|
+
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const PIPE_OPTS = {
|
|
359
|
+
Tc: 40,
|
|
360
|
+
Tu: 60,
|
|
361
|
+
eps: 0.12,
|
|
362
|
+
Dmax: 120,
|
|
363
|
+
minZoom: 1e-6,
|
|
364
|
+
maxZoom: 8,
|
|
365
|
+
maxSplitZoom: 1e9,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function buildTableEvents(
|
|
369
|
+
leaves: readonly LeafInput[],
|
|
370
|
+
opts: typeof PIPE_OPTS = PIPE_OPTS
|
|
371
|
+
): MergeEvent[] {
|
|
372
|
+
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
|
|
373
|
+
return finalize(contract(rawEvents, opts.eps), {
|
|
374
|
+
Tc: opts.Tc,
|
|
375
|
+
Tu: opts.Tu,
|
|
376
|
+
minZoom: opts.minZoom,
|
|
377
|
+
maxZoom: opts.maxZoom,
|
|
378
|
+
maxSplitZoom: opts.maxSplitZoom,
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** All §7.6 invariants over a finalized event list. */
|
|
383
|
+
function expectTableInvariants(
|
|
384
|
+
events: readonly MergeEvent[],
|
|
385
|
+
leaves: readonly LeafInput[],
|
|
386
|
+
opts: typeof PIPE_OPTS
|
|
387
|
+
) {
|
|
388
|
+
// 1–3: threshold ordering and band validity
|
|
389
|
+
for (let i = 1; i < events.length; i++) {
|
|
390
|
+
expect(events[i].zMerge).toBeLessThanOrEqual(events[i - 1].zMerge)
|
|
391
|
+
expect(events[i].zSplit).toBeLessThanOrEqual(events[i - 1].zSplit)
|
|
392
|
+
}
|
|
393
|
+
for (const ev of events) {
|
|
394
|
+
// coincident clusters are the one exception: zMerge = zSplit = Infinity (never split)
|
|
395
|
+
if (Number.isFinite(ev.zMerge)) {
|
|
396
|
+
expect(ev.zSplit).toBeGreaterThan(ev.zMerge)
|
|
397
|
+
} else {
|
|
398
|
+
expect(ev.zSplit).toBe(Infinity)
|
|
399
|
+
}
|
|
400
|
+
if (ev.zMerge < opts.maxZoom) expect(ev.zSplit).toBeLessThanOrEqual(opts.maxZoom)
|
|
401
|
+
expect(ev.zMerge).toBeGreaterThanOrEqual(opts.minZoom)
|
|
402
|
+
}
|
|
403
|
+
// 4: dendrogram integrity — replaying all events over the leaves is well
|
|
404
|
+
// formed (children exist when consumed ⇒ no erased intermediate is referenced)
|
|
405
|
+
const live = new Map<string, ClusterNode>()
|
|
406
|
+
for (const l of leaves) {
|
|
407
|
+
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
|
408
|
+
}
|
|
409
|
+
for (const ev of events) {
|
|
410
|
+
expect(ev.children.length).toBeGreaterThanOrEqual(2)
|
|
411
|
+
for (const child of ev.children) {
|
|
412
|
+
expect(live.get(child.id)).toBeDefined()
|
|
413
|
+
live.delete(child.id)
|
|
414
|
+
}
|
|
415
|
+
for (let i = 1; i < ev.children.length; i++) {
|
|
416
|
+
expect(ev.children[i - 1].members[0] < ev.children[i].members[0]).toBe(true)
|
|
417
|
+
}
|
|
418
|
+
const union = ev.children.flatMap((c) => c.members).sort()
|
|
419
|
+
expect(ev.result.members).toEqual(union)
|
|
420
|
+
expect(ev.result.count).toBe(union.length)
|
|
421
|
+
expect(ev.result.id).toBe(`cluster:${union.length}:${union[0]}`)
|
|
422
|
+
live.set(ev.result.id, ev.result)
|
|
423
|
+
}
|
|
424
|
+
// with minZoom = 1e-6 nothing prunes, so the dendrogram completes to one root
|
|
425
|
+
if (leaves.length >= 2) {
|
|
426
|
+
expect(live.size).toBe(1)
|
|
427
|
+
expect([...live.values()][0].members).toEqual(leaves.map((l) => l.id).sort())
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
describe('composed pipeline (replay → contract → finalize)', () => {
|
|
432
|
+
it('satisfies all table invariants on random inputs', () => {
|
|
433
|
+
for (const seed of [3, 42]) {
|
|
434
|
+
for (const n of [2, 7, 30, 80]) {
|
|
435
|
+
const leaves = randomLeaves(n, seed * 17 + n)
|
|
436
|
+
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('satisfies all table invariants with ties and coincident anchors (lattice)', () => {
|
|
442
|
+
const rand = mulberry32(11)
|
|
443
|
+
const leaves = Array.from({ length: 40 }, (_, i) =>
|
|
444
|
+
leaf(`lat-${i}`, Math.floor(rand() * 8) * 10, Math.floor(rand() * 8) * 10)
|
|
445
|
+
)
|
|
446
|
+
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('reduces to the identity at eps = 0 on tie-free input', () => {
|
|
450
|
+
// Centroid pricing plus the monotonicity clamp can produce exact ties even
|
|
451
|
+
// on random input (a clamped threshold lands exactly on its predecessor),
|
|
452
|
+
// and eps = 0 legitimately fuses chained ties. Identity is only promised
|
|
453
|
+
// for tie-free tables — so probe seeds until one produces distinct
|
|
454
|
+
// thresholds, and assert the identity there.
|
|
455
|
+
let verified = false
|
|
456
|
+
for (const seed of [5, 6, 7, 8, 9, 10]) {
|
|
457
|
+
const leaves = randomLeaves(40, seed)
|
|
458
|
+
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
459
|
+
if (new Set(rawEvents.map((ev) => ev.z)).size !== rawEvents.length) continue
|
|
460
|
+
const contractedEvents = contract(rawEvents, 0)
|
|
461
|
+
expect(
|
|
462
|
+
contractedEvents.map((ev) => ({ z: ev.zMerge, c: ev.children, r: ev.result }))
|
|
463
|
+
).toEqual(rawEvents.map((ev) => ({ z: ev.z, c: ev.children, r: ev.result })))
|
|
464
|
+
verified = true
|
|
465
|
+
}
|
|
466
|
+
expect(verified).toBe(true)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('schedules the uniform chain as synchronized pairs, quads, then the octet', () => {
|
|
470
|
+
// CLUSTERING.md appendix A.2 with centroid pricing: leaf pairs all fire at
|
|
471
|
+
// z = 4 (one contraction window, four disconnected chains → four events at
|
|
472
|
+
// the shared anchor), quads at centroid distance 20 → z = 2, and the
|
|
473
|
+
// quad-quad bridge at centroid distance 40 → z = 1.
|
|
474
|
+
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
|
475
|
+
const events = buildTableEvents(leaves)
|
|
476
|
+
expect(
|
|
477
|
+
events.map((ev) => ({
|
|
478
|
+
zMerge: ev.zMerge,
|
|
479
|
+
children: ev.children.map((c) => c.id),
|
|
480
|
+
result: ev.result.id,
|
|
481
|
+
}))
|
|
482
|
+
).toEqual([
|
|
483
|
+
{ zMerge: 4, children: ['p1', 'p2'], result: 'cluster:2:p1' },
|
|
484
|
+
{ zMerge: 4, children: ['p3', 'p4'], result: 'cluster:2:p3' },
|
|
485
|
+
{ zMerge: 4, children: ['p5', 'p6'], result: 'cluster:2:p5' },
|
|
486
|
+
{ zMerge: 4, children: ['p7', 'p8'], result: 'cluster:2:p7' },
|
|
487
|
+
{ zMerge: 2, children: ['cluster:2:p1', 'cluster:2:p3'], result: 'cluster:4:p1' },
|
|
488
|
+
{ zMerge: 2, children: ['cluster:2:p5', 'cluster:2:p7'], result: 'cluster:4:p5' },
|
|
489
|
+
{ zMerge: 1, children: ['cluster:4:p1', 'cluster:4:p5'], result: 'cluster:8:p1' },
|
|
490
|
+
])
|
|
491
|
+
expect(events.map((ev) => ev.zSplit)).toEqual([6, 6, 6, 6, 3, 3, 1.5])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('is deterministic end to end under input permutation', () => {
|
|
495
|
+
const base = randomLeaves(50, 23)
|
|
496
|
+
const expected = buildTableEvents(base).map((ev) => ({
|
|
497
|
+
zMerge: ev.zMerge,
|
|
498
|
+
zSplit: ev.zSplit,
|
|
499
|
+
children: ev.children.map((c) => c.id),
|
|
500
|
+
result: ev.result.id,
|
|
501
|
+
}))
|
|
502
|
+
for (const shuffleSeed of [7, 29]) {
|
|
503
|
+
const perm = shuffled(base, mulberry32(shuffleSeed))
|
|
504
|
+
expect(
|
|
505
|
+
buildTableEvents(perm).map((ev) => ({
|
|
506
|
+
zMerge: ev.zMerge,
|
|
507
|
+
zSplit: ev.zSplit,
|
|
508
|
+
children: ev.children.map((c) => c.id),
|
|
509
|
+
result: ev.result.id,
|
|
510
|
+
}))
|
|
511
|
+
).toEqual(expected)
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
})
|