# 제스처 계산 — ocGesture

입력 세션과 자유 플링을 연결하려면 [제스처 표면 조합 가이드](./gesture-composition.md)를 참고하세요.

`ocGesture`는 DOM·React·CSS·스케줄러에 의존하지 않는 제스처 계산 진입점입니다. 루트 export 대신 전용 경로로 가져옵니다. 런타임 named export는 `ocGesture` 하나이며 타입은 `OcGesture*` 이름으로 제공합니다.

```ts
import { ocGesture } from '@orioncactuscorp/ui/utils/gesture';

const displayedX = ocGesture.rubberBandClamp(rawX, -200, 200, 100);
const tracker = ocGesture.createVelocityTracker();
tracker.push({ time: performance.now(), x: rawX, y: rawY });
const velocity = tracker.read(performance.now());
const next = ocGesture.decelerate(
  { position: displayedX, velocity: velocity.x },
  elapsedMs,
  { rate: ocGesture.decelerationRates.fast },
);
```

## 단위와 API

위치·dimension은 `px`, 속도는 **`px/s`**, 시간은 명시적인 `ms`입니다. 감속률은 매 밀리초의 속도 유지 비율입니다. 기존 spring에 속도를 전달할 때 추가로 1000을 곱하지 않습니다.

| API                                                              | 계약                                                                       |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `rubberBand(offset, dimension, resistance = 0.55)`               | 부호를 유지하는 저항. dimension은 완충 거리의 점근적 한도이며 0이면 결과 0 |
| `rubberBandClamp(value, min, max, dimension, resistance = 0.55)` | 범위 안은 그대로, 밖은 경계부터 저항 적용. min = max 허용                  |
| `decelerationRates`                                              | 읽기 전용 `{ normal: 0.998, fast: 0.99 }`                                  |
| `decelerate(state, elapsedMs, { rate }?)`                        | 지수 감속의 연속 적분. 새 `{ position, velocity }` 반환                    |
| `projectDeceleration(velocity, { rate }?)`                       | 같은 모델의 남은 이동 거리                                                 |
| `projectFling(position, velocity, { rate }?)`                    | 현재 위치 + 남은 이동 거리                                                 |
| `isDecelerationAtRest(velocity, threshold = 10)`                 | 절댓값이 threshold 미만이면 true. 최종 위치에 도달했다는 의미는 아님       |
| `createRubberBandReturn(initial, boundary)`                      | AppKit 복귀식. 0.5px 정착 또는 경계 통과 시 종료하고 잔여 속도 반환        |
| `createVelocityTracker(options?)`                                | `push(sample)`, `read(now)`, `clear()` 제공                                |

감속 API 기본 rate는 `normal`입니다. 더 짧은 관성 이동이 필요한 이미지 뷰어에서는 `fast`를 명시할 수 있습니다. `Δx = (velocity / 1000) × expm1(elapsedMs × ln(rate)) / ln(rate)`로 계산하므로 경계 없는 적분은 프레임을 나누어도 같은 경로를 따릅니다. 투사 역시 이 연속 모델을 사용합니다.

모든 숫자는 유한해야 합니다. dimension과 elapsedMs는 0 이상, resistance와 정지 threshold는 0 초과, rate는 0 초과 1 미만, min은 max 이하입니다. 비유한 입력은 `TypeError`, 범위 위반은 `RangeError`이며 계산 결과가 표현 가능한 유한 범위를 넘으면 `TypeError`입니다. 내부 계산이 유한 정밀도로 수행되므로 극단적으로 작은 값은 0으로 반올림될 수 있습니다.

## 스크롤 스냅 감속

`projectSnap(position, velocity)`는 스냅 전용 관성 모델의 예상 도착 위치를 반환합니다. 스냅 위치 목록을 검색하지는 않습니다. 호출부가 예상 위치에서 적절한 목표를 선택한 뒤 `createSnap(initial, target)`에 전달합니다.

```ts
const projected = ocGesture.projectSnap(position, velocity);
const target = findSnapTarget(projected); // Consumer-owned geometry and selection.
const snap = ocGesture.createSnap({ position, velocity }, target);
const state = snap.sample(elapsedSinceReleaseMs);
// state: { position, velocity, finished }; snap.durationMs: total duration.
```

`sample`은 release 이후 **누적 시간(ms)**을 받으며 상태를 변경하지 않습니다. 프레임 간격을 전달하지 마세요. 목표와 현재 위치가 같으면 즉시 완료합니다. 종료 시점 이후에는 정확한 목표 위치·속도 0·`finished: true`를 반환합니다. 다른 숫자 API와 동일하게 비유한 입력을 거부하고 음수 시간은 `RangeError`입니다.

이 모델은 macOS 26.6.2에서 WebKit이 사용하는 관성 계산기의 출력과 비교한 이식 가능한 근사입니다. 스냅 목적지에 맞춰 종료 시간을 다시 계산하는 4차 감속이며, OS 비공개 API를 호출하지 않습니다. 짧은 스냅은 빨리 멈추고 큰 이동은 선택된 목적지까지 연속적으로 감속합니다. 목표가 자연 도착점보다 멀면 초기 속도가 조정될 수 있으며, release 속도를 항상 보존한다는 계약은 아닙니다. 기존 `decelerate`와 `projectFling`의 지수 감속은 변경하지 않습니다. OS 버전·입력 장치별 완전한 동일성은 보장하지 않습니다.

CSS 스냅 위치·RTL·padding·margin 측정, 목표 선택, RAF 실행, 중단과 reduced-motion 정책은 호출부에 둡니다. React 스크롤에서는 [useDragScroll](./drag-scroll.md)이 이 역할을 맡습니다. 기존 Modal·PositionSnap의 spring이나 스냅 선택 정책은 이 API 추가로 바뀌지 않습니다.

## 경계 늘어남 복귀

`createRubberBandReturn({ position, velocity }, boundary)`는 `OcGestureSnap`을 반환합니다. `x0 = position - boundary`, 시간 `t`(초)에 대해 `(x0 + 0.31 × velocity × t) × exp(-12.5 × t)`를 계산합니다. 종료 전 위치는 macOS 27.0.0의 AppKit 함수 표본과 1e-6px 이내로 일치합니다. 모든 플랫폼에 같은 모델을 쓰며 입력 장치의 가속까지 동일하다는 의미는 아닙니다.

`durationMs`는 복귀 중 절대 늘어남이 0.5px에 도달하거나 경계를 통과하는 시점입니다. 처음 경계에서 바깥 속도로 출발하면 최대 늘어남 이후의 정착을 사용합니다. `sample(elapsedMs)`은 종료 전 원식과 도함수를 반환하고, 종료 시점 이후에는 `position: boundary`, `finished: true`, **종료 시점의 잔여 속도**를 반환합니다. `createSnap`과 달리 종료 속도가 항상 0은 아닙니다.

안쪽 속도의 경계 통과 시각은 `-x0 / (0.31 × velocity)`입니다. 경계 통과 시 종료하고 잔여 속도를 반환하므로, 호출부는 남은 프레임 시간을 일반 스크롤 release에 인계할 수 있습니다. 반대편 늘어남은 이 함수가 소유하지 않습니다. reduced-motion과 프레임 실행, 취소 처리는 호출부의 책임입니다.

## 속도 추적

| 옵션                        | 기본값 | 의미                                                                 |
| --------------------------- | ------ | -------------------------------------------------------------------- |
| `windowMs`                  | 100    | read 시점부터 최근 샘플을 선택하는 창                                |
| `interpolateWindowBoundary` | false  | 시간 구간 경계를 가로지르는 이동을 보간해 드문 샘플의 속도 유실 방지 |
| `minimumSpanMs`             | 8      | 첫/마지막 샘플 사이 최소 간격. windowMs 이하                         |
| `stopAfterMs`               | 100    | 마지막 좌표 변경 이후 정지로 판단하는 시간                           |
| `maxPxPerSecond`            | 4000   | 축별 속도 상한                                                       |

시간·속도 옵션은 유한한 양수입니다. 최소 두 시각의 샘플이 필요합니다. 같은 시각의 샘플은 마지막 값으로 대체하고 역순 시각은 거부합니다. `read(now)`는 마지막 샘플보다 이른 시각을 거부합니다. timestamp에는 같은 단조 시계를 사용하세요. 샘플이 부족하거나 오래되었거나 stopAfterMs 이상 좌표 변경이 없으면 0을 반환합니다. 중복 좌표 push는 정지 타이머를 연장하지 않습니다. read는 상태를 변경하지 않습니다. `interpolateWindowBoundary: true`는 경계 직전 샘플 하나를 보존하고 실제 경과 시간으로 경계 좌표를 선형 보간합니다. 긴 공백을 빠른 이동으로 가정하지 않으며 정지 시간과 속도 상한은 그대로 적용합니다. `useDragScroll`은 이 옵션을 사용해 첫 입력의 프레임 지연에도 속도 정보를 유지합니다.

포인터 이동 속도와 화면에 표시된 콘텐츠 속도는 rubber band 구간에서 다릅니다. 어떤 좌표를 추적할지는 호출부가 선택합니다. 새 pointerdown과 pointercancel에서는 `clear()`를 호출하고, cancel을 정상 release로 처리하지 마세요. flick 임계값과 스냅 선택은 컴포넌트 정책입니다.

## 마찰과 spring 조합

자유 팬은 `decelerate`로 진행합니다. 스크롤 스냅에는 `createSnap`을, spring이 필요한 표면 이동이나 경계 복귀에는 [spring API](./spring.md)를 사용합니다. spring은 초기 속도를 보존하지만 목표 거리와 설정에 따라 이후 가속할 수 있으므로, 자유 팬을 단순히 `projectFling` 목표로 spring 이동시키면 마찰 감속과 다른 경로가 됩니다.

이 API는 프레임 실행·경계 감지·바운스·중단을 소유하지 않습니다. 경계 있는 실행기를 구현할 때는 정확한 경계 도달 시각과 속도를 구한 후 남은 프레임 시간을 spring에 적용해야 합니다. 프레임 끝에서 경계를 발견하고 bounce를 시작하면 프레임 간격에 따라 경로가 달라집니다. 사용자 모션 설정과 입력 중단은 호출부가 처리합니다.

Modal과 PositionSnap은 같은 rubber band 계산을 사용합니다. 이 유틸이 특정 OS의 모든 스크롤 동작을 재현한다는 보장은 없습니다.

## Bottom 모달의 스냅과 닫힘

`closeThreshold`의 bottom 기본값은 `0.5`입니다. 최하단 스냅에서 보이는 높이의 50%(최소 24px)를 내려놓으면 닫히며, 빠른 아래 방향 드래그는 현재 위치에 최근 속도의 0.2초 예상 이동을 더해 같은 경계로 관성 닫힘을 판정합니다. 명시적인 `closeThreshold` 값은 유지되며 popup 기본값은 `0.35`입니다.

마우스·펜은 핸들이 렌더링된 Header 또는 Navigation chrome에서만 bottom 모달 드래그를 시작합니다. `drag="content"`는 Header 전체를, `drag="handle"`는 Header의 handle-area 또는 Navigation의 핸들을 사용합니다. Header/Navigation의 `handle={false}`는 해당 chrome의 마우스·펜 드래그도 막습니다. Body/Footer에서는 가로·세로 텍스트 선택과 선택 범위 확장에 따른 브라우저 자동 스크롤을 유지하며, 선택이 헤더까지 넘어가도 시트 드래그로 전환하지 않습니다.

끝까지 내린 bottom 모달은 추가 Y 이동 없이 그림자를 페이드 아웃하며 닫힙니다. 끝을 넘겨 내렸더라도 닫힘 위치로 되돌아오지 않으며 reduced-motion에서도 짧은 페이드를 유지합니다.

터치는 기존 `drag="content"`의 본문 스크롤·드래그 판정을 유지합니다. 터치 지원 여부나 화면 폭이 아니라 실제 입력 종류를 기준으로 구분하며, 모든 입력의 드래그를 끄려면 `drag={false}`를 사용합니다.

`snapPoints`가 있는 bottom 모달은 최근 드래그 속도로 예상 도착 위치를 계산해 여러 스냅을 건너뛸 수 있습니다. 충분히 빠른 아래 방향 플릭은 가장 아래 스냅까지 이동하며, 약한 이동은 가까운 스냅으로 정착합니다. 관성은 목적지 선택에 사용하고 실제 이동은 sheet 모션이 담당합니다.

상위 스냅에서 아래로 내리는 제스처는 먼저 시트를 축소합니다. 최하단 스냅에서 다시 내릴 때 닫힘 허용 여부와 남은 높이에 대한 닫힘 기준을 평가합니다. 스냅 없는 모달의 관성 닫힘과 `peekable` 모달의 peek 유지 동작은 그대로입니다. 닫기 버튼과 backdrop은 기존 닫힘·collapse 정책을 따릅니다.

낮은 스냅이나 peek에 머무르는 동안에는 그림자를 유지합니다. 실제 닫힘에서는 그림자와 backdrop이 각자의 현재 농도에서 같은 비율로 사라집니다. 키보드가 열린 상태에서 닫아도 퇴장 중 시트의 기준 높이가 축소되지 않습니다.

### 스냅 선택과 키보드 조작

`defaultSnapPoint`는 매번 열 때의 초기 스냅입니다. `snapPoint`와 `onSnapPointChange`를 함께 지정하면 소비자가 선택을 제어합니다. 콜백의 `details.reason`은 `drag | handle | backdrop`이며 외부 prop 변경·화면 크기 변경에는 호출하지 않습니다. Controlled 모드에서는 요청을 prop에 반영해야 선택이 바뀝니다.

유효 높이가 서로 다른 스냅이 두 개 이상이면 Header의 핸들이 버튼으로 렌더링됩니다. Tab으로 접근하고 Enter·Space 또는 클릭·탭으로 다음 높이를 선택하며, 가장 높은 스냅에서는 가장 낮은 스냅으로 돌아갑니다. 모달을 닫지 않으며 `drag={false}`에서도 사용할 수 있습니다. `handle={false}`를 사용하면 소비자가 별도 높이 조절 버튼을 제공해야 합니다.

화면 비율 스냅은 화면 높이가 바뀌어도 같은 비율 선택을 유지합니다. 콘텐츠보다 큰 스냅은 시트 높이로 제한하고 같은 유효 높이는 하나로 취급합니다. 선택값이 목록의 유효 높이에 없으면 가장 높은 스냅을 사용합니다. 키보드 회피가 활성화된 동안 핸들 버튼은 비활성화되며 외부 스냅 이동은 회피가 끝난 뒤 적용합니다.

### 키보드와 배경 스크롤

배경 문서가 스크롤 가능한 non-modal 표시(`modal={false}` 또는 `'trap-focus'`)에서 소프트 키보드가 열린 채 모달 밖에서 시작한 터치가 드래그로 이어지면 입력 포커스를 해제해 키보드를 닫고, 그 제스처는 페이지를 스크롤하지 않습니다(`keyboardDismiss="background-scroll"` 기본값). 페이지 스크롤은 키보드가 완전히 내려가 복원된 viewport가 보고된 뒤 다음 제스처부터 다시 동작합니다. 네이티브 스크롤 뷰의 `keyboardDismissMode = onDrag`와 같은 동작이며, 시트 드래그가 키보드를 닫는 기존 정책의 연장입니다. 모달 내부 본문 스크롤과 헤더·푸터 스와이프는 키보드를 유지합니다.

Bottom Modal에서 키보드가 열린 동안 ModalBody의 스크롤이 위·아래 경계에 닿고 시트가 그 방향으로 이동할 수 없으면 경계 overscroll을 문서 viewport로 넘기지 않습니다. 스크롤 가능한 구간에서는 ModalBody의 네이티브 스크롤과 관성을 우선하고, 경계에서만 해당 방향의 pan을 차단합니다. 가로로 실제 스크롤할 수 있는 조상이 있는 가로 우세 제스처는 네이티브 동작을 유지하며, 가로 스크롤 영역이 없는 본문의 대각선 제스처는 세로 성분을 경계 판정에 사용합니다.

이 CSS 경계 규칙은 `drag` 설정과 관계없이 적용됩니다. `drag`가 활성화된 touch arbitration은 입력 컨트롤·링크·버튼·`contenteditable`·ARIA role control·`data-oc-modal-no-drag` 영역에서도 시트 드래그는 시작하지 않지만, 키보드 경계의 세로 pan은 임계값 이후 차단할 수 있습니다. 컨트롤에서는 가로 우세 제스처를 네이티브 동작으로 유지합니다. `drag={false}`에서는 이 JavaScript arbitration 없이 CSS hard-stop만 적용됩니다.

iOS Safari는 페이지 스크롤 중 키보드를 유지하고 visual viewport만 layout viewport 안에서 패닝하므로 fixed surface가 페이지와 함께 밀려나며, 키보드가 내려가는 중에 스크롤이 이어지면 스크롤이 끝날 때까지 fixed 레이어 배치를 갱신하지 않습니다. 키보드가 닫히는 동안의 스크롤을 막아야 모달이 제자리에 남습니다. 키보드를 유지해야 하는 화면은 `keyboardDismiss="none"`을 지정하고, 소비자 앱 viewport meta에 `interactive-widget=resizes-content`를 추가하세요. `none`에서는 세션 동안 viewport 레이어를 문서에 앵커해 스크롤 중에도 제자리에 두지만, iOS Safari는 앵커된 레이어가 스크롤 위치만큼 이동한 동안 그 안의 `backdrop-filter` 요소에 backdrop을 공급하지 않아 `BackgroundIconButton`의 `normal` appearance가 단색으로 보일 수 있습니다. Chrome Android는 layout viewport를 키보드만큼 줄이고, WebKit도 같은 값을 구현했지만 Safari 27.0에는 포함되지 않았습니다.

## Migration Notes

- non-modal 표시에서 키보드가 열린 채 배경을 드래그하면 키보드가 닫히고 그 제스처는 페이지를 스크롤하지 않습니다. 이전 동작이 필요하면 `keyboardDismiss="none"`을 지정하세요.
- bottom 닫힘 거리 기본값이 35%에서 50%로 변경됩니다. 이전 경계가 필요하면 `closeThreshold={0.35}`를 지정하세요. 관성 닫힘은 유지됩니다.
- 마우스·펜 본문 드래그는 텍스트 선택에 사용됩니다. 시트 조작이 필요하면 Header 또는 Navigation의 핸들을 렌더링하세요.

- 다중 스냅 핸들에 키보드 포커스 지점이 추가됩니다. 선택 제어에는 `snapPoint`와 `onSnapPointChange`를 사용할 수 있습니다. 스냅 모달의 상위 스냅에서 한 번에 닫던 드래그 동작은 최하단 스냅으로 이동하도록 변경됩니다.
- 로컬 구현을 교체할 때 기존 속도가 px/ms이면 입력에 1000을 곱하고 이후부터 px/s로 통일하세요.
- rate 인자는 `{ rate }` 객체이며 기본값은 `normal`입니다. 기존 fast 기반 뷰어는 명시적으로 `fast`를 지정하세요.
- 프레임 실행이 필요한 자유 팬은 별도 [createFlingAnimator](./fling.md)를 사용할 수 있습니다. `ocGesture` 자체는 계산 전용 계약을 유지합니다.
