| name | optimize-large-map-data |
| description | Optimize Leaflet map khi render hàng chục/trăm k markers. Patterns chuẩn — viewport-bounded RPC, adaptive tile sizing, containment skip cho zoom-in, IndexedDB persist, marker clustering. Use khi page map lag/slow load do dataset lớn (>10k markers). |
Skill: optimize-large-map-data
Reusable patterns cho map page với dataset lớn (>10k markers). Built lần đầu 2026-05-05 cho MapComponent của NetworkPage (~22k companies, scale tới 100k+).
Đọc trước
- [[docs/Architecture/Map Performance.md]] — full architecture + 6 bottlenecks đã giải quyết + tunables
src/features/network/MapComponent.tsx — implementation reference (ViewportLoader, MapBoundsTracker, CompanyMarkerClusterGroup)
src/features/network/services/companyLocationsService.ts — service layer (fetchCompaniesInBounds, tileForBounds)
supabase/migrations/20260505040000_companies_in_view_rpc.sql + 20260505070000_companies_in_view_uncapped.sql — server-side bbox RPC
Khi nào dùng
- Page map mới với dataset >5k markers (events, opportunities có địa điểm, stores, projects)
- Page map hiện tại lag scroll/zoom
- Initial load chậm vì fetch toàn bộ dataset upfront
- localStorage quota exceeded warning trong console
- Reload page mất nhiều giây để render markers
6 patterns chuẩn
1. Server-side viewport query với composite index
DB có column location_lat, location_lng (double precision). Tạo composite btree index:
create index if not exists <table>_latlng_idx
on public.<table> (location_lat, location_lng)
where location_lat is not null and location_lng is not null;
RPC <entity>_in_view:
create or replace function public.<entity>_in_view(
min_lat float, min_lng float, max_lat float, max_lng float,
max_count int default 2000
)
returns table (...)
language sql stable security definer
set search_path = public
as $$
select <cols>
from public.<table>
where location_lat is not null and location_lng is not null
and location_lat between min_lat and max_lat
and location_lng between min_lng and max_lng
order by id
limit greatest(1, least(max_count, 1000000));
$$;
grant execute on function public.<entity>_in_view(float, float, float, float, int)
to anon, authenticated;
Postgres planner xử lý simple BETWEEN với composite index range scan rất nhanh — không cần PostGIS cho simple bbox queries với scale <1M points.
2. Adaptive tile sizing
Tile size scale theo viewport span:
export type Tile = { key: string; bounds: Bounds };
export function tileForBounds(b: Bounds): Tile {
const span = Math.max(b.north - b.south, b.east - b.west);
let size: number;
if (span > 16) size = 32;
else if (span > 8) size = 16;
else if (span > 4) size = 8;
else if (span > 2) size = 4;
else if (span > 1) size = 2;
else if (span > 0.5) size = 1;
else if (span > 0.25) size = 0.5;
else if (span > 0.1) size = 0.25;
else if (span > 0.05) size = 0.1;
else size = 0.05;
const floorTo = (n: number) => Math.floor(n / size) * size;
const ceilTo = (n: number) => Math.ceil(n / size) * size;
const south = floorTo(b.south);
const west = floorTo(b.west);
const north = ceilTo(b.north);
const east = ceilTo(b.east);
return {
key: `s${size}:${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}`,
bounds: { south, west, north, east },
};
}
Tile size luôn ≥ viewport span × ~2 → micro-pan trong cùng tile = cache hit, fetch mới khi pan đáng kể.
3. ViewportLoader child component
function ViewportLoader({ onTileLoaded, onLoadingChange }) {
const map = useMap();
const queryClient = useQueryClient();
const loadedKeysRef = useRef<Set<string>>(new Set());
const loadedBoundsRef = useRef<Bounds[]>([]);
useEffect(() => {
let cancelled = false;
let timer = null;
const fetchForCurrent = async () => {
const tile = tileForBounds(currentBounds);
if (loadedBoundsRef.current.some((bb) => isBoundsContained(tile.bounds, bb))) return;
if (loadedKeysRef.current.has(tile.key)) return;
loadedKeysRef.current.add(tile.key);
try {
const rows = await queryClient.fetchQuery({
queryKey: ['/api/public/<entity>-in-view', tile.key],
queryFn: () => fetchInBounds(tile.bounds, 1_000_000),
staleTime: 30 * 60 * 1000,
gcTime: 7 * 24 * 60 * 60 * 1000,
});
if (!cancelled) {
if (rows.length > 0) onTileLoaded(rows);
loadedBoundsRef.current = mergeBounds(loadedBoundsRef.current, tile.bounds);
}
} catch (e) {
loadedKeysRef.current.delete(tile.key);
}
};
const debounced = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(fetchForCurrent, 300);
};
const initialTimer = setTimeout(fetchForCurrent, 1200);
map.on('moveend', debounced);
return () => { cancelled = true; clearTimeout(initialTimer); };
}, [map, queryClient]);
return null;
}
4. Containment skip cho zoom in
function isBoundsContained(inner: Bounds, outer: Bounds): boolean {
return inner.south >= outer.south && inner.west >= outer.west &&
inner.north <= outer.north && inner.east <= outer.east;
}
function mergeBounds(arr: Bounds[], next: Bounds): Bounds[] {
if (arr.some((b) => isBoundsContained(next, b))) return arr;
const kept = arr.filter((b) => !isBoundsContained(b, next));
kept.push(next);
return kept;
}
→ Zoom out country (fetch all) → zoom in city = 0 RPC, markers đã trong state.
5. Marker clustering với leaflet.markercluster
npm install leaflet.markercluster @types/leaflet.markercluster
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
function MarkerClusterGroup({ items, ...handlers }) {
const map = useMap();
const groupRef = useRef<L.MarkerClusterGroup | null>(null);
useEffect(() => {
const group = L.markerClusterGroup({
maxClusterRadius: 60,
spiderfyOnMaxZoom: false,
showCoverageOnHover: false,
chunkedLoading: true,
chunkInterval: 200,
disableClusteringAtZoom: 18,
});
map.addLayer(group);
groupRef.current = group;
return () => { map.removeLayer(group); };
}, [map]);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
group.clearLayers();
const markers = items.map((item) => {
const m = L.marker([item.lat, item.lng], { icon: buildIcon(item) });
m.bindTooltip(htmlString, { direction: 'top' });
m.on('click', () => handlers.onClick(item));
return m;
});
group.addLayers(markers);
}, [items, ...]);
return null;
}
Tooltips dùng bindTooltip(htmlString) thay vì React <Tooltip> — tránh re-render mỗi marker khi pan/zoom.
6. IndexedDB persist qua localforage
npm install localforage @tanstack/query-async-storage-persister
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import localforage from 'localforage';
const cacheStore = localforage.createInstance({
name: '<app>',
storeName: 'react-query-cache',
});
const asyncStorage = {
getItem: async (k) => (await cacheStore.getItem(k)) ?? null,
setItem: async (k, v) => { await cacheStore.setItem(k, v); },
removeItem: async (k) => { await cacheStore.removeItem(k); },
};
export const queryPersister = createAsyncStoragePersister({
storage: asyncStorage,
key: `<app>.rq-cache.v1`,
throttleTime: 1000,
});
App.tsx (1 lần thôi cho toàn app):
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: 7 * 24 * 60 * 60 * 1000,
buster: CACHE_VERSION,
}}
>
Counter "trong viewport"
Khi user muốn biết "có bao nhiêu markers trong khung nhìn", tách MapBoundsTracker:
function MapBoundsTracker({ onChange }) {
const map = useMap();
useEffect(() => {
const update = () => {
const b = map.getBounds();
onChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => { map.off('moveend', update); map.off('zoomend', update); };
}, [map, onChange]);
return null;
}
Parent: filter accumulated state by current bounds → counter.
Khi nào KHÔNG cần skill này
- Map có <1k markers → dùng pattern đơn giản hơn (single useQuery + filter client-side)
- Map dùng cho 1 vị trí cụ thể (vd company profile address) → không cần clustering
- Realtime markers update — pattern này tối ưu cho data ít thay đổi
Liên quan
- [[docs/Architecture/Map Performance.md]] — full architecture
- [[add-address-autocomplete]] — khi cần thêm field address với lat/lng (1 record, không phải bulk)
- [[pre-geocode-companies]] — pre-fill lat/lng vào DB trước khi map render
- [[bulk-import-companies]] — import dataset cần geocode + map