{"version":3,"file":"geometry.mjs","names":[],"sources":["../../../src/Image/viewer/geometry.ts"],"sourcesContent":["export interface Size {\n  height: number;\n  width: number;\n}\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport interface Rect {\n  height: number;\n  width: number;\n  x: number;\n  y: number;\n}\n\nexport type Rotation = 0 | 90 | 180 | 270;\n\nexport type ZoomPolicy = 'auto' | 'actual' | 'fit';\n\nexport interface TransformState {\n  scale: number;\n  x: number;\n  y: number;\n}\n\nexport interface AxisBounds {\n  max: number;\n  min: number;\n}\n\nexport const VIEWPORT_MARGIN = 24;\nexport const MIN_SCALE = 1;\nexport const DEFAULT_MAX_SCALE = 8;\nexport const DEFAULT_RUBBER_BAND_FACTOR = 0.15;\nexport const WHEEL_ZOOM_SENSITIVITY = 0.002;\nexport const DEFAULT_AUTO_ZOOM_THRESHOLD = 2;\nexport const WHEEL_LINE_HEIGHT = 16;\n\nexport const normalizeRotation = (degrees: number): Rotation =>\n  (((degrees % 360) + 360) % 360) as Rotation;\n\nconst effectiveSize = (natural: Size, rotate: Rotation): Size =>\n  rotate === 90 || rotate === 270 ? { height: natural.width, width: natural.height } : natural;\n\nexport const computeFit = (\n  natural: Size,\n  viewport: Size,\n  rotate: Rotation,\n  fillViewport = false,\n): Rect => {\n  const effective = effectiveSize(natural, rotate);\n  const availableWidth = Math.max(viewport.width - VIEWPORT_MARGIN * 2, 0);\n  const availableHeight = Math.max(viewport.height - VIEWPORT_MARGIN * 2, 0);\n  // fillViewport drops the no-upscale cap: a dual-source thumbnail is a\n  // stand-in for the hi-res image, so it must open at the viewport fit the\n  // hi-res will land in — otherwise the swap re-fits mid-open.\n  const containScale = Math.min(\n    availableWidth / effective.width,\n    availableHeight / effective.height,\n    ...(fillViewport ? [] : [1]),\n  );\n  const width = effective.width * containScale;\n  const height = effective.height * containScale;\n\n  return {\n    height,\n    width,\n    x: (viewport.width - width) / 2,\n    y: (viewport.height - height) / 2,\n  };\n};\n\n// computeFit returns the box the image occupies *after* the CSS rotation, but\n// the element it is applied to is sized before that rotation runs. For quarter\n// turns the two disagree, so the element box has to be the transpose about the\n// same center — otherwise `object-fit: contain` letterboxes the image down to\n// the short side and everything downstream (pan bounds, natural scale) is\n// measuring a box the viewer never actually paints.\nexport const unrotatedRect = (fit: Rect, rotate: Rotation): Rect => {\n  if (rotate !== 90 && rotate !== 270) return fit;\n\n  const centerX = fit.x + fit.width / 2;\n  const centerY = fit.y + fit.height / 2;\n\n  return {\n    height: fit.width,\n    width: fit.height,\n    x: centerX - fit.height / 2,\n    y: centerY - fit.width / 2,\n  };\n};\n\nexport const anchoredZoom = (\n  current: TransformState,\n  targetScale: number,\n  anchor: Point,\n  fitRect: Rect,\n): TransformState => {\n  const centerX = fitRect.x + fitRect.width / 2;\n  const centerY = fitRect.y + fitRect.height / 2;\n  const ratio = targetScale / current.scale;\n\n  return {\n    scale: targetScale,\n    x: anchor.x - centerX - ratio * (anchor.x - centerX - current.x),\n    y: anchor.y - centerY - ratio * (anchor.y - centerY - current.y),\n  };\n};\n\nexport const panBounds = (\n  state: TransformState,\n  fitRect: Rect,\n  viewport: Size,\n): { x: AxisBounds; y: AxisBounds } => {\n  const centerX = fitRect.x + fitRect.width / 2;\n  const centerY = fitRect.y + fitRect.height / 2;\n  const scaledWidth = fitRect.width * state.scale;\n  const scaledHeight = fitRect.height * state.scale;\n\n  const axis = (center: number, scaledSize: number, viewportSize: number): AxisBounds => {\n    const overflow = scaledSize - viewportSize;\n    if (overflow <= 0) return { max: 0, min: 0 };\n    return { max: scaledSize / 2 - center, min: viewportSize - center - scaledSize / 2 };\n  };\n\n  return {\n    x: axis(centerX, scaledWidth, viewport.width),\n    y: axis(centerY, scaledHeight, viewport.height),\n  };\n};\n\nexport const clampPan = (state: TransformState, fitRect: Rect, viewport: Size): Point => {\n  const bounds = panBounds(state, fitRect, viewport);\n  return {\n    x: Math.min(Math.max(state.x, bounds.x.min), bounds.x.max),\n    y: Math.min(Math.max(state.y, bounds.y.min), bounds.y.max),\n  };\n};\n\nexport const rubberBand = (\n  value: number,\n  min: number,\n  max: number,\n  factor: number = DEFAULT_RUBBER_BAND_FACTOR,\n): number => {\n  if (value < min) return min - (min - value) * factor;\n  if (value > max) return max + (value - max) * factor;\n  return value;\n};\n\nexport const clampScale = (scale: number, maxScale: number = DEFAULT_MAX_SCALE): number =>\n  Math.min(Math.max(scale, MIN_SCALE), maxScale);\n\nexport const wheelZoomFactor = (deltaY: number): number =>\n  Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY);\n\n// Firefox on Windows/Linux reports wheel notches as DOM_DELTA_LINE with a\n// deltaY around 3, where Chrome/Safari report ~100 pixels for the same notch.\n// Feeding those raw into wheelZoomFactor is a ~30x under-zoom (0.6% per notch,\n// ~115 notches to double) and an equally dead scroll-to-close accumulator.\nexport const normalizeWheelDelta = (deltaY: number, deltaMode = 0, viewportHeight = 0): number => {\n  if (deltaMode === 1) return deltaY * WHEEL_LINE_HEIGHT;\n  if (deltaMode === 2) return deltaY * (viewportHeight || WHEEL_LINE_HEIGHT * 40);\n  return deltaY;\n};\n\nexport const naturalScale = (natural: Size, fitRect: Rect, rotate: Rotation): number =>\n  effectiveSize(natural, rotate).width / fitRect.width;\n\n// The scale the viewer opens at, expressed like every other scale here:\n// relative to the fit rect, so MIN_SCALE is always still fit. Opening above\n// MIN_SCALE is what makes a screenshot legible on open and — because the image\n// then overflows the viewport — what gives panBounds something to work with.\nexport const resolveInitialScale = (\n  policy: ZoomPolicy,\n  natural: Size,\n  fitRect: Rect,\n  rotate: Rotation,\n  threshold: number = DEFAULT_AUTO_ZOOM_THRESHOLD,\n): number => {\n  if (policy === 'fit') return MIN_SCALE;\n\n  const target = naturalScale(natural, fitRect, rotate);\n  if (!Number.isFinite(target) || target <= 0) return MIN_SCALE;\n  if (policy === 'auto' && target > threshold) return MIN_SCALE;\n\n  // fillViewport drops computeFit's no-upscale cap, so the fit rect can be\n  // larger than the image and target can land below the zoom floor.\n  return Math.max(MIN_SCALE, target);\n};\n\nexport const doubleClickTarget = (\n  currentScale: number,\n  natural: Size,\n  fitRect: Rect,\n  rotate: Rotation,\n): number => {\n  if (currentScale > 1) return MIN_SCALE;\n  return Math.max(2, naturalScale(natural, fitRect, rotate));\n};\n"],"mappings":"AAmCA,MAAa,6BAA6B;AAC1C,MAAa,yBAAyB;AAItC,MAAa,qBAAqB,aAC7B,UAAU,MAAO,OAAO;AAE7B,MAAM,iBAAiB,SAAe,WACpC,WAAW,MAAM,WAAW,MAAM;CAAE,QAAQ,QAAQ;CAAO,OAAO,QAAQ;AAAO,IAAI;AAEvF,MAAa,cACX,SACA,UACA,QACA,eAAe,UACN;CACT,MAAM,YAAY,cAAc,SAAS,MAAM;CAC/C,MAAM,iBAAiB,KAAK,IAAI,SAAS,QAAQ,IAAqB,CAAC;CACvE,MAAM,kBAAkB,KAAK,IAAI,SAAS,SAAS,IAAqB,CAAC;CAIzE,MAAM,eAAe,KAAK,IACxB,iBAAiB,UAAU,OAC3B,kBAAkB,UAAU,QAC5B,GAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAC5B;CACA,MAAM,QAAQ,UAAU,QAAQ;CAChC,MAAM,SAAS,UAAU,SAAS;CAElC,OAAO;EACL;EACA;EACA,IAAI,SAAS,QAAQ,SAAS;EAC9B,IAAI,SAAS,SAAS,UAAU;CAClC;AACF;AAQA,MAAa,iBAAiB,KAAW,WAA2B;CAClE,IAAI,WAAW,MAAM,WAAW,KAAK,OAAO;CAE5C,MAAM,UAAU,IAAI,IAAI,IAAI,QAAQ;CACpC,MAAM,UAAU,IAAI,IAAI,IAAI,SAAS;CAErC,OAAO;EACL,QAAQ,IAAI;EACZ,OAAO,IAAI;EACX,GAAG,UAAU,IAAI,SAAS;EAC1B,GAAG,UAAU,IAAI,QAAQ;CAC3B;AACF;AAEA,MAAa,gBACX,SACA,aACA,QACA,YACmB;CACnB,MAAM,UAAU,QAAQ,IAAI,QAAQ,QAAQ;CAC5C,MAAM,UAAU,QAAQ,IAAI,QAAQ,SAAS;CAC7C,MAAM,QAAQ,cAAc,QAAQ;CAEpC,OAAO;EACL,OAAO;EACP,GAAG,OAAO,IAAI,UAAU,SAAS,OAAO,IAAI,UAAU,QAAQ;EAC9D,GAAG,OAAO,IAAI,UAAU,SAAS,OAAO,IAAI,UAAU,QAAQ;CAChE;AACF;AAEA,MAAa,aACX,OACA,SACA,aACqC;CACrC,MAAM,UAAU,QAAQ,IAAI,QAAQ,QAAQ;CAC5C,MAAM,UAAU,QAAQ,IAAI,QAAQ,SAAS;CAC7C,MAAM,cAAc,QAAQ,QAAQ,MAAM;CAC1C,MAAM,eAAe,QAAQ,SAAS,MAAM;CAE5C,MAAM,QAAQ,QAAgB,YAAoB,iBAAqC;EAErF,IADiB,aAAa,gBACd,GAAG,OAAO;GAAE,KAAK;GAAG,KAAK;EAAE;EAC3C,OAAO;GAAE,KAAK,aAAa,IAAI;GAAQ,KAAK,eAAe,SAAS,aAAa;EAAE;CACrF;CAEA,OAAO;EACL,GAAG,KAAK,SAAS,aAAa,SAAS,KAAK;EAC5C,GAAG,KAAK,SAAS,cAAc,SAAS,MAAM;CAChD;AACF;AAEA,MAAa,YAAY,OAAuB,SAAe,aAA0B;CACvF,MAAM,SAAS,UAAU,OAAO,SAAS,QAAQ;CACjD,OAAO;EACL,GAAG,KAAK,IAAI,KAAK,IAAI,MAAM,GAAG,OAAO,EAAE,GAAG,GAAG,OAAO,EAAE,GAAG;EACzD,GAAG,KAAK,IAAI,KAAK,IAAI,MAAM,GAAG,OAAO,EAAE,GAAG,GAAG,OAAO,EAAE,GAAG;CAC3D;AACF;AAEA,MAAa,cACX,OACA,KACA,KACA,SAAiB,+BACN;CACX,IAAI,QAAQ,KAAK,OAAO,OAAO,MAAM,SAAS;CAC9C,IAAI,QAAQ,KAAK,OAAO,OAAO,QAAQ,OAAO;CAC9C,OAAO;AACT;AAEA,MAAa,cAAc,OAAe,WAAA,MACxC,KAAK,IAAI,KAAK,IAAI,OAAA,CAAgB,GAAG,QAAQ;AAE/C,MAAa,mBAAmB,WAC9B,KAAK,IAAI,CAAC,SAAS,sBAAsB;AAM3C,MAAa,uBAAuB,QAAgB,YAAY,GAAG,iBAAiB,MAAc;CAChG,IAAI,cAAc,GAAG,OAAO,SAAA;CAC5B,IAAI,cAAc,GAAG,OAAO,UAAU,kBAAkB;CACxD,OAAO;AACT;AAEA,MAAa,gBAAgB,SAAe,SAAe,WACzD,cAAc,SAAS,MAAM,CAAC,CAAC,QAAQ,QAAQ;AAMjD,MAAa,uBACX,QACA,SACA,SACA,QACA,YAAA,MACW;CACX,IAAI,WAAW,OAAO,OAAA;CAEtB,MAAM,SAAS,aAAa,SAAS,SAAS,MAAM;CACpD,IAAI,CAAC,OAAO,SAAS,MAAM,KAAK,UAAU,GAAG,OAAA;CAC7C,IAAI,WAAW,UAAU,SAAS,WAAW,OAAA;CAI7C,OAAO,KAAK,IAAA,GAAe,MAAM;AACnC;AAEA,MAAa,qBACX,cACA,SACA,SACA,WACW;CACX,IAAI,eAAe,GAAG,OAAA;CACtB,OAAO,KAAK,IAAI,GAAG,aAAa,SAAS,SAAS,MAAM,CAAC;AAC3D"}