Skip to main content

The Formula Book

Unlike the lessons before it, this page is the chapter's reference sheet — every formula between a pointer pixel and a PTZ payload, laid out for lookup. You've already felt most of these numbers in the overlay's demos; come here when you need the exact arithmetic, and see PTZ Pixel Math for how the pipeline fails.

FileExports that matter
hooks/useMousePosition.tsxgetMouseLocation, transformMousePositionToImage, transformMousePositionToRectangle
util/cameras/calculateZoom.tsgetFieldOfView, getZoomFromFov
util/cameras/calculateImageDims.tscalculateWidth, pixelsToDegrees, calculateInnerDivSize, calculateCoverInsideDivSize
util/cameras/transformToPositiveDegrees.tstransformToPositiveDegrees, convertScrollOffsetToDegrees, convertDegreesToScrollOffset

Field of view

getFieldOfView(model, zoom) answers "how many degrees wide is this frame", and every later stage scales by its result. Axis zoom is [1, 9999]; the output is degrees.

zoomRatio    = (zoom - 1) / 9998
focalLength = focalMin + zoomRatio * (focalMax - focalMin)
rawFoV = 2 * atan(sensorWidth / (2 * focalLength)) * 180/PI
normalized = min_fov + ((rawFoV - rawMinFoV) / (rawMaxFoV - rawMinFoV)) * (max_fov - min_fov)

Specs come from CAMERA_MODEL_DETAILS, keyed by the exact cMo string, with seven fields per model. For Q6075-E: zoom 1 ≈ 65°, zoom 500 ≈ 40°, zoom 9999 ≈ 2°.

getZoomFromFov(model, fov) runs the same arithmetic backwards, and opens with Math.max(min_fov, Math.min(fov, max_fov)) — a negative FOV clamps to min_fov, i.e. maximum zoom. The drag-to-zoom handler depends on that clamp.

estimate_dynamic_fov and estimate_zoom are exported from the same file and called nowhere in the app. Do not extend them expecting the UI to change.

The transform

transformMousePositionToImage(
mousePosition, // client coords
overlayRef,
cameraSelected,
useImageFov, // false for the 360 pano
imgAspectRatio,
center, // camera's current pan/tilt in degrees
pixelsOffset, // pano translation
): { x: number; y: number } // pan degrees, tilt degrees

With mouseX = clientX - rect.left - pixelsOffset and mouseY = clientY - rect.top:

pan  = (mouseX / (rect.height * imgAspectRatio)) * panUnitsAcross - panUnitsAcross/2 + center.x
tilt = (1 - mouseY / rect.height) * tiltUnitsUpandDown - tiltUnitsUpandDown/2 + center.y

Two details the formulas encode:

  • The horizontal divisor is rect.height * imgAspectRatio, never rect.width. The image is letterboxed inside the overlay, so the overlay is usually wider than the picture; substituting rect.width is a silent horizontal scale error.
  • The (1 - …) flips the Y axis — screen Y grows downward, tilt grows upward.

The angular spans depend on the mode:

ModepanUnitsAcrosstiltUnitsUpandDown
Camera, useImageFov = truecamFovcamFov / imgAspectRatio
Pano, useImageFov = false360camFov / (1920 / 1080)

Note the asymmetry in the pano row: pan spans the whole circle, but tilt is still derived from the selected camera's optical FOV at its current zoom, hardcoded against 16:9. Pano pan is the reliable axis; treat pano tilt as approximate.

Both results are rounded to one decimal, and the function returns the sentinel { x: 0, y: 0 } whenever the pointer is outside the overlay or the ref is unmounted.

transformMousePositionToRectangle is the zoom-box companion, returning overlay-space pixels. It takes a pixelsOffset argument and deliberately ignores it — the box is drawn in un-scrolled overlay space, and its start point was captured the same way.

Gesture formulas

GesturePanTiltZoom
Double-clickfreshPosition.x (pano: offset-corrected)freshPosition.ynull; pano forces 1
Pan dragcenter.x + (startDeg.x - endDeg.x) * 2clamp(center.y + (startDeg.y - endDeg.y), -180, 20)null
Zoom dragmidpoint of start and endmidpoint of start and endgetZoomFromFov(cMo, -1 * panMovement)

The * 2 on pan is intentional (ADO Backlog Item 683) — the camera moves twice as far as the pointer did. Tilt is clamped; pan is not, because Axis enforces pan limits camera-side.

The -1 * on the zoom drag flips a rightward drag (negative panMovement) into a positive target FOV. A leftward drag hands getZoomFromFov a negative number, which clamps to min_fov — so any leftward drag in zoom-tool mode commands maximum zoom regardless of distance.

Digital zoom is not in this pipeline at all: 1 + zoomChange / 2000 becomes a CSS transform: scale(...) on the <img>, and never reaches the camera.

Display and pano scroll

transformToPositiveDegrees(deg) converts a signed PTZ pan to a compass value: -30 → 330, 90 → 90, -180 → 180. The test is > 0, not >= 0, so 0 returns 360.

convertScrollOffsetToDegrees(scrollOffset, wideAspectRatio, wideDiv, precise = false) turns the pano translation into the heading at the centre of the viewport:

totalWidthPx    = wideDiv.clientHeight * wideAspectRatio
viewportDegrees = (wideDiv.clientWidth / totalWidthPx) * 360
center = viewportDegrees/2
- pixelsToDegrees(clientWidth, viewportDegrees, scrollOffset)
+ 180 // +180 puts North at 0; it naturally lands on South
result = ((round(center) % 360) + 360) % 360

The fourth parameter skips the whole-degree rounding. It matters more than it looks: on a typical strip one degree is about 11px, so a rounded heading moves anything pinned to the strip in ~11px jumps. Human-readable text uses the default; the PeakFinder 360 overlay passes precise = true.

convertDegreesToScrollOffset(degrees, wideAspectRatio, wideDiv) is the inverse, used to open the strip centred on the camera's current pan:

rawOffset = clientWidth/2 + (180 - degrees) * (totalWidthPx / 360)
result = ((rawOffset % totalWidthPx) + totalWidthPx) % totalWidthPx

The double-modulo keeps the result non-negative when rawOffset goes negative.

Quick reference

FunctionInOut
getFieldOfView(model, zoom)zoom [1, 9999]degrees
getZoomFromFov(model, fov)degreeszoom [1, 9999]
transformMousePositionToImage(...)client px + refs + camera{ x: pan°, y: tilt° }
transformMousePositionToRectangle(...)client px + drag start{ x, y, width, height } in overlay px
transformToPositiveDegrees(deg)signed pan[0, 360], with 0 → 360
convertScrollOffsetToDegrees(...)pano translation pxheading [0, 360)
convertDegreesToScrollOffset(...)headingpano translation px
calculateInnerDivSize(AR, w, h)aspect ratio + boxcontained { width, height }
calculateCoverInsideDivSize(AR, w, h)aspect ratio + boxcovering { width, height }
calculateWidth(AR, height)aspect ratio + heightfull strip width px
  • PTZ Pixel Math — the failure modes of this pipeline (camera-model fallback, NaN on fixed-focal lenses, rounding round-trip loss), with two interactive demos. Read it before trusting any of these numbers in production.
  • XYMouseTrackerOverlay — editable sandboxes for the gesture formulas above.