Skip to main content

Rendering the Camera View

Open a camera in Location View and you can look at the mountain two ways: the 60° live view — a crisp window on where the camera points right now — and the 360° strip, the stitched panorama you can spin endlessly in either direction, like standing on the summit and turning in place.

Two components own those views: CameraImageWrapper and PanoImage. And the first thing to learn about them is a surprise: neither one renders an image.

Concept #1: shells and images

Both components are shells. Their job is deciding — for the current screen size, expansion state, and playback mode — which buttons to show and which image component to hand off to. The actual <img>, its scroll behavior, the tick overlays, and the mouse tracker all live one level down, in IImage / SignalRIImage (components/atoms/IImage/).

This split is your debugging compass for the whole chapter:

A control in the wrong place → edit the shell. The picture, its scrolling, or its overlays wrong → the shell is innocent; open IImage.

The shells differ in predictable ways — different image queries (GetLatestImageByCameraId vs GetLatestPanoForCamera), different overlay modes (usedAsOverlayFor='camera' vs '360'), different tick treatments (one labeled center tick vs a full compass strip), and fully independent playback state (playback* vs threeSixty* keys in timelapseStore).

Concept #2: the pano is not a scroll container

Here's the trick that surprises everyone. How do you let someone spin through 360° forever, in both directions, with no visible seam? Your first guess — overflow-x: auto and wrap the scroll position — is nowhere in the code. There is no scroll container at all.

Instead, IImage renders the strip as two copies of the same image, both positioned with a CSS transform:

<img style={{ transform: `translateX(${position}px)` }}/>
{isPano && <img style={{ transform: `translateX(${calculateTranslateOfSecondPano(position)}px)` }}/>}

The second copy parks exactly one strip-width to the left or right of the first, depending on which way you're dragging — so the seam is always offscreen, and the strip wraps infinitely. Like two stagehands leapfrogging scenery panels behind an actor who thinks the backdrop is endless.

Dragging is handled by a sibling MomentumScrollingContainer that reports a new position upward; PanoImage stores it as scrollOffset and feeds it back down. Two consequences to remember:

  • position is unbounded and signed. It's a translation, not a scroll offset in [0, width] — normalize before comparing.
  • The momentum scroller unmounts in zoom-tool mode. Dragging then belongs to the overlay, which uses it to draw the zoom box. One gesture, one owner — deliberate, not a bug.

Live data: SignalR or polling, decided per render

Both shells contain the image component twice in source, guarded by the enable-all-features-connected-to-signalr feature flag:

{!isSignalREnabled && offlineTier < OfflineTier.OFFLINE_7D && <IImage/>}
{isSignalREnabled && offlineTier < OfflineTier.OFFLINE_7D && <SignalRIImage/>}
The most common way a fix half-lands

The two branches take nearly identical props — so a change to one almost always needs the same change to the other. Fix IImage's props and forget SignalRIImage's, and your fix works in exactly one flag configuration.

A lovely detail in the 60° shell: the polling interval is secondsRemainingInLeaseMode > 0 ? 1000 : 10000. While you hold the lease, the image refreshes every second — the camera appears to respond in near real time to your own commands — and drops back to a calm 10-second poll when you're just watching.

The edges: offline, expansion, playback

Offline cameras skip the image entirely. At seven days offline the backend keeps no image, so both shells render a flat #353535 panel plus OfflineWrapper and never mount the image component — no request to fail, no skeleton to spin. The tier system is its own lesson.

Expansion is a store value, not local state. "Which panel is expanded" is the visibleLocationViewPanels triple (image/map/wide) in mapCameraInteractiveStore, and each shell derives its expanded flag from it. PanoImage guards its sync effect with a prevPanelsRef comparison — the effect reads and writes state in its own dependency list, and without the compare it re-enters. Add a panel, extend that comparison.

Playback runs on two independent tracks. The 60° view and the pano each have a full set of playback state in timelapseStore; both can play at once and neither resets the other. Both render TimelapseCanvasPlayer on top of the live image rather than replacing it — the layering trick from the timelapse lesson.

Wrapping up

Shells decide what surrounds the picture; IImage decides the picture itself — including the two-copies-and-a-transform illusion that makes 360° feel infinite. Live data arrives by flag-selected twins that must be edited in pairs, and offline/expansion/playback are all store signals, not local state.

Next: the control panel over the image.