UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

480 lines (479 loc) 21.2 kB
/** * Wheel input classification for camera routing. * * The library resolves **intent** (pan vs zoom), not device type. This boundary prepares * for a future input event bus where raw DOM events (`wheel`, `pointerdown`, …) are * normalized into semantic graph events (`camera:pan`, `camera:zoom`, …) before Camera * handles them. */ /** Wheel input intent for camera routing (pan vs zoom). */ export var EWheelIntent; (function (EWheelIntent) { EWheelIntent["Pan"] = "pan"; EWheelIntent["Zoom"] = "zoom"; })(EWheelIntent || (EWheelIntent = {})); /** Debug rule ids for {@link createWheelIntentResolver} (see `docs/system/wheel-intent.md`). */ export const WHEEL_INTENT_RULE = { I1_PINCH: "I1:pinch", I2_HORIZONTAL_OR_DIAGONAL: "I2:horizontal-or-diagonal", I3_INPUT_DEVICE_TRACKPAD: "I3:input-device-trackpad", I3_INTEGER_TRACKPAD: "I3:integer-trackpad", I3_INTEGER_TRACKPAD_SLOW: "I3:integer-trackpad-slow", I3_RAPID_SMALL: "I3:rapid-small", I4_MOUSE_WHEEL_STEP: "I4:mouse-wheel-step", I4_LARGE_STEP: "I4:large-step", I4_FRACTIONAL_MOUSE: "I4:fractional-mouse", I4_BURST_SMOOTHING: "I4-burst:smoothing", I4_INPUT_DEVICE_MOUSE: "I4:input-device-mouse", I5_LAST_INTENT: "I5:last-intent", I5_STICKY_STREAM: "I5:sticky-stream", }; const WHEEL_INTENT_I3_RULES = new Set([ WHEEL_INTENT_RULE.I3_INPUT_DEVICE_TRACKPAD, WHEEL_INTENT_RULE.I3_INTEGER_TRACKPAD, WHEEL_INTENT_RULE.I3_INTEGER_TRACKPAD_SLOW, WHEEL_INTENT_RULE.I3_RAPID_SMALL, ]); const WHEEL_INTENT_I4_RULES = new Set([ WHEEL_INTENT_RULE.I4_MOUSE_WHEEL_STEP, WHEEL_INTENT_RULE.I4_LARGE_STEP, WHEEL_INTENT_RULE.I4_FRACTIONAL_MOUSE, WHEEL_INTENT_RULE.I4_BURST_SMOOTHING, WHEEL_INTENT_RULE.I4_INPUT_DEVICE_MOUSE, ]); /** Returns true when the rule id belongs to trackpad classification (I3). */ export function isI3WheelIntentRule(rule) { return WHEEL_INTENT_I3_RULES.has(rule); } /** Returns true when the rule id belongs to mouse-wheel classification (I4). */ export function isI4WheelIntentRule(rule) { return WHEEL_INTENT_I4_RULES.has(rule); } const WHEEL_INTENT_DEBUG_GLOBAL_KEY = "__graphWheelIntentDebugLogger__"; function getWheelIntentDebugLogger() { return globalThis[WHEEL_INTENT_DEBUG_GLOBAL_KEY] ?? null; } function setWheelIntentDebugLogger(logger) { globalThis[WHEEL_INTENT_DEBUG_GLOBAL_KEY] = logger; } function deltaModeLabel(deltaMode) { return ["PIXEL", "LINE", "PAGE"][deltaMode] ?? String(deltaMode); } function formatDiagonalAxisRatio(normX, normY) { const maxAxis = Math.max(normX, normY); if (maxAxis < 0.001) { return null; } return Math.min(normX, normY) / maxAxis; } function defaultDebugLogger(entry) { const { input, session } = entry; const summary = `[wheel-intent] ${entry.rule} → ${entry.result} | Δ(${input.deltaX.toFixed(2)}, ${input.deltaY.toFixed(2)}) ${input.deltaModeLabel} +${Math.round(session.timeSinceLastMs)}ms`; // Stringify so logs are copy-pasteable as plain text (not expandable DevTools objects). // eslint-disable-next-line no-console console.log(summary); // eslint-disable-next-line no-console console.log(JSON.stringify(entry, null, 2)); } /** * Enables per-event debug logging for {@link createWheelIntentResolver}. * * Stored on `globalThis` so it works even when webpack loads duplicate module copies * (e.g. Storybook preview vs story bundle). * * @example * ```typescript * import { enableWheelIntentDebug } from "@gravity-ui/graph"; * enableWheelIntentDebug(); // default console.log: summary + JSON string * enableWheelIntentDebug(entry => myTelemetry.record(entry)); // custom logger * enableWheelIntentDebug(null); // disable * ``` */ export function enableWheelIntentDebug(logger = defaultDebugLogger) { setWheelIntentDebugLogger(logger); if (logger !== null) { // eslint-disable-next-line no-console console.log("[wheel-intent] debug logging enabled"); } } /** After a classic mouse wheel tick, treat nearby vertical fractional events as wheel smoothing. */ const MOUSE_WHEEL_BURST_MS = 120; /** Events closer than this are treated as a continuous stream (typical two-finger scroll / inertia). */ const RAPID_STREAM_MS = 38; /** Both axes must contribute at least this share of the larger axis to count as diagonal pan. */ const DIAGONAL_AXIS_MIN_RATIO = 0.5; /** Minimum absolute delta on both axes to count as diagonal scroll. */ const DIAGONAL_MIN_ABS = 2; /** Normalized horizontal delta at or above this suggests pan (not a vertical-only wheel step). */ const MIN_HORIZONTAL_SCROLL_ABS = 2; const SMALL_DELTA_THRESHOLD = 50; /** Trackpad inertia: per-event normalized delta below this on both axes (I3). */ const TRACKPAD_I3_MAX_PX = 20; /** Minimum normalized vertical delta for a discrete mouse wheel step (I4). */ const MOUSE_WHEEL_DISCRETE_MIN_PX = 20; /** Smooth-scroll mouse notch band (slow fractional PIXEL step, e.g. Δy ≈ -4.000244). */ const MOUSE_WHEEL_NOTCH_MIN_PX = 3; const MOUSE_WHEEL_NOTCH_MAX_PX = 5; /** Approximate pixel equivalent of one scroll LINE (WheelEvent.deltaMode === 1). */ const LINE_TO_PIXEL_APPROX = 16; /** Approximate pixel equivalent of one scroll PAGE (WheelEvent.deltaMode === 2). */ const PAGE_TO_PIXEL_APPROX = 600; /** * Converts a wheel delta value to approximate pixel units. * WheelEvent.deltaMode: 0 = PIXEL, 1 = LINE, 2 = PAGE. */ function normalizeWheelDelta(delta, deltaMode) { if (deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * LINE_TO_PIXEL_APPROX; if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * PAGE_TO_PIXEL_APPROX; return delta; } /** Minimum |wheelDelta| / |wheelDeltaY| for legacy mouse-wheel detection (Chromium ≈ 120). */ const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100; /** Mac Chrome/YaBrowser trackpad: wheelDeltaY ≈ ±3 × deltaY (not fixed ±120 mouse notch). */ const MAC_CHROME_TRACKPAD_WHEEL_DELTA_RATIO = 3; const MAC_CHROME_TRACKPAD_RATIO_TOLERANCE = 0.35; /** * True when wheelDeltaY looks like a fixed ±120 mechanical mouse notch (Chromium/Windows). * * Mac Chrome trackpad also populates wheelDelta, but as a linear ±3 × deltaY scale — that must * not be treated as a mouse wheel or fast trackpad swipes fall through to I4 zoom. */ function isMacChromeLinearWheelDelta(event, wheelDeltaY) { const { deltaY, deltaMode } = event; if (deltaMode !== WheelEvent.DOM_DELTA_PIXEL || deltaY === 0 || !Number.isInteger(deltaY)) { return false; } const ratio = Math.abs(wheelDeltaY / deltaY); return (ratio > MAC_CHROME_TRACKPAD_WHEEL_DELTA_RATIO - MAC_CHROME_TRACKPAD_RATIO_TOLERANCE && ratio < MAC_CHROME_TRACKPAD_WHEEL_DELTA_RATIO + MAC_CHROME_TRACKPAD_RATIO_TOLERANCE); } /** * Chromium still sets deprecated wheelDelta(Y) on mechanical mouse wheels (typically ±120). * Trackpad scroll on Mac Chrome uses wheelDelta ≈ ±3 × deltaY instead. */ function hasLegacyMouseWheelDelta(event) { const legacy = event; const axisDelta = legacy.wheelDeltaY ?? legacy.wheelDelta; if (axisDelta === undefined) { return false; } if (Math.abs(axisDelta) < LEGACY_MOUSE_WHEEL_DELTA_MIN) { return false; } if (isMacChromeLinearWheelDelta(event, axisDelta)) { return false; } return true; } function createWheelContext(event) { const normX = normalizeWheelDelta(event.deltaX, event.deltaMode); const normY = normalizeWheelDelta(event.deltaY, event.deltaMode); const absX = Math.abs(normX); const absY = Math.abs(normY); const isPixelDeltaMode = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL; const hasFractionalDelta = isPixelDeltaMode && (!Number.isInteger(event.deltaY) || !Number.isInteger(event.deltaX)); return { event, normX, normY, absX, absY, hasFractionalDelta, isPixelDeltaMode, isSmallDelta: absX < SMALL_DELTA_THRESHOLD && absY < SMALL_DELTA_THRESHOLD, isVerticalOnly: absX < MIN_HORIZONTAL_SCROLL_ABS, hasLegacyMouseWheelDelta: hasLegacyMouseWheelDelta(event), }; } /** * Vertical-only wheel step — physical mouse (LINE/PAGE, or PIXEL delta ≥ discrete minimum). * * Small integer PIXEL deltas (< {@link MOUSE_WHEEL_DISCRETE_MIN_PX}) are trackpad ticks — excluded here. */ function isClassicMouseWheelStep(ctx) { const { event, absY, isPixelDeltaMode, hasFractionalDelta } = ctx; if (Math.abs(event.deltaX) >= 0.5) { return false; } if (event.deltaMode === WheelEvent.DOM_DELTA_LINE || event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { return true; } if (absY < MOUSE_WHEEL_DISCRETE_MIN_PX) { return false; } if (isPixelDeltaMode && !hasFractionalDelta) { // Chromium/Windows: integer PIXEL + legacy wheelDelta ≈ ±120. return ctx.hasLegacyMouseWheelDelta; } return true; } /** Trackpad scroll in PIXEL mode: integer `deltaX` and `deltaY`. */ function isIntegerPixelTrackpadScroll(ctx, isRapidStream) { if (!ctx.isPixelDeltaMode || ctx.hasFractionalDelta) { return false; } if (ctx.hasLegacyMouseWheelDelta) { return false; } const peak = Math.max(ctx.absX, ctx.absY); // Small integer ticks: always trackpad (slow scroll, inertia, per-frame deltas). if (peak < MOUSE_WHEEL_DISCRETE_MIN_PX) { return true; } // Large integer steps: trackpad only inside a continuous rapid stream. // Isolated large integer PIXEL steps are mouse wheels on Chromium (Windows/macOS). return isRapidStream; } /** Rapid PIXEL-mode stream with small deltas — fractional trackpad inertia on some drivers. */ function isTrackpadLikeRapidSmall(ctx, isRapidStream) { if (!isRapidStream || !ctx.isPixelDeltaMode) { return false; } return ctx.absY < TRACKPAD_I3_MAX_PX && ctx.absX < TRACKPAD_I3_MAX_PX; } /** Smooth-scroll mouse: slow vertical-only **fractional** PIXEL delta in the notch band. */ function isSlowFractionalMouseWheelStep(ctx, isRapidStream, inMouseWheelBurst) { if (isRapidStream || inMouseWheelBurst || !ctx.hasFractionalDelta || !ctx.isVerticalOnly || !ctx.isSmallDelta || !ctx.isPixelDeltaMode) { return false; } return ctx.absY >= MOUSE_WHEEL_NOTCH_MIN_PX && ctx.absY <= MOUSE_WHEEL_NOTCH_MAX_PX; } /** One axis dominates (large step), the other ~0 — classic mouse wheel click. */ function isDominantAxisLargeWheel(ctx) { const { event, absX, absY } = ctx; return ((absX >= SMALL_DELTA_THRESHOLD && Math.abs(event.deltaY) < 0.5) || (absY >= SMALL_DELTA_THRESHOLD && Math.abs(event.deltaX) < 0.5)); } function isPinchZoomWheelEvent(ctx) { const { event, isPixelDeltaMode } = ctx; // Trackpads always emit PIXEL deltas. A held modifier means zoom on every platform: // Mac Cmd (metaKey), pinch (ctrlKey), Windows/Linux Ctrl+scroll (ctrlKey). return isPixelDeltaMode && (event.ctrlKey || event.metaKey); } /** * Returns true when a trackpad modifier-zoom gesture is active (PIXEL mode + ctrl/meta). * Used by Camera for {@link PINCH_ZOOM_SPEED}; mechanical wheels (LINE/PAGE) are excluded. */ export function isPinchZoomGesture(event) { return isPinchZoomWheelEvent(createWheelContext(event)); } /** Horizontal movement dominates — ignores deltaX noise on predominantly vertical wheel ticks. */ function isPredominantHorizontalScroll(ctx) { return ctx.absX >= MIN_HORIZONTAL_SCROLL_ABS && ctx.absX > ctx.absY; } function isDiagonalScroll(ctx) { const { event, absX, absY } = ctx; if (event.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; } function buildWheelSignals(ctx) { return { isPinchZoom: isPinchZoomWheelEvent(ctx), isDiagonalScroll: isDiagonalScroll(ctx), isPredominantHorizontalScroll: isPredominantHorizontalScroll(ctx), isClassicMouseWheelStep: isClassicMouseWheelStep(ctx), isDominantAxisLargeWheel: isDominantAxisLargeWheel(ctx), isVerticalOnly: ctx.isVerticalOnly, hasFractionalDelta: ctx.hasFractionalDelta, isSmallDelta: ctx.isSmallDelta, isPixelDeltaMode: ctx.isPixelDeltaMode, hasLegacyMouseWheelDelta: ctx.hasLegacyMouseWheelDelta, }; } function intentFromMouseWheelBehavior(mouseWheelBehavior) { return mouseWheelBehavior === "scroll" ? EWheelIntent.Pan : EWheelIntent.Zoom; } /** I1/I2 always win; during rapid stream keep prior vertical intent to avoid mid-gesture flips. */ function applyRapidStreamStickyIntent(intent, rule, signals, isRapidStream, lastIntentBefore, lastRuleBefore) { if (!isRapidStream || signals.isPinchZoom || signals.isDiagonalScroll || signals.isPredominantHorizontalScroll || intent === lastIntentBefore || lastRuleBefore === WHEEL_INTENT_RULE.I5_LAST_INTENT) { return { intent, rule }; } return { intent: lastIntentBefore, rule: WHEEL_INTENT_RULE.I5_STICKY_STREAM }; } function emitDebugEntry(ctx, mouseWheelBehavior, inputDevice, timeSinceLastWheel, isRapidStream, isInMouseWheelBurst, mouseWheelBurstRemainingMs, lastIntentBefore, signals, rule, result) { const debugLogger = getWheelIntentDebugLogger(); if (debugLogger === null) { return; } const { event, normX, normY } = ctx; debugLogger({ mouseWheelBehavior, inputDevice, input: { deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, deltaModeLabel: deltaModeLabel(event.deltaMode), ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.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, }); } /** * Creates the default wheel intent resolver (`TGraphSettingsConfig.resolveWheelIntent`). * * Classifies **intent** from gesture shape — not from inferred device type: * * | Signal | Intent | * |---------------------------------------|-----------------| * | ctrlKey / metaKey + PIXEL scroll | Zoom (I1) | * | Horizontal or diagonal movement | Pan (I2) | * | Integer PIXEL delta (trackpad, auto) | Pan (I3) | * | wheelInputDevice `"trackpad"` | Pan (I3:input-device-trackpad) | * | wheelInputDevice `"mouse"` | Zoom/Pan (I4:* per behavior) | * | Large isolated integer PIXEL step | Zoom/Pan (I4)* | * | Classic mouse wheel step (fractional) | Zoom/Pan (I4)* | * | Rapid stream + small delta (fractional)| Pan (I3) | * | Anything else | Last intent (I5)| * | Rapid stream + confident-rule flip | Sticky prior (I5:sticky-stream) | * * *I4 respects `mouseWheelBehavior`: `"scroll"` → Pan, `"zoom"` → Zoom. * * Trackpad: small integer PIXEL ticks, or large integer PIXEL inside a rapid stream → pan (I3). * Isolated large integer PIXEL (Chromium mouse on Windows) → I4 per `mouseWheelBehavior`. * LINE/PAGE mode (`deltaMode !== 0`) is never trackpad — always mouse (I4). * See `docs/system/wheel-intent.md` for rationale. * * Pass `wheelInputDevice` at resolve time (camera constant `WHEEL_INPUT_DEVICE`) when the app * knows the primary wheel device and Mac Chrome/YaBrowser heuristics are ambiguous. */ export function createWheelIntentResolver() { let lastIntent = EWheelIntent.Zoom; let lastRule = WHEEL_INTENT_RULE.I5_LAST_INTENT; let lastTimestamp = null; let mouseWheelBurstUntil = null; const markMouseWheelBurst = (now) => { mouseWheelBurstUntil = now + MOUSE_WHEEL_BURST_MS; }; const isInMouseWheelBurst = (now) => mouseWheelBurstUntil !== null && now <= mouseWheelBurstUntil; const resolveExplicitMouseIntent = (ctx, signals, mouseWheelBehavior, isRapidStream, inMouseWheelBurst, now) => { if (signals.isDominantAxisLargeWheel || signals.isClassicMouseWheelStep || signals.hasLegacyMouseWheelDelta) { markMouseWheelBurst(now); return { intent: intentFromMouseWheelBehavior(mouseWheelBehavior), rule: signals.isClassicMouseWheelStep ? WHEEL_INTENT_RULE.I4_MOUSE_WHEEL_STEP : WHEEL_INTENT_RULE.I4_LARGE_STEP, }; } if (isSlowFractionalMouseWheelStep(ctx, isRapidStream, inMouseWheelBurst)) { markMouseWheelBurst(now); return { intent: intentFromMouseWheelBehavior(mouseWheelBehavior), rule: WHEEL_INTENT_RULE.I4_FRACTIONAL_MOUSE, }; } if (inMouseWheelBurst && signals.isVerticalOnly && isTrackpadLikeRapidSmall(ctx, isRapidStream)) { markMouseWheelBurst(now); return { intent: intentFromMouseWheelBehavior(mouseWheelBehavior), rule: WHEEL_INTENT_RULE.I4_BURST_SMOOTHING, }; } markMouseWheelBurst(now); return { intent: intentFromMouseWheelBehavior(mouseWheelBehavior), rule: WHEEL_INTENT_RULE.I4_INPUT_DEVICE_MOUSE, }; }; return (event, options) => { const mouseWheelBehavior = options.mouseWheelBehavior; const wheelInputDevice = options.wheelInputDevice ?? "auto"; 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(event); const signals = buildWheelSignals(ctx); const lastIntentBefore = lastIntent; const lastRuleBefore = lastRule; let intent; let rule; if (signals.isPinchZoom) { intent = EWheelIntent.Zoom; rule = WHEEL_INTENT_RULE.I1_PINCH; } else if (signals.isDiagonalScroll || signals.isPredominantHorizontalScroll) { intent = EWheelIntent.Pan; rule = WHEEL_INTENT_RULE.I2_HORIZONTAL_OR_DIAGONAL; } else if (wheelInputDevice === "trackpad") { intent = EWheelIntent.Pan; rule = WHEEL_INTENT_RULE.I3_INPUT_DEVICE_TRACKPAD; } else if (wheelInputDevice === "mouse") { ({ intent, rule } = resolveExplicitMouseIntent(ctx, signals, mouseWheelBehavior, isRapidStream, inMouseWheelBurst, now)); } else if (isIntegerPixelTrackpadScroll(ctx, isRapidStream)) { intent = EWheelIntent.Pan; rule = isRapidStream ? WHEEL_INTENT_RULE.I3_INTEGER_TRACKPAD : WHEEL_INTENT_RULE.I3_INTEGER_TRACKPAD_SLOW; } else if (signals.isDominantAxisLargeWheel || signals.isClassicMouseWheelStep || signals.hasLegacyMouseWheelDelta) { intent = intentFromMouseWheelBehavior(mouseWheelBehavior); rule = signals.isClassicMouseWheelStep ? WHEEL_INTENT_RULE.I4_MOUSE_WHEEL_STEP : WHEEL_INTENT_RULE.I4_LARGE_STEP; markMouseWheelBurst(now); } else if (isTrackpadLikeRapidSmall(ctx, isRapidStream)) { if (inMouseWheelBurst && signals.isVerticalOnly) { intent = intentFromMouseWheelBehavior(mouseWheelBehavior); rule = WHEEL_INTENT_RULE.I4_BURST_SMOOTHING; markMouseWheelBurst(now); } else { intent = EWheelIntent.Pan; rule = WHEEL_INTENT_RULE.I3_RAPID_SMALL; } } else if (isSlowFractionalMouseWheelStep(ctx, isRapidStream, inMouseWheelBurst)) { intent = intentFromMouseWheelBehavior(mouseWheelBehavior); rule = WHEEL_INTENT_RULE.I4_FRACTIONAL_MOUSE; markMouseWheelBurst(now); } else { intent = lastIntent; rule = WHEEL_INTENT_RULE.I5_LAST_INTENT; } ({ intent, rule } = applyRapidStreamStickyIntent(intent, rule, signals, isRapidStream, lastIntentBefore, lastRuleBefore)); lastIntent = intent; lastRule = rule; if (getWheelIntentDebugLogger() !== null) { emitDebugEntry(ctx, mouseWheelBehavior, wheelInputDevice, timeSince, isRapidStream, inMouseWheelBurst, mouseWheelBurstRemainingMs, lastIntentBefore, signals, rule, intent); } return intent; }; }