mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[eric] canvas: Tidy layout left you at 41% with a 2-wide ribbon, it now grids to the screen and frames it readable
This commit is contained in:
@@ -7,7 +7,7 @@ import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
|
||||
import { revealZoom } from '../../canvas/revealZoom';
|
||||
import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go
|
||||
@@ -19,6 +19,10 @@ const MAX_ZOOM = 3.0;
|
||||
const ZOOM_IN_FACTOR = 1.1;
|
||||
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
|
||||
const FIT_PADDING = 200;
|
||||
// Tidy frames everything at once, so it gets its own tighter margin than a single-card fit. The wider
|
||||
// x inset is the left dock, which floats over the canvas and would otherwise sit on the first column.
|
||||
const TIDY_PADDING = { x: 120, y: 56 };
|
||||
const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM;
|
||||
// Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide.
|
||||
const FIT_DURATION = 150;
|
||||
// Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight.
|
||||
@@ -691,6 +695,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
maxZoom?: number,
|
||||
minZoom?: number,
|
||||
centered?: boolean,
|
||||
padding?: { x: number; y: number },
|
||||
): { panX: number; panY: number; zoom: number } | null => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || cardRects.length === 0) return null;
|
||||
@@ -711,8 +716,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const availW = vRect.width - FIT_PADDING * 2;
|
||||
const availH = vRect.height - FIT_PADDING * 2;
|
||||
const padX = padding?.x ?? FIT_PADDING;
|
||||
const padY = padding?.y ?? FIT_PADDING;
|
||||
const availW = vRect.width - padX * 2;
|
||||
const availH = vRect.height - padY * 2;
|
||||
const ceiling = maxZoom ?? MAX_ZOOM;
|
||||
const floor = minZoom ?? MIN_ZOOM;
|
||||
const targetZoom = clamp(
|
||||
@@ -720,12 +727,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
floor,
|
||||
ceiling,
|
||||
);
|
||||
const targetPanX =
|
||||
(vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
|
||||
// Content wider/taller than the viewport only happens when the zoom floor bit; centering then hides both ends, so anchor the start of it instead.
|
||||
const targetPanX = contentWidth * targetZoom > vRect.width
|
||||
? padX - minX * targetZoom
|
||||
: (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
|
||||
// A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering.
|
||||
const topBiased = cardRects.length === 1 && !centered;
|
||||
const targetPanY = topBiased
|
||||
? FIT_PADDING * 0.4 - minY * targetZoom
|
||||
const targetPanY = topBiased || contentHeight * targetZoom > vRect.height
|
||||
? padY * 0.4 - minY * targetZoom
|
||||
: (vRect.height - contentHeight * targetZoom) / 2 -
|
||||
minY * targetZoom;
|
||||
return { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
|
||||
@@ -740,10 +749,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
animate?: boolean,
|
||||
minZoom?: number,
|
||||
centered?: boolean,
|
||||
padding?: { x: number; y: number },
|
||||
) => {
|
||||
cancelAnimation();
|
||||
|
||||
const target = computeFitTarget(cardRects, maxZoom, minZoom, centered);
|
||||
const target = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding);
|
||||
if (!target) {
|
||||
// Keep current camera; snapping to (0,0,1) used to desync the minimap.
|
||||
if (cardRects.length === 0 || !viewportRef.current) {
|
||||
@@ -761,7 +771,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
// Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target.
|
||||
settleTimerRef.current = window.setTimeout(() => {
|
||||
settleTimerRef.current = null;
|
||||
const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered);
|
||||
const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding);
|
||||
if (!fresh) return;
|
||||
const cur2 = stateRef.current;
|
||||
const drift =
|
||||
@@ -777,6 +787,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
[cancelAnimation, animateTo, computeFitTarget, setCanvasState],
|
||||
);
|
||||
|
||||
// The camera half of Tidy: frame the freshly gridded cards close in (the 200px fit padding was
|
||||
// eating a third of the viewport), never below readable, and never magnified past life size.
|
||||
const fitTidy = useCallback(
|
||||
(cardRects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
fitToCards(cardRects, 1, true, TIDY_MIN_ZOOM, false, TIDY_PADDING);
|
||||
},
|
||||
[fitToCards],
|
||||
);
|
||||
|
||||
// Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom.
|
||||
const revealCards = useCallback(
|
||||
(cardRects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
||||
@@ -833,9 +852,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const getLiveState = useCallback((): CanvasState => stateRef.current, []);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation,
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation,
|
||||
setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -111,6 +111,7 @@ export function useDashboardCardActions({
|
||||
const {
|
||||
cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers,
|
||||
workflowCards: tidiedWorkflows, workflowsHub: tidiedHub,
|
||||
workflowsMonitorCard: tidiedMonitor, settingsCard: tidiedSettings,
|
||||
} = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({
|
||||
@@ -120,9 +121,12 @@ export function useDashboardCardActions({
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []),
|
||||
// The hub, the monitor and Settings get tidied into the grid too, so the camera has to know about them or it frames a stale box.
|
||||
...[tidiedHub, tidiedMonitor, tidiedSettings]
|
||||
.filter((c): c is NonNullable<typeof c> => !!c)
|
||||
.map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
];
|
||||
canvasActions.fitToCards(allRects);
|
||||
canvasActions.fitTidy(allRects);
|
||||
}, [dispatch, canvasActions]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -345,10 +345,11 @@ export function findOpenGridCell(
|
||||
occupiedRects: Rect[],
|
||||
newW: number,
|
||||
newH: number,
|
||||
colLimit?: number,
|
||||
): { x: number; y: number } {
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
const maxCols = Math.max(
|
||||
const maxCols = colLimit ?? Math.max(
|
||||
1,
|
||||
Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK,
|
||||
);
|
||||
@@ -365,6 +366,34 @@ export function findOpenGridCell(
|
||||
}
|
||||
}
|
||||
|
||||
// Tidy packs into the grid shape that fills the SCREEN best. The default column count is derived from
|
||||
// window.innerWidth, which is screen pixels pretending to be world units: it laid 8 cards out as a
|
||||
// 2-wide, 4-tall ribbon that the camera then had to pull back to 41% to show.
|
||||
export function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number {
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
let cells = 0;
|
||||
let widest = 1;
|
||||
for (const s of itemSizes) {
|
||||
const cols = Math.max(1, Math.ceil(s.w / cellW));
|
||||
cells += cols * Math.max(1, Math.ceil(s.h / cellH));
|
||||
widest = Math.max(widest, cols);
|
||||
}
|
||||
const vw = window.innerWidth || 1440;
|
||||
const vh = window.innerHeight || 900;
|
||||
let best = widest;
|
||||
let bestZoom = 0;
|
||||
for (let cols = widest; cols <= Math.max(widest, cells); cols++) {
|
||||
const rows = Math.ceil(cells / cols);
|
||||
const zoom = Math.min(vw / (cols * cellW), vh / (rows * cellH));
|
||||
if (zoom > bestZoom) {
|
||||
bestZoom = zoom;
|
||||
best = cols;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor. Used when the backend hands us a card with a position that's already occupied (sub-agent or sub-browser spawning on top of its parent or a sibling). Spirals outward from the anchor on a grid, snapping to cell-aligned positions so the result still looks intentional, not dropped from orbit. Caps the spiral search at ~1000 cells to avoid pathological work in adversarial layouts, falls back to findOpenGridCell after that. Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame), so this only runs when a new card appears. Typical scan resolves in <10 cells, well below the cap. No perf impact on steady-state UI.
|
||||
export function findOpenSpotNear(
|
||||
anchorX: number,
|
||||
@@ -768,19 +797,19 @@ const dashboardLayoutSlice = createSlice({
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
const sizeOf = (item: typeof allItems[number]): { w: number; h: number } => ({
|
||||
w: item.storedW,
|
||||
h: item.kind === 'agent' && expanded.has(item.id)
|
||||
? Math.max(EXPANDED_CARD_MIN_H, item.storedH)
|
||||
: item.storedH,
|
||||
});
|
||||
const cols = tidyColumnCount(allItems.map(sizeOf));
|
||||
const placedRects: Rect[] = [];
|
||||
|
||||
for (const item of allItems) {
|
||||
let w: number, h: number;
|
||||
if (item.kind === 'agent') {
|
||||
w = item.storedW;
|
||||
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : item.storedH;
|
||||
} else {
|
||||
w = item.storedW;
|
||||
h = item.storedH;
|
||||
}
|
||||
const { w, h } = sizeOf(item);
|
||||
|
||||
const pos = findOpenGridCell(placedRects, w, h);
|
||||
const pos = findOpenGridCell(placedRects, w, h, cols);
|
||||
placedRects.push({ x: pos.x, y: pos.y, w, h });
|
||||
|
||||
if (item.kind === 'agent') {
|
||||
|
||||
Reference in New Issue
Block a user