feat: offline support, performance benchmark, y-indexeddb integration
- Add y-indexeddb for offline CRDT persistence (IndexedDB) - useYjsBinding now creates IndexeddbPersistence per document - State syncs from IndexedDB on reconnect - Graceful fallback if IndexedDB unavailable - Add Performance Benchmark test (50k elements) - Bulk insert: 50k elements in <2s - Viewport search: <10ms for ~1000 visible elements - Full viewport search: <100ms for all 50k elements - 100 sequential searches: <500ms - Remove 1000 elements: <200ms - Clear all: <50ms - Bearbeitungs-Tools already fully implemented (move, copy, rotate, scale, mirror, trim, extend, fillet, offset) - Bestuhlungs-Tools already fully implemented (seating-row, seating-block, table, stage, templates) All 634 tests passing (254 backend + 380 frontend)
This commit is contained in:
Generated
+20
@@ -13,6 +13,7 @@
|
|||||||
"rbush": "^4.0.1",
|
"rbush": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-websocket": "^2.0.0",
|
"y-websocket": "^2.0.0",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
},
|
},
|
||||||
@@ -3097,6 +3098,25 @@
|
|||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y-indexeddb": {
|
||||||
|
"version": "9.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
|
||||||
|
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.74"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y-leveldb": {
|
"node_modules/y-leveldb": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"rbush": "^4.0.1",
|
"rbush": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-websocket": "^2.0.0",
|
"y-websocket": "^2.0.0",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||||
import { YjsDocument } from './YjsDocument';
|
import { YjsDocument } from './YjsDocument';
|
||||||
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
|
import { WebSocketProvider, type ConnectionStatus } from './WebSocketProvider';
|
||||||
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
|
import { AwarenessManager, type UserCursor, type UserSelection } from './AwarenessManager';
|
||||||
@@ -90,6 +91,22 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
const doc = new YjsDocument();
|
const doc = new YjsDocument();
|
||||||
yjsDocRef.current = doc;
|
yjsDocRef.current = doc;
|
||||||
|
|
||||||
|
// IndexedDB offline persistence — allows editing without network connection
|
||||||
|
let indexedDbProvider: IndexeddbPersistence | null = null;
|
||||||
|
try {
|
||||||
|
indexedDbProvider = new IndexeddbPersistence(`webcad-${docName}`, doc.doc);
|
||||||
|
indexedDbProvider.whenSynced.then(() => {
|
||||||
|
// Sync state from IndexedDB once loaded
|
||||||
|
setElements(doc.getElements());
|
||||||
|
setLayers(doc.getLayers());
|
||||||
|
setBlocks(doc.getBlocks());
|
||||||
|
setGroups(doc.getGroups());
|
||||||
|
setBgConfigs(doc.getBgConfigs());
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[useYjsBinding] IndexedDB persistence unavailable:', err);
|
||||||
|
}
|
||||||
|
|
||||||
const awareness = new AwarenessManager(doc, userId);
|
const awareness = new AwarenessManager(doc, userId);
|
||||||
awarenessRef.current = awareness;
|
awarenessRef.current = awareness;
|
||||||
|
|
||||||
@@ -150,6 +167,10 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
|||||||
awareness.removeSelf();
|
awareness.removeSelf();
|
||||||
awareness.destroy();
|
awareness.destroy();
|
||||||
provider.disconnect();
|
provider.disconnect();
|
||||||
|
if (indexedDbProvider) {
|
||||||
|
indexedDbProvider.destroy();
|
||||||
|
indexedDbProvider = null;
|
||||||
|
}
|
||||||
doc.destroy();
|
doc.destroy();
|
||||||
yjsDocRef.current = null;
|
yjsDocRef.current = null;
|
||||||
providerRef.current = null;
|
providerRef.current = null;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Performance Benchmark: SpatialIndex with 50,000 elements
|
||||||
|
* Verifies that the R-Tree spatial index can handle large datasets efficiently.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { SpatialIndex } from '../src/canvas/SpatialIndex';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
const N = 50_000;
|
||||||
|
|
||||||
|
describe('Performance Benchmark: 50.000 Elemente', () => {
|
||||||
|
let spatialIndex: SpatialIndex;
|
||||||
|
let elements: CADElement[];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
spatialIndex = new SpatialIndex();
|
||||||
|
elements = [];
|
||||||
|
|
||||||
|
// Generate 50k elements in a grid pattern
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const col = i % 200;
|
||||||
|
const row = Math.floor(i / 200);
|
||||||
|
elements.push({
|
||||||
|
id: `perf-el-${i}`,
|
||||||
|
type: 'rect',
|
||||||
|
layerId: 'layer-perf',
|
||||||
|
x: col * 10,
|
||||||
|
y: row * 10,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
properties: { stroke: '#000', fill: '#ccc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bulk-insert 50k elements in under 2s', () => {
|
||||||
|
const start = performance.now();
|
||||||
|
spatialIndex.bulkInsert(elements);
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`Bulk insert ${N} elements: ${elapsed.toFixed(1)}ms`);
|
||||||
|
expect(elapsed).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search viewport with ~100 visible elements in under 10ms', () => {
|
||||||
|
// Query a small viewport that contains ~100 elements
|
||||||
|
const start = performance.now();
|
||||||
|
const results = spatialIndex.search({ minX: 0, minY: 0, maxX: 1000, maxY: 100 });
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`Viewport search (~100 elements): ${elapsed.toFixed(2)}ms, found=${results.length}`);
|
||||||
|
expect(results.length).toBeGreaterThan(50);
|
||||||
|
expect(results.length).toBeLessThan(2000);
|
||||||
|
expect(elapsed).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search large viewport (all elements) in under 100ms', () => {
|
||||||
|
const start = performance.now();
|
||||||
|
const results = spatialIndex.search({ minX: 0, minY: 0, maxX: 20000, maxY: 2500 });
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`Full viewport search: ${elapsed.toFixed(2)}ms, found=${results.length}`);
|
||||||
|
expect(results.length).toBe(N);
|
||||||
|
expect(elapsed).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 100 sequential viewport searches in under 500ms', () => {
|
||||||
|
const start = performance.now();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const x = (i * 20) % 2000;
|
||||||
|
const y = (i * 5) % 500;
|
||||||
|
spatialIndex.search({ minX: x, minY: y, maxX: x + 200, maxY: y + 100 });
|
||||||
|
}
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`100 sequential searches: ${elapsed.toFixed(2)}ms`);
|
||||||
|
expect(elapsed).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove 1000 elements in under 200ms', () => {
|
||||||
|
const start = performance.now();
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
spatialIndex.remove(elements[i]);
|
||||||
|
}
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`Remove 1000 elements: ${elapsed.toFixed(2)}ms`);
|
||||||
|
expect(elapsed).toBeLessThan(200);
|
||||||
|
|
||||||
|
// Verify count
|
||||||
|
const results = spatialIndex.search({ minX: 0, minY: 0, maxX: 20000, maxY: 2500 });
|
||||||
|
expect(results.length).toBe(N - 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all elements in under 50ms', () => {
|
||||||
|
const start = performance.now();
|
||||||
|
spatialIndex.clear();
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
console.log(`Clear all: ${elapsed.toFixed(2)}ms`);
|
||||||
|
expect(elapsed).toBeLessThan(50);
|
||||||
|
expect(spatialIndex.search({ minX: 0, minY: 0, maxX: 20000, maxY: 2500 }).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user