Teaching a Panorama Where North Is
Imagine being handed a beautiful 360° photograph of the horizon — with no idea which way the photographer was facing. That's exactly how every panorama from the external UCSD vendor system arrives: stitched in its own rotational frame, the middle of the strip pointing wherever it happens to point, with no metadata saying where north is.
Yet the app confidently draws compass degrees over these strips, and operators aim real cameras by them. Every one of those degrees is a computed value: the strip's own angle plus a stored correction — and somebody had to teach each camera that correction, by pointing at a mountain whose location is known and telling the app "that's Mount Baldy."
This deep dive covers the whole teaching process: the offset model, what happens for cameras never calibrated, the great-circle bearing formula behind landmark calibration, and a worked example with real numbers. Everything is verified against source.
| Concern | File |
|---|---|
Offset arithmetic (applyPanoOffset / removePanoOffset) | util/cameras/panoOffset.ts |
| True compass bearing from two lat/lng pairs | util/map/bearing.ts |
| The calibration UI and the solve | components/organisms/LocationView/PanoImage/PanoCalibration/PanoCalibration.tsx |
| Offset resolution + where the panel is mounted | components/organisms/LocationView/PanoImage/PanoImage.tsx |
| The hover angle the solve reads | components/molecules/CameraMovement/XYMouseTrackerOverlay/XYMouseTrackerOverlay.tsx |
| Global default storage | stores/staticCameraStore.ts (globalPanoOffsetDegrees) |
| Landmark search + coordinate parsing | util/map/getLocationsFromAutocomplete.ts |
The offset primitives
util/cameras/panoOffset.ts is fourteen lines and deliberately kept separate from
transformToPositiveDegrees.ts so the widely-used PTZ-normalisation helpers there stay
untouched:
const normalizeDegrees = (deg: number): number => ((deg % 360) + 360) % 360;
export const applyPanoOffset = (
imageDegrees: number,
offsetDegrees: number,
): number => normalizeDegrees(imageDegrees + offsetDegrees);
export const removePanoOffset = (
realDegrees: number,
offsetDegrees: number,
): number => normalizeDegrees(realDegrees - offsetDegrees);
applyPanoOffset converts image frame → real world. removePanoOffset is its exact
inverse. Because both normalise, they are total functions over any input, including negative
offsets and angles past 360 — that property is what makes the calibration solve below able to
reuse applyPanoOffset for something that is not conceptually "apply an offset" at all.
Two-tier resolution
There is no single "the offset" for a camera. The value in play is resolved fresh at every consumer, from three inputs, in a fixed order:
The implementation, verbatim:
const panoOffsetDegrees =
cameraSelected?.isExternalPano === true
? (cameraSelected?.panoOffsetDegrees ?? globalPanoOffsetDegrees ?? 0)
: 0;
Three things about this that are easy to misread:
The gate is === true, not truthy. false, null and undefined all mean "internal
pano," and internal panos are stitched from this system's own PTZ frames so they are already
exact. An external camera that the backend has not yet flagged gets offset 0 — the same as an
internal one — so a missing isExternalPano looks like "calibration silently does nothing."
It is ??, not ||. A per-camera stored 0 is a real, winning value. This matters for the
panel's Reset to 0° button: it calls commitOffset(0, 'reset'), which persists 0 for that
camera. That is not "clear the calibration and go back to inheriting the global" — it
permanently opts that camera out of the global default until someone writes a new per-camera
value. There is no UI path that writes null back.
The resolution is duplicated, not shared. The same expression is copy-pasted in four places:
PanoImage.tsx, XYMouseTrackerOverlay.tsx (with an extra usedAsOverlayFor === '360'
conjunct), CameraDetails.tsx, and ExpandedImageButtons.tsx. Any change to the fallback order
has to be made in all four. Note also that the JSDoc on AllStaticCamera.panoOffsetDegrees in
models/Camera/CameraImageModel.ts says a null "should [be treated] the same as 0 (no-op)" —
that predates the global fallback and no longer describes what the four call sites do.
The global value itself comes from the backend on the all-static-cameras response and is pushed
into the store in hooks/usePageUpdates.ts:
setGlobalPanoOffsetDegrees(response.globalPanoOffsetDegrees ?? null);
null means no global default has ever been set — distinct from a global default of 0.
The two target input modes
PanoCalibration has a mode toggle typed as type TargetInputMode = 'coordinates' | 'landmark',
defaulting to 'coordinates'. Both modes converge on the same thing: a LocationResult in
selectedTarget whose coords is a { x: longitude, y: latitude } pair. Once that is set,
mode flips from 'idle' to 'awaiting-click', the pano container's cursor changes to a
pointer, and the click listener above is attached. The solve is identical from that point on.
Related
deep-dives/ptz-pixel-math— where the raw image-frame angle that this page offsets actually comes from, includingtransformToPositiveDegreesand the pano scroll math.- Rendering the camera view — the pano view the calibration panel is mounted inside.
features/camera-movement/img-to-map-calculations— the pixel→degree reference.- One Camera, Two Clocks — where
globalPanoOffsetDegreeslives and how the static camera data that feeds the average is loaded.