跳到正文
DEV Community· Yurii Nazarenko·· 2 小時前AI 評分45

TapMap 不用地圖聚類,將整個國家的地點資料放在手機端

A whole country on the phone, without map clustering

AI 導讀

TapMap 將 26,400 個已發布地點、分佈於 6,185 個城市的整個資料庫放在手機端,地圖不使用聚類。首次載入下載 2.9 MB 原始數據(壓縮後 0.50 MB),其後只同步變更;團隊亦透過按視窗查詢及調整地圖渲染,減少數據量並改善卡頓。

正文

We're building TapMap, a social network built on maps: instead of a feed, you get a map of places, people and their own maps. Stack: Flutter (Android, iOS and web from one codebase), Mapbox (mapbox_maps_flutter on phones, mapbox-gl-js in the browser) and self-hosted Supabase on Postgres 17 with PostGIS.

Today the database holds 26,400 published places in 6,185 cities, and the whole thing lives on the phone. There is no clustering, and there won't be. Below is how we got here, with the numbers for every step. Most of them are not flattering.

Why no clustering

A bubble that says "47" doesn't answer "where should I go?". You see that there's a lot of something somewhere, zoom in, the bubble splits into smaller bubbles, zoom in again. By the fourth zoom people close the app. We wanted specific places visible at every zoom level: the strongest ones from far away, all of them up close.

The product owner made the call with no compromise: no bubbles. Our job was to keep the map alive anyway.

Step 0. Where we started: two megabytes per tap

At 3,738 points, map_points() with no arguments returned 2,093 KB on every launch, wherever the user was looking.

The client was worse. Every filter ran "string → jsonDecode → filter → jsonEncode → string", three filters in a row, plus a merge with the user's own points. That's five parses and four serialisations of two megabytes of JSON per filter tap, on the main isolate. If there was an event with a countdown in the next 24 hours, all of it repeated once a second.

The painful part: the function had city_slug, bbox and max_points parameters from day one. The client simply never passed them.

Step 1. Parse once, query by viewport

  • Parse once. JSON becomes a list of objects, all filters work on lists, and the string for the map is built once at the end.
  • Query by viewport. When the camera stops, the screen reports its bounds. We pad the window by a screen width on each side and cap by zoom. A new request goes out only when the view leaves the loaded area or zoom crosses a threshold. Panning inside the padding costs zero network and zero parsing.

Production numbers: the Phuket viewport returned 432 points, 240 KB; a tight Patong window 146 points, 81 KB. That's 9× and 26× less than 2,093 KB.

Step 2. "The points disappeared"

A day later we got a complaint: "most of the points on the map are gone". The first thinning caps were too greedy: 200 points below zoom 8, 400 below zoom 10.

  • All of Phuket in frame: 432 points uncapped, 400 capped. The 26 quietest beaches and nature spots vanished.
  • Southern Thailand in one frame: 927 uncapped, 200 capped. Three quarters gone.

We set a rule: a single city is never thinned. If someone is looking at a whole city, hiding points from them isn't optimisation, it's data loss. New caps: 400 up to zoom 7, 900 up to 9, 1,600 up to 11, everything after. Bangkok at "whole city in frame" gives 531 points under a 900 cap; Phuket shows all of its points. Thinning only starts when several cities are in frame.

Step 3. Pins that lost silently

Place pins in Mapbox didn't overlap (icon-allow-overlap: false). On collision the higher-rated pin stayed and the other one wasn't drawn at all. Below zoom 11 there was a layer of small dots for the "losers"; from zoom 11 it switched off. Result: at zooms 11–14 in dense areas most pins disappeared without a trace. On Kata Noi beach you could see one pin out of two; in Rawai, one out of four.

The fix: from zoom 11 pins may overlap, and only labels give way to each other. The threshold matches the zoom where labels turn on, on purpose.

Step 4. "The map freezes for 1–2 seconds". It was the decor

On a powerful phone the map would freeze for a second or two. On a weak one it didn't. That alone said it wasn't the points.

The cause was the scenery: trees, cars and 3D buildings. 700 ms after every camera stop, the decor asked Mapbox for roads, buildings and empty lots via querySourceFeatures. That method returns geometry from every loaded tile, not just the screen, and all of it gets decoded on the main isolate. Decor is disabled on weak phones, which is why they never froze.

Measured: Patong, zoom 15. Buildings alone took 3,006 polygons in 2,206 ms; the whole pass took 2,900 ms.

What we changed:

  1. Switched to queryRenderedFeatures for the screen rectangle and specific layers. For roads and empty lots we added two transparent "probe layers" with near-zero opacity. Exactly zero doesn't work: the engine doesn't render such a layer and returns nothing for it, so it's 0.02.
  2. Decor is re-placed only when the camera moves a quarter of the screen or half a zoom step, not after every stop.
  3. The delay before re-placing went from 0.7 s to 1.2 s, so it doesn't land between two finger movements.

Same frame afterwards: 71–184 ms instead of 2,900.

Step 5. Cold start: that second wasn't the network

Next complaint: "sometimes you wait a second before the points appear". Points were already cached in the background. The second was eaten by the queue of work before the first pin:

  • 8 glTF decor models and buildings waited before the "map ready" signal. Now decor comes in 700 ms after the points.
  • About 35 icons were decoded into bitmaps one by one just to read width and height. Now size is read from the PNG header and registration runs in parallel.
  • 72 animation frame layers were added in 72 separate calls. Now it's one batch.

Emulator, debug build: first frame +4.4 s → +1.6 s, points after first frame ~6 s → ~1.1 s, image registration 2,367 → 241 ms.

Step 6. The whole database on the phone

When places reached tens of thousands, we did what first looked reckless: we put the entire database on the phone.

  • First launch downloads all of map_points(). For 26,400 points that's 2.9 MB raw and 0.50 MB gzipped.
  • After that the app only asks for a delta: map_points_changes(since).
  • The first frame is drawn from disk: camera, layers and ranks. Fresh data lands on top in the same places.

Step 7. Ranking decides who's visible, not bubbles

Without clusters, something has to decide which point shows at which zoom. That's PointsRanking:

  • Importance comes from review count, rating and category weight.
  • Below zoom 9 the most important point wins in each grid cell.
  • From zoom 9 a point is visible if its pin has free space without collisions, like Google Maps and OpenMapTiles do it.
  • From zoom 17 everything is visible.
  • Chains like 7-Eleven and Starbucks only get the space left over by regular places.

The key fix came later: the selection is computed once per filter over the whole on-device database, not per camera window, and ties are broken by id. So every point has the same "appearance zoom" no matter how you move the map. Before, a point could blink on a two-pixel pan just because the window was recomputed and its cell neighbours changed.

Where the ceiling is

This can't last forever. There's a constant PointsCache.cap = 40,000: that's how many points still fit on a phone. It's the trigger to move to vector tiles (PMTiles), where thinning happens on the server at tile generation, not on the client. For now we're at 26 thousand, and the map opens faster than it used to at 3.7 thousand.

Takeaways

  1. Measure before you fix. The freeze "caused by points" was decor. The "one second of loading" was image registration, not the network.
  2. The most dangerous bugs don't crash. A pin that silently lost to its neighbour leaves no log and no crash report. You only see it on a before/after screenshot.
  3. Thinning is a product decision, not a technical one. "Never hide points inside a single city" is a product rule, and once we had it, every number fell into place.

If you're curious how the place cards are built (an LLM pipeline enriches them) or how we score a point's importance, ask in the comments and we'll write it up.

TapMap is a social network built on maps. Free on Android and on the web: tapmap.net.

來源:DEV Community · dev.to