Pular para o conteúdo principal

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

ArquivoResponsabilidade
features/client/components/map/map.client.controller.tsCarrega clientes com coordenadas e monta o índice de clustering
features/client/components/map/map.client.tsxRenderiza o mapa nativo (Apple Maps / Google Maps) e os marcadores
features/client/components/map/components/cluster-list-modal.tsxLista 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.