diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index 2094430..654bf50 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -44,6 +44,127 @@ const resolveTagKey = (tag) => { return key != null ? String(key) : null; }; +const generateInitialLayout = ( + entries, + { + canvasWidth, + canvasHeight, + padding, + startZ = 0, + rotationRange = ROTATION_RANGE, + minSpacing = 48, + shelfWidth = 0, + }, +) => { + const layout = new Map(); + let currentZ = startZ; + let maxZ = startZ; + + if (!entries.length) { + return { layout, maxZ }; + } + + const shelfOffset = Math.max(shelfWidth, 0); + const usableWidth = Math.max(canvasWidth - shelfOffset - padding * 2, 1); + const usableHeight = Math.max(canvasHeight - padding * 2, 1); + + const spacingBuffer = Math.max(minSpacing, 0); + const placed = []; + + const resolveBounds = (width, height) => { + const halfWidth = width / 2; + const halfHeight = height / 2; + return { + minCenterX: padding + halfWidth, + maxCenterX: Math.max( + padding + halfWidth, + canvasWidth - shelfOffset - padding - halfWidth, + ), + minCenterY: padding + halfHeight, + maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), + }; + }; + + const evaluateCandidateSpacing = (x, y, radius) => { + if (!placed.length) { + return Number.POSITIVE_INFINITY; + } + let best = Number.POSITIVE_INFINITY; + for (let i = 0; i < placed.length; i += 1) { + const item = placed[i]; + const dx = item.x - x; + const dy = item.y - y; + const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; + if (distance < best) { + best = distance; + } + } + return best; + }; + + entries.forEach((entry, index) => { + const width = Number(entry.width) || 0; + const height = Number(entry.height) || 0; + if (!entry.id || width <= 0 || height <= 0) { + return; + } + + const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); + + const radius = Math.sqrt(width * width + height * height) / 2; + + let bestScore = -Infinity; + let bestX = (minCenterX + maxCenterX) / 2; + let bestY = (minCenterY + maxCenterY) / 2; + const samplesPerAxis = 14; + for (let gx = 0; gx < samplesPerAxis; gx += 1) { + const fracX = (gx + 0.5) / samplesPerAxis; + for (let gy = 0; gy < samplesPerAxis; gy += 1) { + const fracY = (gy + 0.5) / samplesPerAxis; + const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); + const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); + const edgeSpacing = Math.min( + candidateX - minCenterX, + maxCenterX - candidateX, + candidateY - minCenterY, + maxCenterY - candidateY, + ) - spacingBuffer * 0.5; + if (edgeSpacing <= 0) { + continue; + } + const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); + const score = Math.min(edgeSpacing, neighborSpacing); + if (score > bestScore) { + bestScore = score; + bestX = candidateX; + bestY = candidateY; + } + } + } + const centerX = clamp(bestX, minCenterX, maxCenterX); + const centerY = clamp(bestY, minCenterY, maxCenterY); + + const rotation = randomRangeFromSeed( + buildKey(entry.id, 'rotation'), + -rotationRange, + rotationRange, + ); + + currentZ += 1; + layout.set(entry.id, { + centerX, + centerY, + rotation, + z: currentZ, + }); + maxZ = Math.max(maxZ, currentZ); + + placed.push({ x: centerX, y: centerY, radius }); + }); + + return { layout, maxZ }; +}; + const createDragPreview = (node, clientX, clientY) => { if (!(node instanceof HTMLElement)) { return null; @@ -323,6 +444,7 @@ const SkeuomorphicWorkspace = ({ const containerRef = useRef(null); + const tagShelfRef = useRef(null); const layoutRef = useRef(new Map()); const itemRefs = useRef(new Map()); const zCounterRef = useRef(10); @@ -330,6 +452,7 @@ const SkeuomorphicWorkspace = ({ const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const [tagShelfWidth, setTagShelfWidth] = useState(0); const [draggingId, setDraggingId] = useState(null); const [zoomedId, setZoomedId] = useState(null); const [tagDropTargetId, setTagDropTargetId] = useState(null); @@ -697,6 +820,30 @@ const SkeuomorphicWorkspace = ({ [resolvePreviewDimensions], ); + useLayoutEffect(() => { + const shelfNode = tagShelfRef.current; + if (!shelfNode || !availableTags.length) { + setTagShelfWidth(0); + return () => {}; + } + + const measure = () => { + const rect = shelfNode.getBoundingClientRect(); + setTagShelfWidth(Math.ceil(rect.width)); + }; + + measure(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + + const observer = new ResizeObserver(() => measure()); + observer.observe(shelfNode); + return () => observer.disconnect(); + }, [availableTags.length]); + const syncLayoutSnapshot = useCallback(() => { setLayoutSnapshot(new Map(layoutRef.current)); }, []); @@ -705,6 +852,10 @@ const SkeuomorphicWorkspace = ({ const container = containerRef.current; if (!container) return () => {}; + if (process.env.NODE_ENV !== 'production') { + console.log('[skeuo] canvas element', container); + } + const commitSize = () => { const rect = container.getBoundingClientRect(); const width = Math.floor(rect.width) || 0; @@ -733,63 +884,94 @@ const SkeuomorphicWorkspace = ({ observer.observe(container); return () => observer.disconnect(); }, []); - useEffect(() => { - if (!items.length) { - layoutRef.current = new Map(); - syncLayoutSnapshot(); - return; + useLayoutEffect(() => { + const container = containerRef.current; + if (!container || !canvasSize.width || !canvasSize.height) { + return () => {}; } - const previous = layoutRef.current; - const next = new Map(); - let maxZ = zCounterRef.current; - const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; - const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; - - items.forEach((doc) => { - const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); - const halfWidth = docWidth / 2; - const halfHeight = docHeight / 2; - - const minCenterX = CANVAS_PADDING + halfWidth; - const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); - const minCenterY = CANVAS_PADDING + halfHeight; - const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); - - const existing = previous.get(doc.id); - if (existing) { - const defaultCenterX = (minCenterX + maxCenterX) / 2; - const defaultCenterY = (minCenterY + maxCenterY) / 2; - const prevCenterX = - typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; - const prevCenterY = - typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; - const centerX = clamp(prevCenterX, minCenterX, maxCenterX); - const centerY = clamp(prevCenterY, minCenterY, maxCenterY); - const rotation = existing.rotation ?? 0; - const z = existing.z ?? maxZ; - maxZ = Math.max(maxZ, z); - next.set(doc.id, { centerX, centerY, rotation, z }); + const rafId = requestAnimationFrame(() => { + if (!items.length) { + layoutRef.current = new Map(); + syncLayoutSnapshot(); return; } - const seedX = seededRandom(`${resolveSizeKey(doc)}:centerX`); - const seedY = seededRandom(`${resolveSizeKey(doc)}:centerY`); - const rangeX = Math.max(maxCenterX - minCenterX, 0); - const rangeY = Math.max(maxCenterY - minCenterY, 0); - const centerX = - rangeX > 0 ? minCenterX + seedX * rangeX : (minCenterX + maxCenterX) / 2; - const centerY = - rangeY > 0 ? minCenterY + seedY * rangeY : (minCenterY + maxCenterY) / 2; - const rotation = randomRangeFromSeed(buildKey(doc.id, 'rotation'), -ROTATION_RANGE, ROTATION_RANGE); - maxZ += 1; - next.set(doc.id, { centerX, centerY, rotation, z: maxZ }); + const previous = layoutRef.current; + const next = new Map(); + let maxZ = zCounterRef.current; + const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; + const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; + const docsNeedingLayout = []; + + items.forEach((doc) => { + const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); + const halfWidth = docWidth / 2; + const halfHeight = docHeight / 2; + + const minCenterX = CANVAS_PADDING + halfWidth; + const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); + const minCenterY = CANVAS_PADDING + halfHeight; + const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); + + const existing = previous.get(doc.id); + if (existing) { + const defaultCenterX = (minCenterX + maxCenterX) / 2; + const defaultCenterY = (minCenterY + maxCenterY) / 2; + const prevCenterX = + typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; + const prevCenterY = + typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; + const centerX = clamp(prevCenterX, minCenterX, maxCenterX); + const centerY = clamp(prevCenterY, minCenterY, maxCenterY); + const rotation = existing.rotation ?? 0; + const z = existing.z ?? maxZ; + maxZ = Math.max(maxZ, z); + next.set(doc.id, { centerX, centerY, rotation, z }); + return; + } + + docsNeedingLayout.push({ + id: doc.id, + width: docWidth, + height: docHeight, + seedKey: resolveSizeKey(doc), + }); + }); + + if (docsNeedingLayout.length) { + const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( + docsNeedingLayout, + { + canvasWidth, + canvasHeight, + padding: CANVAS_PADDING, + startZ: maxZ, + rotationRange: ROTATION_RANGE, + minSpacing: 48, + shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0, + }, + ); + generatedLayout.forEach((entry, docId) => { + next.set(docId, entry); + }); + maxZ = Math.max(maxZ, updatedMaxZ); + } + + layoutRef.current = next; + zCounterRef.current = Math.max(zCounterRef.current, maxZ); + syncLayoutSnapshot(); }); - layoutRef.current = next; - zCounterRef.current = Math.max(zCounterRef.current, maxZ); - syncLayoutSnapshot(); - }, [items, canvasSize.width, canvasSize.height, ensureDocumentSize, syncLayoutSnapshot]); + return () => cancelAnimationFrame(rafId); + }, [ + items, + canvasSize.width, + canvasSize.height, + ensureDocumentSize, + syncLayoutSnapshot, + tagShelfWidth, + ]); useEffect(() => { if (draggingId && !items.some((doc) => doc.id === draggingId)) { @@ -1492,7 +1674,11 @@ const SkeuomorphicWorkspace = ({ }) )} {(availableTags.length > 0 || onCreateTag) && ( -
+
{availableTags.map((tag) => { const colorValue = normalizeColor(tag.color); const foreground = getContrastingTextColor(colorValue || '#1b1f24');