diff --git a/src/screen-name/graph/AxisGestureHandler.ts b/src/screen-name/graph/AxisGestureHandler.ts new file mode 100644 index 0000000..ef0af54 --- /dev/null +++ b/src/screen-name/graph/AxisGestureHandler.ts @@ -0,0 +1,330 @@ +/** + * Handles per-axis gesture interactions for the configurable graph. + * + * Gestures supported (identical for both axes, transposed as needed): + * - Single-finger touch drag → pan that axis only + * - Two-finger touch pinch → zoom that axis only, centered on the pinch midpoint + * - Mouse drag on axis label → pan that axis only + * - Mouse wheel on axis label → zoom that axis only, centered on the pointer + * + * Sign convention for pan deltas: + * - Touch pan: negate the screen delta so content follows the finger on both axes. + * - Mouse drag Y: keep positive — screen Y is inverted from model Y so the signs cancel. + * - Mouse drag X: negate — screen X and model X share direction, so negation makes + * content follow the drag. + */ + +import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo"; +import { Range, Vector2 } from "scenerystack/dot"; +import { DragListener, type Pointer, type Rectangle } from "scenerystack/scenery"; +import trackLab from "../../TrackLabNamespace.js"; +import type GraphDataManager from "./GraphDataManager.js"; +import type { ChartConfig, GraphDimensions } from "./GraphInteractionHandler.js"; + +/** + * The two axis-interaction regions the handler needs access to. + */ +export interface AxisInteractionRegions { + xAxisInteractionRegion: Rectangle; + yAxisInteractionRegion: Rectangle; +} + +export default class AxisGestureHandler { + private readonly chartTransform: ChartTransform; + private readonly chartRectangle: ChartRectangle; + private readonly dataManager: GraphDataManager; + private readonly xAxisInteractionRegion: Rectangle; + private readonly yAxisInteractionRegion: Rectangle; + + private graphWidth: number; + private graphHeight: number; + + private readonly zoomFactor: number = 1.1; + + public constructor( + chartConfig: ChartConfig, + regions: AxisInteractionRegions, + dimensions: GraphDimensions, + ) { + this.chartTransform = chartConfig.chartTransform; + this.chartRectangle = chartConfig.chartRectangle; + this.dataManager = chartConfig.dataManager; + this.xAxisInteractionRegion = regions.xAxisInteractionRegion; + this.yAxisInteractionRegion = regions.yAxisInteractionRegion; + this.graphWidth = dimensions.width; + this.graphHeight = dimensions.height; + } + + /** + * Attach all axis-specific input listeners. + * Must be called once after construction. + */ + public initialize(): void { + this.setupAxisControls("y"); + this.setupAxisControls("x"); + } + + /** + * Update stored graph dimensions (called when the graph is resized). + */ + public updateDimensions(width: number, height: number): void { + this.graphWidth = width; + this.graphHeight = height; + } + + // ── Private setup ────────────────────────────────────────────────────────── + + private setupAxisControls(axis: "x" | "y"): void { + const isX = axis === "x"; + const region = isX + ? this.xAxisInteractionRegion + : this.yAxisInteractionRegion; + + // Read the current model range for this axis. + const getRange = (): Range => + isX + ? this.chartTransform.modelXRange + : this.chartTransform.modelYRange; + + // Apply a new range for this axis and update tick spacing. + const setRange = (range: Range): void => { + if (isX) { + this.chartTransform.setModelXRange(range); + this.dataManager.updateTickSpacing( + range, + this.chartTransform.modelYRange, + ); + } else { + this.chartTransform.setModelYRange(range); + this.dataManager.updateTickSpacing( + this.chartTransform.modelXRange, + range, + ); + } + }; + + // Extract the scalar coordinate relevant to this axis from a 2-D point. + const coord = (p: Vector2): number => (isX ? p.x : p.y); + + this.setupAxisTouchControls(region, isX, getRange, setRange, coord); + this.setupAxisMouseDrag(region, isX, getRange, setRange, coord); + this.setupAxisMouseWheel(region, isX, getRange, setRange, coord); + + region.pickable = true; + region.cursor = isX ? "ew-resize" : "ns-resize"; + } + + // ── Touch controls ───────────────────────────────────────────────────────── + + private setupAxisTouchControls( + region: Rectangle, + isX: boolean, + getRange: () => Range, + setRange: (range: Range) => void, + coord: (p: Vector2) => number, + ): void { + const activePointers = new Map(); + let initialPinchDistance: number | null = null; + let initialPinchMidpoint: number | null = null; + let initialRange: Range | null = null; + let singleTouchStart: number | null = null; + + region.addInputListener({ + down: (event) => { + if (event.pointer.type !== "touch") return; + const pt = event.pointer.point; + activePointers.set(event.pointer, pt); + + if (activePointers.size === 1) { + singleTouchStart = coord(pt); + initialRange = getRange().copy(); + this.dataManager.setManuallyZoomed(true); + } else if (activePointers.size === 2) { + const points = Array.from(activePointers.values()); + const p0 = points[0]; + const p1 = points[1]; + if (p0 && p1) { + initialPinchDistance = Math.abs(coord(p0) - coord(p1)); + initialPinchMidpoint = (coord(p0) + coord(p1)) / 2; + initialRange = getRange().copy(); + singleTouchStart = null; + this.dataManager.setManuallyZoomed(true); + } + } + }, + + move: (event) => { + if ( + event.pointer.type !== "touch" || + !activePointers.has(event.pointer) + ) + return; + const pt = event.pointer.point; + activePointers.set(event.pointer, pt); + + if ( + activePointers.size === 1 && + singleTouchStart !== null && + initialRange + ) { + // Single touch: pan. Negate so content follows the finger on both axes. + const axisSize = isX ? this.graphWidth : this.graphHeight; + const modelDelta = + -(coord(pt) - singleTouchStart) * + (initialRange.getLength() / axisSize); + setRange( + new Range( + initialRange.min + modelDelta, + initialRange.max + modelDelta, + ), + ); + } else if ( + activePointers.size === 2 && + initialPinchDistance && + initialPinchMidpoint !== null && + initialRange + ) { + // Two-finger pinch: zoom this axis only, centered on the pinch midpoint. + const points = Array.from(activePointers.values()); + const p0 = points[0]; + const p1 = points[1]; + if (!p0 || !p1) return; + + const zoomFactor = + initialPinchDistance / Math.abs(coord(p0) - coord(p1)); + + // Build a view midpoint at the pinch centre; use the graph centre on + // the perpendicular axis so the localToModel conversion is correct. + const viewMidpoint = isX + ? new Vector2(initialPinchMidpoint, this.graphHeight / 2) + : new Vector2(this.graphWidth / 2, initialPinchMidpoint); + const localMidpoint = + this.chartRectangle.globalToLocalPoint(viewMidpoint); + const modelPos = + this.chartTransform.viewToModelPosition(localMidpoint); + const modelCenter = isX ? modelPos.x : modelPos.y; + + setRange( + new Range( + modelCenter - (modelCenter - initialRange.min) * zoomFactor, + modelCenter + (initialRange.max - modelCenter) * zoomFactor, + ), + ); + } + }, + + up: (event) => { + if (event.pointer.type !== "touch") return; + activePointers.delete(event.pointer); + if (activePointers.size < 2) { + initialPinchDistance = null; + initialPinchMidpoint = null; + } + if (activePointers.size === 0) { + singleTouchStart = null; + initialRange = null; + } + }, + + cancel: (event) => { + if (event.pointer.type !== "touch") return; + activePointers.delete(event.pointer); + if (activePointers.size < 2) { + initialPinchDistance = null; + initialPinchMidpoint = null; + } + if (activePointers.size === 0) { + singleTouchStart = null; + initialRange = null; + } + }, + }); + } + + // ── Mouse drag ───────────────────────────────────────────────────────────── + + private setupAxisMouseDrag( + region: Rectangle, + isX: boolean, + getRange: () => Range, + setRange: (range: Range) => void, + coord: (p: Vector2) => number, + ): void { + let mouseDragStart: number | null = null; + let mouseDragInitialRange: Range | null = null; + + const dragListener = new DragListener({ + start: (event) => { + mouseDragStart = coord(event.pointer.point); + mouseDragInitialRange = getRange().copy(); + this.dataManager.setManuallyZoomed(true); + }, + + drag: (event) => { + if (mouseDragStart === null || !mouseDragInitialRange) return; + const axisSize = isX ? this.graphWidth : this.graphHeight; + const delta = coord(event.pointer.point) - mouseDragStart; + // X: negate (screen X and model X share direction; negation makes content follow drag). + // Y: keep positive (screen Y is inverted from model Y; signs cancel, content follows drag). + const modelDelta = + (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize); + setRange( + new Range( + mouseDragInitialRange.min + modelDelta, + mouseDragInitialRange.max + modelDelta, + ), + ); + }, + + end: () => { + mouseDragStart = null; + mouseDragInitialRange = null; + }, + }); + + region.addInputListener(dragListener); + } + + // ── Mouse wheel ──────────────────────────────────────────────────────────── + + private setupAxisMouseWheel( + region: Rectangle, + isX: boolean, + getRange: () => Range, + setRange: (range: Range) => void, + coord: (p: Vector2) => number, + ): void { + region.addInputListener({ + wheel: (event) => { + event.handle(); + const delta = event.domEvent?.deltaY ?? 0; + const mouseCoord = coord(event.pointer.point); + + // Place the zoom anchor at the pointer position on this axis, centred + // on the perpendicular axis so the localToModel lookup is accurate. + const viewMidpoint = isX + ? new Vector2(mouseCoord, this.graphHeight / 2) + : new Vector2(this.graphWidth / 2, mouseCoord); + const localMidpoint = + this.chartRectangle.globalToLocalPoint(viewMidpoint); + const modelPos = + this.chartTransform.viewToModelPosition(localMidpoint); + const modelCenter = isX ? modelPos.x : modelPos.y; + + const currentRange = getRange(); + const zoomFactor = + delta < 0 ? this.zoomFactor : 1 / this.zoomFactor; + + setRange( + new Range( + modelCenter - (modelCenter - currentRange.min) / zoomFactor, + modelCenter + (currentRange.max - modelCenter) / zoomFactor, + ), + ); + this.dataManager.setManuallyZoomed(true); + }, + }); + } +} + +trackLab.register("AxisGestureHandler", AxisGestureHandler); diff --git a/src/screen-name/graph/GraphInteractionHandler.ts b/src/screen-name/graph/GraphInteractionHandler.ts index 66751fc..41f0a22 100644 --- a/src/screen-name/graph/GraphInteractionHandler.ts +++ b/src/screen-name/graph/GraphInteractionHandler.ts @@ -1,10 +1,13 @@ /** - * Handles all user interactions for the configurable graph including: - * - Zoom controls (mouse wheel, touch pinch) - * - Pan controls (drag) - * - Touch controls for X/Y axes - * - Header drag functionality - * - Resize handles + * Coordinates all user interactions for the configurable graph by delegating + * to focused sub-handlers, each of which owns its own state: + * + * - ZoomGestureHandler — mouse-wheel zoom, pinch-to-zoom, double-click reset, + * zoomIn() / zoomOut() buttons + * - PanGestureHandler — drag-to-pan, pan() keyboard/button API + * - AxisGestureHandler — per-axis touch pan/pinch and mouse drag/wheel + * - HeaderDragHandler — header-bar drag to reposition the graph panel + * - ResizeGestureHandler — corner handles for resizing the graph panel */ import type { BooleanProperty } from "scenerystack/axon"; @@ -13,16 +16,17 @@ import type { ChartTransform, TickLabelSet, } from "scenerystack/bamboo"; -import { Range, Vector2 } from "scenerystack/dot"; import { - DragListener, type Node, - type Pointer, Rectangle, } from "scenerystack/scenery"; -import TrackLabColors from "../../TrackLabColors.js"; import trackLab from "../../TrackLabNamespace.js"; import type GraphDataManager from "./GraphDataManager.js"; +import AxisGestureHandler from "./AxisGestureHandler.js"; +import HeaderDragHandler from "./HeaderDragHandler.js"; +import PanGestureHandler from "./PanGestureHandler.js"; +import ResizeGestureHandler from "./ResizeGestureHandler.js"; +import ZoomGestureHandler from "./ZoomGestureHandler.js"; /** * Configuration for the chart and its data management @@ -64,43 +68,19 @@ export interface GraphDimensions { } /** - * Handles all pointer, touch, and keyboard interactions for a ConfigurableGraph. - * - * Responsibilities: - * - Mouse-wheel and pinch-to-zoom (preserving the zoom center point) - * - Two-finger and single-axis touch pan - * - Independent X-axis and Y-axis touch controls (tap the axis label to pan/scale that axis only) - * - Header-bar drag to reposition the graph panel - * - Corner resize handles - * - `zoomIn()` / `zoomOut()` / `pan()` methods called by keyboard shortcuts and toolbar buttons + * Thin coordinator that wires up all gesture sub-handlers for a ConfigurableGraph. * - * Manual zoom is tracked via `GraphDataManager.setManuallyZoomed()`. When set, auto-rescaling - * is suppressed so the user's zoom is preserved as new data arrives. + * The public API is identical to the previous monolithic implementation so that + * ConfigurableGraph requires no changes. * * Call `initialize()` once after construction to attach all input listeners. */ export default class GraphInteractionHandler { - private readonly chartTransform: ChartTransform; - private readonly chartRectangle: ChartRectangle; - private readonly dataManager: GraphDataManager; - private readonly zoomFactor: number = 1.1; // 10% zoom per wheel tick - - // For header drag - private readonly headerBar: Rectangle; - private readonly graphNode: Node; - private readonly dragTargetNode: Node; - private readonly isDraggingProperty: BooleanProperty; - - // For resize - private readonly resizeHandles: Rectangle[] = []; - private readonly isResizingProperty: BooleanProperty; - private readonly onResize: (width: number, height: number) => void; - - // For axis controls - private readonly xAxisInteractionRegion: Rectangle; - private readonly yAxisInteractionRegion: Rectangle; - private graphWidth: number; - private graphHeight: number; + private readonly zoomHandler: ZoomGestureHandler; + private readonly panHandler: PanGestureHandler; + private readonly axisHandler: AxisGestureHandler; + private readonly headerDragHandler: HeaderDragHandler; + private readonly resizeHandler: ResizeGestureHandler; /** * @param chartConfig - Chart transform, chart rectangle, and data manager @@ -116,814 +96,81 @@ export default class GraphInteractionHandler { dimensions: GraphDimensions, onResize: (width: number, height: number) => void, ) { - this.chartTransform = chartConfig.chartTransform; - this.chartRectangle = chartConfig.chartRectangle; - this.dataManager = chartConfig.dataManager; - this.headerBar = uiElements.headerBar; - this.graphNode = uiElements.graphNode; - this.dragTargetNode = uiElements.dragTargetNode ?? uiElements.graphNode; - this.isDraggingProperty = uiState.isDraggingProperty; - this.isResizingProperty = uiState.isResizingProperty; - this.xAxisInteractionRegion = uiElements.xAxisInteractionRegion; - this.yAxisInteractionRegion = uiElements.yAxisInteractionRegion; - this.graphWidth = dimensions.width; - this.graphHeight = dimensions.height; - this.onResize = onResize; - } - - /** - * Initialize all interaction handlers + this.zoomHandler = new ZoomGestureHandler(chartConfig, dimensions); + this.panHandler = new PanGestureHandler(chartConfig); + this.axisHandler = new AxisGestureHandler( + chartConfig, + { + xAxisInteractionRegion: uiElements.xAxisInteractionRegion, + yAxisInteractionRegion: uiElements.yAxisInteractionRegion, + }, + dimensions, + ); + this.headerDragHandler = new HeaderDragHandler( + { + headerBar: uiElements.headerBar, + graphNode: uiElements.graphNode, + dragTargetNode: uiElements.dragTargetNode, + }, + uiState.isDraggingProperty, + ); + this.resizeHandler = new ResizeGestureHandler( + uiElements.graphNode, + uiState.isResizingProperty, + dimensions, + onResize, + ); + } + + /** + * Initialize all interaction handlers. */ public initialize(): void { - this.setupZoomControls(); - this.setupPanControls(); - this.setupTouchZoomControls(); - this.setupAxisControls("y"); - this.setupAxisControls("x"); - this.setupHeaderDrag(); - } - - /** - * Setup non-intrusive zoom controls using mouse wheel and keyboard - */ - private setupZoomControls(): void { - // Mouse wheel zoom on the chart area - this.chartRectangle.addInputListener({ - wheel: (event) => { - event.handle(); - const delta = event.domEvent?.deltaY ?? 0; - - // Get mouse position relative to chart - const pointerPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - - // Zoom in or out - if (delta < 0) { - this.zoom(this.zoomFactor, pointerPoint); - } else { - this.zoom(1 / this.zoomFactor, pointerPoint); - } - }, - }); - - // Double-click to reset to auto-scale - this.chartRectangle.addInputListener({ - down: (event) => { - if (event.domEvent && event.domEvent.detail === 2) { - // Double click detected - event.handle(); - this.resetZoom(); - } - }, - }); - - // Make chart rectangle pickable so it can receive input - this.chartRectangle.pickable = true; + this.zoomHandler.initialize(); + this.panHandler.initialize(); + this.axisHandler.initialize(); + this.headerDragHandler.initialize(); } - /** - * Setup pan controls using drag - */ - private setupPanControls(): void { - let dragStartModelPoint: Vector2 | null = null; - let dragStartXRange: Range | null = null; - let dragStartYRange: Range | null = null; - - const dragListener = new DragListener({ - start: (event) => { - // Record the starting point in model coordinates - const viewPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - dragStartModelPoint = - this.chartTransform.viewToModelPosition(viewPoint); - dragStartXRange = this.chartTransform.modelXRange.copy(); - dragStartYRange = this.chartTransform.modelYRange.copy(); - - // Mark as manually zoomed so auto-scaling doesn't interfere - this.dataManager.setManuallyZoomed(true); - }, - - drag: (event) => { - if (dragStartModelPoint && dragStartXRange && dragStartYRange) { - // Get current point in model coordinates - const viewPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - const currentModelPoint = - this.chartTransform.viewToModelPosition(viewPoint); - - // Calculate the delta in model coordinates - const deltaX = dragStartModelPoint.x - currentModelPoint.x; - const deltaY = dragStartModelPoint.y - currentModelPoint.y; - - // Translate the ranges by the delta - const newXRange = new Range( - dragStartXRange.min + deltaX, - dragStartXRange.max + deltaX, - ); - const newYRange = new Range( - dragStartYRange.min + deltaY, - dragStartYRange.max + deltaY, - ); - - // Update the chart transform - this.chartTransform.setModelXRange(newXRange); - this.chartTransform.setModelYRange(newYRange); - - // Update tick spacing - this.dataManager.updateTickSpacing(newXRange, newYRange); - } - }, - - end: () => { - // Clean up - dragStartModelPoint = null; - dragStartXRange = null; - dragStartYRange = null; - }, - }); - - this.chartRectangle.addInputListener(dragListener); - this.chartRectangle.cursor = "move"; - } - - /** - * Setup touch-based pinch-to-zoom on the chart area - */ - private setupTouchZoomControls(): void { - // Track active touch pointers - const activePointers = new Map(); - let initialDistance: number | null = null; - let initialMidpoint: Vector2 | null = null; - let initialXRange: Range | null = null; - let initialYRange: Range | null = null; - - this.chartRectangle.addInputListener({ - down: (event) => { - // Only track touch events (not mouse) - if (event.pointer.type === "touch") { - const localPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - activePointers.set(event.pointer, localPoint); - - // If we now have exactly 2 touches, start pinch gesture - if (activePointers.size === 2) { - const points = Array.from(activePointers.values()); - const point0 = points[0]; - const point1 = points[1]; - if (point0 && point1) { - initialDistance = point0.distance(point1); - initialMidpoint = point0.average(point1); - initialXRange = this.chartTransform.modelXRange.copy(); - initialYRange = this.chartTransform.modelYRange.copy(); - this.dataManager.setManuallyZoomed(true); - } - } - } - }, - - move: (event) => { - // Only handle touch events - if ( - event.pointer.type === "touch" && - activePointers.has(event.pointer) - ) { - const localPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - activePointers.set(event.pointer, localPoint); - - // If we have exactly 2 touches, perform pinch zoom - if ( - activePointers.size === 2 && - initialDistance && - initialMidpoint && - initialXRange && - initialYRange - ) { - const points = Array.from(activePointers.values()); - const point0 = points[0]; - const point1 = points[1]; - if (!point0 || !point1) return; - const currentDistance = point0.distance(point1); - - // Calculate zoom factor from distance ratio - const zoomFactor = initialDistance / currentDistance; - - // Convert initial midpoint to model coordinates - const initialModelCenter = - this.chartTransform.viewToModelPosition(initialMidpoint); - - // Calculate new ranges centered on the initial midpoint - const xMin = - initialModelCenter.x - - (initialModelCenter.x - initialXRange.min) * zoomFactor; - const xMax = - initialModelCenter.x + - (initialXRange.max - initialModelCenter.x) * zoomFactor; - const yMin = - initialModelCenter.y - - (initialModelCenter.y - initialYRange.min) * zoomFactor; - const yMax = - initialModelCenter.y + - (initialYRange.max - initialModelCenter.y) * zoomFactor; - - // Apply the zoom - this.chartTransform.setModelXRange(new Range(xMin, xMax)); - this.chartTransform.setModelYRange(new Range(yMin, yMax)); - - // Update tick spacing - this.dataManager.updateTickSpacing( - this.chartTransform.modelXRange, - this.chartTransform.modelYRange, - ); - } - } - }, - - up: (event) => { - // Remove this pointer from tracking - if (event.pointer.type === "touch") { - activePointers.delete(event.pointer); - - // Reset pinch state if we no longer have 2 touches - if (activePointers.size < 2) { - initialDistance = null; - initialMidpoint = null; - initialXRange = null; - initialYRange = null; - } - } - }, - - cancel: (event) => { - // Handle cancelled touches (e.g., when gesture is interrupted) - if (event.pointer.type === "touch") { - activePointers.delete(event.pointer); - if (activePointers.size < 2) { - initialDistance = null; - initialMidpoint = null; - initialXRange = null; - initialYRange = null; - } - } - }, - }); - } + // ── Resize handle management ─────────────────────────────────────────────── /** - * Setup touch, mouse-drag, and mouse-wheel controls for one axis. - * - * Gestures supported (identical for both axes, just transposed): - * - Single-finger touch drag → pan the axis range - * - Two-finger touch pinch → zoom the axis range around the pinch midpoint - * - Mouse drag on axis label → pan the axis range - * - Mouse wheel on axis label → zoom the axis range around the pointer - * - * Sign convention for pan deltas - * - Touch pan: negate the screen delta so content follows the finger on both axes. - * - Mouse drag Y: keep positive — screen Y is inverted from model Y so the signs - * cancel and content still follows the drag. - * - Mouse drag X: negate — screen X and model X share the same direction, so - * negation is required to make content follow the drag. - */ - private setupAxisControls(axis: "x" | "y"): void { - const isX = axis === "x"; - const region = isX - ? this.xAxisInteractionRegion - : this.yAxisInteractionRegion; - - // Read the current model range for this axis. - const getRange = (): Range => - isX - ? this.chartTransform.modelXRange - : this.chartTransform.modelYRange; - - // Apply a new range for this axis and update tick spacing. - const setRange = (range: Range): void => { - if (isX) { - this.chartTransform.setModelXRange(range); - this.dataManager.updateTickSpacing( - range, - this.chartTransform.modelYRange, - ); - } else { - this.chartTransform.setModelYRange(range); - this.dataManager.updateTickSpacing( - this.chartTransform.modelXRange, - range, - ); - } - }; - - // Extract the scalar coordinate relevant to this axis from a 2-D point. - const coord = (p: Vector2): number => (isX ? p.x : p.y); - - // ── Touch controls ────────────────────────────────────────────────────── - const activePointers = new Map(); - let initialPinchDistance: number | null = null; - let initialPinchMidpoint: number | null = null; - let initialRange: Range | null = null; - let singleTouchStart: number | null = null; - - region.addInputListener({ - down: (event) => { - if (event.pointer.type !== "touch") return; - const pt = event.pointer.point; - activePointers.set(event.pointer, pt); - - if (activePointers.size === 1) { - singleTouchStart = coord(pt); - initialRange = getRange().copy(); - this.dataManager.setManuallyZoomed(true); - } else if (activePointers.size === 2) { - const points = Array.from(activePointers.values()); - const p0 = points[0]; - const p1 = points[1]; - if (p0 && p1) { - initialPinchDistance = Math.abs(coord(p0) - coord(p1)); - initialPinchMidpoint = (coord(p0) + coord(p1)) / 2; - initialRange = getRange().copy(); - singleTouchStart = null; - this.dataManager.setManuallyZoomed(true); - } - } - }, - - move: (event) => { - if ( - event.pointer.type !== "touch" || - !activePointers.has(event.pointer) - ) - return; - const pt = event.pointer.point; - activePointers.set(event.pointer, pt); - - if (activePointers.size === 1 && singleTouchStart !== null && initialRange) { - // Single touch: pan. Negate so content follows the finger on both axes. - const axisSize = isX ? this.graphWidth : this.graphHeight; - const modelDelta = - -(coord(pt) - singleTouchStart) * - (initialRange.getLength() / axisSize); - setRange( - new Range( - initialRange.min + modelDelta, - initialRange.max + modelDelta, - ), - ); - } else if ( - activePointers.size === 2 && - initialPinchDistance && - initialPinchMidpoint !== null && - initialRange - ) { - // Two-finger pinch: zoom this axis only, centered on the pinch midpoint. - const points = Array.from(activePointers.values()); - const p0 = points[0]; - const p1 = points[1]; - if (!p0 || !p1) return; - const zoomFactor = - initialPinchDistance / Math.abs(coord(p0) - coord(p1)); - - // Build a view midpoint at the pinch centre; use the graph centre on - // the perpendicular axis so the localToModel conversion is correct. - const viewMidpoint = isX - ? new Vector2(initialPinchMidpoint, this.graphHeight / 2) - : new Vector2(this.graphWidth / 2, initialPinchMidpoint); - const localMidpoint = - this.chartRectangle.globalToLocalPoint(viewMidpoint); - const modelPos = - this.chartTransform.viewToModelPosition(localMidpoint); - const modelCenter = isX ? modelPos.x : modelPos.y; - - setRange( - new Range( - modelCenter - (modelCenter - initialRange.min) * zoomFactor, - modelCenter + (initialRange.max - modelCenter) * zoomFactor, - ), - ); - } - }, - - up: (event) => { - if (event.pointer.type !== "touch") return; - activePointers.delete(event.pointer); - if (activePointers.size < 2) { - initialPinchDistance = null; - initialPinchMidpoint = null; - } - if (activePointers.size === 0) { - singleTouchStart = null; - initialRange = null; - } - }, - - cancel: (event) => { - if (event.pointer.type !== "touch") return; - activePointers.delete(event.pointer); - if (activePointers.size < 2) { - initialPinchDistance = null; - initialPinchMidpoint = null; - } - if (activePointers.size === 0) { - singleTouchStart = null; - initialRange = null; - } - }, - }); - - // ── Mouse drag ────────────────────────────────────────────────────────── - let mouseDragStart: number | null = null; - let mouseDragInitialRange: Range | null = null; - - const mouseDragListener = new DragListener({ - start: (event) => { - mouseDragStart = coord(event.pointer.point); - mouseDragInitialRange = getRange().copy(); - this.dataManager.setManuallyZoomed(true); - }, - - drag: (event) => { - if (mouseDragStart === null || !mouseDragInitialRange) return; - const axisSize = isX ? this.graphWidth : this.graphHeight; - const delta = coord(event.pointer.point) - mouseDragStart; - // X: negate (screen X and model X share direction; negation makes content follow drag). - // Y: keep positive (screen Y is inverted from model Y; signs cancel, content follows drag). - const modelDelta = - (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize); - setRange( - new Range( - mouseDragInitialRange.min + modelDelta, - mouseDragInitialRange.max + modelDelta, - ), - ); - }, - - end: () => { - mouseDragStart = null; - mouseDragInitialRange = null; - }, - }); - - region.addInputListener(mouseDragListener); - region.pickable = true; - region.cursor = isX ? "ew-resize" : "ns-resize"; - - // ── Mouse wheel ───────────────────────────────────────────────────────── - region.addInputListener({ - wheel: (event) => { - event.handle(); - const delta = event.domEvent?.deltaY ?? 0; - const mouseCoord = coord(event.pointer.point); - - // Place the zoom anchor at the pointer position on this axis, centred - // on the perpendicular axis so the localToModel lookup is accurate. - const viewMidpoint = isX - ? new Vector2(mouseCoord, this.graphHeight / 2) - : new Vector2(this.graphWidth / 2, mouseCoord); - const localMidpoint = - this.chartRectangle.globalToLocalPoint(viewMidpoint); - const modelPos = - this.chartTransform.viewToModelPosition(localMidpoint); - const modelCenter = isX ? modelPos.x : modelPos.y; - - const currentRange = getRange(); - const zoomFactor = delta < 0 ? this.zoomFactor : 1 / this.zoomFactor; - - setRange( - new Range( - modelCenter - (modelCenter - currentRange.min) / zoomFactor, - modelCenter + (currentRange.max - modelCenter) / zoomFactor, - ), - ); - this.dataManager.setManuallyZoomed(true); - }, - }); - } - - /** - * Setup drag functionality for the header bar to move the entire graph - */ - private setupHeaderDrag(): void { - let dragStartPosition: Vector2 | null = null; - let dragStartPointerPoint: Vector2 | null = null; - - const dragListener = new DragListener({ - start: (event) => { - // Record the starting position of the drag target and pointer - dragStartPosition = new Vector2( - this.dragTargetNode.x, - this.dragTargetNode.y, - ); - dragStartPointerPoint = event.pointer.point.copy(); - this.isDraggingProperty.value = true; - }, - - drag: (event) => { - if (dragStartPosition && dragStartPointerPoint) { - // Move the drag target node - const delta = event.pointer.point.minus(dragStartPointerPoint); - this.dragTargetNode.x = dragStartPosition.x + delta.x; - this.dragTargetNode.y = dragStartPosition.y + delta.y; - } - }, - - end: () => { - dragStartPosition = null; - dragStartPointerPoint = null; - this.isDraggingProperty.value = false; - }, - }); - - this.headerBar.addInputListener(dragListener); - } - - /** - * Create and return resize handles for the graph corners + * Create and return resize handles for the graph corners. + * The caller is responsible for adding them to the scene graph. */ public createResizeHandles(): Rectangle[] { - const handleSize = 12; - const handleOffset = -6; // Center the handle on the corner - - // Define corner positions and cursors - const corners = [ - { x: 0, y: 0, cursor: "nwse-resize" }, // Top-left - { x: this.graphWidth, y: 0, cursor: "nesw-resize" }, // Top-right - { x: 0, y: this.graphHeight, cursor: "nesw-resize" }, // Bottom-left - { x: this.graphWidth, y: this.graphHeight, cursor: "nwse-resize" }, // Bottom-right - ]; - - corners.forEach((corner, index) => { - const handle = new Rectangle( - corner.x + handleOffset, - corner.y + handleOffset, - handleSize, - handleSize, - 2, - 2, - { - fill: TrackLabColors.controlPanelFillProperty, - stroke: TrackLabColors.controlPanelStrokeProperty, - lineWidth: 2, - cursor: corner.cursor, - }, - ); - - this.resizeHandles.push(handle); - this.setupResizeHandleDrag(handle, index); - }); - - return this.resizeHandles; + return this.resizeHandler.createResizeHandles(); } /** - * Setup drag listener for a resize handle - */ - private setupResizeHandleDrag(handle: Rectangle, cornerIndex: number): void { - let dragStartGraphBounds: { - width: number; - height: number; - x: number; - y: number; - } | null = null; - let dragStartPointerPoint: Vector2 | null = null; - - const dragListener = new DragListener({ - start: (event) => { - dragStartGraphBounds = { - width: this.graphWidth, - height: this.graphHeight, - x: this.graphNode.x, - y: this.graphNode.y, - }; - dragStartPointerPoint = event.pointer.point.copy(); - this.isResizingProperty.value = true; - }, - - drag: (event) => { - if (!dragStartGraphBounds || !dragStartPointerPoint) return; - - const delta = event.pointer.point.minus(dragStartPointerPoint); - let newWidth = dragStartGraphBounds.width; - let newHeight = dragStartGraphBounds.height; - let deltaX = 0; - let deltaY = 0; - - // Minimum graph size - const minWidth = 200; - const minHeight = 150; - - // Handle different corners - switch (cornerIndex) { - case 0: // Top-left - newWidth = Math.max(minWidth, dragStartGraphBounds.width - delta.x); - newHeight = Math.max( - minHeight, - dragStartGraphBounds.height - delta.y, - ); - deltaX = dragStartGraphBounds.width - newWidth; - deltaY = dragStartGraphBounds.height - newHeight; - break; - case 1: // Top-right - newWidth = Math.max(minWidth, dragStartGraphBounds.width + delta.x); - newHeight = Math.max( - minHeight, - dragStartGraphBounds.height - delta.y, - ); - deltaY = dragStartGraphBounds.height - newHeight; - break; - case 2: // Bottom-left - newWidth = Math.max(minWidth, dragStartGraphBounds.width - delta.x); - newHeight = Math.max( - minHeight, - dragStartGraphBounds.height + delta.y, - ); - deltaX = dragStartGraphBounds.width - newWidth; - break; - case 3: // Bottom-right - newWidth = Math.max(minWidth, dragStartGraphBounds.width + delta.x); - newHeight = Math.max( - minHeight, - dragStartGraphBounds.height + delta.y, - ); - break; - } - - // Call the resize callback with new dimensions - this.onResize(newWidth, newHeight); - - // Update position if needed (for top-left and bottom-left corners) - if (deltaX !== 0 || deltaY !== 0) { - this.graphNode.x = dragStartGraphBounds.x + deltaX; - this.graphNode.y = dragStartGraphBounds.y + deltaY; - } - }, - - end: () => { - dragStartGraphBounds = null; - dragStartPointerPoint = null; - this.isResizingProperty.value = false; - }, - }); - - handle.addInputListener(dragListener); - } - - /** - * Update graph dimensions (called when graph is resized) + * Update all sub-handlers when the graph is resized. */ public updateDimensions(width: number, height: number): void { - this.graphWidth = width; - this.graphHeight = height; + this.zoomHandler.updateDimensions(width, height); + this.axisHandler.updateDimensions(width, height); + this.resizeHandler.updateDimensions(width, height); } /** - * Update resize handle positions after a resize + * Reposition the corner resize handles to match the current graph size. */ public updateResizeHandlePositions(): void { - const handleOffset = -6; - const corners = [ - { x: 0, y: 0 }, - { x: this.graphWidth, y: 0 }, - { x: 0, y: this.graphHeight }, - { x: this.graphWidth, y: this.graphHeight }, - ]; - - this.resizeHandles.forEach((handle, index) => { - const corner = corners[index]; - if (corner) { - handle.setRect( - corner.x + handleOffset, - corner.y + handleOffset, - 12, - 12, - ); - } - }); + this.resizeHandler.updateResizeHandlePositions(); } - /** - * Zoom the graph by a given factor, centered on a point - * @param factor - Zoom factor (>1 zooms in, <1 zooms out) - * @param centerPoint - Point to zoom around (in view coordinates) - * @param setManualFlag - Whether to set the manual zoom flag (default: true) - */ - private zoom( - factor: number, - centerPoint: Vector2, - setManualFlag: boolean = true, - ): void { - if (setManualFlag) { - this.dataManager.setManuallyZoomed(true); - } - - const currentXRange = this.chartTransform.modelXRange; - const currentYRange = this.chartTransform.modelYRange; - - // Convert center point from view to model coordinates - const modelCenter = this.chartTransform.viewToModelPosition(centerPoint); + // ── Programmatic zoom / pan (toolbar buttons, keyboard shortcuts) ────────── - // Calculate new ranges centered on the mouse position - const xMin = modelCenter.x - (modelCenter.x - currentXRange.min) / factor; - const xMax = modelCenter.x + (currentXRange.max - modelCenter.x) / factor; - const yMin = modelCenter.y - (modelCenter.y - currentYRange.min) / factor; - const yMax = modelCenter.y + (currentYRange.max - modelCenter.y) / factor; - - const newXRange = new Range(xMin, xMax); - const newYRange = new Range(yMin, yMax); - - // Update chart transform - this.chartTransform.setModelXRange(newXRange); - this.chartTransform.setModelYRange(newYRange); - - // Update tick spacing - this.dataManager.updateTickSpacing(newXRange, newYRange); - } - - /** - * Reset zoom to auto-scale mode - */ - private resetZoom(): void { - this.dataManager.setManuallyZoomed(false); - - // Recalculate axis ranges based on current data - if (this.dataManager.getDataPointCount() > 1) { - this.dataManager.updateAxisRanges(); - } - } - - /** - * Zoom in centered on the graph - * Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data - */ public zoomIn(): void { - // Zoom centered on the middle of the chart; set manual flag so auto-rescale won't override. - const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2); - this.zoom(this.zoomFactor, centerPoint, true); + this.zoomHandler.zoomIn(); } - /** - * Zoom out centered on the graph - */ public zoomOut(): void { - // Zoom out centered on the middle of the chart; set manual flag so auto-rescale won't override. - const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2); - this.zoom(1 / this.zoomFactor, centerPoint, true); + this.zoomHandler.zoomOut(); } - /** - * Pan the graph in a given direction by 10% of the current range - * Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data - */ public pan(direction: "left" | "right" | "up" | "down"): void { - const currentXRange = this.chartTransform.modelXRange; - const currentYRange = this.chartTransform.modelYRange; - - // Pan by 10% of the current range - const xDelta = (currentXRange.max - currentXRange.min) * 0.1; - const yDelta = (currentYRange.max - currentYRange.min) * 0.1; - - let newXRange = currentXRange; - let newYRange = currentYRange; - - switch (direction) { - case "left": - newXRange = new Range( - currentXRange.min - xDelta, - currentXRange.max - xDelta, - ); - break; - case "right": - newXRange = new Range( - currentXRange.min + xDelta, - currentXRange.max + xDelta, - ); - break; - case "up": - newYRange = new Range( - currentYRange.min + yDelta, - currentYRange.max + yDelta, - ); - break; - case "down": - newYRange = new Range( - currentYRange.min - yDelta, - currentYRange.max - yDelta, - ); - break; - } - - // Update the chart transform - this.chartTransform.setModelXRange(newXRange); - this.chartTransform.setModelYRange(newYRange); - - // Update tick spacing - this.dataManager.updateTickSpacing(newXRange, newYRange); + this.panHandler.pan(direction); } } -// Register with namespace for debugging accessibility trackLab.register("GraphInteractionHandler", GraphInteractionHandler); diff --git a/src/screen-name/graph/HeaderDragHandler.ts b/src/screen-name/graph/HeaderDragHandler.ts new file mode 100644 index 0000000..afddbe5 --- /dev/null +++ b/src/screen-name/graph/HeaderDragHandler.ts @@ -0,0 +1,76 @@ +/** + * Handles drag-to-reposition for the graph panel's header bar. + * + * Dragging the header bar moves the graph's drag-target node (typically the + * top-level graph node) and toggles `isDraggingProperty` for visual feedback. + */ + +import type { BooleanProperty } from "scenerystack/axon"; +import { Vector2 } from "scenerystack/dot"; +import { DragListener, type Node, type Rectangle } from "scenerystack/scenery"; +import trackLab from "../../TrackLabNamespace.js"; + +export interface HeaderDragElements { + headerBar: Rectangle; + graphNode: Node; + /** If provided, this node is moved instead of graphNode. */ + dragTargetNode?: Node; +} + +export default class HeaderDragHandler { + private readonly headerBar: Rectangle; + private readonly dragTargetNode: Node; + private readonly isDraggingProperty: BooleanProperty; + + public constructor( + elements: HeaderDragElements, + isDraggingProperty: BooleanProperty, + ) { + this.headerBar = elements.headerBar; + this.dragTargetNode = elements.dragTargetNode ?? elements.graphNode; + this.isDraggingProperty = isDraggingProperty; + } + + /** + * Attach the header-drag listener. + * Must be called once after construction. + */ + public initialize(): void { + this.setupHeaderDrag(); + } + + // ── Private setup ────────────────────────────────────────────────────────── + + private setupHeaderDrag(): void { + let dragStartPosition: Vector2 | null = null; + let dragStartPointerPoint: Vector2 | null = null; + + const dragListener = new DragListener({ + start: (event) => { + dragStartPosition = new Vector2( + this.dragTargetNode.x, + this.dragTargetNode.y, + ); + dragStartPointerPoint = event.pointer.point.copy(); + this.isDraggingProperty.value = true; + }, + + drag: (event) => { + if (!dragStartPosition || !dragStartPointerPoint) return; + const delta = event.pointer.point.minus(dragStartPointerPoint); + this.dragTargetNode.x = dragStartPosition.x + delta.x; + this.dragTargetNode.y = dragStartPosition.y + delta.y; + }, + + end: () => { + dragStartPosition = null; + dragStartPointerPoint = null; + this.isDraggingProperty.value = false; + }, + }); + + this.headerBar.addInputListener(dragListener); + } +} + +trackLab.register("HeaderDragHandler", HeaderDragHandler); diff --git a/src/screen-name/graph/PanGestureHandler.ts b/src/screen-name/graph/PanGestureHandler.ts new file mode 100644 index 0000000..e0fdb83 --- /dev/null +++ b/src/screen-name/graph/PanGestureHandler.ts @@ -0,0 +1,140 @@ +/** + * Handles pan interactions for the configurable graph: + * - Mouse/touch drag on the chart area to pan both axes simultaneously + * - Programmatic pan() (used by toolbar buttons and keyboard shortcuts) + */ + +import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo"; +import { Range, Vector2 } from "scenerystack/dot"; +import { DragListener } from "scenerystack/scenery"; +import trackLab from "../../TrackLabNamespace.js"; +import type GraphDataManager from "./GraphDataManager.js"; +import type { ChartConfig } from "./GraphInteractionHandler.js"; + +export default class PanGestureHandler { + private readonly chartTransform: ChartTransform; + private readonly chartRectangle: ChartRectangle; + private readonly dataManager: GraphDataManager; + + public constructor(chartConfig: ChartConfig) { + this.chartTransform = chartConfig.chartTransform; + this.chartRectangle = chartConfig.chartRectangle; + this.dataManager = chartConfig.dataManager; + } + + /** + * Attach the drag-to-pan listener to the chart rectangle. + * Must be called once after construction. + */ + public initialize(): void { + this.setupDragPan(); + this.chartRectangle.cursor = "move"; + } + + // ── Public programmatic API ──────────────────────────────────────────────── + + /** + * Pan the chart by 10% of the current range in the given direction. + */ + public pan(direction: "left" | "right" | "up" | "down"): void { + const currentXRange = this.chartTransform.modelXRange; + const currentYRange = this.chartTransform.modelYRange; + + const xDelta = currentXRange.getLength() * 0.1; + const yDelta = currentYRange.getLength() * 0.1; + + let newXRange = currentXRange; + let newYRange = currentYRange; + + switch (direction) { + case "left": + newXRange = new Range( + currentXRange.min - xDelta, + currentXRange.max - xDelta, + ); + break; + case "right": + newXRange = new Range( + currentXRange.min + xDelta, + currentXRange.max + xDelta, + ); + break; + case "up": + newYRange = new Range( + currentYRange.min + yDelta, + currentYRange.max + yDelta, + ); + break; + case "down": + newYRange = new Range( + currentYRange.min - yDelta, + currentYRange.max - yDelta, + ); + break; + } + + this.chartTransform.setModelXRange(newXRange); + this.chartTransform.setModelYRange(newYRange); + this.dataManager.updateTickSpacing(newXRange, newYRange); + } + + // ── Private setup helpers ────────────────────────────────────────────────── + + private setupDragPan(): void { + let dragStartModelPoint: Vector2 | null = null; + let dragStartXRange: Range | null = null; + let dragStartYRange: Range | null = null; + + const dragListener = new DragListener({ + start: (event) => { + const viewPoint = this.chartRectangle.globalToLocalPoint( + event.pointer.point, + ); + dragStartModelPoint = + this.chartTransform.viewToModelPosition(viewPoint); + dragStartXRange = this.chartTransform.modelXRange.copy(); + dragStartYRange = this.chartTransform.modelYRange.copy(); + + // Suppress auto-scaling while the user is panning + this.dataManager.setManuallyZoomed(true); + }, + + drag: (event) => { + if (!dragStartModelPoint || !dragStartXRange || !dragStartYRange) + return; + + const viewPoint = this.chartRectangle.globalToLocalPoint( + event.pointer.point, + ); + const currentModelPoint = + this.chartTransform.viewToModelPosition(viewPoint); + + const deltaX = dragStartModelPoint.x - currentModelPoint.x; + const deltaY = dragStartModelPoint.y - currentModelPoint.y; + + const newXRange = new Range( + dragStartXRange.min + deltaX, + dragStartXRange.max + deltaX, + ); + const newYRange = new Range( + dragStartYRange.min + deltaY, + dragStartYRange.max + deltaY, + ); + + this.chartTransform.setModelXRange(newXRange); + this.chartTransform.setModelYRange(newYRange); + this.dataManager.updateTickSpacing(newXRange, newYRange); + }, + + end: () => { + dragStartModelPoint = null; + dragStartXRange = null; + dragStartYRange = null; + }, + }); + + this.chartRectangle.addInputListener(dragListener); + } +} + +trackLab.register("PanGestureHandler", PanGestureHandler); diff --git a/src/screen-name/graph/ResizeGestureHandler.ts b/src/screen-name/graph/ResizeGestureHandler.ts new file mode 100644 index 0000000..08e4487 --- /dev/null +++ b/src/screen-name/graph/ResizeGestureHandler.ts @@ -0,0 +1,202 @@ +/** + * Manages corner resize handles for the graph panel. + * + * Responsibilities: + * - Create the four corner Rectangle handles via `createResizeHandles()` + * - Attach drag listeners that enforce minimum graph dimensions and invoke + * the `onResize` callback with the new (width, height) + * - Reposition handles after a resize via `updateResizeHandlePositions()` + */ + +import type { BooleanProperty } from "scenerystack/axon"; +import { Vector2 } from "scenerystack/dot"; +import { DragListener, type Node, Rectangle } from "scenerystack/scenery"; +import TrackLabColors from "../../TrackLabColors.js"; +import trackLab from "../../TrackLabNamespace.js"; +import type { GraphDimensions } from "./GraphInteractionHandler.js"; + +const HANDLE_SIZE = 12; +const HANDLE_OFFSET = -6; // centers the 12px handle on each corner +const MIN_WIDTH = 200; +const MIN_HEIGHT = 150; + +export default class ResizeGestureHandler { + private readonly graphNode: Node; + private readonly isResizingProperty: BooleanProperty; + private readonly onResize: (width: number, height: number) => void; + private readonly handles: Rectangle[] = []; + + private graphWidth: number; + private graphHeight: number; + + public constructor( + graphNode: Node, + isResizingProperty: BooleanProperty, + dimensions: GraphDimensions, + onResize: (width: number, height: number) => void, + ) { + this.graphNode = graphNode; + this.isResizingProperty = isResizingProperty; + this.graphWidth = dimensions.width; + this.graphHeight = dimensions.height; + this.onResize = onResize; + } + + /** + * Update stored graph dimensions (called when the graph is resized). + */ + public updateDimensions(width: number, height: number): void { + this.graphWidth = width; + this.graphHeight = height; + } + + // ── Public API ───────────────────────────────────────────────────────────── + + /** + * Create and return the four corner resize handles. + * Each handle already has a drag listener attached. + * The caller is responsible for adding the handles to the scene graph. + */ + public createResizeHandles(): Rectangle[] { + const corners: Array<{ x: number; y: number; cursor: string }> = [ + { x: 0, y: 0, cursor: "nwse-resize" }, + { x: this.graphWidth, y: 0, cursor: "nesw-resize" }, + { x: 0, y: this.graphHeight, cursor: "nesw-resize" }, + { x: this.graphWidth, y: this.graphHeight, cursor: "nwse-resize" }, + ]; + + corners.forEach((corner, index) => { + const handle = new Rectangle( + corner.x + HANDLE_OFFSET, + corner.y + HANDLE_OFFSET, + HANDLE_SIZE, + HANDLE_SIZE, + 2, + 2, + { + fill: TrackLabColors.controlPanelFillProperty, + stroke: TrackLabColors.controlPanelStrokeProperty, + lineWidth: 2, + cursor: corner.cursor, + }, + ); + + this.handles.push(handle); + this.attachDragListener(handle, index); + }); + + return this.handles; + } + + /** + * Reposition all four corner handles to match current graph dimensions. + * Call this after every resize. + */ + public updateResizeHandlePositions(): void { + const corners = [ + { x: 0, y: 0 }, + { x: this.graphWidth, y: 0 }, + { x: 0, y: this.graphHeight }, + { x: this.graphWidth, y: this.graphHeight }, + ]; + + this.handles.forEach((handle, index) => { + const corner = corners[index]; + if (corner) { + handle.setRect( + corner.x + HANDLE_OFFSET, + corner.y + HANDLE_OFFSET, + HANDLE_SIZE, + HANDLE_SIZE, + ); + } + }); + } + + // ── Private setup ────────────────────────────────────────────────────────── + + private attachDragListener(handle: Rectangle, cornerIndex: number): void { + let dragStartGraphBounds: { + width: number; + height: number; + x: number; + y: number; + } | null = null; + let dragStartPointerPoint: Vector2 | null = null; + + const dragListener = new DragListener({ + start: (event) => { + dragStartGraphBounds = { + width: this.graphWidth, + height: this.graphHeight, + x: this.graphNode.x, + y: this.graphNode.y, + }; + dragStartPointerPoint = event.pointer.point.copy(); + this.isResizingProperty.value = true; + }, + + drag: (event) => { + if (!dragStartGraphBounds || !dragStartPointerPoint) return; + + const delta = event.pointer.point.minus(dragStartPointerPoint); + let newWidth = dragStartGraphBounds.width; + let newHeight = dragStartGraphBounds.height; + let deltaX = 0; + let deltaY = 0; + + switch (cornerIndex) { + case 0: // Top-left + newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width - delta.x); + newHeight = Math.max( + MIN_HEIGHT, + dragStartGraphBounds.height - delta.y, + ); + deltaX = dragStartGraphBounds.width - newWidth; + deltaY = dragStartGraphBounds.height - newHeight; + break; + case 1: // Top-right + newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width + delta.x); + newHeight = Math.max( + MIN_HEIGHT, + dragStartGraphBounds.height - delta.y, + ); + deltaY = dragStartGraphBounds.height - newHeight; + break; + case 2: // Bottom-left + newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width - delta.x); + newHeight = Math.max( + MIN_HEIGHT, + dragStartGraphBounds.height + delta.y, + ); + deltaX = dragStartGraphBounds.width - newWidth; + break; + case 3: // Bottom-right + newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width + delta.x); + newHeight = Math.max( + MIN_HEIGHT, + dragStartGraphBounds.height + delta.y, + ); + break; + } + + this.onResize(newWidth, newHeight); + + if (deltaX !== 0 || deltaY !== 0) { + this.graphNode.x = dragStartGraphBounds.x + deltaX; + this.graphNode.y = dragStartGraphBounds.y + deltaY; + } + }, + + end: () => { + dragStartGraphBounds = null; + dragStartPointerPoint = null; + this.isResizingProperty.value = false; + }, + }); + + handle.addInputListener(dragListener); + } +} + +trackLab.register("ResizeGestureHandler", ResizeGestureHandler); diff --git a/src/screen-name/graph/ZoomGestureHandler.ts b/src/screen-name/graph/ZoomGestureHandler.ts new file mode 100644 index 0000000..64d64e7 --- /dev/null +++ b/src/screen-name/graph/ZoomGestureHandler.ts @@ -0,0 +1,250 @@ +/** + * Handles zoom interactions for the configurable graph: + * - Mouse-wheel zoom (preserving the pointer as the zoom center) + * - Pinch-to-zoom on touch devices (preserving the pinch midpoint) + * - Double-click to reset to auto-scale + * - Programmatic zoomIn() / zoomOut() (used by toolbar buttons and keyboard shortcuts) + */ + +import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo"; +import { Range, Vector2 } from "scenerystack/dot"; +import type { Pointer } from "scenerystack/scenery"; +import trackLab from "../../TrackLabNamespace.js"; +import type GraphDataManager from "./GraphDataManager.js"; +import type { ChartConfig, GraphDimensions } from "./GraphInteractionHandler.js"; + +export default class ZoomGestureHandler { + private readonly chartTransform: ChartTransform; + private readonly chartRectangle: ChartRectangle; + private readonly dataManager: GraphDataManager; + private readonly zoomFactor: number = 1.1; // 10% zoom per wheel tick + + private graphWidth: number; + private graphHeight: number; + + public constructor(chartConfig: ChartConfig, dimensions: GraphDimensions) { + this.chartTransform = chartConfig.chartTransform; + this.chartRectangle = chartConfig.chartRectangle; + this.dataManager = chartConfig.dataManager; + this.graphWidth = dimensions.width; + this.graphHeight = dimensions.height; + } + + /** + * Attach all zoom-related input listeners to the chart rectangle. + * Must be called once after construction. + */ + public initialize(): void { + this.setupWheelZoom(); + this.setupDoubleClickReset(); + this.setupPinchZoom(); + + // Make chart rectangle pickable so it can receive input + this.chartRectangle.pickable = true; + } + + /** + * Update stored graph dimensions (called when the graph is resized). + */ + public updateDimensions(width: number, height: number): void { + this.graphWidth = width; + this.graphHeight = height; + } + + // ── Public programmatic API ──────────────────────────────────────────────── + + /** + * Zoom in by one step, centered on the graph midpoint. + */ + public zoomIn(): void { + const center = new Vector2(this.graphWidth / 2, this.graphHeight / 2); + this.zoom(this.zoomFactor, center, true); + } + + /** + * Zoom out by one step, centered on the graph midpoint. + */ + public zoomOut(): void { + const center = new Vector2(this.graphWidth / 2, this.graphHeight / 2); + this.zoom(1 / this.zoomFactor, center, true); + } + + // ── Private setup helpers ────────────────────────────────────────────────── + + private setupWheelZoom(): void { + this.chartRectangle.addInputListener({ + wheel: (event) => { + event.handle(); + const delta = event.domEvent?.deltaY ?? 0; + const pointerPoint = this.chartRectangle.globalToLocalPoint( + event.pointer.point, + ); + + if (delta < 0) { + this.zoom(this.zoomFactor, pointerPoint); + } else { + this.zoom(1 / this.zoomFactor, pointerPoint); + } + }, + }); + } + + private setupDoubleClickReset(): void { + this.chartRectangle.addInputListener({ + down: (event) => { + if (event.domEvent && event.domEvent.detail === 2) { + event.handle(); + this.resetZoom(); + } + }, + }); + } + + private setupPinchZoom(): void { + const activePointers = new Map(); + let initialDistance: number | null = null; + let initialMidpoint: Vector2 | null = null; + let initialXRange: Range | null = null; + let initialYRange: Range | null = null; + + this.chartRectangle.addInputListener({ + down: (event) => { + if (event.pointer.type !== "touch") return; + const localPoint = this.chartRectangle.globalToLocalPoint( + event.pointer.point, + ); + activePointers.set(event.pointer, localPoint); + + if (activePointers.size === 2) { + const points = Array.from(activePointers.values()); + const point0 = points[0]; + const point1 = points[1]; + if (point0 && point1) { + initialDistance = point0.distance(point1); + initialMidpoint = point0.average(point1); + initialXRange = this.chartTransform.modelXRange.copy(); + initialYRange = this.chartTransform.modelYRange.copy(); + this.dataManager.setManuallyZoomed(true); + } + } + }, + + move: (event) => { + if ( + event.pointer.type !== "touch" || + !activePointers.has(event.pointer) + ) + return; + + const localPoint = this.chartRectangle.globalToLocalPoint( + event.pointer.point, + ); + activePointers.set(event.pointer, localPoint); + + if ( + activePointers.size === 2 && + initialDistance && + initialMidpoint && + initialXRange && + initialYRange + ) { + const points = Array.from(activePointers.values()); + const point0 = points[0]; + const point1 = points[1]; + if (!point0 || !point1) return; + + const currentDistance = point0.distance(point1); + const zoomFactor = initialDistance / currentDistance; + const initialModelCenter = + this.chartTransform.viewToModelPosition(initialMidpoint); + + const xMin = + initialModelCenter.x - + (initialModelCenter.x - initialXRange.min) * zoomFactor; + const xMax = + initialModelCenter.x + + (initialXRange.max - initialModelCenter.x) * zoomFactor; + const yMin = + initialModelCenter.y - + (initialModelCenter.y - initialYRange.min) * zoomFactor; + const yMax = + initialModelCenter.y + + (initialYRange.max - initialModelCenter.y) * zoomFactor; + + this.chartTransform.setModelXRange(new Range(xMin, xMax)); + this.chartTransform.setModelYRange(new Range(yMin, yMax)); + this.dataManager.updateTickSpacing( + this.chartTransform.modelXRange, + this.chartTransform.modelYRange, + ); + } + }, + + up: (event) => { + if (event.pointer.type !== "touch") return; + activePointers.delete(event.pointer); + if (activePointers.size < 2) { + initialDistance = null; + initialMidpoint = null; + initialXRange = null; + initialYRange = null; + } + }, + + cancel: (event) => { + if (event.pointer.type !== "touch") return; + activePointers.delete(event.pointer); + if (activePointers.size < 2) { + initialDistance = null; + initialMidpoint = null; + initialXRange = null; + initialYRange = null; + } + }, + }); + } + + // ── Core zoom primitive ──────────────────────────────────────────────────── + + /** + * Zoom the chart by `factor`, keeping `centerPoint` (view coordinates) fixed. + * + * @param factor - >1 zooms in, <1 zooms out + * @param centerPoint - Zoom anchor in local view coordinates + * @param setManualFlag - When true, suppresses subsequent auto-rescaling + */ + public zoom( + factor: number, + centerPoint: Vector2, + setManualFlag: boolean = true, + ): void { + if (setManualFlag) { + this.dataManager.setManuallyZoomed(true); + } + + const currentXRange = this.chartTransform.modelXRange; + const currentYRange = this.chartTransform.modelYRange; + const modelCenter = this.chartTransform.viewToModelPosition(centerPoint); + + const xMin = modelCenter.x - (modelCenter.x - currentXRange.min) / factor; + const xMax = modelCenter.x + (currentXRange.max - modelCenter.x) / factor; + const yMin = modelCenter.y - (modelCenter.y - currentYRange.min) / factor; + const yMax = modelCenter.y + (currentYRange.max - modelCenter.y) / factor; + + const newXRange = new Range(xMin, xMax); + const newYRange = new Range(yMin, yMax); + + this.chartTransform.setModelXRange(newXRange); + this.chartTransform.setModelYRange(newYRange); + this.dataManager.updateTickSpacing(newXRange, newYRange); + } + + private resetZoom(): void { + this.dataManager.setManuallyZoomed(false); + if (this.dataManager.getDataPointCount() > 1) { + this.dataManager.updateAxisRanges(); + } + } +} + +trackLab.register("ZoomGestureHandler", ZoomGestureHandler);