跪拜 Guibai
← Back to the summary

Cesium Basemap Migration: When Changing Tile URLs Moves Every Marker 400 Meters


theme: condensed-night-purple highlight: atelier-cave-light

The requirement was just one sentence: change the default basemap from Tianditu to AMap, and let users switch back in the panel. After I changed the tile URLs, the map loaded. Then I spent the rest of the day wondering why all icons were collectively offset by three to four hundred meters.

This post is a full reconstruction of that migration: what happened → how I debugged → which few lines actually fixed it. It is not a primer on basemap fundamentals, just what you have to get right when replacing Tianditu with Gaode. If you're facing the same requirement, you can follow the order below directly.


1. Understand exactly how the two tile sources differ

Before touching any code, I wrote both providers' tile parameters side by side on a piece of paper. That's not rigor, it's the result of being burned—almost every "something looks wrong after the switch" symptom can be traced back to one row on that table.

Tianditu AMap (Gaode)
Coordinate datum WGS84 GCJ-02
Projection Web Mercator Web Mercator
Imagery tile set img_w satellite imagery style (style=6)
Label tile set cia_w, separate layer transparent label style (style=8)
Number of subdomains 8 (t0–t7) 4 (1–4)
Max level 17 18
Tile matrix origin 0-based, one-to-one with engine level 0-based

Three points in this table are must-know.

① Subdomain counts differ, so the URL concatenation rule has to be written separately. Tianditu rotates through 8 subdomains, AMap through 4. Writing one "generic URL builder" to fit both will leave one side unable to fetch tiles—because the parameters are not isomorphic.

② Max levels differ. AMap goes to level 18, Tianditu stops at 17. maximumLevel must be declared per provider. If not, zooming to level 18 on Tianditu will request non-existent tiles, resulting in a blurry mess or a blank screen.

③ Whether the tile matrix starts from 0 or 1 is the easiest piece to get wrong. Tianditu's _w tile sets have TILEMATRIX starting at 0, one-to-one with the engine's level; do not do a +1 mapping. I initially added 1 based on a "hunch" and the whole map was "one level behind": lower zoom levels look fine, but zooming in shows misalignment, and zooming out restores it—that kind of bug that only appears in a certain level range is the most time-consuming.

One more rule determines whether labels can be toggled independently:

Labels must be a separate tile set. Tianditu's cia_w is naturally a layer of its own, and that's an advantage. AMap's vector basemap style bakes roads and place names into the same tile image; the frontend cannot turn it off. The only workaround is using a pair of "satellite imagery + transparent labels" tiles.

Checklist (Section 1)

  1. Maintain per-provider lists of: subdomains, max level, tile set name/style parameter, and tile matrix base;
  2. Don't abstract a universal URL builder, the two providers' parameters are not isomorphic;
  3. Before writing code, verify both providers supply a "pure imagery + transparent labels" pair of tiles.

2. Symptom: after switching URLs, all points are offset by hundreds of meters

The basemap appeared, but everything on the map was wrong:

I debugged in three rounds.

Round 1: Suspect the data. Compare against a known landmark—a building I verified manually—the icon should sit exactly on it, and it didn't. Single bad record? Test three different points; all are offset, in the same direction. Not a data problem.

Round 2: Suspect a fixed offset. If it's just a constant, adding it back would fix it. So I picked three distant points on the map and measured their offsets: direction is roughly consistent, but distance varies. Not a constant.

Round 3: Go back to the coordinate system. Now it's clear: Tianditu uses WGS84, AMap uses GCJ-02. The latter is a non-linear offset applied to public coordinates in China, generated from a superposition of trigonometric functions, so it changes with latitude and longitude.

At this point I made a decision that I believe is the single most important one of the whole migration:

Our business data—points, routes, fences, model coordinates—is WGS84. That's the ground truth of the data; it should not be altered to accommodate the basemap.

If I changed it, historical data, external APIs, exported files, and integrating parties would all need to change too. A UI requirement would escalate into a data migration, and switching back to Tianditu would require another migration.

So the conclusion is: the thing to fix is not the business coordinates, but the tiles.

Checklist (Section 2)

  1. Quantify the offset before fixing: take three distant points and see if the offset is constant. If not constant, it's a coordinate system difference, don't try to fit a constant;
  2. Keep business coordinates in WGS84, do the error correction only at render time;
  3. Separate the data layer from the render layer—that way, when a third basemap provider comes along, you only need to add one more set of tile parameters.

3. Correcting the offset: inject the coordinate conversion into the "tile number ↔ lat/lng rectangle" layer

Once I decided not to touch business coordinates, the only place left is the tile mapping layer. When the map engine renders tiles, it will ask the TilingScheme two questions:

  1. Which tile does this point on the screen fall into? (lng/lat → tile number)

  2. Which lat/lng rectangle does this tile cover? (tile number → lng/lat rectangle)

By overriding both methods, the engine still computes everything in WGS84, but we insert a coordinate conversion in between.

class OffsetTileScheme extends MercatorScheme {
  // ① lat/lng -> tile number: first forward-convert, then get the index using standard Mercator
  tileAt(lng, lat, zoom, out) {
    const [gLng, gLat] = forward(lng, lat); // WGS84 -> GCJ-02, has an analytic expression
    return mercatorIndexOf(gLng, gLat, zoom, out);
  }
  // ② tile number -> the lat/lng rectangle it covers: first get the rectangle in encrypted coordinates, then invert it back
  rectAt(x, y, zoom, out) {
    const box = mercatorBounds(x, y, zoom); // lat/lng rectangle in GCJ-02 space
    const [w, n] = invert(box.west, box.north, 4);
    const [e, s] = invert(box.east, box.south, 4);
    out.west = toRad(w);
    out.north = toRad(n);
    out.east = toRad(e);
    out.south = toRad(s);
    return out;
  }
}

There are three details that must be right, and I tripped over every one. ① The two methods must be overridden together; missing either one fails. Only forward conversion will paste tiles in the wrong place; only reverse conversion makes the engine fetch the wrong tile. These two methods are natural inverses of each other, and if either side is "optimized" away, seams immediately appear.

② The inverse has no closed form; it must be solved iteratively. The forward transform is a superposition of trigonometric functions and can be computed directly; the inverse has no closed-form solution. A common shortcut is "subtract the same offset in the reverse direction", but the forward and reverse offsets are not equal, leaving residual errors at the meter level.

Tiles have distinct boundaries. That residual error will misalign neighboring tile edges, showing as: a thin seam occasionally flashing during zoom, or half-pixel ghosting at the edges. A static screenshot never shows it; you have to zoom while watching.

My solution is iterative inversion—start at the target point, forward-transform once, see the difference, feed it back, repeat:

function invert(lng, lat, rounds) {
  let x = lng;
  let y = lat;
  for (let i = 0; i < rounds; i++) {
    const [fx, fy] = forward(x, y); // run forward again
    x += lng - fx; // feed back the residual
    y += lat - fy;
  }
  return [x, y];
}

After four iterations the residual is sub-millimeter and tile boundaries align flawlessly. You don't need many rounds—two or three is enough for the naked eye; I used four for safety.

③ Don't flip the Y axis out of cleverness. Many people writing a tile scheme for the first time want to flip it. In fact, mainstream XYZ tiles are numbered from north to south, which matches the engine's internal convention—no flipping needed. What's really error-prone is not Y, but whether the tile matrix starts at 0 or 1 (the Section 1 item).

One more boundary note: this change only affects the AMap provider. The Tianditu provider continues to use the standard Mercator scheme, both run side by side without interference.

Checklist (Section 3)

  1. Create a new tile scheme subclass, only override those two methods, keep the rest inherited;
  2. Use the analytic expression for forward, and iterative inversion (4 rounds) for reverse;
  3. Attach the new scheme to the AMap provider, keep Tianditu provider on standard Mercator;
  4. Acceptance criteria: the same WGS84 point should be within a few pixels on both basemaps.

4. Layer orchestration: load all four layers once, switch by toggling visibility

After the offset fix, the second problem surfaced: every switch flashed once, and the camera got reset.

The cause was my initial approach of "rebuilding the imagery provider on each switch". The map engine allows it, but it forces the entire layer's tiles to be re-fetched, which visually looks like a white flash; if you also rebuild the viewer, the camera, models, and events all have to be reattached.

The fix is straightforward: for both basemaps, each made of two layers (imagery + labels), load all four layers once at initialization. After that, switching only changes show.

function initBasemaps(viewer) {
  // First remove the engine's default basemap, otherwise it's always stuck under the four layers
  viewer.imageryLayers.removeAll();
  const scheme = new OffsetTileScheme();
  // Tianditu: standard Mercator, 8 subdomains, max 17
  const tdtBase = addLayer(viewer, rasterOf('tdt', 'image'));
  const tdtLabels = addLayer(viewer, rasterOf('tdt', 'label'));
  // AMap: corrected tile scheme, 4 subdomains, max 18
  const amapBase = addLayer(viewer, rasterOf('amap', 'image', scheme));
  const amapLabels = addLayer(viewer, rasterOf('amap', 'label', scheme));
  const groups = {
    tdt: { base: tdtBase, labels: tdtLabels },
    amap: { base: amapBase, labels: amapLabels },
  };
  applyVisibility(groups, loadPrefs());
}

// The core of switching: only change visibility, not the provider
function applyVisibility(groups, pref) {
  for (const kind of Object.keys(groups)) {
    const active = kind === pref.kind;
    groups[kind].base.show = active;
    groups[kind].labels.show = active && pref.withLabels;
  }
}

This code brings two immediate benefits.

Switching is synchronous. No loading state, no flicker, changes immediately on click.

The two dimensions of "basemap" and "label toggle" converge into one expression. Note the label condition is active && withLabels—it must be "the current basemap" and "the toggle is on". Dropping either condition will show a mismatch like "basemap is Tianditu but labels are from AMap", and it only reproduces under a specific order of operations.

Checklist (Section 4)

  1. Add all four layers once at initialization, only change .show when switching;
  2. Call removeAll() before adding layers to clear the default basemap;
  3. Label visibility must be active && withLabels;
  4. Group structure: "basemap name → {imagery layer, label layer}", so adding a third provider is just one more key.

5. State: making one switch reach everywhere

By now, the switch on the main page worked. Then QA asked three questions:

  1. After switching on the main page, open a business popup, its map still shows the old basemap;
  2. Refresh, goes back to Tianditu;
  3. Log out and log back in, it goes back again.

These three are three faces of one problem: the preference is a one-time parameter, not a state that propagates.

I did four things here.

① State is isolated per instance, preferences are shared globally. Use a WeakMap to store "instance → state". Do not use a module-level singleton—the main page and the popup may exist simultaneously, and a singleton will make the switch on page A affect page B, which is painful to debug.

② On commit, both persist and propagate.

function commit(pref, source) {
  savePrefs(pref); // write to localStorage, survives refresh
  notify(pref, source); // notify panels to update selected state
  forEachLive((v) => {
    // propagate to other live instances
    if (v === source) return;
    applyVisibility(groupsOf(v), pref);
  });
}

"Live instances" are collected with WeakRef and removed with FinalizationRegistry. Not doing this causes memory leaks: when a map instance is destroyed, the collection still holds it, and a later switch writes state to a dead object.

③ The storage event only solves half the problem.

The storage event fires only when another document modifies it. Calling localStorage.setItem in the same tab will not call back to itself.

So: cross-tab sync uses storage listener; while "other logic inside this page writes the preference" (most typically after login, loading config from the server) must be pulled explicitly. ④ Don't use module-level snapshots. This is the deepest pit.

ESM modules are only evaluated once per page session. If you read the preference into a module-scope variable during initialization, then along the path "log out → log in → server sends new config", that variable will always hold the value from the first evaluation. The symptom: DevTools shows the new localStorage, but the page still shows the old one, and pressing F5 fixes it—that kind of "refresh fixes it" bug is very likely a snapshot problem.

So always read the storage live during initialization, don't trust the value from the moment the module was evaluated.

In addition to the storage listener, I added three active pull points: after successful login, before entering the map page, and when a cached page is reactivated (keep-alive pages don't rerun mounted; miss this and it's "coming back still shows the old one").

Checklist (Section 5)

  1. Wrap reads and writes of the preference into two functions, use only these across the whole project, do not touch localStorage directly in business logic;
  2. Commit = persist + notify UI + propagate to other live instances;
  3. storage listener + three active pulls (login success / entering map page / page reactivation);
  4. Whitelist-validate plus silent fallback for server-provided values, a bad config must not block the login flow.

6. Pre-launch checks I'd do again

All of these are actual verified actions; skipping them could break the release in any of the ways above:

  1. Pixel-level alignment: the same WGS84 point on both basemaps, within a few pixels;
  2. Seams: continuously zoom from the minimum level to the maximum, then back, watching for any thin gap or ghosting;
  3. Out-of-range levels: zoom to AMap's maximum (18), then switch back to Tianditu, no blank or blurry blocks;
  4. Labels: the "roads and labels" toggle works on both basemaps, and doesn't mismatch after switching;
  5. Multiple instances: open a business popup, its map matches the main one; close the popup and switch, the main map unaffected;
  6. Three state paths: refresh, cross-tab, and re-login all preserve the preference;
  7. Cleanup: repeatedly open/close popups, memory doesn't continuously rise (the WeakRef set is actually working).

About resources: both Tianditu and AMap offer free developer quotas—register on Tianditu's website to get a key, register an app in AMap's open platform to get a key. Individual developers can run through all the above with zero cost. However, quotas, commercial licensing, and terms vary significantly; always check the official open platform's current terms before launch.


7. Summary

If I had to sum up this migration in one sentence:

Switching from Tianditu to AMap really isn't about changing an address—it's three things: forcing the coordinate conversion into the tile mapping layer, fetching tiles per provider's rules, and making the preference a state that propagates on its own.

Symptom Root cause Fix
Points collectively offset by hundreds of meters WGS84 vs GCJ-02 basis differs, offset varies with lat/lng Do a bidirectional conversion in the tile scheme, strictly invert forward and reverse
Fine seams when zooming Reverse used an approximate solution, tile rectangles misalign Iterative inversion, four rounds converge
Labels can't be turned off Server bakes roads and names into imagery tiles Use "imagery + transparent labels" pair
Switch flashes and camera resets Rebuild provider on each switch Load four layers once, only toggle show
Popup maps don't follow the switch Preference is one-time parameter WeakMap isolates instances + propagate on commit
Refresh / re-login revert Module-level snapshot used Read storage live at init
Cross-page not working storage event doesn't call back in the same page Listener + active pull, two legs

Finally, one thing that surprised me: the time sink wasn't "how to switch" but "why the switch is wrong". The former is just building strings from a parameter table; the latter demands you first think through who should change and who shouldn't.

Once that's clear, adding a third basemap provider becomes only one task—add a new row to that parameter table.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

卷土的土

Coordinate transformation should be decoupled from business data.