snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,333 @@
import { TLShape } from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import {
boundsGapDistance,
getShapeText,
labelClusters,
ClusterBounds,
computeAutoEps,
getShapeClusters,
MAX_CLUSTERING_ATOMS,
} from './shapeClusters'
const PAGE = 'page:a'
const shape = (id: string, parentId: string = PAGE) => ({ id, parentId }) as unknown as TLShape
const arrow = (id: string) => ({ id, parentId: PAGE, type: 'arrow' }) as unknown as TLShape
const box = (minX: number, minY: number, w = 10, h = 10): ClusterBounds => ({
minX,
minY,
maxX: minX + w,
maxY: minY + h,
})
/** A row of top-level shapes at the given x positions, with matching bounds. */
function row(positions: Record<string, number>) {
const shapes = Object.keys(positions).map((id) => shape(id))
const bounds = Object.fromEntries(Object.entries(positions).map(([id, x]) => [id, box(x, 0)]))
return { shapes, bounds }
}
const idsOf = (clusters: { shapes: TLShape[] }[]) =>
clusters
.map((c) =>
c.shapes
.map((s) => s.id)
.sort()
.join(',')
)
.sort()
describe('getShapeClusters without bounds', () => {
// The Worker has no editor, so hierarchy is all the structure available to it.
it('makes each top-level shape its own cluster', () => {
const clusters = getShapeClusters([shape('a'), shape('b'), shape('c')], PAGE)
expect(idsOf(clusters)).toEqual(['a', 'b', 'c'])
})
it('keeps a frame and its descendants together', () => {
const clusters = getShapeClusters(
[shape('frame'), shape('child', 'frame'), shape('grandchild', 'child'), shape('loose')],
PAGE
)
expect(idsOf(clusters)).toEqual(['child,frame,grandchild', 'loose'])
})
})
describe('getShapeClusters with bounds', () => {
it('merges atoms that sit near each other', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, far: 3000 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'far'])
})
// The property that distinguishes single linkage: membership chains, so a long row is one
// cluster even though its ends are far apart.
it('chains through intermediates rather than requiring mutual closeness', () => {
const { shapes, bounds } = row({ a: 0, b: 40, c: 80, d: 120 })
expect(boundsGapDistance(bounds.a, bounds.d)).toBe(110)
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c,d'])
})
it('splits where the gap is large relative to the page', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, x: 2000, y: 2020, z: 2040 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'x,y,z'])
})
it('carries a frame’s descendants into the merged cluster', () => {
const shapes = [shape('frame'), shape('child', 'frame'), shape('near')]
const bounds = { frame: box(0, 0, 100, 100), child: box(10, 10), near: box(110, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['child,frame,near'])
})
it('puts every shape in exactly one cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20, lonely: 9000 })
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters.flatMap((c) => c.shapes.map((s) => s.id)).sort()).toEqual(['a', 'b', 'lonely'])
})
// A shape the caller could not measure has no position to cluster on, so it is left alone rather
// than given an invented one.
it('leaves an unmeasured atom as its own cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20 })
shapes.push(shape('unmeasured'))
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b', 'unmeasured'])
})
it('is independent of the order shapes arrive in', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
expect(idsOf(getShapeClusters([...shapes].reverse(), PAGE, bounds))).toEqual(
idsOf(getShapeClusters(shapes, PAGE, bounds))
)
})
// Ids are derived from membership, so the same page always produces the same handles — that is
// what lets one MCP call hand an id to the next.
it('gives the same ids on every call, and new ids when membership changes', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
const first = getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)
expect(getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)).toEqual(first)
const regrouped = getShapeClusters(shapes, PAGE, { ...bounds, far: box(40, 0) })
expect(regrouped).toHaveLength(1)
expect(regrouped[0].id).not.toBe(first[0])
})
it('returns atoms unmerged past the cap rather than doing the O(n²) work', () => {
const positions: Record<string, number> = {}
for (let i = 0; i <= MAX_CLUSTERING_ATOMS; i++) positions[`s${i}`] = i * 5
const { shapes, bounds } = row(positions)
expect(getShapeClusters(shapes, PAGE, bounds)).toHaveLength(shapes.length)
})
it('handles empty and single-shape pages', () => {
expect(getShapeClusters([], PAGE, {})).toEqual([])
expect(getShapeClusters([shape('only')], PAGE, { only: box(0, 0) })).toHaveLength(1)
})
})
// An arrow drawn between two distant groups has bounds spanning the whole gap, so its gap distance
// to both ends is zero. With single-linkage chaining, one arrow would otherwise pull two unrelated
// groups — and everything they touch — into a single cluster.
describe('arrows do not bridge clusters', () => {
it('keeps two distant groups apart when an arrow spans them', () => {
const shapes = [shape('a1'), shape('a2'), shape('b1'), shape('b2'), arrow('link')]
const bounds = {
a1: box(0, 0),
a2: box(20, 0),
b1: box(3000, 0),
b2: box(3020, 0),
// Spans the gap, touching both groups.
link: box(10, 0, 3000, 2),
}
const clusters = idsOf(getShapeClusters(shapes, PAGE, bounds))
expect(clusters).toContain('a1,a2')
expect(clusters).toContain('b1,b2')
})
it('still gives the arrow a cluster of its own, so every shape stays addressable', () => {
const shapes = [shape('a'), arrow('link')]
const bounds = { a: box(0, 0), link: box(10, 0, 3000, 2) }
const all = getShapeClusters(shapes, PAGE, bounds).flatMap((c) => c.shapes.map((s) => s.id))
expect(all.sort()).toEqual(['a', 'link'])
})
// Arrows are left out of the threshold too: their long thin boxes sit on top of their endpoints
// and would drag the median nearest-neighbour distance down.
it('excludes arrows from the eps calculation', () => {
const shapes = [shape('a'), shape('b'), arrow('link')]
const withArrow = { a: box(0, 0), b: box(600, 0), link: box(5, 0, 600, 2) }
const withoutArrow = { a: box(0, 0), b: box(600, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, withArrow)).filter((k) => k !== 'link')).toEqual(
idsOf(getShapeClusters([shape('a'), shape('b')], PAGE, withoutArrow))
)
})
})
describe('containment', () => {
it('pulls a shape fully inside another into its cluster', () => {
const shapes = [shape('outer'), shape('inner'), shape('far')]
const bounds = { outer: box(0, 0, 500, 500), inner: box(100, 100, 50, 50), far: box(9000, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toContain('inner,outer')
})
// Containment cannot bridge: a container only ever absorbs what it encloses.
it('does not merge two shapes that merely overlap', () => {
const shapes = [shape('a'), shape('b')]
const bounds = { a: box(0, 0, 100, 100), b: box(90, 0, 100, 100) }
// These two are adjacent, so proximity may merge them — but not via containment.
expect(getShapeClusters(shapes, PAGE, bounds).length).toBeLessThanOrEqual(2)
})
})
describe('computeAutoEps', () => {
it('scales with the spacing of the page', () => {
const tight = Array.from({ length: 6 }, (_, i) => box(i * 20, 0))
const sparse = Array.from({ length: 6 }, (_, i) => box(i * 400, 0))
expect(computeAutoEps(sparse)).toBeGreaterThan(computeAutoEps(tight))
})
// The point of deriving eps: the same arrangement at a different scale clusters the same way,
// rather than a fixed threshold swallowing a zoomed-out board whole.
it('clusters a layout the same way at 10x scale', () => {
const small = row({ a: 0, b: 20, c: 600 })
const large = {
shapes: small.shapes,
bounds: { a: box(0, 0, 100, 100), b: box(200, 0, 100, 100), c: box(6000, 0, 100, 100) },
}
expect(idsOf(getShapeClusters(large.shapes, PAGE, large.bounds))).toEqual(
idsOf(getShapeClusters(small.shapes, PAGE, small.bounds))
)
})
it('stays within its clamps', () => {
expect(computeAutoEps([box(0, 0), box(0, 0)])).toBeGreaterThanOrEqual(8)
expect(computeAutoEps([box(0, 0), box(1e6, 0)])).toBeLessThanOrEqual(5000)
})
})
describe('getShapeText', () => {
const withProps = (props: object) => ({ type: 'text', props }) as unknown as TLShape
it('reads a plain text prop', () => {
expect(getShapeText(withProps({ text: 'hello world' }))).toBe('hello world')
})
it('walks a rich text document for its text nodes', () => {
const rich = withProps({
richText: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] },
],
},
})
expect(getShapeText(rich)).toBe('first second')
})
it('returns empty for a shape carrying no text', () => {
expect(getShapeText(withProps({ w: 10, h: 10 }))).toBe('')
})
})
describe('labelClusters', () => {
const text = (value: string) => ({ type: 'text', props: { text: value } }) as unknown as TLShape
const frame = (name: string) => ({ type: 'frame', props: { name } }) as unknown as TLShape
// The reason c-TF-IDF is used at all: a term shared by every cluster carries no information about
// which cluster you are looking at, so at equal frequency it loses to a term confined to one.
it('prefers a distinguishing term over an equally frequent shared one', () => {
const [login, pricing] = labelClusters([
[text('button'), text('password')],
[text('button'), text('invoice')],
[text('button'), text('shipping')],
[text('button'), text('avatar')],
])
expect(login.keywords[0]).toBe('password')
expect(pricing.keywords[0]).toBe('invoice')
})
// The discount is a discount, not a veto: log(1 + n/df) spans only log2..log3 across two clusters,
// so a shared term at twice the frequency still wins. It separates properly as the cluster count
// grows. Worth knowing before reading a two-cluster label as meaningless.
it('lets a much more frequent shared term win when there are only two clusters', () => {
const [labelled] = labelClusters([
[text('button'), text('button'), text('password')],
[text('button'), text('button'), text('invoice')],
])
expect(labelled.keywords[0]).toBe('button')
})
it('uses a frame name verbatim as the label', () => {
const [labelled] = labelClusters([[frame('Login flow'), text('email'), text('password')]])
expect(labelled.label).toBe('Login flow')
expect(labelled.frameNames).toEqual(['Login flow'])
})
it('joins multiple frame names in one cluster', () => {
expect(labelClusters([[frame('Header'), frame('Footer')]])[0].label).toBe('Header + Footer')
})
it('falls back to keywords when there is no frame', () => {
const [labelled] = labelClusters([
[text('checkout payment'), text('checkout payment')],
[text('onboarding tour')],
])
expect(labelled.label).toContain('checkout')
})
it('gives an empty label to a cluster with no text at all', () => {
expect(labelClusters([[{ type: 'geo', props: {} } as unknown as TLShape]])[0]).toEqual({
label: '',
keywords: [],
frameNames: [],
})
})
it('drops stopwords, short tokens and bare numbers', () => {
const [labelled] = labelClusters([[text('the and of a 42 ok signup')]])
expect(labelled.keywords).toContain('signup')
for (const noise of ['the', 'and', 'of', 'a', '42', 'ok']) {
expect(labelled.keywords).not.toContain(noise)
}
})
it('prefers a bigram and suppresses the unigrams it covers', () => {
const [labelled] = labelClusters([
[text('user research'), text('user research')],
[text('billing')],
])
expect(labelled.keywords[0]).toBe('user research')
expect(labelled.keywords).not.toContain('user')
})
// Two unrelated labels sitting next to each other never wrote a phrase between them.
it('does not form bigrams across shape boundaries', () => {
expect(labelClusters([[text('alpha'), text('beta')]])[0].keywords).not.toContain('alpha beta')
})
it('is deterministic for the same input', () => {
const build = () => labelClusters([[text('alpha alpha'), text('beta')], [text('gamma')]])
expect(build()).toEqual(build())
})
})
describe('clusters carry their labels', () => {
it('labels each cluster relative to the others on the page', () => {
const shapes = [
{ id: 'a1', parentId: PAGE, type: 'text', props: { text: 'password reset' } },
{ id: 'a2', parentId: PAGE, type: 'text', props: { text: 'password' } },
{ id: 'b1', parentId: PAGE, type: 'text', props: { text: 'invoice total' } },
{ id: 'b2', parentId: PAGE, type: 'text', props: { text: 'invoice' } },
] as unknown as TLShape[]
const bounds = { a1: box(0, 0), a2: box(20, 0), b1: box(3000, 0), b2: box(3020, 0) }
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters).toHaveLength(2)
expect(clusters.map((c) => c.label).sort()).toEqual([
'invoice · invoice total',
'password · password reset',
])
})
})