Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-spatial-index.ts
ddCat 3f4a9ea3d0 feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
2026-09-01 14:57:05 +08:00

124 lines
5.7 KiB
TypeScript

export type CanvasSpatialBounds = {
left: number;
top: number;
right: number;
bottom: number;
};
export type CanvasSpatialIndexEntry<T> = {
id: string;
bounds: CanvasSpatialBounds;
value: T;
};
type Bucket = number[];
export type CanvasSpatialIndex<T> = {
query: (bounds: CanvasSpatialBounds, limit?: number) => T[];
};
const DEFAULT_CELL_SIZE = 1024;
const MAX_BUCKET_COVERAGE = 256;
/**
* A uniform grid keeps viewport queries proportional to nearby content. The
* original entry order is restored after bucket de-duplication so stacking
* behavior remains deterministic.
*/
export function buildCanvasSpatialIndex<T>(entries: CanvasSpatialIndexEntry<T>[], cellSize = DEFAULT_CELL_SIZE): CanvasSpatialIndex<T> {
const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : DEFAULT_CELL_SIZE;
const buckets = new Map<string, Bucket>();
const largeEntryIndexes: number[] = [];
const validEntries: CanvasSpatialIndexEntry<T>[] = [];
entries.forEach((entry) => {
const bounds = normalizeBounds(entry.bounds);
if (!bounds || !entry.id) return;
const index = validEntries.push({ ...entry, bounds }) - 1;
const minColumn = Math.floor(bounds.left / normalizedCellSize);
const maxColumn = Math.floor((bounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(bounds.top / normalizedCellSize);
const maxRow = Math.floor((bounds.bottom - Number.EPSILON) / normalizedCellSize);
if ((maxColumn - minColumn + 1) * (maxRow - minRow + 1) > MAX_BUCKET_COVERAGE) {
largeEntryIndexes.push(index);
return;
}
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
const key = `${column}:${row}`;
const bucket = buckets.get(key);
if (bucket) bucket.push(index);
else buckets.set(key, [index]);
}
}
});
return {
query(bounds, limit) {
const normalizedBounds = normalizeBounds(bounds);
if (!normalizedBounds || validEntries.length === 0) return [];
const resultLimit = Number.isFinite(limit) && (limit || 0) > 0 ? Math.floor(limit as number) : null;
const minColumn = Math.floor(normalizedBounds.left / normalizedCellSize);
const maxColumn = Math.floor((normalizedBounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(normalizedBounds.top / normalizedCellSize);
const maxRow = Math.floor((normalizedBounds.bottom - Number.EPSILON) / normalizedCellSize);
if (resultLimit) {
const candidateIndexes = new Set<number>();
const resultIndexes: number[] = [];
const addBucket = (bucket: Bucket | undefined) => {
if (!bucket) return false;
for (const index of bucket) {
if (candidateIndexes.has(index)) continue;
candidateIndexes.add(index);
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) return true;
}
return false;
};
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
if (addBucket(buckets.get(`${column}:${row}`))) return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
}
for (const index of largeEntryIndexes) {
if (candidateIndexes.has(index)) continue;
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) break;
}
return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
const candidateIndexes = new Set<number>();
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
buckets.get(`${column}:${row}`)?.forEach((index) => candidateIndexes.add(index));
}
}
largeEntryIndexes.forEach((index) => candidateIndexes.add(index));
return [...candidateIndexes]
.sort((left, right) => left - right)
.map((index) => validEntries[index])
.filter((entry) => intersects(entry.bounds, normalizedBounds))
.map((entry) => entry.value);
},
};
}
export function canvasNodeBounds(node: { position: { x: number; y: number }; width: number; height: number }): CanvasSpatialBounds {
return { left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height };
}
function normalizeBounds(bounds: CanvasSpatialBounds | undefined): CanvasSpatialBounds | null {
if (!bounds) return null;
const { left, top, right, bottom } = bounds;
if (![left, top, right, bottom].every(Number.isFinite) || right <= left || bottom <= top) return null;
return { left, top, right, bottom };
}
function intersects(left: CanvasSpatialBounds, right: CanvasSpatialBounds) {
return left.right > right.left && left.left < right.right && left.bottom > right.top && left.top < right.bottom;
}