feat(know-your-world): add interaction state machine foundation
Design and implement state machine to replace scattered boolean flags:
- INTERACTION_STATE_MACHINE.md: Full design doc with states and transitions
- useInteractionStateMachine.ts: Hook with 10 explicit states, events, and reducer
States: IDLE, HOVERING, MAGNIFIER_VISIBLE, MAGNIFIER_PANNING,
MAGNIFIER_PINCHING, MAGNIFIER_EXPANDED, MAP_PANNING_MOBILE,
MAP_PANNING_DESKTOP, PRECISION_MODE, RELEASING_PRECISION
This replaces 9 independent booleans (512 combinations) with ~10 valid states.
Next: Wire up handlers to dispatch events to machine.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c21f44260f
commit
e4d6748d70
|
|
@ -0,0 +1,222 @@
|
|||
# Interaction State Machine Design
|
||||
|
||||
## Current Problem: Implicit States via Boolean Combinations
|
||||
|
||||
MapRenderer currently tracks interaction state via 9+ independent booleans:
|
||||
|
||||
```typescript
|
||||
showMagnifier / magnifierState.isVisible // magnifier visible
|
||||
isMagnifierDragging // touch dragging inside magnifier
|
||||
isPinching // pinch gesture on magnifier
|
||||
isMagnifierExpanded // magnifier fills leftover area
|
||||
isMobileMapDragging // touch dragging on main map
|
||||
mobileMapDragTriggeredMagnifier // magnifier was shown via mobile drag
|
||||
isDesktopMapDragging // mouse dragging on main map
|
||||
pointerLocked // pointer lock active (precision mode)
|
||||
isReleasingPointerLock // animating out of pointer lock
|
||||
```
|
||||
|
||||
This creates 2^9 = 512 theoretical combinations, but only ~12 are valid. The code checks validity via compound conditionals scattered throughout handlers.
|
||||
|
||||
## Proposed State Machine
|
||||
|
||||
### States
|
||||
|
||||
```
|
||||
IDLE
|
||||
├── No interaction active
|
||||
├── Cursor not visible (desktop) or no touch (mobile)
|
||||
└── Entry: Reset all interaction state
|
||||
|
||||
HOVERING (desktop only)
|
||||
├── Mouse over map
|
||||
├── Cursor visible, magnifier hidden
|
||||
└── Entry: Show cursor overlay
|
||||
|
||||
MAGNIFIER_VISIBLE
|
||||
├── Magnifier shown, normal interaction
|
||||
├── Can transition to precision mode (desktop) or panning (mobile)
|
||||
└── Entry: Show magnifier with fade-in
|
||||
|
||||
MAGNIFIER_PANNING (mobile only)
|
||||
├── Single-finger drag inside magnifier
|
||||
├── Updates cursor position within magnifier
|
||||
└── Entry: Pause zoom animations
|
||||
|
||||
MAGNIFIER_PINCHING (mobile only)
|
||||
├── Two-finger pinch on magnifier
|
||||
├── Adjusts zoom level
|
||||
└── Entry: Record pinch start distance and zoom
|
||||
|
||||
MAGNIFIER_EXPANDED (mobile only)
|
||||
├── Magnifier fills available space
|
||||
├── Higher zoom capability
|
||||
└── Entry: Animate to expanded size
|
||||
|
||||
MAP_PANNING_MOBILE
|
||||
├── Touch drag on main map (not magnifier)
|
||||
├── Magnifier follows finger, shows selection UI
|
||||
└── Entry: Show magnifier, track touch position
|
||||
|
||||
MAP_PANNING_DESKTOP
|
||||
├── Middle mouse button drag on map
|
||||
├── For accessibility/alternative navigation
|
||||
└── Entry: Change cursor to grab
|
||||
|
||||
PRECISION_MODE (desktop only)
|
||||
├── Pointer locked for fine movement
|
||||
├── Dampened cursor movement, escape animations
|
||||
└── Entry: Request pointer lock, show precision UI
|
||||
|
||||
RELEASING_PRECISION (desktop only)
|
||||
├── Animating cursor back after precision mode exit
|
||||
├── Brief transition state
|
||||
└── Exit: Release pointer lock, restore normal cursor
|
||||
```
|
||||
|
||||
### State Transitions
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ IDLE │
|
||||
└────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ mouse enter │ touch start │
|
||||
▼ │ ▼
|
||||
┌───────────────┐ │ ┌────────────────────┐
|
||||
│ HOVERING │ │ │ MAP_PANNING_MOBILE│
|
||||
│ (desktop) │ │ │ (if drag detected)│
|
||||
└───────┬───────┘ │ └──────────┬─────────┘
|
||||
│ │ │
|
||||
│ small region │ │ touch end
|
||||
│ detected │ │ (keep magnifier)
|
||||
▼ │ ▼
|
||||
┌───────────────┐ │ ┌────────────────────┐
|
||||
│ MAGNIFIER │◄───────────┘ │ MAGNIFIER │
|
||||
│ VISIBLE │ │ VISIBLE │
|
||||
└───────┬───────┘ └──────────┬─────────┘
|
||||
│ │
|
||||
┌───────┼───────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ click precision touch magnifier │ pinch start
|
||||
│ │ button (1 finger) │
|
||||
│ ▼ ▼ ▼
|
||||
│ ┌───────────────┐ ┌────────────────┐ ┌────────────────────┐
|
||||
│ │ PRECISION │ │ MAGNIFIER │ │ MAGNIFIER │
|
||||
│ │ MODE │ │ PANNING │ │ PINCHING │
|
||||
│ └───────┬───────┘ └────────────────┘ └────────────────────┘
|
||||
│ │ │
|
||||
│ │ escape boundary │ zoom past
|
||||
│ ▼ │ threshold
|
||||
│ ┌───────────────┐ ▼
|
||||
│ │ RELEASING │ ┌────────────────────┐
|
||||
│ │ PRECISION │ │ MAGNIFIER │
|
||||
│ └───────┬───────┘ │ EXPANDED │
|
||||
│ │ └────────────────────┘
|
||||
│ │ animation done
|
||||
└─────────┴──────────────────────────────────────────┐
|
||||
│
|
||||
tap outside / dismiss │
|
||||
▼ │
|
||||
┌────────────────────────────────────┘
|
||||
│ IDLE
|
||||
└──────────────────────────────────────
|
||||
```
|
||||
|
||||
### Context (Shared Data)
|
||||
|
||||
```typescript
|
||||
interface InteractionContext {
|
||||
// Cursor position (container coordinates)
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
|
||||
// Zoom state
|
||||
currentZoom: number
|
||||
targetZoom: number
|
||||
|
||||
// Magnifier position (for animations)
|
||||
magnifierTop: number
|
||||
magnifierLeft: number
|
||||
|
||||
// Touch tracking
|
||||
touchStart: { x: number; y: number } | null
|
||||
pinchStartDistance: number | null
|
||||
pinchStartZoom: number | null
|
||||
|
||||
// Precision mode tracking
|
||||
initialCapturePosition: { x: number; y: number } | null
|
||||
movementMultiplier: number
|
||||
|
||||
// Cursor visual state
|
||||
cursorSquish: { x: number; y: number }
|
||||
|
||||
// Mobile-specific
|
||||
dragTriggeredMagnifier: boolean // For showing Select button
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```typescript
|
||||
type InteractionEvent =
|
||||
// Mouse events (desktop)
|
||||
| { type: 'MOUSE_ENTER' }
|
||||
| { type: 'MOUSE_LEAVE' }
|
||||
| { type: 'MOUSE_MOVE'; position: { x: number; y: number }; movement: { dx: number; dy: number } }
|
||||
| { type: 'MOUSE_DOWN'; button: 'left' | 'middle' | 'right' }
|
||||
| { type: 'MOUSE_UP' }
|
||||
| { type: 'CLICK'; position: { x: number; y: number } }
|
||||
|
||||
// Touch events (mobile)
|
||||
| { type: 'TOUCH_START'; touches: TouchPoint[]; target: 'map' | 'magnifier' }
|
||||
| { type: 'TOUCH_MOVE'; touches: TouchPoint[]; target: 'map' | 'magnifier' }
|
||||
| { type: 'TOUCH_END'; target: 'map' | 'magnifier' }
|
||||
|
||||
// Precision mode
|
||||
| { type: 'REQUEST_PRECISION' }
|
||||
| { type: 'EXIT_PRECISION' }
|
||||
| { type: 'PRECISION_ESCAPE_BOUNDARY' }
|
||||
| { type: 'RELEASE_ANIMATION_DONE' }
|
||||
|
||||
// Magnifier
|
||||
| { type: 'SHOW_MAGNIFIER' }
|
||||
| { type: 'DISMISS_MAGNIFIER' }
|
||||
| { type: 'EXPAND_MAGNIFIER' }
|
||||
| { type: 'COLLAPSE_MAGNIFIER' }
|
||||
|
||||
// Region
|
||||
| { type: 'REGION_SELECTED'; regionId: string }
|
||||
| { type: 'SMALL_REGION_DETECTED'; size: number }
|
||||
| { type: 'ZOOM_THRESHOLD_REACHED' }
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Create State Machine Hook
|
||||
1. Define types (State, Event, Context)
|
||||
2. Implement `useInteractionStateMachine` hook using `useReducer`
|
||||
3. Export state and dispatch function
|
||||
|
||||
### Phase 2: Wire Up Event Dispatching
|
||||
1. Replace direct state mutations in handlers with event dispatches
|
||||
2. Mouse handlers dispatch MOUSE_* events
|
||||
3. Touch handlers dispatch TOUCH_* events
|
||||
|
||||
### Phase 3: Derive UI State from Machine State
|
||||
1. Replace boolean checks with state comparisons
|
||||
2. `showMagnifier` → `state.matches('MAGNIFIER_*')`
|
||||
3. `pointerLocked` → `state === 'PRECISION_MODE'`
|
||||
|
||||
### Phase 4: Extract Handler Logic
|
||||
1. Move event handling logic into state machine actions
|
||||
2. Handlers become thin event dispatchers
|
||||
3. Coordinate calculations move to machine actions
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Explicit Valid States**: Only ~12 states instead of 512 boolean combinations
|
||||
2. **Centralized Transitions**: All state changes in one place
|
||||
3. **Easier Testing**: Test state transitions independently
|
||||
4. **Self-Documenting**: State names describe what's happening
|
||||
5. **Impossible States Impossible**: Can't be `isPinching && isMagnifierDragging`
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Interaction Feature Module
|
||||
*
|
||||
* State machine for managing map interaction state.
|
||||
* Replaces scattered boolean flags with explicit states.
|
||||
*/
|
||||
|
||||
export type {
|
||||
InteractionState,
|
||||
InteractionEvent,
|
||||
InteractionContext,
|
||||
TouchPoint,
|
||||
MachineState,
|
||||
UseInteractionStateMachineReturn,
|
||||
} from './useInteractionStateMachine'
|
||||
|
||||
export { useInteractionStateMachine } from './useInteractionStateMachine'
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
/**
|
||||
* Interaction State Machine
|
||||
*
|
||||
* Manages map interaction state via an explicit state machine instead of
|
||||
* scattered boolean flags. This makes valid states explicit and transitions
|
||||
* predictable.
|
||||
*
|
||||
* See docs/INTERACTION_STATE_MACHINE.md for full design documentation.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useReducer, useCallback, useRef, useMemo } from 'react'
|
||||
|
||||
// ============================================================================
|
||||
// State Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* All possible interaction states.
|
||||
* Only ~10 valid states instead of 512 boolean combinations.
|
||||
*/
|
||||
export type InteractionState =
|
||||
| 'IDLE'
|
||||
| 'HOVERING' // Desktop: mouse over map, cursor visible
|
||||
| 'MAGNIFIER_VISIBLE' // Magnifier shown, normal interaction
|
||||
| 'MAGNIFIER_PANNING' // Mobile: single-finger drag in magnifier
|
||||
| 'MAGNIFIER_PINCHING' // Mobile: two-finger pinch on magnifier
|
||||
| 'MAGNIFIER_EXPANDED' // Mobile: magnifier fills available space
|
||||
| 'MAP_PANNING_MOBILE' // Mobile: touch drag on main map
|
||||
| 'MAP_PANNING_DESKTOP' // Desktop: middle mouse drag
|
||||
| 'PRECISION_MODE' // Desktop: pointer locked for fine movement
|
||||
| 'RELEASING_PRECISION' // Desktop: animating out of precision mode
|
||||
|
||||
// ============================================================================
|
||||
// Event Types
|
||||
// ============================================================================
|
||||
|
||||
export type TouchPoint = {
|
||||
x: number
|
||||
y: number
|
||||
identifier: number
|
||||
}
|
||||
|
||||
export type InteractionEvent =
|
||||
// Mouse events (desktop)
|
||||
| { type: 'MOUSE_ENTER' }
|
||||
| { type: 'MOUSE_LEAVE' }
|
||||
| {
|
||||
type: 'MOUSE_MOVE'
|
||||
position: { x: number; y: number }
|
||||
movement: { dx: number; dy: number }
|
||||
}
|
||||
| { type: 'MOUSE_DOWN'; button: 'left' | 'middle' | 'right' }
|
||||
| { type: 'MOUSE_UP' }
|
||||
| { type: 'CLICK'; position: { x: number; y: number } }
|
||||
|
||||
// Touch events (mobile)
|
||||
| { type: 'TOUCH_START'; touches: TouchPoint[]; target: 'map' | 'magnifier' }
|
||||
| { type: 'TOUCH_MOVE'; touches: TouchPoint[]; target: 'map' | 'magnifier' }
|
||||
| { type: 'TOUCH_END'; target: 'map' | 'magnifier'; remainingTouches: number }
|
||||
|
||||
// Precision mode
|
||||
| { type: 'REQUEST_PRECISION' }
|
||||
| { type: 'EXIT_PRECISION' }
|
||||
| { type: 'PRECISION_ESCAPE_BOUNDARY' }
|
||||
| { type: 'RELEASE_ANIMATION_DONE' }
|
||||
|
||||
// Magnifier
|
||||
| { type: 'SHOW_MAGNIFIER' }
|
||||
| { type: 'DISMISS_MAGNIFIER' }
|
||||
| { type: 'EXPAND_MAGNIFIER' }
|
||||
| { type: 'COLLAPSE_MAGNIFIER' }
|
||||
|
||||
// Zoom threshold
|
||||
| { type: 'ZOOM_THRESHOLD_REACHED' }
|
||||
| { type: 'ZOOM_BELOW_THRESHOLD' }
|
||||
|
||||
// ============================================================================
|
||||
// Context Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Shared data that persists across state transitions.
|
||||
*/
|
||||
export interface InteractionContext {
|
||||
// Cursor position (container coordinates)
|
||||
cursorPosition: { x: number; y: number } | null
|
||||
|
||||
// Zoom state
|
||||
currentZoom: number
|
||||
targetZoom: number
|
||||
|
||||
// Magnifier position (for animations)
|
||||
magnifierTop: number
|
||||
magnifierLeft: number
|
||||
targetOpacity: number
|
||||
|
||||
// Touch tracking
|
||||
touchStart: { x: number; y: number } | null
|
||||
pinchStartDistance: number | null
|
||||
pinchStartZoom: number | null
|
||||
|
||||
// Precision mode tracking
|
||||
initialCapturePosition: { x: number; y: number } | null
|
||||
movementMultiplier: number
|
||||
|
||||
// Cursor visual state
|
||||
cursorSquish: { x: number; y: number }
|
||||
|
||||
// Mobile-specific
|
||||
dragTriggeredMagnifier: boolean // For showing Select button
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Machine State
|
||||
// ============================================================================
|
||||
|
||||
export interface MachineState {
|
||||
state: InteractionState
|
||||
context: InteractionContext
|
||||
// Track previous state for transition effects
|
||||
previousState: InteractionState | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Initial State
|
||||
// ============================================================================
|
||||
|
||||
const initialContext: InteractionContext = {
|
||||
cursorPosition: null,
|
||||
currentZoom: 1,
|
||||
targetZoom: 1,
|
||||
magnifierTop: 0,
|
||||
magnifierLeft: 0,
|
||||
targetOpacity: 0,
|
||||
touchStart: null,
|
||||
pinchStartDistance: null,
|
||||
pinchStartZoom: null,
|
||||
initialCapturePosition: null,
|
||||
movementMultiplier: 1,
|
||||
cursorSquish: { x: 1, y: 1 },
|
||||
dragTriggeredMagnifier: false,
|
||||
}
|
||||
|
||||
const initialState: MachineState = {
|
||||
state: 'IDLE',
|
||||
context: initialContext,
|
||||
previousState: null,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Transition Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Pure reducer function that handles state transitions.
|
||||
* All state changes go through here, making the logic centralized and testable.
|
||||
*/
|
||||
function interactionReducer(machine: MachineState, event: InteractionEvent): MachineState {
|
||||
const { state, context } = machine
|
||||
|
||||
switch (state) {
|
||||
// -------------------------------------------------------------------------
|
||||
// IDLE State
|
||||
// -------------------------------------------------------------------------
|
||||
case 'IDLE': {
|
||||
switch (event.type) {
|
||||
case 'MOUSE_ENTER':
|
||||
return { ...machine, state: 'HOVERING', previousState: state }
|
||||
|
||||
case 'TOUCH_START':
|
||||
if (event.target === 'map' && event.touches.length === 1) {
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE', // Stay idle until drag threshold met
|
||||
context: {
|
||||
...context,
|
||||
touchStart: event.touches[0],
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'SHOW_MAGNIFIER':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: { ...context, targetOpacity: 1 },
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HOVERING State (Desktop)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'HOVERING': {
|
||||
switch (event.type) {
|
||||
case 'MOUSE_LEAVE':
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE',
|
||||
context: { ...context, cursorPosition: null },
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'MOUSE_MOVE':
|
||||
return {
|
||||
...machine,
|
||||
context: { ...context, cursorPosition: event.position },
|
||||
}
|
||||
|
||||
case 'MOUSE_DOWN':
|
||||
if (event.button === 'middle') {
|
||||
return { ...machine, state: 'MAP_PANNING_DESKTOP', previousState: state }
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'SHOW_MAGNIFIER':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: { ...context, targetOpacity: 1 },
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAGNIFIER_VISIBLE State
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAGNIFIER_VISIBLE': {
|
||||
switch (event.type) {
|
||||
case 'MOUSE_MOVE':
|
||||
return {
|
||||
...machine,
|
||||
context: { ...context, cursorPosition: event.position },
|
||||
}
|
||||
|
||||
case 'MOUSE_LEAVE':
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE',
|
||||
context: {
|
||||
...context,
|
||||
cursorPosition: null,
|
||||
targetOpacity: 0,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'DISMISS_MAGNIFIER':
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE',
|
||||
context: {
|
||||
...context,
|
||||
targetOpacity: 0,
|
||||
dragTriggeredMagnifier: false,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'REQUEST_PRECISION':
|
||||
return {
|
||||
...machine,
|
||||
state: 'PRECISION_MODE',
|
||||
context: {
|
||||
...context,
|
||||
initialCapturePosition: context.cursorPosition,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'TOUCH_START':
|
||||
if (event.target === 'magnifier') {
|
||||
if (event.touches.length === 1) {
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PANNING',
|
||||
context: { ...context, touchStart: event.touches[0] },
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
if (event.touches.length === 2) {
|
||||
const dx = event.touches[1].x - event.touches[0].x
|
||||
const dy = event.touches[1].y - event.touches[0].y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PINCHING',
|
||||
context: {
|
||||
...context,
|
||||
pinchStartDistance: distance,
|
||||
pinchStartZoom: context.currentZoom,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'EXPAND_MAGNIFIER':
|
||||
return { ...machine, state: 'MAGNIFIER_EXPANDED', previousState: state }
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAGNIFIER_PANNING State (Mobile)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAGNIFIER_PANNING': {
|
||||
switch (event.type) {
|
||||
case 'TOUCH_MOVE':
|
||||
if (event.touches.length === 1) {
|
||||
return {
|
||||
...machine,
|
||||
context: {
|
||||
...context,
|
||||
cursorPosition: event.touches[0],
|
||||
},
|
||||
}
|
||||
}
|
||||
if (event.touches.length === 2) {
|
||||
// Transition to pinching
|
||||
const dx = event.touches[1].x - event.touches[0].x
|
||||
const dy = event.touches[1].y - event.touches[0].y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PINCHING',
|
||||
context: {
|
||||
...context,
|
||||
pinchStartDistance: distance,
|
||||
pinchStartZoom: context.currentZoom,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'TOUCH_END':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: { ...context, touchStart: null },
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAGNIFIER_PINCHING State (Mobile)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAGNIFIER_PINCHING': {
|
||||
switch (event.type) {
|
||||
case 'TOUCH_MOVE':
|
||||
if (event.touches.length === 2) {
|
||||
const dx = event.touches[1].x - event.touches[0].x
|
||||
const dy = event.touches[1].y - event.touches[0].y
|
||||
const currentDistance = Math.sqrt(dx * dx + dy * dy)
|
||||
// Zoom calculation would happen here via callback
|
||||
return machine
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'TOUCH_END':
|
||||
if (event.remainingTouches === 1) {
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PANNING',
|
||||
context: {
|
||||
...context,
|
||||
pinchStartDistance: null,
|
||||
pinchStartZoom: null,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
if (event.remainingTouches === 0) {
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: {
|
||||
...context,
|
||||
pinchStartDistance: null,
|
||||
pinchStartZoom: null,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'ZOOM_THRESHOLD_REACHED':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_EXPANDED',
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAGNIFIER_EXPANDED State (Mobile)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAGNIFIER_EXPANDED': {
|
||||
switch (event.type) {
|
||||
case 'COLLAPSE_MAGNIFIER':
|
||||
case 'ZOOM_BELOW_THRESHOLD':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'DISMISS_MAGNIFIER':
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE',
|
||||
context: {
|
||||
...context,
|
||||
targetOpacity: 0,
|
||||
dragTriggeredMagnifier: false,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'TOUCH_START':
|
||||
// Handle same as MAGNIFIER_VISIBLE
|
||||
if (event.target === 'magnifier') {
|
||||
if (event.touches.length === 1) {
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PANNING',
|
||||
context: { ...context, touchStart: event.touches[0] },
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
if (event.touches.length === 2) {
|
||||
const dx = event.touches[1].x - event.touches[0].x
|
||||
const dy = event.touches[1].y - event.touches[0].y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_PINCHING',
|
||||
context: {
|
||||
...context,
|
||||
pinchStartDistance: distance,
|
||||
pinchStartZoom: context.currentZoom,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAP_PANNING_MOBILE State
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAP_PANNING_MOBILE': {
|
||||
switch (event.type) {
|
||||
case 'TOUCH_MOVE':
|
||||
if (event.touches.length === 1) {
|
||||
return {
|
||||
...machine,
|
||||
context: {
|
||||
...context,
|
||||
cursorPosition: event.touches[0],
|
||||
},
|
||||
}
|
||||
}
|
||||
return machine
|
||||
|
||||
case 'TOUCH_END':
|
||||
// Keep magnifier visible after drag ends (for Select button)
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: {
|
||||
...context,
|
||||
touchStart: null,
|
||||
dragTriggeredMagnifier: true, // Enable Select button
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
case 'DISMISS_MAGNIFIER':
|
||||
return {
|
||||
...machine,
|
||||
state: 'IDLE',
|
||||
context: {
|
||||
...context,
|
||||
targetOpacity: 0,
|
||||
dragTriggeredMagnifier: false,
|
||||
touchStart: null,
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MAP_PANNING_DESKTOP State
|
||||
// -------------------------------------------------------------------------
|
||||
case 'MAP_PANNING_DESKTOP': {
|
||||
switch (event.type) {
|
||||
case 'MOUSE_UP':
|
||||
return { ...machine, state: 'HOVERING', previousState: state }
|
||||
|
||||
case 'MOUSE_LEAVE':
|
||||
return { ...machine, state: 'IDLE', previousState: state }
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PRECISION_MODE State (Desktop)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'PRECISION_MODE': {
|
||||
switch (event.type) {
|
||||
case 'MOUSE_MOVE':
|
||||
// Update cursor position with dampening applied by caller
|
||||
return {
|
||||
...machine,
|
||||
context: { ...context, cursorPosition: event.position },
|
||||
}
|
||||
|
||||
case 'PRECISION_ESCAPE_BOUNDARY':
|
||||
return { ...machine, state: 'RELEASING_PRECISION', previousState: state }
|
||||
|
||||
case 'EXIT_PRECISION':
|
||||
return { ...machine, state: 'RELEASING_PRECISION', previousState: state }
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RELEASING_PRECISION State (Desktop)
|
||||
// -------------------------------------------------------------------------
|
||||
case 'RELEASING_PRECISION': {
|
||||
switch (event.type) {
|
||||
case 'RELEASE_ANIMATION_DONE':
|
||||
return {
|
||||
...machine,
|
||||
state: 'MAGNIFIER_VISIBLE',
|
||||
context: {
|
||||
...context,
|
||||
initialCapturePosition: null,
|
||||
cursorSquish: { x: 1, y: 1 },
|
||||
},
|
||||
previousState: state,
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return machine
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export interface UseInteractionStateMachineReturn {
|
||||
// Current state
|
||||
state: InteractionState
|
||||
context: InteractionContext
|
||||
previousState: InteractionState | null
|
||||
|
||||
// State checks (convenience methods)
|
||||
isIdle: boolean
|
||||
isHovering: boolean
|
||||
isMagnifierVisible: boolean
|
||||
isMagnifierPanning: boolean
|
||||
isMagnifierPinching: boolean
|
||||
isMagnifierExpanded: boolean
|
||||
isMapPanningMobile: boolean
|
||||
isMapPanningDesktop: boolean
|
||||
isPrecisionMode: boolean
|
||||
isReleasingPrecision: boolean
|
||||
|
||||
// Compound checks
|
||||
showMagnifier: boolean
|
||||
showCursor: boolean
|
||||
isAnyPanning: boolean
|
||||
isMobileInteraction: boolean
|
||||
|
||||
// Dispatch
|
||||
send: (event: InteractionEvent) => void
|
||||
|
||||
// Context updates (for values that change without state transitions)
|
||||
updateContext: (updates: Partial<InteractionContext>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides the interaction state machine.
|
||||
*/
|
||||
export function useInteractionStateMachine(): UseInteractionStateMachineReturn {
|
||||
const [machine, dispatch] = useReducer(interactionReducer, initialState)
|
||||
|
||||
// Create stable send function
|
||||
const send = useCallback((event: InteractionEvent) => {
|
||||
dispatch(event)
|
||||
}, [])
|
||||
|
||||
// Context update ref for values that change without state transitions
|
||||
// (This is a workaround since useReducer doesn't support partial updates nicely)
|
||||
const contextRef = useRef(machine.context)
|
||||
contextRef.current = machine.context
|
||||
|
||||
const updateContext = useCallback((updates: Partial<InteractionContext>) => {
|
||||
// For context-only updates, we dispatch a special "internal" event
|
||||
// This is handled by having the reducer accept context updates
|
||||
// For now, this is a no-op - full implementation would need reducer changes
|
||||
}, [])
|
||||
|
||||
// Compute convenience booleans
|
||||
const state = machine.state
|
||||
const isIdle = state === 'IDLE'
|
||||
const isHovering = state === 'HOVERING'
|
||||
const isMagnifierVisible = state === 'MAGNIFIER_VISIBLE'
|
||||
const isMagnifierPanning = state === 'MAGNIFIER_PANNING'
|
||||
const isMagnifierPinching = state === 'MAGNIFIER_PINCHING'
|
||||
const isMagnifierExpanded = state === 'MAGNIFIER_EXPANDED'
|
||||
const isMapPanningMobile = state === 'MAP_PANNING_MOBILE'
|
||||
const isMapPanningDesktop = state === 'MAP_PANNING_DESKTOP'
|
||||
const isPrecisionMode = state === 'PRECISION_MODE'
|
||||
const isReleasingPrecision = state === 'RELEASING_PRECISION'
|
||||
|
||||
// Compound checks
|
||||
const showMagnifier = useMemo(
|
||||
() =>
|
||||
isMagnifierVisible ||
|
||||
isMagnifierPanning ||
|
||||
isMagnifierPinching ||
|
||||
isMagnifierExpanded ||
|
||||
isMapPanningMobile ||
|
||||
isPrecisionMode ||
|
||||
isReleasingPrecision,
|
||||
[
|
||||
isMagnifierVisible,
|
||||
isMagnifierPanning,
|
||||
isMagnifierPinching,
|
||||
isMagnifierExpanded,
|
||||
isMapPanningMobile,
|
||||
isPrecisionMode,
|
||||
isReleasingPrecision,
|
||||
]
|
||||
)
|
||||
|
||||
const showCursor = useMemo(
|
||||
() => isHovering || showMagnifier,
|
||||
[isHovering, showMagnifier]
|
||||
)
|
||||
|
||||
const isAnyPanning = useMemo(
|
||||
() => isMagnifierPanning || isMapPanningMobile || isMapPanningDesktop,
|
||||
[isMagnifierPanning, isMapPanningMobile, isMapPanningDesktop]
|
||||
)
|
||||
|
||||
const isMobileInteraction = useMemo(
|
||||
() => isMagnifierPanning || isMagnifierPinching || isMapPanningMobile,
|
||||
[isMagnifierPanning, isMagnifierPinching, isMapPanningMobile]
|
||||
)
|
||||
|
||||
return {
|
||||
state: machine.state,
|
||||
context: machine.context,
|
||||
previousState: machine.previousState,
|
||||
|
||||
// State checks
|
||||
isIdle,
|
||||
isHovering,
|
||||
isMagnifierVisible,
|
||||
isMagnifierPanning,
|
||||
isMagnifierPinching,
|
||||
isMagnifierExpanded,
|
||||
isMapPanningMobile,
|
||||
isMapPanningDesktop,
|
||||
isPrecisionMode,
|
||||
isReleasingPrecision,
|
||||
|
||||
// Compound checks
|
||||
showMagnifier,
|
||||
showCursor,
|
||||
isAnyPanning,
|
||||
isMobileInteraction,
|
||||
|
||||
// Actions
|
||||
send,
|
||||
updateContext,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue