Skip to content

feat(camera): add wheel intent resolver and fix layer DPR observation#302

Open
draedful wants to merge 8 commits into
mainfrom
fix_wheel_and_dpr
Open

feat(camera): add wheel intent resolver and fix layer DPR observation#302
draedful wants to merge 8 commits into
mainfrom
fix_wheel_and_dpr

Conversation

@draedful

@draedful draedful commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace isTrackpadDetector with wheelIntent.ts resolver that classifies wheel input as pan or zoom intent from gesture shape (deltaMode, integer vs fractional PIXEL deltas, timing).
  • Route camera wheel handling through resolveWheelIntent on graph settings; MOUSE_WHEEL_BEHAVIOR applies to mouse I4 rules only, integer trackpad scroll always pans.
  • Fix DPR observation in observeDPR / LayersService and add docs, unit tests, and updated e2e coverage.

Test plan

  • npm test -- wheelIntent.test.ts (21 tests)
  • Manual: trackpad two-finger scroll → pan on macOS and Windows
  • Manual: mouse wheel with MOUSE_WHEEL_BEHAVIOR: zoom → zoom
  • Manual: mouse wheel with MOUSE_WHEEL_BEHAVIOR: scroll → pan
  • Manual: pinch-to-zoom on trackpad uses PINCH_ZOOM_SPEED
  • Storybook: Mouse Wheel Behavior Scroll story (debug logging disabled)

Made with Cursor

Summary by Sourcery

Route camera wheel handling through an intent-based resolver and fix DPR handling for layers.

New Features:

  • Introduce an intent-based wheel input resolver that classifies pan vs zoom and exposes debug hooks and public types for customization.

Bug Fixes:

  • Fix device pixel ratio observation to avoid races and ensure layers read the current DPR value correctly.

Enhancements:

  • Refactor camera wheel handling to use semantic pan/zoom handlers driven by resolved wheel intent instead of inferred device type.
  • Update graph settings, public exports, and Storybook examples to use wheel intent classification and clarify how MOUSE_WHEEL_BEHAVIOR interacts with the resolver.
  • Adjust Layer DPR access to use the graph root size state rather than the layers service wrapper.

Documentation:

  • Document the wheel intent resolution model, its heuristics, and how it integrates with camera configuration, including updates to camera system docs.

Tests:

  • Add unit tests for the wheel intent resolver and update e2e tests to validate camera pan/zoom routing based on resolved intent rather than device kind.

draedful and others added 5 commits June 19, 2026 19:18
Replace device detection with pan/zoom intent rules keyed on integer PIXEL deltas for trackpad and fractional deltas for mouse, with tests and updated docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Apply integer deltaMode=0 trackpad heuristics on all platforms, document deltaMode rules, and add debug signal plus tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Classify trackpad vs mouse from deltaMode and delta shape only; drop OS priors and related public API.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rename slow integer trackpad rule id, expand test coverage, align camera docs with resolver behavior, and remove enableWheelIntentDebug from the story.

Co-authored-by: Cursor <cursoragent@cursor.com>
@draedful draedful requested a review from Antamansid as a code owner June 22, 2026 00:23
@sourcery-ai

sourcery-ai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors camera wheel handling to use a new intent-based resolver (pan vs zoom) instead of device detection, adds a configurable wheel-intent hook on graph settings, updates docs/Storybook/e2e tests, and fixes DPR observation usage in layers.

File-Level Changes

Change Details Files
Route camera wheel handling through an intent-based pan/zoom API instead of device-type detection.
  • Rename and repurpose Camera wheel handlers to handlePan and handleZoom, with a single handleWheelEvent entry point.
  • Replace wheelDeviceFromEvent usage with wheelIntentFromEvent, passing DPR and MOUSE_WHEEL_BEHAVIOR into the resolver.
  • Use EWheelIntent to branch between pan and zoom, with pinch gestures detected via isPinchZoomGesture to apply PINCH_ZOOM_SPEED.
src/services/camera/Camera.ts
Expose and use a new wheel intent resolver API in graph settings and public exports.
  • Change graph settings config from resolveWheelDevice to resolveWheelIntent and add wheelIntentFromEvent helper on GraphEditorSettings.
  • Set DefaultSettings.resolveWheelIntent to createWheelIntentResolver() and update type exports from isTrackpadDetector to the new wheel-intent types and helpers.
  • Update the e2e GraphCamera page object to override resolveWheelIntent instead of resolveWheelDevice.
src/store/settings.ts
src/graphConfig.ts
src/index.ts
src/utils/functions/index.ts
e2e/page-objects/GraphCameraComponentObject.ts
Replace device-based mouse-wheel behavior tests and Storybook story with intent-based ones.
  • Update the Mouse Wheel Behavior Scroll Storybook example to show resolved wheel intent (Pan/Zoom) via resolveWheelIntent and a shared resolver instance.
  • Adjust e2e tests to describe and override simulated wheel intents instead of wheel device kinds, and to assert behavior naming in terms of intent.
  • Clarify the MOUSE_WHEEL_BEHAVIOR Storybook control description to reference intent resolution rules (I4).
src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx
e2e/tests/camera/mouse-wheel-behavior-by-device.spec.ts
Document the new wheel intent model and update camera docs away from device detection.
  • Revise camera system docs to explain that MOUSE_WHEEL_BEHAVIOR only affects mouse-like I4 classification and that all wheel input flows through resolveWheelIntent.
  • Replace the "wheel device" customization section with a "wheel intent" section that documents createWheelIntentResolver and EWheelIntent.
  • Add a new wheel-intent.md system doc detailing the I1–I5 heuristics, signals, and integration with Camera.
docs/system/camera.md
docs/system/wheel-intent.md
Introduce a new wheel intent resolver implementation with debugging and tests.
  • Add wheelIntent.ts implementing the EWheelIntent enum, TResolveWheelIntent type, and createWheelIntentResolver with I1–I5 heuristics based on delta mode, fractional vs integer deltas, timing, and gesture shape.
  • Provide enableWheelIntentDebug, debug entry types, and isPinchZoomGesture for consumers, storing the logger on globalThis for cross-bundle visibility.
  • Add unit tests for the resolver covering integer PIXEL (trackpad) scroll, LINE-mode wheel, fractional mouse ramps, pinch detection, timing windows, and debug logging output.
src/utils/functions/wheelIntent.ts
src/utils/functions/wheelIntent.test.ts
Fix DPR handling and propagation in layers and DPR observer.
  • Change Layer.getDRP to read DPR from graph.layers.rootSize.value.dpr instead of graph.layers.getDPR().
  • Update observeDPR to schedule DPR reads on the next animation frame and to cancel pending RAFs on unsubscription, avoiding races where devicePixelRatio isn’t updated when the media query fires.
  • Clarify observeDPR comments about the race condition and callback timing.
src/services/Layer.ts
src/utils/functions/observeDPR.ts

Possibly linked issues

  • #Add configurable mouse wheel behavior with scroll option: PR implements and refines the configurable mouse wheel zoom/scroll behavior using an intent-based resolver and tests.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The new wheelIntent.ts resolver is quite large and stateful; consider extracting the rule evaluation (I1–I5) into a smaller strategy table or clearly separated helper functions to make future tuning or bugfixes to specific rules less error-prone.
  • TResolveWheelIntent and createWheelIntentResolver accept a dpr argument that is currently unused in the implementation; if DPR is not needed yet, consider omitting it from the public signature (or at least marking it as reserved) to avoid implying it has an effect today.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `wheelIntent.ts` resolver is quite large and stateful; consider extracting the rule evaluation (I1–I5) into a smaller strategy table or clearly separated helper functions to make future tuning or bugfixes to specific rules less error-prone.
- `TResolveWheelIntent` and `createWheelIntentResolver` accept a `dpr` argument that is currently unused in the implementation; if DPR is not needed yet, consider omitting it from the public signature (or at least marking it as reserved) to avoid implying it has an effect today.

## Individual Comments

### Comment 1
<location path="src/utils/functions/wheelIntent.ts" line_range="401" />
<code_context>
+ * LINE/PAGE mode (`deltaMode !== 0`) is never trackpad — always mouse (I4).
+ * See `docs/system/wheel-intent.md` for rationale.
+ */
+export function createWheelIntentResolver(): TResolveWheelIntent {
+  let lastIntent: EWheelIntent = EWheelIntent.Zoom;
+  let lastTimestamp: number | null = null;
+  let mouseWheelBurstUntil: number | null = null;
+
+  const markMouseWheelBurst = (now: number): void => {
+    mouseWheelBurstUntil = now + MOUSE_WHEEL_BURST_MS;
+  };
+
+  const isInMouseWheelBurst = (now: number): boolean => mouseWheelBurstUntil !== null && now <= mouseWheelBurstUntil;
+
+  return (e: WheelEvent, dpr: number, mouseWheelBehavior: TMouseWheelBehavior): EWheelIntent => {
+    const now = performance.now();
+    const timeSince = lastTimestamp !== null ? now - lastTimestamp : Number.POSITIVE_INFINITY;
</code_context>
<issue_to_address>
**suggestion:** The `dpr` argument is currently unused in the resolver implementation.

`dpr` is passed into the resolver but never used. If it’s intended for future heuristics, consider either actually integrating it now or renaming to `_dpr` / removing it so it doesn’t mislead callers into thinking DPI affects the intent classification or trigger unused-parameter lint warnings.

```suggestion
  // Note: `dpr` is currently unused but kept for future heuristics and to satisfy TResolveWheelIntent.
  return (e: WheelEvent, _dpr: number, mouseWheelBehavior: TMouseWheelBehavior): EWheelIntent => {
```
</issue_to_address>

### Comment 2
<location path="src/utils/functions/wheelIntent.ts" line_range="175" />
<code_context>
+ * Converts a wheel delta value to approximate pixel units.
+ * WheelEvent.deltaMode: 0 = PIXEL, 1 = LINE, 2 = PAGE.
+ */
+function normalizeWheelDelta(delta: number, deltaMode: number): number {
+  if (deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * LINE_TO_PIXEL_APPROX;
+  if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * PAGE_TO_PIXEL_APPROX;
</code_context>
<issue_to_address>
**issue (complexity):** Consider introducing a per-event WheelContext object so all normalization and derived flags are computed once and reused across helpers and the resolver.

You’re doing a lot of work per event that’s scattered across tiny helpers, and many of them re-run `normalizeWheelDelta` and re-derive the same booleans. You can keep all behavior and rules as-is but centralize the per-event “context” so the decision tree is easier to follow and normalization happens once.

### 1. Create a per-event context

Instead of passing `WheelEvent` into every helper and normalizing inside each one, derive a context once:

```ts
type WheelContext = {
  e: WheelEvent;
  normX: number;
  normY: number;
  absX: number;
  absY: number;
  hasFractionalDelta: boolean;
  isPixelDeltaMode: boolean;
  isSmallDelta: boolean;
  isVerticalOnly: boolean;
};

function createWheelContext(e: WheelEvent): WheelContext {
  const normX = normalizeWheelDelta(e.deltaX, e.deltaMode);
  const normY = normalizeWheelDelta(e.deltaY, e.deltaMode);
  const absX = Math.abs(normX);
  const absY = Math.abs(normY);

  const isPixel = e.deltaMode === WheelEvent.DOM_DELTA_PIXEL;
  const hasFractional =
    isPixel && (!Number.isInteger(e.deltaX) || !Number.isInteger(e.deltaY));

  const isSmall =
    absX < SMALL_DELTA_THRESHOLD && absY < SMALL_DELTA_THRESHOLD;

  const isVerticalOnly = absX < MIN_HORIZONTAL_SCROLL_ABS;

  return {
    e,
    normX,
    normY,
    absX,
    absY,
    hasFractionalDelta: hasFractional,
    isPixelDeltaMode: isPixel,
    isSmallDelta: isSmall,
    isVerticalOnly,
  };
}
```

### 2. Rewrite helpers to use `WheelContext`

Helpers become simpler, avoid repeated normalization, and the intent logic is easier to audit:

```ts
function isClassicMouseWheelStep(ctx: WheelContext): boolean {
  const { e, absX, absY, isPixelDeltaMode, hasFractionalDelta } = ctx;
  if (absX >= 0.5) return false;
  if (
    e.deltaMode === WheelEvent.DOM_DELTA_LINE ||
    e.deltaMode === WheelEvent.DOM_DELTA_PAGE
  ) {
    return true;
  }
  if (absY < MOUSE_WHEEL_DISCRETE_MIN_PX) return false;
  if (isPixelDeltaMode && !hasFractionalDelta) return false;
  return true;
}

function isDominantAxisLargeWheel(ctx: WheelContext): boolean {
  const { absX, absY, e } = ctx;
  return (
    (absX >= SMALL_DELTA_THRESHOLD && Math.abs(e.deltaY) < 0.5) ||
    (absY >= SMALL_DELTA_THRESHOLD && Math.abs(e.deltaX) < 0.5)
  );
}

function isDiagonalScroll(ctx: WheelContext): boolean {
  const { e, absX, absY } = ctx;
  if (e.shiftKey || absX <= DIAGONAL_MIN_ABS || absY <= DIAGONAL_MIN_ABS) {
    return false;
  }
  const minAxis = Math.min(absX, absY);
  const maxAxis = Math.max(absX, absY);
  return minAxis / maxAxis >= DIAGONAL_AXIS_MIN_RATIO;
}
```

You can similarly adapt `isTrackpadLikeRapidSmall`, `isSlowFractionalMouseWheelStep`, `isPredominantHorizontalScroll`, `isPinchZoomWheelEvent`, etc., to accept `WheelContext`.

### 3. Use `WheelContext` in the resolver and debug emitter

In `createWheelIntentResolver`, compute the context once and feed it to both signals and debug logging:

```ts
return (e: WheelEvent, dpr: number, mouseWheelBehavior: TMouseWheelBehavior): EWheelIntent => {
  const now = performance.now();
  const timeSince = lastTimestamp !== null ? now - lastTimestamp : Number.POSITIVE_INFINITY;
  lastTimestamp = now;

  const isRapidStream = timeSince < RAPID_STREAM_MS;
  const inMouseWheelBurst = isInMouseWheelBurst(now);
  const mouseWheelBurstRemainingMs =
    mouseWheelBurstUntil !== null ? Math.max(0, mouseWheelBurstUntil - now) : null;

  const ctx = createWheelContext(e);
  const { hasFractionalDelta, isSmallDelta, isVerticalOnly, isPixelDeltaMode, normX, normY } = ctx;
  const lastIntentBefore = lastIntent;

  const signals: TWheelIntentDebugEntry["signals"] = {
    isPinchZoom: isPinchZoomWheelEvent(ctx),
    isDiagonalScroll: isDiagonalScroll(ctx),
    isPredominantHorizontalScroll: isPredominantHorizontalScroll(ctx),
    isClassicMouseWheelStep: isClassicMouseWheelStep(ctx),
    isDominantAxisLargeWheel: isDominantAxisLargeWheel(ctx),
    isVerticalOnly,
    hasFractionalDelta,
    isSmallDelta,
    isPixelDeltaMode,
  };

  // ... existing rule chain, but use ctx instead of e where possible ...

  if (getWheelIntentDebugLogger() !== null) {
    emitDebugEntry(
      ctx, // change signature to accept ctx
      dpr,
      mouseWheelBehavior,
      timeSince,
      isRapidStream,
      inMouseWheelBurst,
      mouseWheelBurstRemainingMs,
      lastIntentBefore,
      signals,
      rule,
      intent
    );
  }

  return intent;
};
```

And adjust `emitDebugEntry` to take `WheelContext` instead of recomputing normalized deltas:

```ts
function emitDebugEntry(
  ctx: WheelContext,
  dpr: number,
  mouseWheelBehavior: TMouseWheelBehavior,
  timeSinceLastWheel: number,
  isRapidStream: boolean,
  isInMouseWheelBurst: boolean,
  mouseWheelBurstRemainingMs: number | null,
  lastIntentBefore: EWheelIntent,
  signals: TWheelIntentDebugEntry["signals"],
  rule: string,
  result: EWheelIntent
): void {
  const debugLogger = getWheelIntentDebugLogger();
  if (debugLogger === null) return;

  const { e, normX, normY } = ctx;

  debugLogger({
    mouseWheelBehavior,
    dpr,
    input: {
      deltaX: e.deltaX,
      deltaY: e.deltaY,
      deltaMode: e.deltaMode,
      deltaModeLabel: deltaModeLabel(e.deltaMode),
      ctrlKey: e.ctrlKey,
      metaKey: e.metaKey,
      shiftKey: e.shiftKey,
      altKey: e.altKey,
    },
    normalized: {
      deltaX: normX,
      deltaY: normY,
      diagonalAxisRatio: formatDiagonalAxisRatio(Math.abs(normX), Math.abs(normY)),
    },
    session: {
      timeSinceLastMs: timeSinceLastWheel === Number.POSITIVE_INFINITY ? -1 : timeSinceLastWheel,
      isRapidStream,
      isInMouseWheelBurst,
      mouseWheelBurstRemainingMs,
      lastIntentBefore,
    },
    signals,
    rule,
    result,
  });
}
```

This keeps all rules, constants, and behavior intact but:

- Eliminates repeated `normalizeWheelDelta` calls across helpers and debug logic.
- Makes each helper a simple predicate over `WheelContext`, which matches the conceptual “event context” the reviewer suggested.
- Makes the decision tree in `createWheelIntentResolver` easier to read because each branch is phrased in terms of precomputed, named properties.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/utils/functions/wheelIntent.ts Outdated
Comment thread src/utils/functions/wheelIntent.ts
@gravity-ui-bot

Copy link
Copy Markdown
Contributor

Preview is ready.

draedful and others added 3 commits June 22, 2026 03:27
Compute normalized wheel signals once per event, prefix unused dpr with underscore, and fix Prettier export formatting.

Co-authored-by: Cursor <cursoragent@cursor.com>
The resolver never used device pixel ratio, so drop it from TResolveWheelIntent, settings helpers, Camera, tests, and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Playwright mouse.wheel emits integer PIXEL deltas that resolveWheelIntent treats as trackpad pan; LINE mode matches mechanical mouse zoom (I4).

Co-authored-by: Cursor <cursoragent@cursor.com>
@draedful draedful changed the title Replace wheel device detection with intent-based camera routing feat(camera): add wheel intent resolver and fix layer DPR observation Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants