# presentation/components/stage.component.ts

> 131 lines of code and 30 definitions.

Tree: Site tree
Language: typescript
Layer: product
Canonical: https://banes-lab.com/anatomy/tree#file-presentation-components-stage-component-ts
Source text: https://banes-lab.com/assets/sources/source.4af7f324015bf7a8ad2cff5a82c6467cd9358f8000b361cacdf94d4f6b61807a.generated.txt

## Definitions

- `apply` (method_definition, line 48, exported)
- `reset` (method_definition, line 55, exported)
- `zoomAtCenter` (method_definition, line 68, exported)
- `constructor` (method_definition, line 40, exported)
- `zoomBy` (method_definition, line 64, exported)
- `pairDistance` (method_definition, line 133, exported)
- `pinch` (method_definition, line 138, exported)
- `PRIMARY_BUTTON` (lexical_declaration, line 28)
- `PanelState` (class_declaration, line 30, exported)
- `view` (public_field_definition, line 31, exported)
- `element` (public_field_definition, line 32, exported)
- `stage` (public_field_definition, line 33, exported)
- `zoom` (public_field_definition, line 34, exported)
- `mode` (public_field_definition, line 35, exported)
- `pointers` (public_field_definition, line 36, exported)
- `pinchDistance` (public_field_definition, line 37, exported)
- `armed` (public_field_definition, line 38, exported)
- `ceiling` (lexical_declaration, line 60, exported)
- `reveal` (method_definition, line 72, exported)
- `press` (method_definition, line 76, exported)
- `move` (method_definition, line 87, exported)
- `previous` (lexical_declaration, line 88, exported)
- `current` (lexical_declaration, line 92, exported)
- `release` (method_definition, line 102, exported)
- `wheel` (method_definition, line 110, exported)
- `local` (method_definition, line 123, exported)
- `rect` (lexical_declaration, line 124, exported)
- `[first, second]` (lexical_declaration, line 129, exported)
- `pair` (lexical_declaration, line 139, exported)
- `now` (lexical_declaration, line 143, exported)

## Uses

- [core/analyzers/panel.analyzer.ts](https://banes-lab.com/source/tree/core/analyzers/panel.analyzer.ts.md)
- [core/observers/panel.observer.ts](https://banes-lab.com/source/tree/core/observers/panel.observer.ts.md)
- [core/registries/style.registry.ts](https://banes-lab.com/source/tree/core/registries/style.registry.ts.md)

## Source

```typescript
import {
    MOUSE_POINTER,
    PANEL_DRAGGING_CLASS,
    PANEL_MAX_SCALE,
    PANEL_POINTER_PAIR,
    PANEL_REVEAL_MARGIN_PX,
    PANEL_SCALE_PROPERTY,
    PANEL_WHEEL_ZOOM_RATE,
    PANEL_X_PROPERTY,
    PANEL_Y_PROPERTY,
} from "#configuration/constants/panel.constants";
import {
    ORIGIN_VIEW,
    boundedView,
    centerOf,
    contentExtentOf,
    distanceBetween,
    extentOf,
    fittedView,
    midpointOf,
    zoomedView,
} from "#core/analyzers/panel.analyzer";
import type { PanelMode, PanelView, PanelPoint as Point } from "#types/panel.types";
import { PIXEL } from "#configuration/constants/element.constants";
import { declareStyle } from "#core/registries/style.registry";
import { observeArming } from "#core/observers/panel.observer";

const PRIMARY_BUTTON = 0;

export class PanelState {
    public view: PanelView = ORIGIN_VIEW;
    private readonly element: HTMLElement;
    private readonly stage: HTMLElement;
    private readonly zoom: Element;
    private readonly mode: PanelMode;
    private readonly pointers = new Map<number, Point>();
    private pinchDistance = 0;
    private readonly armed: () => boolean;

    public constructor(element: HTMLElement, stage: HTMLElement, mode: PanelMode) {
        this.element = element;
        this.stage = stage;
        this.zoom = stage.firstElementChild ?? stage;
        this.mode = mode;
        this.armed = observeArming(element);
    }

    public apply(next: PanelView): void {
        this.view = boundedView(next, extentOf(this.element), contentExtentOf(this.zoom, this.view.scale));
        declareStyle(this.stage, PANEL_X_PROPERTY, String(this.view.x) + PIXEL);
        declareStyle(this.stage, PANEL_Y_PROPERTY, String(this.view.y) + PIXEL);
        declareStyle(this.stage, PANEL_SCALE_PROPERTY, String(this.view.scale));
    }

    public reset(): void {
        if (this.mode === "origin") {
            this.apply(ORIGIN_VIEW);
            return;
        }
        const ceiling = this.mode === "fill" ? PANEL_MAX_SCALE : 1;
        this.apply(fittedView(extentOf(this.element), contentExtentOf(this.zoom, this.view.scale), ceiling));
    }

    public zoomBy(factor: number, anchor: Point): void {
        this.apply(zoomedView(this.view, factor, anchor));
    }

    public zoomAtCenter(factor: number): void {
        this.zoomBy(factor, centerOf(extentOf(this.element)));
    }

    public reveal(top: number): void {
        this.apply({ ...this.view, y: PANEL_REVEAL_MARGIN_PX - top * this.view.scale });
    }

    public press(event: PointerEvent): void {
        if (event.pointerType === MOUSE_POINTER && (event.button !== PRIMARY_BUTTON || !this.armed())) {
            return;
        }
        this.pointers.set(event.pointerId, this.local(event));
        this.element.setPointerCapture(event.pointerId);
        this.element.classList.add(PANEL_DRAGGING_CLASS);
        this.pinchDistance = this.pairDistance();
        event.preventDefault();
    }

    public move(event: PointerEvent): void {
        const previous = this.pointers.get(event.pointerId);
        if (previous === undefined) {
            return;
        }
        const current = this.local(event);
        this.pointers.set(event.pointerId, current);
        event.preventDefault();
        if (this.pointers.size >= PANEL_POINTER_PAIR) {
            this.pinch();
            return;
        }
        this.apply({ ...this.view, x: this.view.x + current.x - previous.x, y: this.view.y + current.y - previous.y });
    }

    public release(event: PointerEvent): void {
        this.pointers.delete(event.pointerId);
        this.pinchDistance = this.pairDistance();
        if (this.pointers.size === 0) {
            this.element.classList.remove(PANEL_DRAGGING_CLASS);
        }
    }

    public wheel(event: WheelEvent): void {
        if (!this.armed()) {
            return;
        }
        if (event.ctrlKey || event.metaKey) {
            this.zoomBy(Math.exp(-event.deltaY * PANEL_WHEEL_ZOOM_RATE), this.local(event));
            event.preventDefault();
            return;
        }
        this.apply({ ...this.view, x: this.view.x - event.deltaX, y: this.view.y - event.deltaY });
        event.preventDefault();
    }

    private local(event: MouseEvent): Point {
        const rect = this.element.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
    }

    private pair(): [Point, Point] | null {
        const [first, second] = [...this.pointers.values()];
        return first !== undefined && second !== undefined ? [first, second] : null;
    }

    private pairDistance(): number {
        const pair = this.pair();
        return pair === null ? 0 : distanceBetween(pair[0], pair[1]);
    }

    private pinch(): void {
        const pair = this.pair();
        if (pair === null || this.pinchDistance === 0) {
            return;
        }
        const now = distanceBetween(pair[0], pair[1]);
        this.zoomBy(now / this.pinchDistance, midpointOf(pair[0], pair[1]));
        this.pinchDistance = now;
    }
}
```
