Skip to main content

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.

ConcernFile
Offset arithmetic (applyPanoOffset / removePanoOffset)util/cameras/panoOffset.ts
True compass bearing from two lat/lng pairsutil/map/bearing.ts
The calibration UI and the solvecomponents/organisms/LocationView/PanoImage/PanoCalibration/PanoCalibration.tsx
Offset resolution + where the panel is mountedcomponents/organisms/LocationView/PanoImage/PanoImage.tsx
The hover angle the solve readscomponents/molecules/CameraMovement/XYMouseTrackerOverlay/XYMouseTrackerOverlay.tsx
Global default storagestores/staticCameraStore.ts (globalPanoOffsetDegrees)
Landmark search + coordinate parsingutil/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.