Skip to content

feat: Add Explore snap-to-grid BED-9229 - #3137

Open
justin-prime1 wants to merge 7 commits into
mainfrom
feature/explore-snap-to-grid
Open

feat: Add Explore snap-to-grid BED-9229#3137
justin-prime1 wants to merge 7 commits into
mainfrom
feature/explore-snap-to-grid

Conversation

@justin-prime1

@justin-prime1 justin-prime1 commented Aug 11, 2026

Copy link
Copy Markdown

Related BHE implementation: https://github.com/SpecterOps/bloodhound-enterprise/pull/1726

Description

Adds an optional, session-local Snap to grid control to the Explore graph. When enabled, the current graph aligns to a deterministic grid, nodes follow the pointer freely while dragged, and released nodes settle visibly into the nearest collision-free grid cell.

The implementation uses BHCE's native Sigma and Graphology capabilities. It preserves free-form behavior while disabled, re-applies grid alignment after supported layout operations and graph replacement, and cancels stale settlement transitions during rapid re-grab, disable, layout changes, graph replacement, and unmount.

Blast radius / risk: Limited to client-side Explore graph controls, Sigma drag handling, layout coordination, and node positioning. The state is not persisted. There are no API, database, migration, authorization, or collection changes. Primary risks are interaction regressions during drag/settlement and additional positioning work when enabled; disabled-mode tests verify that graph-wide occupancy work is avoided.

Test changes: Adds focused coverage for grid rounding, deterministic collision resolution, dense collision allocation, pointer-following drag behavior, animated settlement, rapid re-grab, enable/disable behavior, graph replacement, unmount cleanup, layout integration, control state and styling, keyboard operation, and table-view visibility. No expectations were weakened or removed to accommodate product regressions; one redundant toggle test that did not assert its claimed layout behavior was removed before submission.

Rollback: Revert this PR. The feature stores no persistent state and requires no migration, configuration rollback, or deployment ordering.

Review size: 770 reviewable changed lines — 291 product and 479 tests. Independent review accepted this as one cohesive interaction change with its renderer and lifecycle coverage.

Motivation and Context

Resolves BED-9229

Explore users need a way to organize manually manipulated graphs without losing the existing free-form workflow. This adds a desktop-icon-style grid mode while keeping free-form positioning as the default.

How Has This Been Tested?

  • yarn workspace bloodhound-ui test GraphEvents.test.tsx GraphView.test.tsx snapToGrid.test.ts --run — 29/29 passed.
  • Direct UI TypeScript, ESLint, and Prettier checks passed.
  • just prepare-for-codereview passed on clean head 5f73cb16ef39eff8c20c25f9234a588dc1b4e27d.
  • Seeded local Sigma/WebGL browser validation confirmed graph rendering, keyboard toggle behavior, selected-state semantics, drag/settlement behavior, session reset, table-view behavior, and no browser errors on the product-equivalent predecessor head. Subsequent commits only added direct lifecycle tests and removed one redundant test.
  • Independent enterprise review passed on the exact submitted head with no blocking, important, minor, or follow-up findings.

Not directly validated: touch-specific dragging, release outside the Sigma canvas, large real-world graph profiling, and narrow-viewport layout.

Screenshots (optional):

Not included because no stable PR-accessible capture URL is available. Browser interaction evidence is summarized above.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist:

Summary by CodeRabbit

  • New Features

    • Added an optional snap-to-grid mode for graph layouts.
    • Added a toggle control in the Explore graph view, including keyboard support.
    • Graph nodes now align to a consistent grid while avoiding overlapping positions.
    • Dragged nodes smoothly settle into available grid positions.
    • Fixed-position nodes remain unchanged during snapping.
  • Bug Fixes

    • Improved drag handling, animation cancellation, and cleanup during rapid interactions or view changes.

@justin-prime1 justin-prime1 added enhancement New feature or request user interface A pull request containing changes affecting the UI code. javascript Pull requests that update javascript code labels Aug 11, 2026
@justin-prime1 justin-prime1 self-assigned this Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: a2c6c1c8-330d-4df9-9f6d-572ff22fb4fb

📥 Commits

Reviewing files that changed from the base of the PR and between 5f73cb1 and 0e2f45b.

📒 Files selected for processing (3)
  • cmd/ui/src/components/SigmaChart/GraphEvents.test.tsx
  • cmd/ui/src/views/Explore/snapToGrid.test.ts
  • cmd/ui/src/views/Explore/snapToGrid.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • cmd/ui/src/views/Explore/snapToGrid.ts
  • cmd/ui/src/views/Explore/snapToGrid.test.ts

📝 Walkthrough

Walkthrough

Adds deterministic grid-snapping utilities, collision-free Sigma drag behavior, layout resnapping, lifecycle cleanup, and an Explore view toggle connected to SigmaChart.

Changes

Snap-to-grid graph interaction

Layer / File(s) Summary
Grid snapping utilities
cmd/ui/src/views/Explore/snapToGrid.ts, cmd/ui/src/views/Explore/snapToGrid.test.ts
Adds deterministic coordinate rounding, occupied-cell tracking, collision-free placement, batch snapping, fixed-node preservation, and utility tests.
Sigma graph snapping
cmd/ui/src/components/SigmaChart/GraphEvents.tsx, cmd/ui/src/components/SigmaChart/SigmaChart.tsx, cmd/ui/src/components/SigmaChart/GraphEvents.test.tsx
Passes optional snapping configuration through SigmaChart. Snaps graph positions during layouts and drag release. Cancels animations and delayed resets during re-grabs and unmount.
Explore view toggle
cmd/ui/src/views/Explore/GraphView.tsx, cmd/ui/src/views/Explore/GraphView.test.tsx
Adds a mouse- and keyboard-accessible snap-to-grid toggle. Hides the control in table view and passes its state to SigmaChart.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ExploreUser
  participant GraphView
  participant SigmaChart
  participant GraphEvents
  participant snapToGrid
  ExploreUser->>GraphView: Toggle snap-to-grid
  GraphView->>SigmaChart: Pass snapToGridEnabled
  SigmaChart->>GraphEvents: Register graph events
  GraphEvents->>snapToGrid: Snap graph positions
  snapToGrid-->>GraphEvents: Return collision-free positions
  GraphEvents-->>SigmaChart: Update and refresh graph
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the new Explore snap-to-grid feature and includes the associated ticket.
Description check ✅ Passed The description covers the change, motivation, testing, risks, rollback, change type, ticket, and completed checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/explore-snap-to-grid

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
cmd/ui/src/components/SigmaChart/GraphEvents.tsx (2)

93-99: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid a shared mutable Set in the default drag metadata.

DEFAULT_DRAGGED_META is a module-level constant. Every reset assigns the same Set instance to state. The current code always replaces occupiedGridPoints instead of mutating it, so behavior is correct today. A later change that mutates the set would leak entries into every subsequent drag. Build the default with a factory to remove the hazard.

♻️ Proposed refactor
-const DEFAULT_DRAGGED_META = {
+const createDefaultDraggedMeta = (): DragMetadata => ({
     id: null,
     cancelNextClick: false,
     offset: null,
     origin: null,
     occupiedGridPoints: new Set<string>(),
-};
+});

Replace each DEFAULT_DRAGGED_META usage with createDefaultDraggedMeta().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ui/src/components/SigmaChart/GraphEvents.tsx` around lines 93 - 99,
Replace the shared DEFAULT_DRAGGED_META object with a createDefaultDraggedMeta()
factory that returns a fresh metadata object and Set on every call, then update
all DEFAULT_DRAGGED_META usages to invoke the factory when initializing or
resetting drag state.

300-308: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The occupied cell set can be stale at release.

downNode snapshots occupiedGridPoints at drag start. The snap on release uses that snapshot. If node positions change during the drag, for example through a layout run or a graph update, the snapshot no longer matches the graph. The released node can then settle onto an occupied cell.

Recomputing the occupied cells inside mouseup removes the stale window and keeps the per-drag cost the same.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ui/src/components/SigmaChart/GraphEvents.tsx` around lines 300 - 308,
Update the mouseup release logic to recompute occupied grid points from the
current graph state immediately before snapping, instead of using the
occupiedGridPoints snapshot captured by downNode. Preserve the existing release
generation and drag reset behavior while ensuring the released node cannot snap
onto a cell occupied after the drag began.
cmd/ui/src/views/Explore/snapToGrid.test.ts (1)

61-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting placement quality, not only uniqueness.

The dense test confirms 5,000 unique cells. It does not bound how far nodes travel from the origin cell. A regression that scatters nodes across a very large area still passes. Add an assertion on the maximum offset to lock in compact packing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ui/src/views/Explore/snapToGrid.test.ts` around lines 61 - 69, Extend the
dense collision test around snapPositionsToGrid to assert a maximum distance or
coordinate offset from the original origin cell in addition to uniqueness.
Compute the largest resulting x/y offset and require it to stay within a compact
packing bound appropriate for 5,000 nodes.
cmd/ui/src/views/Explore/GraphView.tsx (1)

314-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bind the tooltip to the button so keyboard users receive it.

MUI Tooltip attaches its focus and hover handlers to its direct child. The direct child here is a <span>, and a plain <span> does not receive keyboard focus. A keyboard user who tabs to GraphButton therefore never sees the tooltip text. The wrapper is only required when the child is disabled, which is not the case here.

aria-label and aria-pressed still convey the control and its state, so this is a polish item rather than a blocker.

♻️ Proposed refactor
                     {!displayTable && (
                         <Tooltip title={`${isSnapToGridEnabled ? 'Disable' : 'Enable'} snap to grid`} placement='top'>
-                            <span>
-                                <GraphButton
-                                    aria-label='Snap to grid'
-                                    ...
-                                />
-                            </span>
+                            <GraphButton
+                                aria-label='Snap to grid'
+                                ...
+                            />
                         </Tooltip>
                     )}

GraphButton must forward its ref for this to work. Verify that before applying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ui/src/views/Explore/GraphView.tsx` around lines 314 - 334, Update the
Tooltip usage in the displayTable control block to make GraphButton its direct
child instead of wrapping it in a span, so Tooltip focus handlers reach the
keyboard-focusable button. Verify that GraphButton forwards its ref; preserve
the existing tooltip text, accessibility attributes, state styling, and click
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/ui/src/components/SigmaChart/GraphEvents.test.tsx`:
- Around line 196-224: Move fake-timer cleanup out of the test body and add an
afterEach hook alongside the existing beforeEach in GraphEvents.test.tsx that
always calls vi.useRealTimers(); remove the trailing vi.useRealTimers() call
from the rapid re-grab test.

In `@cmd/ui/src/views/Explore/snapToGrid.test.ts`:
- Line 46: Update both Set assertions in snapToGrid tests to access the Set’s
size property and use toBe(3), including the assertions near lines 46 and 68; do
not use toHaveLength for Set values.

In `@cmd/ui/src/views/Explore/snapToGrid.ts`:
- Around line 51-67: Update findAvailableGridPosition to validate position.x and
position.y before entering its infinite search loop. Normalize any non-finite
coordinate to a finite fallback value, then use the normalized coordinates for
roundToGrid so offsets can produce distinct candidate cells and the existing
occupied-grid lookup remains unchanged.

---

Nitpick comments:
In `@cmd/ui/src/components/SigmaChart/GraphEvents.tsx`:
- Around line 93-99: Replace the shared DEFAULT_DRAGGED_META object with a
createDefaultDraggedMeta() factory that returns a fresh metadata object and Set
on every call, then update all DEFAULT_DRAGGED_META usages to invoke the factory
when initializing or resetting drag state.
- Around line 300-308: Update the mouseup release logic to recompute occupied
grid points from the current graph state immediately before snapping, instead of
using the occupiedGridPoints snapshot captured by downNode. Preserve the
existing release generation and drag reset behavior while ensuring the released
node cannot snap onto a cell occupied after the drag began.

In `@cmd/ui/src/views/Explore/GraphView.tsx`:
- Around line 314-334: Update the Tooltip usage in the displayTable control
block to make GraphButton its direct child instead of wrapping it in a span, so
Tooltip focus handlers reach the keyboard-focusable button. Verify that
GraphButton forwards its ref; preserve the existing tooltip text, accessibility
attributes, state styling, and click behavior.

In `@cmd/ui/src/views/Explore/snapToGrid.test.ts`:
- Around line 61-69: Extend the dense collision test around snapPositionsToGrid
to assert a maximum distance or coordinate offset from the original origin cell
in addition to uniqueness. Compute the largest resulting x/y offset and require
it to stay within a compact packing bound appropriate for 5,000 nodes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: fc6433c1-f1a0-40d8-a5c5-53624515a452

📥 Commits

Reviewing files that changed from the base of the PR and between ad0b7e3 and 5f73cb1.

📒 Files selected for processing (7)
  • cmd/ui/src/components/SigmaChart/GraphEvents.test.tsx
  • cmd/ui/src/components/SigmaChart/GraphEvents.tsx
  • cmd/ui/src/components/SigmaChart/SigmaChart.tsx
  • cmd/ui/src/views/Explore/GraphView.test.tsx
  • cmd/ui/src/views/Explore/GraphView.tsx
  • cmd/ui/src/views/Explore/snapToGrid.test.ts
  • cmd/ui/src/views/Explore/snapToGrid.ts

Comment thread cmd/ui/src/components/SigmaChart/GraphEvents.test.tsx
const snapped = snapPositionsToGrid(positions);

expect(snapped).toEqual(snapPositionsToGrid(reverseOrder));
expect(new Set(Object.values(snapped).map(({ x, y }) => `${x}:${y}`))).toHaveLength(3);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Vitest toHaveLength work with Set size?

💡 Result:

No, Vitest's toHaveLength matcher does not work with Set objects [1][2][3]. The toHaveLength matcher specifically checks for the existence and value of a .length property on the received object [1][3]. Because Set objects in JavaScript use a .size property to report the number of elements instead of a .length property, toHaveLength will not function as expected when passed a Set [1][3]. To assert the size of a Set in Vitest, you should instead use toBe or toEqual to check the .size property directly: typescript import { expect, test } from 'vitest' test('check set size', () => { const mySet = new Set([1, 2, 3]); expect(mySet.size).toBe(3); })

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'snapToGrid.test.ts|package.json|pnpm-lock.yaml|yarn.lock|package-lock.json' .
printf '%s\n' '--- relevant test lines ---'
test_file="$(fd -i -t f 'snapToGrid.test.ts' . | head -n 1)"
if [ -n "$test_file" ]; then
  cat -n "$test_file" | sed -n '35,75p'
fi
printf '%s\n' '--- Vitest declarations ---'
rg -n --glob 'package.json' --glob '*lock*' --glob '*.yaml' --glob '*.yml' '"vitest"|vitest@' . | head -n 80

Repository: SpecterOps/BloodHound

Length of output: 2814


🌐 Web query:

Vitest 4.1.7 toHaveLength matcher Set size implementation

💡 Result:

In Vitest, the toHaveLength matcher is specifically designed to check for a.length property on an object [1][2]. It is not designed to work with the Set data structure, which uses a.size property rather than.length [3][4]. Because Set objects do not have a.length property, using toHaveLength(n) on a Set will fail [1][2]. The matcher implementation in Vitest calls this.have.length(length), which performs a direct check for the existence and value of the.length property [3][4]. To check the number of elements in a Set in Vitest, you should check the.size property directly using the toBe matcher: expect(mySet.size).toBe(n);

Citations:


Assert the Set size directly.

toHaveLength checks .length, while Set uses .size. Update both assertions at lines 46 and 68 to check new Set(...).size with toBe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ui/src/views/Explore/snapToGrid.test.ts` at line 46, Update both Set
assertions in snapToGrid tests to access the Set’s size property and use
toBe(3), including the assertions near lines 46 and 68; do not use toHaveLength
for Set values.

Comment thread cmd/ui/src/views/Explore/snapToGrid.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request javascript Pull requests that update javascript code user interface A pull request containing changes affecting the UI code.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant