System Walkthrough
Map pins:
how the pieces fit together
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 pure engine is
map-marker-layout.ts—computeMarkerLayouttakes a cluster query result plus one anchor region and returns plain{ clusters, fans, pins }descriptors. No React, no map SDK, no side effects. It is the only place layout decisions live. - The orchestrator is
map-view.tsx— it owns the camera, decides when the map has settled, snaps the settle into a stable cluster query, runs supercluster, callscomputeMarkerLayout, and maps its output to JSX. - The renderer is
map-markers.tsx—PinMarkerandClusterMarker, plus the iOS/Android rasterization discipline that decides when a marker updates in place versus remounts. - The query math is
map-bounds.ts— bbox snapping, discrete zoom, and the quarter-zoom quantum that make consecutive settles from a pan produce the identical query. - The one load-bearing invariant: all layout derives from the query snapshot (the "anchor region"), never the live camera. Panning within a query changes nothing on screen — no marker mounts, moves, or unmounts.
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/>(anchor region only)]
CL --> J[JSX: PinMarker / ClusterMarker]
L -.->|collapseFans / expandFans<br/>hysteresis| CLStep by step:
- Settle detection.
onRegionChangeCompletefires 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). - 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 atmap-view.tsx:259) —fitToCoordinatesemits several settles per fit, and Android can re-fire a single idle. - Cluster region state. A real settle calls
setClusterRegion(map-view.tsx:261). This is the state that drives the whole downstream chain. - 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). - Supercluster query.
clusterIndex.getClusters(bbox, zoom)returns a mix of cluster badges and individual pins, split intoclusterFeatures/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). - Layout.
computeMarkerLayoutruns against the anchor region (map-view.tsx:473, passingclusterQuery.anchorRegion), returning descriptors. - JSX.
markerElementsmapsfans,clusters, andpinsto<PinMarker>/<ClusterMarker>(map-view.tsx:488-593).
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 ≤ 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).
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.
| Mechanism | Displaced in | Computed at | During camera motion | Used by |
|---|---|---|---|---|
| Plain marker | nothing — sits at its map coordinate | n/a | ✓ | un-nudged pins, un-docked badges |
| Nudge | map COORDINATES (lat/lng offset) | anchor zoom | on-screen offset SCALES with zoom, then snaps at settle | decluttered pills, nudged badges |
| Fan leg / dock | view PIXELS inside the marker view | viewport height | constant on screen through the whole gesture | fan 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).
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:
collapseFans— a zoom-out crosses the threshold: fans render as their badges now, instead of columns (handleRegionChangeLive,map-view.tsx:191-202).expandFans— a zoom-in crosses it: coincident clusters of the current query fan now (map-view.tsx:203-211).
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 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).
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:
| Group | What it proves |
|---|---|
| Pill declutter | one 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 claims | cluster badges and circle pins claim footprints before pills place around them |
| Fan geometry | coincident cluster fans into px-offset legs on the shared spot; offsets symmetric and independent of anchor span |
| Budget | pin set capped at 120 with the selected pin rescued back in |
| Determinism | same per-pin result regardless of input array order (pointId tiebreak) |
| Docking | badge 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 declutter | smaller of two overlapping badges nudges, bigger holds anchor |
| Offscreen flag | isOffscreen tests the live viewport while content stays anchored |
Two specs are special because they assert a pair of layouts are related:
collapseFansbit-identity — the collapsed layout's pills aretoEqualthe fanned layout's pills (map-marker-layout.spec.ts:215). This is the proof that the mid-gesture preview doesn't disturb anything but the fan/badge swap.- Dock lockstep collapse — under
collapseFans, a docked circle and badge both fall back to their true anchors withoffsetYPx === 0(map-marker-layout.spec.ts:410-430).
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.
- Dock displacements are large. A docked marker sits one
stepPxbeyond the outermost leg — the specs measure 170px, 255px for a stacked second dock (map-marker-layout.spec.ts:337,:375), against pill nudges capped at ±37px (map-marker-layout.ts:77-78). A docked badge can therefore render a long way from its true anchor. - Supercluster ids are per-zoom.
cluster_idis only stable within one zoom level, so a badge has no identity across zoom levels — which is why the dock offset must live in the key (a remount is unavoidable when the id itself changes across a zoom crossing). - The fan threshold is all-or-nothing.
FAN_ZOOM_DELTAreconfigures every coincident cluster on screen at a single zoom line (map-marker-layout.ts:26-31). Crossing it is a whole-screen form change, previewed mid-gesture but still a mass transition. - Dock-release is deferred to settle. The mid-gesture
collapseFanspreview drops docks to their anchors, but the full re-decision (and any remount churn) only lands at the settle, gated by the hysteresis band (map-view.tsx:197).