// cropViewport.js — pure viewport/crop math for the crop UI (plan Step 4) // // Viewport state: { scale, ox, oy } where (ox, oy) is the SOURCE-pixel // coordinate at the viewport's top-left corner and scale is screen px per // source px. All functions are pure and unit-testable; the React component // just wires pointer/wheel events through them. import { assertSliceCount } from './geometry'; // Clamp a scale into [minScale, maxScale]. export function clampScale(scale, minScale, maxScale) { return Math.min(Math.max(scale, minScale), maxScale); } // Scale that fits the whole image in the viewport (never below 1px/px... no: // fit can legitimately be < 1 for large images; guard only against 0 dims). export function fitScale(viewW, viewH, imgW, imgH) { if (!viewW || !viewH || !imgW || !imgH) return 1; return Math.min(viewW / imgW, viewH / imgH); } // Clamp the viewport offset so the viewport never shows empty space beyond // the image: when the scaled image is smaller than the viewport it is // centered; otherwise the offset stays within [0, scaled - view]. export function clampOffset(ox, oy, scale, viewW, viewH, imgW, imgH) { const scaledW = imgW * scale; const scaledH = imgH * scale; let cx = ox; let cy = oy; if (scaledW <= viewW) { cx = -(viewW - scaledW) / (2 * scale); } else { cx = Math.min(Math.max(ox, 0), (scaledW - viewW) / scale); } if (scaledH <= viewH) { cy = -(viewH - scaledH) / (2 * scale); } else { cy = Math.min(Math.max(oy, 0), (scaledH - viewH) / scale); } return { ox: cx, oy: cy }; } // Initial viewport: fit the image, centered. export function initialViewport(viewW, viewH, imgW, imgH) { const scale = fitScale(viewW, viewH, imgW, imgH); return { scale, ...clampOffset(0, 0, scale, viewW, viewH, imgW, imgH) }; } // Zoom about a screen-space point (cx, cy), keeping the source pixel under // the cursor fixed. Returns a clamped { scale, ox, oy }. export function zoomAt(viewport, cx, cy, factor, minScale, maxScale, viewW, viewH, imgW, imgH) { const newScale = clampScale(viewport.scale * factor, minScale, maxScale); const srcX = viewport.ox + cx / viewport.scale; const srcY = viewport.oy + cy / viewport.scale; const ox = srcX - cx / newScale; const oy = srcY - cy / newScale; return { scale: newScale, ...clampOffset(ox, oy, newScale, viewW, viewH, imgW, imgH) }; } // Pan by a screen-space delta. Positive dx/dy = content follows the pointer. export function panBy(viewport, dx, dy, viewW, viewH, imgW, imgH) { return { scale: viewport.scale, ...clampOffset( viewport.ox - dx / viewport.scale, viewport.oy - dy / viewport.scale, viewport.scale, viewW, viewH, imgW, imgH ), }; } // Screen (px, py) -> source pixel. Returns { x, y } in source pixels. export function screenToSource(viewport, px, py) { return { x: viewport.ox + px / viewport.scale, y: viewport.oy + py / viewport.scale, }; } // Clamp a crop rect so it stays fully inside the source (plan Step 4 // acceptance: "crop cannot be dragged outside source bounds"). export function clampCropRect(crop, imgW, imgH) { const maxX = Math.max(0, imgW - crop.width); const maxY = Math.max(0, imgH - crop.height); return { ...crop, x: Math.min(Math.max(crop.x, 0), maxX), y: Math.min(Math.max(crop.y, 0), maxY), }; } // Source-space x of the N-1 vertical slice boundary lines inside a crop. export function getSliceGridLines(crop, n) { assertSliceCount(n); const lines = []; for (let i = 1; i < n; i++) { lines.push(crop.x + (crop.width * i) / n); } return lines; } // Convert a source-space rect to screen-space using the viewport. export function sourceRectToScreen(rect, viewport) { const x = (rect.x - viewport.ox) * viewport.scale; const y = (rect.y - viewport.oy) * viewport.scale; return { x, y, width: rect.width * viewport.scale, height: rect.height * viewport.scale, }; }