System Walkthrough

Map pins:
how the pieces fit together

Updated: July 14, 2026· Version 2

One coherent mental model of the Pracino map-marker pipeline — from a finger drag to the JSX markers on screen. It follows the life of a gesture through the settle, the cluster query, the pure layout engine, and the render discipline that keeps native markers from blinking. Honest to the code, with file:line for every load-bearing mechanism.

01The shape of it

Five files, one pipeline. Layout is a pure function; everything else feeds it or renders its output.

The whole system exists to solve one hard problem: native map markers are expensive and fragile. Mounting a marker arms a tracksViewChanges timer; adding a fresh marker in the same commit as a mass unmount can render blank on both platforms. So the design spends enormous effort making the marker set change as rarely as possible, and making every change it does make take a safe path. Almost every oddity below traces back to that.

02The pipeline: gesture to markers

A drag or pinch settles, the settle becomes a snapped query, the query becomes a layout, the layout becomes JSX. Content is pinned to the query snapshot at every step.

Here is the full path a gesture takes. The dashed feedback arrow is the mid-gesture fan preview (section 4) — the only thing the live camera is allowed to touch.

flowchart TD
G[User gesture: pan / pinch] --> L[onRegionChange(live)]
G --> S[onRegionChangeComplete: settle]
S --> N{regionsAreClose to<br/>last clustered?}
N -->|yes, no-op| X[skip recompute]
N -->|no| CR[setClusterRegion]
CR --> Q[build cluster query:<br/>snapped bbox + discrete zoom<br/>+ quarter-zoom quantum]
Q --> K{query key<br/>changed?}
K -->|no| SAME[reuse prior query snapshot]
K -->|yes| NEW[new anchor region + bbox]
SAME --> SC[supercluster.getClusters]
NEW --> SC
SC --> CL[computeMarkerLayout<br/>&#40;anchor region only&#41;]
CL --> J[JSX: PinMarker / ClusterMarker]
L -.->|collapseFans / expandFans<br/>hysteresis| CL

Step by step:

  1. Settle detection. onRegionChangeComplete fires when the camera stops (map-view.tsx:252). Programmatic moves (fitToPoints, nudgeForCard) also settle, so a flag armed around our own camera moves swallows those (armProgrammatic, map-view.tsx:223); a cluster-tap zoom is the deliberate exception that wants its settle to refetch (clusterZoomPendingRef, map-view.tsx:270).
  2. No-op guard. If the settled region is within REGION_NOOP_FRACTION (0.5%) of the last clustered one, the recompute is skipped entirely (regionsAreClose, map-bounds.ts:85; called at map-view.tsx:259) — fitToCoordinates emits several settles per fit, and Android can re-fire a single idle.
  3. Cluster region state. A real settle calls setClusterRegion (map-view.tsx:261). This is the state that drives the whole downstream chain.
  4. Query snapping. The region is turned into a query key: a grid-snapped bbox, a discrete integer zoom, and a quarter-zoom quantum (map-view.tsx:318-325). The key only changes when a padded edge crosses a grid line or the zoom quantum moves — so panning at constant zoom produces the same key, and the anchor region is frozen (map-view.tsx:322).
  5. Supercluster query. clusterIndex.getClusters(bbox, zoom) returns a mix of cluster badges and individual pins, split into clusterFeatures / pinFeatures (map-view.tsx:340-350). The index itself is built once per point-set and only rebuilt when the points change (map-view.tsx:282).
  6. Layout. computeMarkerLayout runs against the anchor region (map-view.tsx:473, passing clusterQuery.anchorRegion), returning descriptors.
  7. JSX. markerElements maps fans, clusters, and pins to <PinMarker> / <ClusterMarker> (map-view.tsx:488-593).
The anchor-region invariant

Every content decision — fan mode, fan spacing, declutter, budget — derives from clusterRegion / the query's anchorRegion, not the live camera (map-marker-layout.ts:499-501). A pan that doesn't change the query key re-derives a bit-identical layout: same keys, same coordinates, zero marker churn. Only the diagnostics-only isOffscreen flags read the live camera.

Why the snapping matters: the bbox is padded half a viewport on each side then snapped outward to a zoom-derived grid (regionToBoundingBox, map-bounds.ts:53). Successive settles from a small pan reuse the identical bbox, so the mounted marker set only changes when a padded edge crosses a grid line — not on every settle. That stability is the whole point: fewer feature-set changes means fewer blank-add dice rolls.

03Inside computeMarkerLayout

One pass, strict order. Each stage lays down immovable obstacles the next stage must avoid — fans first, then docked pins, then badges, then the pill sweep.

The engine runs as a pipeline of passes, each one placing markers that the later passes treat as fixed obstacles. Order is priority: whatever places first wins the contested screen space.

flowchart TD
A[inFanMode?<br/>anchor span &le; FAN_ZOOM_DELTA<br/>or expandFans] --> B[fan-selection pass<br/>coincident clusters, nearest-center first<br/>budget-capped at MAX_RENDERED_MARKERS]
B --> C[pin budget<br/>fill remainder, rescue selected pin]
C --> D[fan columns become<br/>fixed obstacles + dock geometry]
D --> E[immovable-pin dock pass<br/>circle + selected pins<br/>dock onto column ends]
E --> F[badge pass: declutterClusters<br/>dock onto columns OR small nudge]
F --> G[pill sweep: declutterPins<br/>nudge steps then mini-pill collapse]
G --> H[emit clusters / fans / pins]

1. Fan-mode decision. inFanMode is true when the anchor region's longitudeDelta is at or below FAN_ZOOM_DELTA (0.02), or when expandFans forces it (map-marker-layout.ts:537). Fanning is a property of zoom level, not an open/close toggle: at or below the threshold, every coincident cluster in view becomes a fanned column at once.

2. Coincident-cluster fanning + marker budget. A cluster is "coincident" when all its leaves sit within SPIDERFY_COORD_EPS (~50m) on both axes — zoom can never pull them apart (isCoincidentCluster, map-marker-layout.ts:415). Those get spiderfied into a vertical column, one pill per leaf, evenly spaced in screen px (buildSpiderfyLegs, map-marker-layout.ts:127). But every marker costs against the MAX_RENDERED_MARKERS ceiling of 120 (map-marker-layout.ts:9) — the crash ceiling for native marker density. So fans are spent nearest the anchor center first, and a fan that would push past the cap degrades to a plain badge rather than dropping the cluster (map-marker-layout.ts:543-565).

3. Fan columns as fixed obstacles. Each fan yields a FanColumn — the union footprint of its leg pills plus the dock geometry (shared spot projection, outermost leg offset, pill spacing) that later passes dock onto (map-marker-layout.ts:584-615). The leg pill rects go straight into fixedRects: nothing may cover a fan leg.

4. Immovable-pin dock pass. Circle (no-salary) pins and the selected pin never nudge and never collapse (isImmovablePin, map-marker-layout.ts:204). But an immovable pin whose anchor lands inside a fan column can't render there — the leg pills are fixed. So it docks onto the column's end, exactly like a badge: rendered at the fan's shared coordinate, displaced in view px one step beyond the outermost leg (map-marker-layout.ts:620-659). Selected first, then pointId order, so several docking pins take slots deterministically.

5. Badge pass. declutterClusters places the remaining (non-fanned) cluster badges (map-marker-layout.ts:664). A badge whose anchor touches a fan column docks onto the column's end — nearest end first, flipping ends when a slot would leave the viewport, stacking outward when several badges share a fan (findDockSlot, map-marker-layout.ts:307). A badge colliding with anything else (fixed pins, earlier badges) tries the small pill nudge steps; all exhausted → it stays at its anchor (a residual overlap beats a vanished count). Biggest count places first, coordinate tiebreak.

6. Pill declutter sweep. declutterPins places the movable salary pills last (map-marker-layout.ts:749). Obstacles arrive final: fan legs, docked pins, and the placed badges. Each pill tries the DECLUTTER_NUDGE_STEPS_PX ladder — step 0, then ±18px vertical, then ±28px sideways, then the ±37px far rows and diagonals (map-marker-layout.ts:71-83) — smallest displacement first, vertical preferred to keep the label near its anchor. A pill that finds no slot collapses to a textless mini-pill at its true anchor (map-marker-layout.ts:267).

Determinism

Every pass sorts before placing, with a coordinate or pointId tiebreak (map-marker-layout.ts:236-238, 355-360). Pills also place in tier order — selected (0) > available-now (1) > regular (2) > dimmed/viewed (3) — so the least-important pill is the one that collapses first (tierOf, map-marker-layout.ts:230). Input array order never affects the result; the spec proves it (section 6).

04Three displacement mechanisms

This is the subtle part. A marker can be offset three different ways, and they behave completely differently WHILE the camera is moving. Getting this wrong is what makes markers drift or blink.

Every marker sits at a map coordinate. But two of the three families are displaced from that coordinate, and the displacement is computed in different spaces — which means they respond to camera motion differently between the anchor query and the next settle.

MechanismDisplaced inComputed atDuring camera motionUsed by
Plain markernothing — sits at its map coordinaten/aun-nudged pins, un-docked badges
Nudgemap COORDINATES (lat/lng offset)anchor zoomon-screen offset SCALES with zoom, then snaps at settledecluttered pills, nudged badges
Fan leg / dockview PIXELS inside the marker viewviewport heightconstant on screen through the whole gesturefan legs, docked pins & badges

(a) Plain markers are map-locked. Their coordinate is the truth; Google Maps moves them with the map. Nothing to compute.

(b) Nudged markers are displaced in map coordinates. declutterPins computes a screen px offset, then converts it back to a lat/lng delta using the anchor region's span (map-marker-layout.ts:258-262). The pin renders at coordinate + offset. The catch: that lat/lng offset was sized for the anchor zoom. As the camera zooms mid-gesture, the map stretches, so the pin's on-screen separation from its neighbour scales with it — it drifts — until the settle recomputes the offset for the new zoom and it snaps back into place. This is invisible for a pan (zoom constant) and only briefly visible during a pinch.

(c) Fan legs and docked markers are displaced in view pixels inside the marker view itself (map-markers.tsx:221-256 for legs, :184-208 for docks). The marker's coordinate is the shared spot; the pill floats a fixed number of device px above or below it via padding and spacer views. Because the displacement lives in the view, not the coordinate, the column's on-screen geometry is constant through every camera transition by construction — no per-frame correction, no drift, no snap. SPIDERFY_STACK_STEP is a fraction of viewport height applied in px for exactly this reason (map-marker-layout.ts:17-20).

Plain
at map coordinate
map-locked
moves with the map
zoom mid-gesture
Nudge (coord)
offset in lat/lng
sized at anchor zoom
on-screen gap scales, snaps at settle
zoom mid-gesture
Fan / dock (px)
offset in view px
inside the marker view
constant on screen, never drifts

The trade the design makes: pills use coordinate nudges (cheap, small displacements ≤37px where a little mid-pinch drift is imperceptible), while fans and docks use px displacements (large, structured, must stay rigid). This split is exactly the "known tension" in section 7.

05Mid-gesture fan preview

The one place the live camera is allowed to change content — and only because it applies the exact rule the settle will.

Fanning reconfigures the whole screen at the FAN_ZOOM_DELTA threshold. If that only happened at settle, a zoom-out would leave full-size px columns plowing through the converging map until you lift your finger, and a zoom-in would make you wait for the settle to learn you're zoomed in far enough. So map-view.tsx previews the settle's verdict from the live camera, in both directions:

Both flags flip ON when the live camera crosses the threshold exactly (the settle's own rule), but flip OFF only once the camera retreats FAN_TOGGLE_HYSTERESIS (5%) past it (map-view.tsx:102, applied at :197 and :208). Hovering right at the threshold can't flicker fans in and out. Every settle resets both flags and re-owns the decision (map-view.tsx:263-266).

The crucial detail for correctness: under collapseFans, fan selection still runs — the budget is spent and the declutter obstacle set is built exactly as if the fans were drawn; only the final emit swaps the column for a badge (map-marker-layout.ts:696). So the pill layout stays bit-identical whether or not the preview is active. expandFans, by contrast, is a full fan-mode pass — legs are born and the budget reflects them (map-marker-layout.ts:492-496).

06Rendering discipline: why markers blink (by design)

iOS freezes a marker's icon after one rasterization; Android updates in place. The key strategy on both platforms decides mount vs remount.

Native markers are the fragile resource, and iOS and Android fail differently.

iOS — one rasterization per lifetime. Sim experiments established that adding a fresh marker on-screen and freezing it once after paint is reliable, but every subsequent re-rasterization of a mounted marker is a dice roll that can blank a good icon (map-markers.tsx:44-50). So on iOS the icon is immutable after its first freeze. Any visual change — selected, dimmed, mini, a changed fan offset — is folded into the marker's React key via iosVariantKey (map-view.tsx:457), so a content change remounts the marker through the safe fresh-add path instead of morphing a mounted one.

Android — in-place updates. Android renders marker bitmaps RN-side and updates them reliably, so it keeps stable keys and re-arms tracksViewChanges on content props instead (map-markers.tsx:130-142). The freeze itself is driven by onLayout + a double-rAF so the snapshot captures painted pixels, with a TRACKS_FREEZE_FALLBACK_MS backstop (map-markers.tsx:53, :71-77).

Dock offsets live in the BASE key on both platforms. A badge or pin switching between anchored and docked swaps the marker's child tree (wrapper + spacer views appear), and reshaping a live native marker is what crashes Android Fabric's MarkerManager — so the dock offset goes into the base key, forcing a remount on every dock flip (map-view.tsx:534 for clusters, :562 for pins).

The designed-in blink

The direct consequence: dock flips and zoom-quantum crossings remount markers, which blinks them. This is deliberate — a blink on a rare structural change is the price of never crash-morphing a live marker and never re-rasterizing a frozen iOS icon. The whole snapping/anchor machinery exists to make these events rare, not to eliminate the blink when they do happen.

Coordinates are deliberately not in the key on either platform — moving a marker (a declutter nudge) repositions the existing icon and must not re-rasterize it (map-markers.tsx:126-129).

Unverified: the iOS justification may be obsolete

The description above is accurate to map-markers.tsx today. But the reason for the iOS one-rasterization discipline comes from sim experiments run under react-native-maps 1.20.1, whose Fabric legacy-interop layer is what actually misdirected the re-rasterization and blanked icons. The 1.29.0 bump (beads pracino-ctn.11, still in progress) moves iOS Google markers onto native Fabric views and removes that interop layer — so the blanking constraint may no longer exist. There is already a noted follow-up to try stripping the freeze discipline and the variant-key remounts once iOS verifies clean. Until that verification lands, read this section as a possibly-obsolete workaround, not current platform truth.

Note the asymmetry: the dock-offset-in-BASE-key remounts have a separate justification — reshaping a live marker's child tree (wrapper + spacer views) is what crashes Android Fabric's MarkerManager.onLayoutChange. That constraint is Android-side and independent of the iOS rasterization one, so even if the iOS freeze discipline falls away, dock-flip remounts do not automatically go with it.

07What the specs actually assert

20 specs, all single-frame. They prove the layout is CORRECT for one query; they cannot see anything about the relationship between two consecutive layouts.

The spec file drives computeMarkerLayout with a linear 1000×1000 region (1° = 1000px) and asserts properties of a single layout pass (map-marker-layout.spec.ts). What they cover:

GroupWhat it proves
Pill declutterone of two overlapping pills nudges, the other holds anchor; a boxed-in pill collapses to mini; selected never moves; tier priority (regular before dimmed)
Obstacle claimscluster badges and circle pins claim footprints before pills place around them
Fan geometrycoincident cluster fans into px-offset legs on the shared spot; offsets symmetric and independent of anchor span
Budgetpin set capped at 120 with the selected pin rescued back in
Determinismsame per-pin result regardless of input array order (pointId tiebreak)
Dockingbadge docks onto a column end; flips ends near the viewport edge; stacks a second badge outward; circle pin docks and takes the nearest slot before a badge
Badge decluttersmaller of two overlapping badges nudges, bigger holds anchor
Offscreen flagisOffscreen tests the live viewport while content stays anchored

Two specs are special because they assert a pair of layouts are related:

The blind spot

Every spec is single-frame. They assert nothing about the relationship between two consecutive layouts across a real query change: displacement continuity (does a pin jump when the query snaps?), form flips (pill → mini → pill churn), or remount counts. Those are exactly the properties that produce visible blinking and drift, and they live in map-view.tsx's keying and the camera pipeline — none of which the layout specs can see. The determinism and collapseFans pairs are the closest the suite gets, and they only cover two specific transitions.

08Known tensions

Facts of the current design, stated neutrally. A keep/fix/revert discussion happens separately — this is just the honest inventory.

FYIRISK