diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index bdcd74d..f39642f 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -90,6 +90,7 @@ const DesktopWorkspace: React.FC = ({ viewId, }) => { const containerRef = useRef(null); + const [isLayoutReady, setIsLayoutReady] = useState(false); const items = useMemo(() => { return entries @@ -103,6 +104,24 @@ const DesktopWorkspace: React.FC = ({ const layoutStore = useMemo(() => new LayoutStore(), []); const layoutRef = useRef>(new Map()); + // Update container size in store + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + layoutStore.setContainerSize(width, height); + if (width > 0 && height > 0) { + setIsLayoutReady(true); + } + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [layoutStore]); + useEffect(() => { if (tenantId && viewId) { layoutStore.loadLayout(String(tenantId), viewId); @@ -191,7 +210,7 @@ const DesktopWorkspace: React.FC = ({ } }} > - {items.map((doc, index) => { + {isLayoutReady && items.map((doc, index) => { const docId = doc.id ? String(doc.id) : `temp-${index}`; const isSelected = selectedDocumentIds.includes(docId); const size = ensureDocumentSize(doc); diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index 5bda88e..b19afcb 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -1,4 +1,4 @@ -import { constrainDimensions } from './utils/layoutUtils'; +import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from './utils/layoutUtils'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; export interface LayoutCardState { @@ -23,18 +23,25 @@ export class LayoutCard implements LayoutCardState { private _innerRadius: number = 0; private _outerRadius: number = 0; + private _centerX: number = 0; + private _centerY: number = 0; public store: LayoutStore; public dragStartX: number = 0; public dragStartY: number = 0; public isDirty: boolean = false; + public intendedX: number = 0; + public intendedY: number = 0; private dragOffset: { x: number, y: number } | null = null; constructor(id: string, store: LayoutStore, initialData: Partial = {}, ref: HTMLElement | null = null) { this.id = id; this.store = store; Object.assign(this, initialData); + this.intendedX = this.x; + this.intendedY = this.y; this.ref = ref; this.recalculateRadii(); + this.recalculateCenters(); } setRef(ref: HTMLElement | null) { @@ -48,11 +55,33 @@ export class LayoutCard implements LayoutCardState { this.dragStartY = this.y; } + 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 }; + } + continueDrag(delta: { x: number, y: number }) { if (!this.dragOffset) return; - const newX = this.x + delta.x; - const newY = this.y + delta.y; + 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, @@ -64,12 +93,22 @@ export class LayoutCard implements LayoutCardState { this.dragOffset = null; } - update(changes: Partial, options: { markDirty?: boolean } = {}) { + 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 (options.markDirty) { this.isDirty = true; } @@ -167,6 +206,11 @@ export class LayoutCard implements LayoutCardState { 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() { @@ -226,11 +270,21 @@ export class LayoutCard implements LayoutCardState { get outerRadius(): number { return this._outerRadius; } + + get centerX(): number { + return this._centerX; + } + + get centerY(): number { + return this._centerY; + } } export class LayoutStore { items = new Map(); zCounter = 100; + containerWidth: number = 0; + containerHeight: number = 0; private savedLayouts = new Map(); private tenantId: string | null = null; @@ -263,26 +317,33 @@ export class LayoutStore { if (z >= this.zCounter) { this.zCounter = z + 1; } + + card = new LayoutCard(id, this, { + x, + y, + rotation, + width, + height, + z + }, ref); } else { - // Apply defaults if not provided - x = Math.random() * 500; - y = Math.random() * 500; - rotation = Math.random() * 10 - 5; - z = this.zCounter++; - } + // Create card with temporary position + card = new LayoutCard(id, this, { + width, + height, + z: this.zCounter++ + }, ref); - card = new LayoutCard(id, this, { - x, - y, - rotation, - width, - height, - z - }, ref); + // Calculate initial position using the card instance + const { x: initX, y: initY } = getInitialPosition( + this.containerWidth, + this.containerHeight, + card, + Array.from(this.items.values()) + ); - // If it was a saved layout, it's not dirty. - if (!saved) { - card.isDirty = true; + // Update card with calculated position + card.update({ x: initX, y: initY }, { markDirty: true }); } this.items.set(id, card); @@ -302,6 +363,21 @@ export class LayoutStore { this.items.delete(id); } + 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()) { @@ -386,6 +462,9 @@ export class LayoutStore { card.update(saved, { markDirty: false }); } } + + // Ensure everything is within bounds + this.relayout(); } async saveLayout() { @@ -396,8 +475,8 @@ export class LayoutStore { const entries = dirtyCards.map(card => ({ documentId: card.id, - centerX: card.x, - centerY: card.y, + centerX: card.intendedX, + centerY: card.intendedY, rotation: card.rotation, zIndex: card.z, updatedAt: Date.now() diff --git a/frontend/src/desktop/utils/layoutUtils.ts b/frontend/src/desktop/utils/layoutUtils.ts index cbcde30..8dbbc24 100644 --- a/frontend/src/desktop/utils/layoutUtils.ts +++ b/frontend/src/desktop/utils/layoutUtils.ts @@ -1,38 +1,6 @@ -export interface CardBounds { - minX: number; - maxX: number; - minY: number; - maxY: number; -} +import type { LayoutCard } from '../LayoutSystem'; -export interface ComputeBoundsOptions { - width: number; - height: number; - canvasWidth: number; - canvasHeight: number; - padding: number; - shelfWidth?: number; -} - -export const computeCardBounds = ({ - width, - height, - canvasWidth, - canvasHeight, - padding, - shelfWidth = 0, -}: ComputeBoundsOptions): CardBounds => { - const halfW = width / 2; - const halfH = height / 2; - const shelfOffset = Math.max(shelfWidth, 0); - - return { - minX: padding + halfW, - maxX: Math.max(padding + halfW, canvasWidth - shelfOffset - padding - halfW), - minY: padding + halfH, - maxY: Math.max(padding + halfH, canvasHeight - padding - halfH), - }; -}; +export const CONTAINER_PADDING = 18; export const constrainDimensions = (width: number, height: number, maxDimension: number) => { if (width <= maxDimension && height <= maxDimension) { @@ -52,3 +20,68 @@ export const constrainDimensions = (width: number, height: number, maxDimension: }; } }; + +export const getInitialPosition = ( + containerWidth: number, + containerHeight: number, + card: LayoutCard, + _existingCards: LayoutCard[] = [] +): { x: number, y: number } => { + // Mitchell's Best-Candidate Algorithm (Monte Carlo) + const K = 20; // Number of candidates to test + let bestCandidate = { x: 0, y: 0 }; + let bestScore = -Infinity; + + // Padding to keep cards inside + const padding = CONTAINER_PADDING; + + // Calculate safe bounds for top-left corner + const minX = padding; + const maxX = Math.max(padding, containerWidth - card.width - padding); + const minY = padding; + const maxY = Math.max(padding, containerHeight - card.height - padding); + + const newCardRadius = card.outerRadius; + const halfWidth = card.width / 2; + const halfHeight = card.height / 2; + + for (let i = 0; i < K; i++) { + const x = minX + Math.random() * (maxX - minX); + const y = minY + Math.random() * (maxY - minY); + + const cx = x + halfWidth; + const cy = y + halfHeight; + + // Distance to nearest edge + const distEdge = Math.min( + x, // Left + containerWidth - (x + card.width), // Right + y, // Top + containerHeight - (y + card.height) // Bottom + ); + + // Distance to nearest neighbor + let minNeighborDist = Infinity; + for (const other of _existingCards) { + const dx = cx - other.centerX; + const dy = cy - other.centerY; + const distSq = dx * dx + dy * dy; + + const radiiSum = newCardRadius + other.outerRadius; + const distToEdge = distSq - radiiSum * radiiSum; + + if (distToEdge < minNeighborDist) { + minNeighborDist = distToEdge; + } + } + + const score = Math.min(distEdge, minNeighborDist); + + if (score > bestScore) { + bestScore = score; + bestCandidate = { x, y }; + } + } + + return bestCandidate; +};