import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from './utils/layoutUtils'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; import { CardPhysics } from './CardPhysics'; export interface LayoutCardState { id: string; x: number; y: number; z: number; rotation: number; width: number; height: number; pageCount: number; } export class LayoutCard implements LayoutCardState { id: string; x: number = 0; y: number = 0; z: number = 0; rotation: number = 0; width: number = 0; height: number = 0; pageCount: number = 1; ref: HTMLElement | null = null; private _innerRadius: number = 0; private _outerRadius: number = 0; private _centerX: number = 0; private _centerY: number = 0; public store: LayoutStore; public physics: CardPhysics; public intendedX: number = 0; public intendedY: number = 0; 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; this.recalculateRadii(); this.recalculateCenters(); } setRef(ref: HTMLElement | null) { this.ref = ref; this.applyTransform(); } getConstrainedPosition(x: number, y: number): { x: number, y: number } { const rad = (this.rotation * Math.PI) / 180; const sin = Math.abs(Math.sin(rad)); const cos = Math.abs(Math.cos(rad)); const rotatedWidth = this.width * cos + this.height * sin; const rotatedHeight = this.width * sin + this.height * cos; const minX = CONTAINER_PADDING + (rotatedWidth - this.width) / 2; const maxX = this.store.containerWidth - CONTAINER_PADDING - this.width - (rotatedWidth - this.width) / 2; const minY = CONTAINER_PADDING + (rotatedHeight - this.height) / 2; const maxY = this.store.containerHeight - CONTAINER_PADDING - this.height - (rotatedHeight - this.height) / 2; const newX = Math.max(minX, Math.min(x, maxX)); const newY = Math.max(minY, Math.min(y, maxY)); return { x: newX, y: newY }; } update(changes: Partial, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) { Object.assign(this, changes); if (!options.isConstraintUpdate) { if (changes.x !== undefined) this.intendedX = changes.x; if (changes.y !== undefined) this.intendedY = changes.y; } if (changes.width !== undefined || changes.height !== undefined) { this.recalculateRadii(); } if (changes.x !== undefined || changes.y !== undefined || changes.width !== undefined || changes.height !== undefined) { this.recalculateCenters(); } if (changes.pageCount !== undefined) { this.physics.updateMass(changes.pageCount); } if (options.markDirty) { this.isDirty = true; } this.applyTransform(); } isUnobstructed(): boolean { for (const other of this.store.items.values()) { if (other.id === this.id) continue; if (other.z <= this.z) continue; const dx = other.x - this.x; const dy = other.y - this.y; const distance = Math.sqrt(dx * dx + dy * dy); // Broad phase: check outer radii if (distance < this.outerRadius + other.outerRadius) { // Narrow phase: SAT intersection test if (this.intersects(other)) { return false; } } } return true; } bringToFront() { this.z = this.store.zCounter++; } private getVertices(): { x: number; y: number }[] { const rad = (this.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const hw = this.width / 2; const hh = this.height / 2; // Corners relative to center, then rotated, then translated // (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh) const corners = [ { x: -hw, y: -hh }, { x: hw, y: -hh }, { x: hw, y: hh }, { x: -hw, y: hh } ]; return corners.map(p => ({ x: (p.x * cos - p.y * sin) + this._centerX, y: (p.x * sin + p.y * cos) + this._centerY })); } containsPoint(x: number, y: number): boolean { // Translate point to local space relative to center const dx = x - this._centerX; const dy = y - this._centerY; // Rotate point by -rotation to align with AABB const rad = (-this.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const localX = dx * cos - dy * sin; const localY = dx * sin + dy * cos; const hw = this.width / 2; const hh = this.height / 2; return localX >= -hw && localX <= hw && localY >= -hh && localY <= hh; } getVisibleFraction(): number { const samplesX = 4; const samplesY = 4; const totalSamples = samplesX * samplesY; let visibleSamples = 0; // Get potential occluders (higher Z-index) const occluders = Array.from(this.store.items.values()).filter(other => other.id !== this.id && other.z > this.z ); if (occluders.length === 0) return 1.0; const rad = (this.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const hw = this.width / 2; const hh = this.height / 2; // Sample points across the card surface for (let i = 0; i < samplesX; i++) { for (let j = 0; j < samplesY; j++) { // Normalized coordinates [-1, 1] const nx = (i / (samplesX - 1)) * 2 - 1; const ny = (j / (samplesY - 1)) * 2 - 1; // Local coordinates const lx = nx * hw * 0.9; // 0.9 to avoid edge cases const ly = ny * hh * 0.9; // World coordinates const wx = (lx * cos - ly * sin) + this._centerX; const wy = (lx * sin + ly * cos) + this._centerY; // Check occlusion let isOccluded = false; for (const occluder of occluders) { if (occluder.containsPoint(wx, wy)) { isOccluded = true; break; } } if (!isOccluded) { visibleSamples++; } } } return visibleSamples / totalSamples; } private getAxes(): { x: number; y: number }[] { const rad = (this.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); // Normals of the edges (local x and y axes) return [ { x: cos, y: sin }, { x: -sin, y: cos } ]; } private intersects(other: LayoutCard): boolean { const verticesA = this.getVertices(); const verticesB = other.getVertices(); const axes = [...this.getAxes(), ...other.getAxes()]; for (const axis of axes) { const pA = this.project(verticesA, axis); const pB = this.project(verticesB, axis); if (pA.max < pB.min || pB.max < pA.min) { return false; // Gap found, no intersection } } return true; } private project(vertices: { x: number; y: number }[], axis: { x: number; y: number }) { let min = Infinity; let max = -Infinity; for (const v of vertices) { const dot = v.x * axis.x + v.y * axis.y; if (dot < min) min = dot; if (dot > max) max = dot; } return { min, max }; } private recalculateRadii() { this._innerRadius = Math.min(this.width, this.height) / 2; this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2; } private recalculateCenters() { this._centerX = this.x + this.width / 2; this._centerY = this.y + this.height / 2; } private rafId: number | null = null; private applyTransform() { if (this.rafId) { cancelAnimationFrame(this.rafId); } this.rafId = requestAnimationFrame(() => { if (this.ref) { this.ref.style.transform = `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; this.ref.style.zIndex = String(this.z); this.ref.style.width = `${this.width}px`; this.ref.style.height = `${this.height}px`; } this.rafId = null; }); } snapTo(x: number, y: number) { this.update({ x, y }, { markDirty: true }); if (!this.ref) return; this.ref.classList.add('desk-item--swoop'); this.ref.style.transform = `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; const cleanup = () => { if (this.ref) { this.ref.classList.remove('desk-item--swoop'); } }; this.ref.addEventListener('transitionend', cleanup, { once: true }); // Safety timeout in case transitionend doesn't fire (e.g. element removed) setTimeout(cleanup, 350); } toSnapshot(): LayoutCardState { return { id: this.id, x: this.x, y: this.y, z: this.z, rotation: this.rotation, width: this.width, height: this.height, pageCount: this.pageCount }; } get innerRadius(): number { return this._innerRadius; } get outerRadius(): number { return this._outerRadius; } get centerX(): number { return this._centerX; } get centerY(): number { return this._centerY; } get isDragging(): boolean { return this.physics.isDragging; } } export class LayoutStore { items = new Map(); zCounter = 100; containerWidth: number = 0; containerHeight: number = 0; private savedLayouts = new Map(); private tenantId: string | null = null; private viewId: string | null = null; initialize(id: string, ref: HTMLElement | null, config: { width: number; height: number; pageCount: number; }) { let card = this.items.get(id); const { width, height } = constrainDimensions( config.width, config.height, 320 ); if (!card) { // Check for saved layout const saved = this.savedLayouts.get(id); let x, y, rotation, z; if (saved) { x = saved.x; y = saved.y; rotation = saved.rotation; z = saved.z; // Ensure zCounter is higher than any loaded z if (z >= this.zCounter) { this.zCounter = z + 1; } card = new LayoutCard(id, this, { x, y, rotation, width, height, z, pageCount: config.pageCount }, ref); } else { // Create card with temporary position card = new LayoutCard(id, this, { width, height, z: this.zCounter++, pageCount: config.pageCount }, ref); // Calculate initial position using the card instance const { x: initX, y: initY, rotation: initRotation } = getInitialPosition( this.containerWidth, this.containerHeight, card, Array.from(this.items.values()) ); // Update card with calculated position card.update({ x: initX, y: initY, rotation: initRotation }, { markDirty: true }); } this.items.set(id, card); } else { card.update({ width, height, pageCount: config.pageCount }, { markDirty: false }); } // Always update ref and ensure transform is applied if (card.ref !== ref) { card.setRef(ref); } return card; } unregister(id: string) { this.items.delete(id); } clear() { this.items.clear(); this.zCounter = 100; this.savedLayouts.clear(); } setContainerSize(width: number, height: number) { this.containerWidth = width; this.containerHeight = height; this.relayout(); } relayout() { for (const card of this.items.values()) { const { x, y } = card.getConstrainedPosition(card.intendedX, card.intendedY); if (x !== card.x || y !== card.y) { card.update({ x, y }, { markDirty: false, isConstraintUpdate: true }); } } } getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] { const result: LayoutCard[] = []; for (const card of this.items.values()) { // Calculate center of candidate card const cx = card.x + card.width / 2; const cy = card.y + card.height / 2; const dx = cx - x; const dy = cy - y; const distSq = dx * dx + dy * dy; const limit = radius; if (distSq < limit * limit) { result.push(card); } } return result; } getStackBelow(topCard: LayoutCard): string[] { const centerX = topCard.x + topCard.width / 2; const centerY = topCard.y + topCard.height / 2; const candidates = this.getCardsInCircle(centerX, centerY, topCard.innerRadius); return candidates .filter(other => { if (other.id === topCard.id) return false; if (other.z >= topCard.z) return false; return true; }) .map(c => c.id); } bringToFront(ids: string[]) { const cards = ids .map(id => this.items.get(id)) .filter((c): c is LayoutCard => !!c); // Sort by current Z-index to preserve relative order cards.sort((a, b) => a.z - b.z); // Assign new Z-indices for (const card of cards) { card.update({ z: this.zCounter++ }, { markDirty: true }); } } getSnapshot() { return Array.from(this.items.values()).map(card => card.toSnapshot()); } async loadLayout(tenantId: string, viewId: string) { this.tenantId = tenantId; this.viewId = viewId; const records = await fetchLayoutRecords({ tenantId, viewId }); this.savedLayouts.clear(); let maxZ = this.zCounter; for (const record of records) { if (record.documentId && record.centerX !== undefined && record.centerY !== undefined) { this.savedLayouts.set(record.documentId, { x: record.centerX, y: record.centerY, rotation: record.rotation || 0, z: record.zIndex || 0 }); if (record.zIndex && record.zIndex >= maxZ) { maxZ = record.zIndex + 1; } } } this.zCounter = maxZ; // Apply to existing items if any (though usually this runs before items are created) for (const [id, card] of this.items) { const saved = this.savedLayouts.get(id); if (saved) { card.update(saved, { markDirty: false }); } } // Ensure everything is within bounds this.relayout(); } async saveLayout() { if (!this.tenantId || !this.viewId) return; const dirtyCards = Array.from(this.items.values()).filter(card => card.isDirty); if (dirtyCards.length === 0) return; const entries = dirtyCards.map(card => ({ documentId: card.id, centerX: card.intendedX, centerY: card.intendedY, rotation: card.rotation, zIndex: card.z, updatedAt: Date.now() })); await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries }); // Reset dirty flag for saved cards for (const card of dirtyCards) { card.isDirty = false; } } }