import type { Container, FederatedPointerEvent, FederatedWheelEvent, Graphics } from 'pixi.js'; import { clamp, drawGrid, zoomFactor } from '../../ui'; import type { Point } from '../framing'; import { ViewTween } from '../viewTween'; /** * What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid * is drawn and how the wheel zooms about the pointer. The graph editor and a plugin's node board * look and move the same because they both come through here; the Tile tab's board borrows the * panning and zooming. */ /** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */ export const GRID_STEP = 24; export const ZOOM_LIMITS = { min: 0.125, max: 2.5 }; /** Breathing room around the framed nodes, so they do not touch the edge. */ export const FIT_MARGIN = 24; /** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ const MIN_GRID_STEP = 8; export interface GraphView { /** Where the graph origin sits on screen. */ pan: Point; /** Screen pixels per graph unit. */ zoom: number; } /** * The view after zooming by `factor` about a screen point, which keeps the graph point under it. * `limits` are the graph's own unless a surface with other content says otherwise – the tile * board shows 48 px tiles, which one wants to come closer to than a node graph. */ export function zoomedAbout( view: GraphView, at: Point, factor: number, limits: { min: number; max: number } = ZOOM_LIMITS, ): GraphView { const zoom = clamp(view.zoom * factor, limits.min, limits.max); const graphX = (at.x - view.pan.x) / view.zoom; const graphY = (at.y - view.pan.y) / view.zoom; return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom }; } /** * How the wheel zooms: smoothly within limits, or by a rule of the surface's own – the Tile tab * takes the scene view's steps. A rule returns null while the wheel has not made a step yet. */ export type WheelZoom = { min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null); /** The transition (#309) of a board whose view is a `GraphView`. */ export function graphViewTween( get: () => GraphView, put: (view: GraphView) => void, draw: () => void, ): ViewTween { return new ViewTween({ read: () => ({ scale: get().zoom, x: get().pan.x, y: get().pan.y }), write: (map) => put({ pan: { x: map.x, y: map.y }, zoom: map.scale }), draw, }); } /** * Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer * when its content takes none. `set` receives each new view and which gesture made it; `tween` * shows the wheel's views as a transition (#309). */ export function panAndZoom( surface: Container, get: () => GraphView, set: (view: GraphView, gesture: 'pan' | 'zoom') => void, zoom: WheelZoom = ZOOM_LIMITS, tween: ViewTween, ): void { let panning: { start: Point; pan: Point } | null = null; surface.on('pointerdown', (e: FederatedPointerEvent) => { tween.target(() => { panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } }; }); }); surface.on('globalpointermove', (e: FederatedPointerEvent) => { if (!panning) return; const { start, pan } = panning; tween.carry(() => set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'), ); }); surface.on('pointerup', () => (panning = null)); surface.on('pointerupoutside', () => (panning = null)); surface.on('wheel', (e: FederatedWheelEvent) => { e.preventDefault(); const at = surface.toLocal(e.global); tween.change(() => { const view = typeof zoom === 'function' ? zoom(get(), at, e.deltaY) : zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom); if (view) set(view, 'zoom'); }); }); } /** What a node draws beyond its box: the shadow, the pins on its edges and the ring around a drop target. */ export const NODE_OVERHANG = 12; /** * Whether any of a box, with what hangs over its edges, is inside a view of `w` by `h` screen pixels. * A graph is mostly looked at in parts, and a node outside the picture costs as much to draw as one * inside it (#292). */ export function inView( box: { x: number; y: number; w: number; h: number }, view: GraphView, w: number, h: number, overhang = NODE_OVERHANG, ): boolean { const left = (0 - view.pan.x) / view.zoom - overhang; const top = (0 - view.pan.y) / view.zoom - overhang; const right = (w - view.pan.x) / view.zoom + overhang; const bottom = (h - view.pan.y) / view.zoom + overhang; return box.x < right && box.x + box.w > left && box.y < bottom && box.y + box.h > top; } export function drawGraphGrid(g: Graphics, view: GraphView, w: number, h: number): void { g.clear(); const step = GRID_STEP * view.zoom; if (step < MIN_GRID_STEP) return; // `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing. drawGrid(g, { originX: view.pan.x, originY: view.pan.y, step, w, h, alpha: 0.04 }); }