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.
| File | Exports that matter |
|---|---|
hooks/useMousePosition.tsx | getMouseLocation, transformMousePositionToImage, transformMousePositionToRectangle |
util/cameras/calculateZoom.ts | getFieldOfView, getZoomFromFov |
util/cameras/calculateImageDims.ts | calculateWidth, pixelsToDegrees, calculateInnerDivSize, calculateCoverInsideDivSize |
util/cameras/transformToPositiveDegrees.ts | transformToPositiveDegrees, 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, neverrect.width. The image is letterboxed inside the overlay, so the overlay is usually wider than the picture; substitutingrect.widthis 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:
| Mode | panUnitsAcross | tiltUnitsUpandDown |
|---|---|---|
Camera, useImageFov = true | camFov | camFov / imgAspectRatio |
Pano, useImageFov = false | 360 | camFov / (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
| Gesture | Pan | Tilt | Zoom |
|---|---|---|---|
| Double-click | freshPosition.x (pano: offset-corrected) | freshPosition.y | null; pano forces 1 |
| Pan drag | center.x + (startDeg.x - endDeg.x) * 2 | clamp(center.y + (startDeg.y - endDeg.y), -180, 20) | null |
| Zoom drag | midpoint of start and end | midpoint of start and end | getZoomFromFov(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
| Function | In | Out |
|---|---|---|
getFieldOfView(model, zoom) | zoom [1, 9999] | degrees |
getZoomFromFov(model, fov) | degrees | zoom [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 px | heading [0, 360) |
convertDegreesToScrollOffset(...) | heading | pano translation px |
calculateInnerDivSize(AR, w, h) | aspect ratio + box | contained { width, height } |
calculateCoverInsideDivSize(AR, w, h) | aspect ratio + box | covering { width, height } |
calculateWidth(AR, height) | aspect ratio + height | full strip width px |
Related
- PTZ Pixel Math — the failure modes of this pipeline
(camera-model fallback,
NaNon 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.