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