diff --git a/frontend/docs/physics model.md b/frontend/docs/physics model.md deleted file mode 100644 index 1c13bd8..0000000 --- a/frontend/docs/physics model.md +++ /dev/null @@ -1,177 +0,0 @@ -Design Document: Spatial Workspace Architecture Refactor -Status: Draft Target System: Desk/Workspace (Canvas, Dragging, Physics) Primary Goal: Decompose "God Objects" into a composable, layered architecture to improve performance, maintainability, and testability. - -1. Executive Summary -The current implementation relies on a monolithic class (WorkspaceEngine) and an overloaded hook (useDeskPointer). This coupling forces React to handle high-frequency logic (physics/drag), resulting in brittle code and potential performance bottlenecks. - -The Proposal: Transition to a Layered Architecture. We will separate "Pure Math" (Physics/Geometry), "Mutable State" (Performance), and "React Interaction" (Events/Business Logic). - -2. Architectural Overview -We will adopt a unidirectional, event-driven flow for interactions, bypassing React's render cycle for high-frequency updates (dragging/animating), while using React for low-frequency updates (selection/mounting). - -The Four Layers - -The Physics Layer (Core): Stateless, pure functions for geometry and kinetics. - -The Scene Layer (Store): A lightweight, mutable registry that holds the "truth" of layout (x, y, rotation) and manages direct DOM updates. - -The Interaction Layer (Hooks): React hooks that bind DOM events to the Scene Layer. - -The Persistence Layer: An asynchronous observer that syncs the Scene Layer to the Backend/DB. - -Code-Snippet -graph TD - User[User Input] -->|Pointer Events| Interaction[Interaction Layer Hooks] - Interaction -->|Calculate| Physics[Physics Layer Pure Math] - Interaction -->|Update| Scene[Scene Layer Mutable Store] - Scene -->|Direct Manipulation| DOM[DOM Elements 60fps] - Scene -.->|Debounced Snapshot| DB[Persistence Layer] -3. Detailed Component Design -Layer 1: Physics & Geometry (lib/spatial) - -Responsibility: Pure math. No side effects. No DOM references. - -Key Modules: - -geometry.ts: Hit testing, polygon intersection, coordinate projection (Screen <-> Canvas). - -kinetics.ts: Inertia decay, angular velocity calculation, clamping. - -Benefit: 100% Unit testable without mocking the DOM. - -Layer 2: The Scene Store (lib/scene) - -Responsibility: High-performance state management. It acts as the bridge between React and the DOM. - -Structure: - -TypeScript -class SceneStore { - // Fast lookups - items: Map; - - // Updates DOM style immediately, skips React render - updateItem(id, transform) { ... } - - // Used by Persistence Layer - getSnapshot() { ... } -} -Why: React State is too slow for 60fps drag interactions on complex DOM trees. We need direct manipulation. - -Layer 3: Interaction Hooks (hooks/) - -We split the "God Hook" (useDeskPointer) into specific responsibilities. - -usePointerGesture: - -Role: The "driver." Handles down, move, up, cancel. - -Logic: Manages drag thresholds, long-press timers, and distinguishing taps from drags. - -Output: Emits high-level events: onTap, onDragStart, onDrag, onDragEnd. - -useSpatialQuery: - -Role: The "eyes." - -Logic: Wraps lib/spatial. Given an event (x, y), returns [DocID, StackInfo]. - -useDragController: - -Role: The "business logic." - -Logic: Listens to usePointerGesture. When a drag starts: - -Locks the React View (prevents re-renders). - -Calculates physics via lib/spatial. - -Pushes updates to SceneStore. - -On release, triggers inertia animation loop. - -Layer 4: Persistence (Observer) - -Role: Syncs the mutable SceneStore back to the database. - -Mechanism: - -Subscribes to onDragEnd or an internal dirty flag in the Store. - -Uses a debounce strategy (e.g., wait 500ms after last movement) to save to the backend. - -4. Data Flow Scenarios -Scenario A: Selecting a Card - -User: Clicks on a card. - -usePointerGesture: Detects pointerDown + pointerUp (no movement). Fires onTap. - -useDeskSelection: Receives onTap. Checks event.metaKey. Updates React State (setSelectedIds). - -React: Re-renders to show selection border. - -Scenario B: Dragging a Card (The Performance Path) - -User: Presses and moves mouse > 5px. - -usePointerGesture: Fires onDragStart. - -useDragController: - -Calculates initialOffsets. - -While moving: - -Calculates new x, y, rotation (using Physics Layer). - -Calls SceneStore.updateItem(). - -Result: The DOM element moves via CSS Transform. React does not re-render. - -User: Releases mouse. - -useDragController: Fires onDragEnd. Starts Inertia Animation loop (updating SceneStore via requestAnimationFrame). - -Persistence: Detects end of movement, saves new coordinates. - -5. Migration Strategy -We will apply the Strangler Fig Pattern: replacing pieces of the monolith gradually. - -Phase 1: Math Extraction (Safe) - -Extract geometry/physics logic from WorkspaceEngine and pointerUtils into pure functions in lib/spatial. - -Risk: Low. - -Phase 2: The Gesture Hook (Cleanup) - -Implement usePointerGesture. Replace the event listeners in useDeskPointer with this hook. - -Risk: Low. - -Phase 3: The Scene Store (Core Replacement) - -Build SceneStore. - -Modify useDocumentDrag to write to SceneStore instead of WorkspaceEngine. - -Risk: Medium. Visual synchronization bugs might occur during transition. - -Phase 4: Persistence Decoupling - -Move loadPersistedLayout and upsertLayoutRecords out of the Engine and into a specialized React Effect or standard async function triggered by the Store. - -6. Comparison: Old vs. New -Feature Old Architecture New Architecture -State Monolithic Class (WorkspaceEngine) Mutable Store (SceneStore) + React State -Dragging Mixed into Engine & Hooks Isolated Controller Hook -Physics Hardcoded in Engine Pure Functional Module -DOM Access Cached Refs inside Engine Direct management via Store -Testing Difficult (Mocking Engine required) Easy (Test Physics/Store in isolation) -7. Open Questions / Risks -Z-Index Management: Currently handled by zCounter in the Engine. The SceneStore must maintain a global Z-index counter to ensure "Bring to Front" works reliably. - -** React Context vs. Global Singleton:** Should SceneStore be a global singleton or provided via Context? - -Decision: Context. This allows multiple independent Workspaces on one screen if needed in the future. \ No newline at end of file diff --git a/frontend/src/desktop/CardDragLogic.ts b/frontend/src/desktop/CardDragLogic.ts index 0f473c9..16a1233 100644 --- a/frontend/src/desktop/CardDragLogic.ts +++ b/frontend/src/desktop/CardDragLogic.ts @@ -10,7 +10,7 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading const card = store.items.get(id); if (card) { // If card is already dragging by another pointer, skip it - if (card.isDragging && card.dragPointerId !== pointerId) return; + if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return; const leadingCenterX = leadingCard.x + leadingCard.width / 2; const leadingCenterY = leadingCard.y + leadingCard.height / 2; @@ -27,7 +27,7 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading if (!card) return; // If card is already dragging by another pointer, skip it - if (card.isDragging && card.dragPointerId !== pointerId) return; + if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return; let myOffset = offset; @@ -41,20 +41,20 @@ export const handleDragStart = (store: LayoutStore, selection: string[], leading }; } - card.beginDrag(myOffset, pointerId); + card.physics.beginDrag(myOffset, pointerId); }); }; export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, pointerId: number) => { const leadingCard = store.items.get(leadingId); - if (!leadingCard || !leadingCard.isDragging) return; + if (!leadingCard || !leadingCard.physics.isDragging) return; selection.forEach(id => { if (id === leadingId) return; const card = store.items.get(id); // If card exists and is not already dragging, attach it - if (card && !card.isDragging) { + if (card && !card.physics.isDragging) { const leadingCenterX = leadingCard.x + leadingCard.width / 2; const leadingCenterY = leadingCard.y + leadingCard.height / 2; @@ -62,23 +62,23 @@ export const attachToDragGroup = (store: LayoutStore, selection: string[], leadi const targetY = leadingCenterY - card.height / 2; card.snapTo(targetX, targetY); - card.beginDrag({ x: 0, y: 0 }, pointerId); + card.physics.beginDrag({ x: 0, y: 0 }, pointerId); } }); }; export const handleDragMove = (store: LayoutStore, _selection: string[], delta: { x: number, y: number }, pointerId: number) => { for (const card of store.items.values()) { - if (card.isDragging && card.dragPointerId === pointerId) { - card.continueDrag(delta); + if (card.physics.isDragging && card.physics.dragPointerId === pointerId) { + card.physics.continueDrag(delta); } } }; export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => { for (const card of store.items.values()) { - if (card.dragPointerId === pointerId) { - card.finishDrag(); + if (card.physics.dragPointerId === pointerId) { + card.physics.finishDrag(); } } store.saveLayout(); diff --git a/frontend/src/desktop/CardPhysics.ts b/frontend/src/desktop/CardPhysics.ts new file mode 100644 index 0000000..5a17fa9 --- /dev/null +++ b/frontend/src/desktop/CardPhysics.ts @@ -0,0 +1,92 @@ +import { LayoutCard } from './LayoutSystem'; + +export class CardPhysics { + // State + private velocity: { x: number, y: number, rotation: number } = { x: 0, y: 0, rotation: 0 }; + private _isDragging: boolean = false; + public dragPointerId: number | null = null; + private pendingDelta: { x: number, y: number } = { x: 0, y: 0 }; + + get isDragging(): boolean { + return this._isDragging; + } + + // Loop State + private physicsRafId: number | null = null; + private lastTickTime: number = 0; + private card: LayoutCard; + + constructor(card: LayoutCard) { + this.card = card; + } + + beginDrag(offset: { x: number, y: number }, pointerId: number) { + this._isDragging = true; + this.dragPointerId = pointerId; + this.startPhysicsLoop(); + } + + continueDrag(delta: { x: number, y: number }) { + this.pendingDelta.x += delta.x; + this.pendingDelta.y += delta.y; + } + + finishDrag() { + this._isDragging = false; + this.dragPointerId = null; + // Loop continues for decay/settling if needed + } + + private startPhysicsLoop() { + if (this.physicsRafId) return; + this.lastTickTime = performance.now(); + this.physicsRafId = requestAnimationFrame(this.physicsTick); + } + + private stopPhysicsLoop() { + if (this.physicsRafId) { + cancelAnimationFrame(this.physicsRafId); + this.physicsRafId = null; + } + } + + private physicsTick = (time: number) => { + const dt = Math.min((time - this.lastTickTime) / 1000, 0.1); + this.lastTickTime = time; + + if (this._isDragging) { + // Minimal Physics: Apply pending delta directly + const dx = this.pendingDelta.x; + const dy = this.pendingDelta.y; + + // Update velocity (simple instantaneous) + if (dt > 0.001) { + this.velocity.x = dx / dt; + this.velocity.y = dy / dt; + } + + // Reset pending delta + this.pendingDelta = { x: 0, y: 0 }; + + // Update position + const newX = this.card.x + dx; + const newY = this.card.y + dy; + + // Apply Constraints + const constrained = this.card.getConstrainedPosition(newX, newY); + + this.card.update({ + x: constrained.x, + y: constrained.y, + rotation: this.card.rotation // No rotation change for now + }, { markDirty: true }); + } else { + // Minimal Decay: Stop immediately for now (skeleton behavior) + this.stopPhysicsLoop(); + return; + } + + // Request next frame + this.physicsRafId = requestAnimationFrame(this.physicsTick); + }; +} diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index 4cd3da3..e7f3b3c 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -1,5 +1,7 @@ import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from './utils/layoutUtils'; + import { fetchLayoutRecords, upsertLayoutRecords } from './db'; +import { CardPhysics } from './CardPhysics'; export interface LayoutCardState { id: string; @@ -26,18 +28,16 @@ export class LayoutCard implements LayoutCardState { private _centerX: number = 0; private _centerY: number = 0; public store: LayoutStore; - public dragStartX: number = 0; - public dragStartY: number = 0; - public isDirty: boolean = false; + public physics: CardPhysics; public intendedX: number = 0; public intendedY: number = 0; - private dragOffset: { x: number, y: number } | null = null; - public dragPointerId: number | null = null; + public isDirty: boolean = false; constructor(id: string, store: LayoutStore, initialData: Partial = {}, ref: HTMLElement | null = null) { this.id = id; this.store = store; Object.assign(this, initialData); + this.physics = new CardPhysics(this); this.intendedX = this.x; this.intendedY = this.y; this.ref = ref; @@ -50,12 +50,7 @@ export class LayoutCard implements LayoutCardState { this.applyTransform(); } - beginDrag(offset: { x: number, y: number }, pointerId: number) { - this.dragOffset = offset; - this.dragStartX = this.x; - this.dragStartY = this.y; - this.dragPointerId = pointerId; - } + getConstrainedPosition(x: number, y: number): { x: number, y: number } { const rad = (this.rotation * Math.PI) / 180; @@ -77,24 +72,7 @@ export class LayoutCard implements LayoutCardState { return { x: newX, y: newY }; } - continueDrag(delta: { x: number, y: number }) { - if (!this.dragOffset) return; - const targetX = this.x + delta.x; - const targetY = this.y + delta.y; - - const { x: newX, y: newY } = this.getConstrainedPosition(targetX, targetY); - - this.update({ - x: newX, - y: newY - }, { markDirty: true }); - } - - finishDrag() { - this.dragOffset = null; - this.dragPointerId = null; - } update(changes: Partial, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) { Object.assign(this, changes); @@ -283,7 +261,7 @@ export class LayoutCard implements LayoutCardState { } get isDragging(): boolean { - return !!this.dragOffset; + return this.physics.isDragging; } } diff --git a/frontend/src/desktop/useCardPointer.ts b/frontend/src/desktop/useCardPointer.ts index cf39e99..03b8475 100644 --- a/frontend/src/desktop/useCardPointer.ts +++ b/frontend/src/desktop/useCardPointer.ts @@ -87,7 +87,7 @@ export const useCardPointer = ( const c = card.store.items.get(cId); if (c && c.isDragging) { targetLeaderId = cId; - targetPointerId = c.dragPointerId; // Use the pointer driving that card + targetPointerId = c.physics.dragPointerId; // Use the pointer driving that card break; // Attach to the first found drag group } }