feat: introduce a new layered spatial workspace architecture design, implement initial card positioning logic, and add container size observation for the layout system.

This commit is contained in:
2025-11-26 21:54:11 +01:00
parent ee1c5dc606
commit e36732cc2d
3 changed files with 189 additions and 58 deletions
+20 -1
View File
@@ -90,6 +90,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
viewId,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isLayoutReady, setIsLayoutReady] = useState(false);
const items = useMemo(() => {
return entries
@@ -103,6 +104,24 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const layoutStore = useMemo(() => new LayoutStore(), []);
const layoutRef = useRef<Map<string, LayoutCard>>(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<DesktopWorkspaceProps> = ({
}
}}
>
{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);
+102 -23
View File
@@ -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<LayoutCardState> = {}, 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<LayoutCardState>, options: { markDirty?: boolean } = {}) {
update(changes: Partial<LayoutCardState>, 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<string, LayoutCard>();
zCounter = 100;
containerWidth: number = 0;
containerHeight: number = 0;
private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>();
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()
+67 -34
View File
@@ -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;
};