25 KiB
Architecture Documentation
Deep dive into how ngx-pinch-zoom works internally. For quick lookups, see QUICK_REFERENCE.md. For implementation examples, see IMPLEMENTATION_GUIDE.md.
Table of Contents
- Overview
- Component Architecture
- Data Flow
- Signal Architecture
- Core Classes
- Event System
- Transform Mathematics
- Touch Gesture Detection
- Constraint System
- Lifecycle
Overview
High-Level Architecture
graph TB
subgraph Component["PinchZoomComponent<br/>(Angular Component)"]
direction TB
Inputs["<b>Input Signals</b><br/>disabled()<br/>limitZoom()<br/>doubleTap()"]:::inputStyle
Computed["<b>Computed Signals</b><br/>isZoomedIn()<br/>maxScale()<br/>isControl()"]:::computedStyle
Outputs["<b>Output Signals</b><br/>zoomChanged"]:::outputStyle
Merged["mergedProperties"]:::mergedStyle
Inputs -.-> Merged
Inputs -.-> Computed
Merged --> CoreLogic
end
subgraph IvyPinchBox["IvyPinch<br/>(Core Logic - Pure TS)"]
direction TB
CoreLogic["<b>Properties</b><br/>limitZoom<br/>doubleTap"]:::propsStyle
State["<b>State</b><br/>scale<br/>moveX/moveY<br/>distance"]:::stateStyle
Methods["<b>Transform Methods</b><br/>transformElement()<br/>handlePinch()<br/>handlePan()"]:::methodsStyle
CoreLogic --> State
State --> Methods
Methods --> TouchesBox
end
subgraph TouchesBox["Touches<br/>(Event Detection - Pure TS)"]
direction TB
Listeners["<b>Event Listeners</b><br/>touchstart<br/>touchmove<br/>touchend"]:::listenersStyle
Detection["<b>Gesture Detection</b><br/>detectPinch()<br/>detectPan()<br/>detectDoubleTap()"]:::detectionStyle
Emission["<b>Event Emission</b><br/>emit('pinch')<br/>emit('pan')<br/>emit('tap')"]:::emissionStyle
Listeners --> Detection
Detection --> Emission
Emission -.-> Methods
end
DOM["Browser DOM/Events"]:::domStyle
TouchesBox --> DOM
DOM -.-> Listeners
Methods -.-> Outputs
classDef inputStyle fill:#58a6ff,stroke:#79c0ff,color:#fff,stroke-width:2px
classDef computedStyle fill:#d29922,stroke:#e3b341,color:#fff,stroke-width:2px
classDef outputStyle fill:#db61a2,stroke:#f778ba,color:#fff,stroke-width:2px
classDef mergedStyle fill:#a371f7,stroke:#d29eff,color:#fff,stroke-width:2px
classDef propsStyle fill:#58a6ff,stroke:#79c0ff,color:#fff,stroke-width:2px
classDef stateStyle fill:#3fb950,stroke:#56d364,color:#fff,stroke-width:2px
classDef methodsStyle fill:#58a6ff,stroke:#79c0ff,color:#fff,stroke-width:2px
classDef listenersStyle fill:#bc8cff,stroke:#d2a8ff,color:#fff,stroke-width:2px
classDef detectionStyle fill:#3fb950,stroke:#56d364,color:#fff,stroke-width:2px
classDef emissionStyle fill:#db61a2,stroke:#f778ba,color:#fff,stroke-width:2px
classDef domStyle fill:#f85149,stroke:#ff7b72,color:#fff,stroke-width:2px
Design Principles
-
Separation of Concerns
- PinchZoomComponent: Angular integration, signals, lifecycle
- IvyPinch: Pure business logic, no Angular dependencies
- Touches: Pure event handling, no Angular dependencies
-
Signal-Based Reactivity
- All inputs use Angular 20+ input signals
- Computed signals for derived state
- Output signals for events
- Effects for side effects
-
Framework Independence
- IvyPinch and Touches can work without Angular
- Could be ported to React, Vue, or vanilla JS
- DOM manipulation is isolated
-
Performance First
- CSS transforms for hardware acceleration
translate3dforces GPU rendering- No layout thrashing (read/write separation)
- Minimal change detection triggers
Component Architecture
PinchZoomComponent (Angular Layer)
Responsibilities:
- Provide Angular component interface
- Manage input/output signals
- Initialize and cleanup IvyPinch
- Bridge between Angular and core logic
Key Features:
@Component({
selector: 'pinch-zoom, [pinch-zoom]',
standalone: true, // No NgModule needed
// ...
})
export class PinchZoomComponent {
// INPUT SIGNALS (from user)
disabled = input<boolean>(false);
limitZoom = input<number>(3);
// INTERNAL SIGNALS (state)
private currentScale = signal<number>(1);
// COMPUTED SIGNALS (derived)
isZoomedIn = computed(() => this.scale() > 1);
// OUTPUT SIGNALS (to user)
zoomChanged = output<number>();
// EFFECTS (side effects)
constructor() {
effect(() => {
if (this.disabled()) {
this.pinchZoom?.destroy();
}
});
}
}
IvyPinch (Core Logic Layer)
Responsibilities:
- Calculate zoom scale from pinch gestures
- Calculate pan position from swipe gestures
- Apply constraints (min/max zoom, pan limits)
- Update DOM with CSS transforms
- Notify component of changes
Key State:
class IvyPinch {
// Zoom state
public scale: number = 1; // Current scale (1 = 100%)
private initialScale: number = 1; // Scale at gesture start
private maxScale: number = 3; // Maximum allowed scale
// Position state
public moveX: number = 0; // Horizontal offset (px)
public moveY: number = 0; // Vertical offset (px)
private initialMoveX: number = 0; // Position at gesture start
private initialMoveY: number = 0;
// Gesture state
private distance: number = 0; // Distance between fingers
private initialDistance: number = 0; // Distance at pinch start
}
Touches (Event Handling Layer)
Responsibilities:
- Listen to touch/mouse events
- Detect gesture types (pinch, pan, double-tap)
- Calculate touch positions and distances
- Emit typed events to IvyPinch
Event Flow:
class Touches {
// Event type state machine
private eventType: 'pinch' | 'pan' | 'tap' | undefined;
// Touch tracking
private touches: Touch[] = [];
// Event handlers
private handlers = {
pinch: [],
pan: [],
tap: [],
};
// Core detection loop
detectGesture() {
this.detectDoubleTap() || // Check tap first
this.detectPinch() || // Then pinch
this.detectLinearSwipe(); // Finally pan
}
}
Data Flow
Complete User Interaction Flow
sequenceDiagram
participant User
participant Browser
participant Touches
participant IvyPinch
participant Component
User->>Browser: 1. Touch screen
Browser->>Touches: 2. touchstart event
Note over Touches: 3. handleTouchStart()<br/>Store initial positions<br/>Set touches array
User->>Browser: 4. Move fingers (pinch)
Browser->>Touches: 5. touchmove event<br/>(many times/second)
Note over Touches: 6. handleTouchMove()<br/>Calculate positions<br/>Calculate distance
Touches->>Touches: 7. detectGesture()<br/>Determine type<br/>Set eventType='pinch'
Touches->>IvyPinch: 8. emit('pinch', event)
Note over IvyPinch: 9. handlePinch()<br/>Calculate scale ratio<br/>Apply constraints<br/>Update scale, moveX, moveY
Note over IvyPinch: 10. transformElement()<br/>Build CSS transform<br/>Update element.style.transform
IvyPinch->>Browser: 11. Apply transform
Note over Browser: GPU accelerated rendering
IvyPinch->>Component: 12. zoomChanged(scale)
Note over Component: 13. handleScaleCallback()<br/>currentScale.set(scale)
Note over Component: 14. Signal propagation<br/>isZoomedIn recomputes<br/>Change detection (if needed)
Component->>User: 15. emit zoomChanged event
User->>Browser: 16. Lift fingers
Browser->>Touches: 17. touchend event
Note over Touches: 18. handleTouchEnd()<br/>Reset eventType<br/>Clear gesture state
Note over Touches: DONE
%% Colors for dark mode
participant User as User
participant Browser as Browser
participant Touches as Touches
participant IvyPinch as IvyPinch
participant Component as Component
Signal Reactivity Flow
flowchart TD
A[User sets limitZoom=5]:::userStyle
B[limitZoom signal updates]:::signalStyle
C[mergedProperties recomputes]:::computedStyle
D[effect runs]:::effectStyle
E[IvyPinch.properties updated]:::updateStyle
F[maxScale recalculated]:::calcStyle
G[Next pinch gesture<br/>respects new limit]:::resultStyle
A --> B --> C --> D --> E --> F --> G
classDef userStyle fill:#a371f7,stroke:#d29eff,color:#fff
classDef signalStyle fill:#58a6ff,stroke:#79c0ff,color:#fff
classDef computedStyle fill:#d29922,stroke:#e3b341,color:#fff
classDef effectStyle fill:#db61a2,stroke:#f778ba,color:#fff
classDef updateStyle fill:#3fb950,stroke:#56d364,color:#fff
classDef calcStyle fill:#58a6ff,stroke:#79c0ff,color:#fff
classDef resultStyle fill:#3fb950,stroke:#56d364,color:#fff
Signal Architecture
Why Signals?
Angular 20's signals provide:
- Fine-grained reactivity - Only affected components update
- Automatic dependency tracking - Computed signals track dependencies
- Better performance - Less change detection overhead
- Simpler mental model - Clear data flow
Input Signals Pattern
// OLD WAY (Angular <16)
@Input() disabled: boolean = false;
ngOnChanges(changes: SimpleChanges) {
if (changes['disabled']) {
// React to change
}
}
// NEW WAY (Angular 20+)
disabled = input<boolean>(false);
constructor() {
effect(() => {
if (this.disabled()) {
// Automatically runs when disabled changes
this.pinchZoom?.destroy();
}
});
}
Computed Signals Pattern
// Automatically recalculates when dependencies change
isZoomedIn = computed<boolean>(() => {
return this.scale() > 1;
// ↑ Dependency tracked automatically
});
maxScale = computed<number>(() => {
const limit = this.limitZoom();
if (limit === 'original image size') {
return this.calculateImageScale();
}
return limit;
});
Output Signals Pattern
// OLD WAY
@Output() zoomChanged = new EventEmitter<number>();
this.zoomChanged.emit(scale);
// NEW WAY
zoomChanged = output<number>();
this.zoomChanged.emit(scale);
Signal Update Flow
// Component receives scale update from IvyPinch
private handleScaleCallback = (scale: number) => {
// 1. Update internal signal
this.currentScale.set(scale);
// 2. Computed signals automatically recalculate
// isZoomedIn() will update
// maxScale() will update (if dependencies changed)
// 3. Template updates (if using signals in template)
// {{ scale() }} will show new value
// 4. Emit to parent component
this.zoomChanged.emit(scale);
};
Core Classes
PinchZoomComponent Deep Dive
mergedProperties Computed Signal
The most important computed signal merges all inputs:
mergedProperties = computed<ComponentProperties>(() => {
return {
// Start with defaults
...this.defaultComponentProperties,
// Override with user properties object
...this.properties(),
// Override with individual input signals
transitionDuration: this.transitionDuration(),
doubleTap: this.doubleTap(),
doubleTapScale: this.doubleTapScale(),
limitZoom: this.limitZoom(),
disabled: this.disabled(),
disablePan: this.disablePan(),
// ... all other inputs
};
});
This ensures:
- Individual inputs take priority
- properties object can set multiple at once
- Defaults are always present
- Single source of truth
Initialization Flow
ngOnInit(): void {
// 1. Get merged configuration
const properties = this.mergedProperties();
// 2. Get DOM element
const element = this.elementRef.nativeElement;
// 3. Create IvyPinch instance
this.pinchZoom = new IvyPinch(
properties,
this.handleScaleCallback // Callback for updates
);
// 4. IvyPinch creates Touches instance
// 5. Event listeners attached
// 6. Ready for user interaction
}
Cleanup Flow
ngOnDestroy(): void {
this.pinchZoom?.destroy();
// ↓
// IvyPinch.destroy() called
// ↓
// Touches.destroy() called
// ↓
// Event listeners removed
// ↓
// References cleared
// ↓
// Memory freed
}
IvyPinch Deep Dive
Transform Calculation (The Heart of the Library)
private transformElement(scale: number, moveX: number, moveY: number) {
// 1. Build transform string
const transform = `
translate3d(${moveX}px, ${moveY}px, 0)
scale(${scale})
`;
// 2. Apply to element
this.element.style.transform = transform;
// Why translate3d instead of translateX/translateY?
// - Forces GPU acceleration (3D rendering pipeline)
// - Better performance on mobile devices
// - Smoother animations
// Why scale() after translate?
// - Transform order matters!
// - translate then scale = scale around new position
// - scale then translate = translate scaled amount
}
Pinch Zoom Algorithm
handlePinch(event: any) {
// 1. Get current distance between fingers
const currentDistance = this.touches.distance;
// 2. Initialize if first pinch move
if (this.distance === 0) {
this.distance = currentDistance;
this.initialDistance = currentDistance;
}
// 3. Calculate scale ratio
// If fingers move apart: ratio > 1 (zoom in)
// If fingers move together: ratio < 1 (zoom out)
const scaleRatio = currentDistance / this.initialDistance;
// 4. Apply ratio to initial scale
let newScale = this.initialScale * scaleRatio;
// 5. Apply constraints
newScale = Math.max(this.minScale, newScale);
newScale = Math.min(this.maxScale, newScale);
// 6. Calculate zoom center (between fingers)
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
// 7. Adjust position to zoom at center point
// (See Transform Mathematics section)
this.moveX = this.calculateMoveX(newScale, centerX);
this.moveY = this.calculateMoveY(newScale, centerY);
// 8. Apply transform
this.transformElement(newScale, this.moveX, this.moveY);
// 9. Update state
this.scale = newScale;
// 10. Notify component
this.zoomChanged(this.scale);
}
Touches Deep Dive
Gesture Detection State Machine
stateDiagram-v2
[*] --> IDLE
IDLE: IDLE<br/>eventType = undefined
IDLE --> TOUCHED: touchstart
TOUCHED: TOUCHED<br/>Waiting for movement
TOUCHED --> PINCH: touchmove<br/>(2 fingers)
TOUCHED --> PAN: touchmove<br/>(1 finger)
PINCH: PINCH<br/>eventType = 'pinch'
PINCH --> RELEASED: touchend
PAN: PAN<br/>eventType = 'pan'
PAN --> RELEASED: touchend
RELEASED: RELEASED<br/>Check for double-tap
RELEASED --> DOUBLETAP: rapid tap detected
RELEASED --> IDLE: normal release
DOUBLETAP: DOUBLE TAP<br/>eventType = 'tap'
DOUBLETAP --> IDLE
classDef idleStyle fill:#58a6ff,stroke:#79c0ff,color:#fff
classDef touchedStyle fill:#d29922,stroke:#e3b341,color:#fff
classDef pinchStyle fill:#3fb950,stroke:#56d364,color:#fff
classDef panStyle fill:#db61a2,stroke:#f778ba,color:#fff
classDef releasedStyle fill:#f85149,stroke:#ff7b72,color:#fff
classDef tapStyle fill:#a371f7,stroke:#d29eff,color:#fff
class IDLE idleStyle
class TOUCHED touchedStyle
class PINCH pinchStyle
class PAN panStyle
class RELEASED releasedStyle
class DOUBLETAP tapStyle
Touch Distance Calculation
getTouchesDistance(touches: Touch[]): number {
if (touches.length < 2) {
return 0;
}
const touch1 = touches[0];
const touch2 = touches[1];
// Pythagorean theorem
const deltaX = touch1.clientX - touch2.clientX;
const deltaY = touch1.clientY - touch2.clientY;
const distance = Math.sqrt(
(deltaX * deltaX) + (deltaY * deltaY)
);
return distance;
}
Event System
Event Registration
// In IvyPinch constructor
this.touches = new Touches(/* ... */);
// Register handlers
this.touches.on('pinch', this.handlePinch as any);
this.touches.on('pan', this.handleLinearSwipe as any);
this.touches.on('tap', this.handleDoubleTap as any);
Event Emission
// In Touches class
emit(eventName: string, event: any) {
const handlers = this.handlers[eventName];
if (handlers) {
handlers.forEach(handler => {
handler(event);
});
}
}
Event Types
type TouchEvent =
| 'touchstart' // Finger touches screen
| 'touchmove' // Finger moves
| 'touchend' // Finger lifts
| 'mousedown' // Mouse button pressed
| 'mousemove' // Mouse moves
| 'mouseup' // Mouse button released
| 'wheel'; // Mouse wheel scrolled
type GestureEvent =
| 'pinch' // Two-finger zoom
| 'pan' // One-finger drag
| 'tap'; // Double-tap
Transform Mathematics
Fixed-Point Zooming
When zooming, we want to zoom "at" a specific point (like between your fingers). This requires adjusting the position.
The Problem
Without adjustment:
┌──────────┐
│ Image │ User pinches here ●
│ │
└──────────┘
↓ zoom
┌────────────────┐
│ │ Zoom point moved!
│ Image │ ●
│ │
└────────────────┘
With adjustment:
┌──────────┐
│ Image │ User pinches here ●
│ │
└──────────┘
↓ zoom
┌────────────────┐
│ │
│ Image ● │ Zoom point stayed!
│ │
└────────────────┘
The Solution
// Calculate new position to keep zoom center fixed
const newMoveX = this.moveX - (centerX - this.moveX) * (newScale / this.scale - 1);
const newMoveY = this.moveY - (centerY - this.moveY) * (newScale / this.scale - 1);
Mathematical Derivation
Let:
P= point to zoom at (in screen coordinates)S₁= old scaleS₂= new scaleT₁= old translation (moveX/moveY)T₂= new translation (what we're solving for)
We want: Point P maps to same position before and after zoom.
Before zoom: P_image = (P - T₁) / S₁
After zoom: P_image = (P - T₂) / S₂
Setting equal:
(P - T₁) / S₁ = (P - T₂) / S₂
Solving for T₂:
T₂ = P - S₂ * (P - T₁) / S₁
T₂ = P - (P - T₁) * (S₂ / S₁)
T₂ = T₁ + (P - T₁) * (1 - S₂ / S₁)
T₂ = T₁ - (P - T₁) * (S₂ / S₁ - 1)
This is the formula used in the code!
Transform Order
CSS transforms are applied right to left:
transform: translate(100px, 50px) scale(2);
Is equivalent to:
- Scale by 2
- Then translate by (100px, 50px)
Our transform:
transform: translate3d(moveX, moveY, 0) scale(scale);
- Apply scale
- Then translate
This means moveX and moveY are in scaled coordinates.
Transform Matrix
Under the hood, CSS converts our transform to a matrix:
transform: translate3d(100px, 50px, 0) scale(2);
Becomes:
| 2 0 0 100 |
| 0 2 0 50 |
| 0 0 1 0 |
| 0 0 0 1 |
The browser then multiplies this matrix by each pixel coordinate to get the final position.
Touch Gesture Detection
Pinch Detection
detectPinch(): boolean {
// Need exactly 2 touches
if (this.touches.length !== 2) {
return false;
}
// Set event type
this.eventType = 'pinch';
// Calculate and store distance
this.distance = this.getTouchesDistance(this.touches);
return true;
}
Pan Detection
detectLinearSwipe(): boolean {
// Need exactly 1 touch
if (this.touches.length !== 1) {
return false;
}
// Set event type
this.eventType = 'pan';
return true;
}
Double-Tap Detection
detectDoubleTap(): boolean {
// Must have touches
if (this.touches.length === 0) {
return false;
}
// Check if within time window
const now = Date.now();
const timeSinceLastTap = now - this.lastTapTime;
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
// It's a double-tap!
this.eventType = 'tap';
this.lastTapTime = 0; // Reset
return true;
}
// Record this tap
this.lastTapTime = now;
// Start timeout to reset
clearTimeout(this.doubleTapTimeout);
this.doubleTapTimeout = window.setTimeout(() => {
this.lastTapTime = 0;
}, 300);
return false;
}
Constraint System
Zoom Constraints
// Minimum scale (can't zoom out below this)
const minScale = this.properties.minScale || 0;
// Maximum scale
let maxScale: number;
if (this.properties.limitZoom === 'original image size') {
// Calculate based on image dimensions
const img = this.element.querySelector('img');
const naturalWidth = img?.naturalWidth || 0;
const displayWidth = img?.width || 0;
maxScale = naturalWidth / displayWidth;
} else {
maxScale = this.properties.limitZoom;
}
// Apply constraints
scale = Math.max(minScale, scale);
scale = Math.min(maxScale, scale);
Pan Constraints
if (this.properties.limitPan) {
const elementRect = this.element.getBoundingClientRect();
const parentRect = this.parentElement.getBoundingClientRect();
// Calculate maximum allowed movement
const maxMoveX = (elementRect.width * scale - parentRect.width) / 2;
const maxMoveY = (elementRect.height * scale - parentRect.height) / 2;
// Apply constraints
moveX = Math.max(-maxMoveX, Math.min(maxMoveX, moveX));
moveY = Math.max(-maxMoveY, Math.min(maxMoveY, moveY));
}
Lifecycle
Component Lifecycle
flowchart TD
A[Component Created]:::createStyle
B["constructor() runs<br/>• Inject dependencies<br/>• Set up effects"]:::initStyle
C["ngOnInit() runs<br/>• Create IvyPinch instance<br/>• Event listeners attached<br/>• Ready for interaction"]:::initStyle
D["User interacts<br/>• Pinch, pan, tap<br/>• Signals update<br/>• Events emit"]:::interactStyle
E["Input signal changes<br/>• mergedProperties recomputes<br/>• Effect runs<br/>• IvyPinch updated"]:::updateStyle
F[Component Destroyed]:::destroyStyle
G["ngOnDestroy() runs<br/>• Call pinchZoom.destroy()<br/>• Remove event listeners<br/>• Clear references"]:::cleanupStyle
H[Memory freed]:::endStyle
A --> B --> C --> D
D --> E
E --> D
D --> F
F --> G --> H
classDef createStyle fill:#58a6ff,stroke:#79c0ff,color:#fff
classDef initStyle fill:#3fb950,stroke:#56d364,color:#fff
classDef interactStyle fill:#d29922,stroke:#e3b341,color:#fff
classDef updateStyle fill:#a371f7,stroke:#d29eff,color:#fff
classDef destroyStyle fill:#f85149,stroke:#ff7b72,color:#fff
classDef cleanupStyle fill:#db61a2,stroke:#f778ba,color:#fff
classDef endStyle fill:#3fb950,stroke:#56d364,color:#fff
Event Listener Lifecycle
flowchart TD
A[IvyPinch created]:::createStyle
B[Touches instance created]:::instanceStyle
C["Event listeners registered<br/>• addEventListener('touchstart')<br/>• addEventListener('touchmove')<br/>• addEventListener('touchend')<br/>• (same for mouse events)"]:::registerStyle
D[Events fire and are handled]:::activeStyle
E[destroy called]:::destroyStyle
F["Listeners removed<br/>• removeEventListener('touchstart')<br/>• removeEventListener('touchmove')<br/>• etc."]:::cleanupStyle
G[No memory leaks!]:::successStyle
A --> B --> C --> D --> E --> F --> G
classDef createStyle fill:#58a6ff,stroke:#79c0ff,color:#fff
classDef instanceStyle fill:#3fb950,stroke:#56d364,color:#fff
classDef registerStyle fill:#d29922,stroke:#e3b341,color:#fff
classDef activeStyle fill:#a371f7,stroke:#d29eff,color:#fff
classDef destroyStyle fill:#f85149,stroke:#ff7b72,color:#fff
classDef cleanupStyle fill:#db61a2,stroke:#f778ba,color:#fff
classDef successStyle fill:#3fb950,stroke:#56d364,color:#fff
Summary
The library's architecture is based on three principles:
- Clear separation between Angular (PinchZoomComponent), logic (IvyPinch), and events (Touches)
- Signal-based reactivity for modern Angular performance
- Transform-based rendering for hardware-accelerated smoothness
This makes the code maintainable, testable, and performant.
For implementation examples, see IMPLEMENTATION_GUIDE.md. For quick lookups, see QUICK_REFERENCE.md.