Mapa de Clientes
Última atualização: 15 de julho de 2026
Visão Geral
Existem dois recursos de mapa completamente diferentes dentro da carteira, e vale não confundi-los. Este documento cobre a aba Mapa interna da carteira, que mostra todos os clientes com coordenadas de uma vez, agrupados em clusters. O botão "Como Chegar", que abre o aplicativo de mapas nativo do aparelho para um único cliente, é outro recurso, documentado em Seleção e Navegação.
Arquivos-chave
| Arquivo | Responsabilidade |
|---|---|
features/client/components/map/map.client.controller.ts | Carrega clientes com coordenadas e monta o índice de clustering |
features/client/components/map/map.client.tsx | Renderiza o mapa nativo (Apple Maps / Google Maps) e os marcadores |
features/client/components/map/components/cluster-list-modal.tsx | Lista os clientes de um cluster ao tocar nele |
Carregando os clientes com coordenadas
useMapClientController reaproveita o controller da carteira (useClientController), lendo dele a busca, a letra e os filtros já aplicados na lista, para que o mapa mostre exatamente o mesmo recorte de clientes que a aba Carteira estaria mostrando. Os dados só são carregados enquanto a aba Mapa está de fato visível:
// map.client.controller.ts
const { search, letter, selectedFilters, service, navigatorBar } = controller;
const isMapVisible = navigatorBar === "Mapa";
const load = useCallback(async () => {
if (!isMapVisible) return;
const gen = ++requestGenRef.current;
// ...
const result = await service.listAllWithCoordinates({
search, letterSearch: letter, sortBy: "nomeFantasia", checkOptions: selectedFilters,
});
if (gen !== requestGenRef.current) return;
setClients(result);
}, [isMapVisible, search, letter, selectedFilters, service]);
A checagem isMapVisible evita consultar o banco para montar clusters quando o usuário nem está olhando para essa aba. O carregamento também é debounced em 300ms sempre que busca, letra ou filtros mudam, e usa o mesmo padrão de número de geração para descartar respostas antigas visto em Lista, Busca e Filtros.
Transformando clientes em pontos do mapa
Cada cliente com latitudeEntrega/longitudeEntrega válidas se torna um ponto GeoJSON, com um título que respeita a preferência do usuário de exibir razão social ou nome fantasia primeiro:
function buildClientPoint(client: Client, isEnabledSwitchName: boolean): ClientPoint | null {
const lat = parseCoordinate(client.latitudeEntrega);
const lng = parseCoordinate(client.longitudeEntrega);
if (lat === null || lng === null) return null;
const title = (isEnabledSwitchName
? client.razaoSocial || client.nomeFantasia
: client.nomeFantasia || client.razaoSocial) || "Cliente";
// ...
return { type: "Feature", geometry: { type: "Point", coordinates: [lng, lat] }, properties: { cluster: false, clientId: String(client.id), client, title, snippet } };
}
Clientes sem coordenadas válidas simplesmente não entram no mapa, e a tela mostra um aviso de "Nenhum cliente com coordenadas disponível" quando a lista de pontos fica vazia.
Clustering com Supercluster
Os pontos são indexados numa instância de Supercluster, reconstruída sempre que a lista de pontos muda:
const SUPERCLUSTER_CONFIG = { radius: 60, maxZoom: 18, minZoom: 0 };
const clusterIndex = useMemo(() => {
const index = new Supercluster<GeoClientProperties, GeoClusterProperties>(SUPERCLUSTER_CONFIG);
index.load(points);
return index;
}, [points]);
A cada movimento de câmera no mapa, a tela recalcula quais clusters ou pontos individuais devem aparecer, passando a área visível e o zoom atual para o índice:
const getClusters = useCallback(
(currentRegion: MapRegion, zoom: number) => {
const bbox = regionToBBox(currentRegion);
const clusterZoom = toClusterZoom(zoom);
return clusterIndex.getClusters(bbox, clusterZoom);
},
[clusterIndex],
);
Renderização nativa por plataforma
O mapa em si não é um MapView genérico, é renderizado com expo-maps, usando componentes diferentes por plataforma (AppleMaps.View no iOS, GoogleMaps.View no Android), cada um recebendo sua própria lista de marcadores montada a partir dos clusters visíveis:
// map.client.tsx
{Platform.OS === "ios" ? (
<AppleMaps.View
markers={iosMarkers}
annotations={iosAnnotations}
onMarkerClick={(marker) => handleId(marker.id)}
onAnnotationClick={(annotation) => handleId(annotation.id)}
onCameraMove={handleCameraMove}
/>
) : (
<GoogleMaps.View
markers={androidMarkers}
onMarkerClick={(marker) => handleId(marker.id)}
onCameraMove={handleCameraMove}
/>
)}
No iOS, um cluster é desenhado como uma "annotation" colorida com a contagem de clientes dentro, enquanto no Android ele aparece como um marcador com título "N clientes". Um cliente individual aparece como marcador simples nos dois casos.
O que acontece ao tocar num marcador
handleId decide, pelo prefixo do id, se o toque foi num cluster ou num cliente individual:
const handleId = useCallback((id?: string) => {
if (!id) return;
if (id.startsWith("cluster_")) {
const clusterId = parseInt(id.replace("cluster_", ""), 10);
setClusterState({ visible: true, clusterId });
} else {
controller.handleMarkerPress(id);
}
}, [controller]);
Tocar num cluster abre o ClusterListModal, que lista todos os clientes daquele cluster através de getClusterLeaves. Tocar num cliente individual chama handleMarkerPress, que faz exatamente a mesma coisa que tocar num card na lista da aba Carteira: define selectedClient e abre o mesmo modal de opções documentado em Seleção e Navegação. O mapa não tem seu próprio conjunto de ações, ele reaproveita o modal da carteira por completo.
Armadilhas conhecidas
O controller calcula um defaultRegion inteligente, centrado no primeiro cliente com coordenadas, e uma constante DEFAULT_ZOOM, mas map.client.tsx não usa nenhum dos dois: a câmera inicial do mapa vem de uma constante própria da tela (INITIAL_CAMERA), fixa em São Paulo com zoom 7, independentemente de onde os clientes do vendedor realmente estejam. Antes de assumir que o mapa abre focado nos clientes do usuário, confirme se esse comportamento já foi ligado ou se ainda depende de controller.defaultRegion ser conectado à câmera.