fix(agent-graph): tune stable slot spacing
This commit is contained in:
parent
937b23be4b
commit
ade312ad87
6 changed files with 163 additions and 52 deletions
|
|
@ -1,7 +1,8 @@
|
|||
export const STABLE_SLOT_GEOMETRY = {
|
||||
slotVerticalGap: 24,
|
||||
slotHorizontalGap: 32,
|
||||
slotVerticalGap: 12,
|
||||
slotHorizontalGap: 77.7,
|
||||
ringGap: 140,
|
||||
centralHorizontalGap: 77.7,
|
||||
centralSafetyPadding: 48,
|
||||
memberSlotInnerPadding: 16,
|
||||
centralBlockGap: 56,
|
||||
|
|
@ -9,7 +10,7 @@ export const STABLE_SLOT_GEOMETRY = {
|
|||
unassignedGap: 72,
|
||||
maxGeneratedRings: 12,
|
||||
ownerCollisionPadding: 28,
|
||||
ownerBandHeight: 96,
|
||||
ownerBandHeight: 32,
|
||||
ownerMinWidth: 200,
|
||||
processBandHeight: 32,
|
||||
processRailWidth: 220,
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ const PROCESS_RAIL_NODE_GAP = 42;
|
|||
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
||||
const GEOMETRY_EPSILON = 0.001;
|
||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
||||
|
||||
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
||||
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||
|
|
@ -265,7 +266,9 @@ function rectOverlapsAnyCentralRect(
|
|||
rect: StableRect,
|
||||
centralCollisionRects: readonly StableRect[]
|
||||
): boolean {
|
||||
return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect));
|
||||
return centralCollisionRects.some((centralRect) =>
|
||||
rectsOverlapWithAxisGap(rect, centralRect, SLOT_GEOMETRY.centralHorizontalGap, 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function computeOwnerFootprints(
|
||||
|
|
@ -988,21 +991,25 @@ function planStrictSmallTeamOwnerSlots(
|
|||
return null;
|
||||
}
|
||||
|
||||
let radius = Math.max(
|
||||
...slotConfigs.map((slot) =>
|
||||
resolveMinimumDirectionalRadiusForVector({
|
||||
vector: slot!.vector,
|
||||
footprint: slot!.footprint,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
})
|
||||
)
|
||||
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis(
|
||||
slotConfigs.map((slot) => slot!),
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion
|
||||
);
|
||||
|
||||
for (let iteration = 0; iteration < 48; iteration += 1) {
|
||||
const frames = slotConfigs.map((slot) =>
|
||||
buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector)
|
||||
);
|
||||
const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP;
|
||||
const frames = slotConfigs.map((slot) => {
|
||||
const axis = resolveStrictSmallTeamVectorAxis(slot!.vector);
|
||||
return buildSlotFrameAtRadiusWithVector(
|
||||
slot!.footprint,
|
||||
slot!.assignment,
|
||||
baseRadiusByAxis[axis] +
|
||||
(axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) +
|
||||
radiusBump,
|
||||
slot!.vector
|
||||
);
|
||||
});
|
||||
const allValid = frames.every((frame, frameIndex) =>
|
||||
isSlotFramePlacementValid(
|
||||
frame,
|
||||
|
|
@ -1013,12 +1020,42 @@ function planStrictSmallTeamOwnerSlots(
|
|||
if (allValid) {
|
||||
return frames;
|
||||
}
|
||||
radius += SMALL_TEAM_CARDINAL_RADIUS_STEP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamRadiusByAxis(
|
||||
slotConfigs: readonly {
|
||||
footprint: OwnerFootprint;
|
||||
vector: { x: number; y: number };
|
||||
}[],
|
||||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect
|
||||
): Record<'horizontal' | 'vertical', number> {
|
||||
const radiusByAxis = {
|
||||
horizontal: 0,
|
||||
vertical: 0,
|
||||
};
|
||||
|
||||
for (const slot of slotConfigs) {
|
||||
const axis = resolveStrictSmallTeamVectorAxis(slot.vector);
|
||||
const radius = resolveMinimumDirectionalRadiusForVector({
|
||||
vector: slot.vector,
|
||||
footprint: slot.footprint,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
});
|
||||
radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius);
|
||||
}
|
||||
|
||||
return radiusByAxis;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamVectorAxis(vector: { x: number; y: number }): 'horizontal' | 'vertical' {
|
||||
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
|
||||
}
|
||||
|
||||
function buildPreferredAssignmentsMap(
|
||||
assignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): Map<string, GraphOwnerSlotAssignment> {
|
||||
|
|
@ -1329,7 +1366,7 @@ function rankNearestSlotAssignmentResult(args: {
|
|||
if (
|
||||
!isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) ||
|
||||
!isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) ||
|
||||
rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding)
|
||||
ownerSlotFramesOverlap(frame.bounds, displacedFrame.bounds)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1735,12 +1772,17 @@ function resolveTaskColumnKey(task: GraphNode): string {
|
|||
return 'todo';
|
||||
}
|
||||
|
||||
function rectsOverlapWithGap(a: StableRect, b: StableRect, gap: number): boolean {
|
||||
function rectsOverlapWithAxisGap(
|
||||
a: StableRect,
|
||||
b: StableRect,
|
||||
horizontalGap: number,
|
||||
verticalGap: number
|
||||
): boolean {
|
||||
return (
|
||||
a.left - gap < b.right &&
|
||||
a.right + gap > b.left &&
|
||||
a.top - gap < b.bottom &&
|
||||
a.bottom + gap > b.top
|
||||
a.left - horizontalGap < b.right &&
|
||||
a.right + horizontalGap > b.left &&
|
||||
a.top - verticalGap < b.bottom &&
|
||||
a.bottom + verticalGap > b.top
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1748,6 +1790,15 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean {
|
|||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
function ownerSlotFramesOverlap(a: StableRect, b: StableRect): boolean {
|
||||
return rectsOverlapWithAxisGap(
|
||||
a,
|
||||
b,
|
||||
SLOT_GEOMETRY.slotHorizontalGap,
|
||||
SLOT_GEOMETRY.ringPadding
|
||||
);
|
||||
}
|
||||
|
||||
function rectContainsRect(outer: StableRect, inner: StableRect): boolean {
|
||||
return (
|
||||
inner.left >= outer.left - GEOMETRY_EPSILON &&
|
||||
|
|
@ -1788,9 +1839,7 @@ function isSlotFramePlacementValid(
|
|||
if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) {
|
||||
return false;
|
||||
}
|
||||
return !existingFrames.some((existing) =>
|
||||
rectsOverlapWithGap(frame.bounds, existing.bounds, SLOT_GEOMETRY.ringPadding)
|
||||
);
|
||||
return !existingFrames.some((existing) => ownerSlotFramesOverlap(frame.bounds, existing.bounds));
|
||||
}
|
||||
|
||||
function buildAssignmentKey(assignment: GraphOwnerSlotAssignment): string {
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
export function useTeamGraphSlotReset(teamName: string, enabled = true): void {
|
||||
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || !isTeamGraphSlotPersistenceDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||
}, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
|
|||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
|
|
@ -61,8 +60,6 @@ export const TeamGraphOverlay = ({
|
|||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||
|
||||
useTeamGraphSlotReset(teamName);
|
||||
|
||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
|
|||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
|
|
@ -52,8 +51,6 @@ export const TeamGraphTab = ({
|
|||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
||||
useTeamGraphSlotReset(teamName, isActive);
|
||||
|
||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||
const dispatchOpenTask = useCallback(
|
||||
(taskId: string) =>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,26 @@ function rectsOverlap(
|
|||
);
|
||||
}
|
||||
|
||||
function rectsOverlapVertically(
|
||||
left: { top: number; bottom: number },
|
||||
right: { top: number; bottom: number }
|
||||
): boolean {
|
||||
return left.top < right.bottom && left.bottom > right.top;
|
||||
}
|
||||
|
||||
function horizontalGapBetween(
|
||||
left: { left: number; right: number },
|
||||
right: { left: number; right: number }
|
||||
): number {
|
||||
if (left.right <= right.left) {
|
||||
return right.left - left.right;
|
||||
}
|
||||
if (right.right <= left.left) {
|
||||
return left.left - right.right;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
describe('stable slot layout planner', () => {
|
||||
it('does not build a stable slot snapshot when the lead is missing', () => {
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
|
|
@ -153,6 +173,7 @@ describe('stable slot layout planner', () => {
|
|||
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0));
|
||||
expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
|
||||
});
|
||||
|
||||
it('uses strict cardinal owner slots for teams with up to four members', () => {
|
||||
|
|
@ -200,6 +221,7 @@ describe('stable slot layout planner', () => {
|
|||
|
||||
expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1);
|
||||
expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1);
|
||||
expect(Math.abs(topFrame.ownerY)).toBeLessThan(Math.abs(rightFrame.ownerX));
|
||||
});
|
||||
|
||||
it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => {
|
||||
|
|
@ -246,6 +268,67 @@ describe('stable slot layout planner', () => {
|
|||
expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('keeps horizontal spacing around lead columns and between side-by-side owners', () => {
|
||||
const teamName = 'team-horizontal-spacing';
|
||||
const lead = createLead(teamName);
|
||||
const members = [
|
||||
createMember(teamName, 'agent-top', 'top'),
|
||||
createMember(teamName, 'agent-right', 'right'),
|
||||
createMember(teamName, 'agent-bottom', 'bottom'),
|
||||
createMember(teamName, 'agent-left', 'left'),
|
||||
createMember(teamName, 'agent-top-right', 'top-right'),
|
||||
createMember(teamName, 'agent-bottom-right', 'bottom-right'),
|
||||
];
|
||||
const tasks = [
|
||||
createTask(teamName, 'lead-todo', lead.id),
|
||||
createTask(teamName, 'lead-wip', lead.id, { taskStatus: 'in_progress' }),
|
||||
...members.map((member, index) => createTask(teamName, `task-${index + 1}`, member.id)),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {
|
||||
[members[0]!.id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[members[1]!.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[members[2]!.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[members[3]!.id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
[members[4]!.id]: { ringIndex: 0, sectorIndex: 4 },
|
||||
[members[5]!.id]: { ringIndex: 0, sectorIndex: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, ...members, ...tasks],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
|
||||
for (const frame of snapshot!.memberSlotFrames) {
|
||||
for (const centralRect of snapshot!.centralCollisionRects) {
|
||||
if (!rectsOverlapVertically(frame.bounds, centralRect)) {
|
||||
continue;
|
||||
}
|
||||
expect(horizontalGapBetween(frame.bounds, centralRect)).toBeGreaterThanOrEqual(
|
||||
STABLE_SLOT_GEOMETRY.centralHorizontalGap
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, left] of snapshot!.memberSlotFrames.entries()) {
|
||||
for (const right of snapshot!.memberSlotFrames.slice(index + 1)) {
|
||||
if (!rectsOverlapVertically(left.bounds, right.bounds)) {
|
||||
continue;
|
||||
}
|
||||
expect(horizontalGapBetween(left.bounds, right.bounds)).toBeGreaterThanOrEqual(
|
||||
STABLE_SLOT_GEOMETRY.slotHorizontalGap
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves a full empty activity column and minimum kanban width for idle members', () => {
|
||||
const teamName = 'team-empty-slot';
|
||||
const lead = createLead(teamName);
|
||||
|
|
@ -825,8 +908,10 @@ describe('stable slot layout planner', () => {
|
|||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect);
|
||||
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width);
|
||||
expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan(
|
||||
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(
|
||||
snapshot!.leadSlotFrame.bounds.width
|
||||
);
|
||||
expect(snapshot!.leadCentralReservedBlock.height).toBeLessThanOrEqual(
|
||||
snapshot!.leadSlotFrame.bounds.height
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue