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