inclusão de mapas

This commit is contained in:
Luiz Silva 2026-06-06 18:54:24 -03:00
parent b564c2c302
commit 6b9a9dc705
20 changed files with 4200 additions and 3696 deletions

View file

@ -27,11 +27,14 @@
```bash
pnpm add eli-vue
# Dependências (Peer Dependencies)
pnpm add vue vuetify lucide-vue-next
pnpm add vue vuetify lucide-vue-next leaflet
pnpm add -D @types/leaflet
```
Certifique-se de que seu projeto esteja configurado com Vuetify 3.
> **Nota**: O CSS do Leaflet é incluído automaticamente no bundle do eli-vue. Não é necessário importar `leaflet/dist/leaflet.css` manualmente.
---
## 📦 Referência de Componentes
@ -249,7 +252,9 @@ Um conjunto de wrappers padronizados em torno dos campos do Vuetify.
- `EliEntradaNumero`: Inteiros, Decimais, Moeda.
- `EliEntradaDataHora`: Data, DataHora.
- `EliEntradaSelecao`: Select/Dropdown (pode ser assíncrono).
- `EliEntradaSelecaoMultipla`: Multi-select (pode ser assíncrono).
- `EliEntradaParagrafo`: Textarea.
- `EliEntradaCoordenadas`: Mapa Leaflet interativo com inputs de lat/lng.
#### API Comum
Todas as entradas aceitam duas props principais:
@ -299,6 +304,22 @@ opcoes: {
}
```
**EliEntradaCoordenadas**
```typescript
// value: { formato: "decimal", datum: "EPSG:4326", latitude: number, longitude: number }
opcoes: {
rotulo: string;
zoom?: number; // Nível de zoom inicial (padrão: 4)
alturaMapa?: number; // Altura do mapa em pixels (padrão: 300)
apenasLeitura?: boolean; // Desabilita clique e arraste no mapa
}
```
> **Importante**: Requer `import "leaflet/dist/leaflet.css"` no entry point do app consumidor.
> O componente inclui: mapa Leaflet interativo, inputs editáveis de lat/lng (aceita vírgula BR),
> marcador arrastável, botão "Usar minha localização" (GPS + GeoIP fallback),
> e seletor de camadas (Mapa / Satélite).
#### Exemplo de Uso
```vue

6990
dist/eli-vue.es.js vendored

File diff suppressed because one or more lines are too long

124
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -8,43 +8,24 @@ export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: PropType<EntradaCoordenadas["value"]>;
default: () => {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
};
default: null;
};
opcoes: {
type: PropType<EntradaCoordenadas["opcoes"]>;
required: true;
};
}>, {
attrs: import("vue").Attrs;
emit: ((event: "input", _v: {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => void) & ((event: "blur") => void) & ((event: "update:value", _v: {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => void) & ((event: "change", _v: {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => void) & ((event: "focus") => void);
mapaRef: import("vue").Ref<HTMLElement | null, HTMLElement | null>;
alturaEfetiva: import("vue").ComputedRef<number>;
geolocalizando: import("vue").Ref<boolean, boolean>;
possuiValor: import("vue").ComputedRef<boolean>;
latDisplay: import("vue").ComputedRef<string>;
lngDisplay: import("vue").ComputedRef<string>;
onLatChange: (v: string | number) => void;
onLngChange: (v: string | number) => void;
usarMinhaLocalizacao: () => Promise<void>;
limpar: () => void;
attrs: import("vue").Attrs;
mapaRef: import("vue").Ref<HTMLElement | null, HTMLElement | null>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
"update:value": (_v: EntradaCoordenadas["value"]) => true;
input: (_v: EntradaCoordenadas["value"]) => true;
@ -54,12 +35,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
value: {
type: PropType<EntradaCoordenadas["value"]>;
default: () => {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
};
default: null;
};
opcoes: {
type: PropType<EntradaCoordenadas["opcoes"]>;
@ -71,20 +47,20 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => any) | undefined;
} | null | undefined) => any) | undefined;
onBlur?: (() => any) | undefined;
"onUpdate:value"?: ((_v: {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => any) | undefined;
} | null | undefined) => any) | undefined;
onChange?: ((_v: {
formato: "decimal";
datum: "EPSG:4326";
latitude: number;
longitude: number;
}) => any) | undefined;
} | null | undefined) => any) | undefined;
onFocus?: (() => any) | undefined;
}>, {
value: {
@ -92,5 +68,5 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
datum: "EPSG:4326";
latitude: number;
longitude: number;
};
} | null | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,7 @@
/**
* Tipos base para componentes de entrada (EliEntrada*)
*
* Objetivo:
* - Padronizar o shape de dados de todos os componentes de entrada.
* - Cada entrada possui sempre:
* 1) `value`: o valor atual (tipado)
* 2) `opcoes`: configuração do componente (rótulo, placeholder e extras por tipo)
*
* Como usar:
* - `PadroesEntradas[tipo]` retorna a tipagem completa (value + opcoes) daquele tipo.
* - `TipoEntrada` é a união com todos os tipos suportados (ex.: "texto" | "numero").
* Tipos base para componentes de entrada (EliEntrada*).
* PadroesEntradas[tipo] → tipagem completa (value + opcoes).
* TipoEntrada → união de todos os tipos suportados.
*/
import type { zCoordenadas } from "p-comuns";
import type z from "zod";
@ -174,11 +166,18 @@ export type PadroesEntradas = {
/** Densidade do campo (Vuetify). */
densidade?: import("../../tipos").CampoDensidade;
}>;
coordenadas: tipoPadraoEntrada<z.infer<typeof zCoordenadas>, {
coordenadas: tipoPadraoEntrada<z.infer<typeof zCoordenadas> | null | undefined, {
/** Nível de zoom inicial do mapa Leaflet (default: 4) */
zoom?: number;
/** Altura do container do mapa em pixels (default: 300) */
alturaMapa?: number;
/** Camadas vetoriais GeoJSON (contornos do IBGE) */
camadas?: {
/** Exibir contornos dos estados */
estados?: boolean;
/** Exibir contornos dos municípios (carregados por UF conforme viewport) */
municipios?: boolean;
};
}>;
};
/**

View file

@ -0,0 +1,11 @@
import L from "leaflet";
export interface OpcoesCamadas {
estados?: boolean;
municipios?: boolean;
}
/**
* Inicializa camadas vetoriais GeoJSON no mapa Leaflet.
* - Estados: overlay imediato
* - Municípios: sob demanda por UF, com 3 níveis de detalhe por zoom
*/
export declare function inicializarCamadas(mapa: L.Map, opcoes: OpcoesCamadas): void;

View file

@ -0,0 +1,22 @@
import { type Ref } from "vue";
import type { PadroesEntradas } from "../tiposEntradas";
type EntradaCoordenadas = PadroesEntradas["coordenadas"];
type EmitFn = (event: string, ...args: unknown[]) => void;
interface OpcoesComposable {
value: () => EntradaCoordenadas["value"];
opcoes: () => EntradaCoordenadas["opcoes"];
emit: EmitFn;
mapaRef: Ref<HTMLElement | null>;
}
export declare function useCoordenadas({ value, opcoes, emit, mapaRef }: OpcoesComposable): {
alturaEfetiva: import("vue").ComputedRef<number>;
geolocalizando: Ref<boolean, boolean>;
possuiValor: import("vue").ComputedRef<boolean>;
latDisplay: import("vue").ComputedRef<string>;
lngDisplay: import("vue").ComputedRef<string>;
onLatChange: (v: string | number) => void;
onLngChange: (v: string | number) => void;
usarMinhaLocalizacao: () => Promise<void>;
limpar: () => void;
};
export {};

View file

@ -1070,6 +1070,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[], import("./useFiltroAvancado").LinhaFiltro[] | {
@ -1165,6 +1169,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[]>;
@ -1216,12 +1224,12 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
default: string;
};
}>> & Readonly<{
onLimpar?: (() => any) | undefined;
onFechar?: (() => any) | undefined;
onSalvar?: ((_linhas: {
chave: string;
valor: unknown;
}[], _formula?: string | undefined) => any) | undefined;
onLimpar?: (() => any) | undefined;
}>, {
modeloFormula: string;
}, {}, {

View file

@ -119,6 +119,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[], import("./useFiltroAvancado").LinhaFiltro[] | {
@ -214,6 +218,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[]>;
@ -267,12 +275,12 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
default: string;
};
}>> & Readonly<{
onLimpar?: (() => any) | undefined;
onFechar?: (() => any) | undefined;
onSalvar?: ((_linhas: {
chave: string;
valor: unknown;
}[], _formula?: string | undefined) => any) | undefined;
onLimpar?: (() => any) | undefined;
}>, {
modeloFormula: string;
}, {}, {

View file

@ -150,6 +150,10 @@ export declare function useFiltroAvancado({ aberto, filtrosBase, modelo, modeloF
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[], LinhaFiltro[] | {
@ -245,6 +249,10 @@ export declare function useFiltroAvancado({ aberto, filtrosBase, modelo, modeloF
apenasLeitura?: boolean | undefined;
zoom?: number | undefined;
alturaMapa?: number | undefined;
camadas?: {
estados?: boolean | undefined;
municipios?: boolean | undefined;
} | undefined;
}];
valor: unknown;
}[]>;

View file

@ -1,6 +1,6 @@
{
"name": "eli-vue",
"version": "0.1.198",
"version": "0.1.208",
"private": false,
"main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js",
@ -23,6 +23,7 @@
"fechar": "fuser -k 5555/tcp || true"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"vue": "^3.4.0",
"vuetify": "^3.0.0"
},
@ -31,6 +32,7 @@
"@mdi/font": "^7.4.47",
"@types/leaflet": "^1.9.21",
"@vitejs/plugin-vue": "^5.0.0",
"leaflet": "^1.9.4",
"sass": "^1.94.2",
"typescript": "^6.0.3",
"vite": "^6.0.0",
@ -45,7 +47,6 @@
"alasql": "^4.17.3",
"cross-fetch": "^4.1.0",
"dayjs": "^1.11.19",
"leaflet": "^1.9.4",
"p-comuns": "git+https://git2.idz.one/publico/_comuns.git",
"p-respostas": "git+https://git2.idz.one/publico/_respostas.git",
"uuid": "^13.0.0",

52
scripts/baixar-geojson-ibge.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# Baixa GeoJSON de estados e municípios da API do IBGE (v3)
# e organiza na estrutura esperada pelo componente EliEntradaCoordenadas.
#
# Estrutura gerada:
# ./geo/
# ├── estados.geojson
# └── municipios/
# ├── 11.geojson (RO)
# ├── 12.geojson (AC)
# └── ...
#
# Após rodar, faça upload para o MinIO:
# mc cp --recursive ./geo/ paiol/geo/
# ──────────────────────────────────────────────────────────────
set -euo pipefail
BASE_URL="https://servicodados.ibge.gov.br/api/v3/malhas"
QUALIDADE="minima"
FORMATO="application/vnd.geo+json"
SAIDA="./geo"
UFS=(11 12 13 14 15 16 17 21 22 23 24 25 26 27 28 29 31 32 33 35 41 42 43 50 51 52 53)
mkdir -p "$SAIDA/municipios"
echo "📥 Baixando estados..."
curl -sS "$BASE_URL/paises/BR?intrarregiao=UF&formato=$FORMATO&qualidade=$QUALIDADE" \
-o "$SAIDA/estados.geojson"
TAMANHO=$(du -h "$SAIDA/estados.geojson" | cut -f1)
echo " ✅ estados.geojson ($TAMANHO)"
echo ""
echo "📥 Baixando municípios por UF..."
for UF in "${UFS[@]}"; do
echo -n " UF $UF... "
curl -sS "$BASE_URL/estados/$UF?intrarregiao=municipio&formato=$FORMATO&qualidade=$QUALIDADE" \
-o "$SAIDA/municipios/$UF.geojson"
TAMANHO=$(du -h "$SAIDA/municipios/$UF.geojson" | cut -f1)
echo "✅ ($TAMANHO)"
sleep 0.5 # respeitar rate-limit do IBGE
done
echo ""
TOTAL=$(du -sh "$SAIDA" | cut -f1)
echo "🎉 Concluído! Total: $TOTAL em $SAIDA/"
echo ""
echo "Para enviar ao MinIO:"
echo " mc alias set paiol https://paiol.idz.one CHAVE_ACESSO CHAVE_SECRETA"
echo " mc cp --recursive $SAIDA/ paiol/geo/"

View file

@ -50,33 +50,27 @@
>
<v-icon>mdi-crosshairs-gps</v-icon>
</v-btn>
<v-btn
v-if="possuiValor && !Boolean(opcoes?.apenasLeitura)"
icon
variant="tonal"
color="error"
size="small"
title="Limpar localização"
@click="limpar"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</div>
</template>
<script lang="ts">
import L from "leaflet"
import "leaflet/dist/leaflet.css"
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
type PropType,
ref,
watch,
} from "vue"
import { defineComponent, type PropType, ref } from "vue"
import "./EliEntradaCoordenadas.css"
import type { PadroesEntradas } from "./tiposEntradas"
import {
ALTURA_MAPA_PADRAO,
COORDENADAS_PADRAO,
coordenadaValida,
formatarCoordenada,
obterLocalizacao,
parseCoordenada,
ZOOM_PADRAO,
} from "./utils/coordenadas"
import { useCoordenadas } from "./utils/useCoordenadas"
type EntradaCoordenadas = PadroesEntradas["coordenadas"]
@ -86,7 +80,7 @@ export default defineComponent({
props: {
value: {
type: Object as PropType<EntradaCoordenadas["value"]>,
default: () => ({ ...COORDENADAS_PADRAO }),
default: null,
},
opcoes: {
type: Object as PropType<EntradaCoordenadas["opcoes"]>,
@ -102,128 +96,15 @@ export default defineComponent({
},
setup(props, { attrs, emit }) {
const mapaRef = ref<HTMLElement | null>(null)
const geolocalizando = ref(false)
let mapa: L.Map | null = null
let marcador: L.Marker | null = null
const alturaEfetiva = computed(() => props.opcoes?.alturaMapa ?? ALTURA_MAPA_PADRAO)
const zoomEfetivo = computed(() => props.opcoes?.zoom ?? ZOOM_PADRAO)
const somenteLeitura = computed(() => Boolean(props.opcoes?.apenasLeitura))
const latDisplay = computed(() =>
formatarCoordenada(props.value?.latitude ?? COORDENADAS_PADRAO.latitude),
)
const lngDisplay = computed(() =>
formatarCoordenada(props.value?.longitude ?? COORDENADAS_PADRAO.longitude),
)
function emitirValor(lat: number, lng: number) {
if (!coordenadaValida(lat, lng)) return
const novoValor: EntradaCoordenadas["value"] = {
formato: "decimal",
datum: "EPSG:4326",
latitude: Number.parseFloat(lat.toFixed(6)),
longitude: Number.parseFloat(lng.toFixed(6)),
}
emit("update:value", novoValor)
emit("input", novoValor)
emit("change", novoValor)
}
function onLatChange(v: string | number) {
const lat = parseCoordenada(v)
if (lat === null) return
emitirValor(lat, props.value?.longitude ?? COORDENADAS_PADRAO.longitude)
}
function onLngChange(v: string | number) {
const lng = parseCoordenada(v)
if (lng === null) return
emitirValor(props.value?.latitude ?? COORDENADAS_PADRAO.latitude, lng)
}
function atualizarMarcador(lat: number, lng: number) {
if (!marcador || !mapa) return
const posAtual = marcador.getLatLng()
if (posAtual.lat === lat && posAtual.lng === lng) return
marcador.setLatLng([lat, lng])
mapa.setView([lat, lng], mapa.getZoom())
}
async function usarMinhaLocalizacao() {
geolocalizando.value = true
try {
const pos = await obterLocalizacao()
emitirValor(pos.latitude, pos.longitude)
} finally {
geolocalizando.value = false
}
}
function inicializarMapa() {
if (!mapaRef.value) return
const lat = props.value?.latitude ?? COORDENADAS_PADRAO.latitude
const lng = props.value?.longitude ?? COORDENADAS_PADRAO.longitude
mapa = L.map(mapaRef.value).setView([lat, lng], zoomEfetivo.value)
const camadaMapa = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
})
const camadaSatelite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ attribution: "© Esri", maxZoom: 19 },
)
camadaMapa.addTo(mapa)
L.control.layers({ Mapa: camadaMapa, Satélite: camadaSatelite }).addTo(mapa)
const arrastavel = !somenteLeitura.value
marcador = L.marker([lat, lng], { draggable: arrastavel }).addTo(mapa)
marcador.on("dragend", () => {
if (!marcador) return
const pos = marcador.getLatLng()
emitirValor(pos.lat, pos.lng)
})
mapa.on("click", (e: L.LeafletMouseEvent) => {
if (somenteLeitura.value) return
emitirValor(e.latlng.lat, e.latlng.lng)
})
}
watch(
() => props.value,
(v) => {
if (!v) return
atualizarMarcador(v.latitude, v.longitude)
},
{ deep: true },
)
onMounted(() => inicializarMapa())
onBeforeUnmount(() => {
if (mapa) {
mapa.remove()
mapa = null
marcador = null
}
})
return {
attrs,
emit,
const coordenadas = useCoordenadas({
value: () => props.value,
opcoes: () => props.opcoes,
emit: emit as (event: string, ...args: unknown[]) => void,
mapaRef,
alturaEfetiva,
geolocalizando,
latDisplay,
lngDisplay,
onLatChange,
onLngChange,
usarMinhaLocalizacao,
}
})
return { attrs, mapaRef, ...coordenadas }
},
})
</script>

View file

@ -1,15 +1,7 @@
/**
* Tipos base para componentes de entrada (EliEntrada*)
*
* Objetivo:
* - Padronizar o shape de dados de todos os componentes de entrada.
* - Cada entrada possui sempre:
* 1) `value`: o valor atual (tipado)
* 2) `opcoes`: configuração do componente (rótulo, placeholder e extras por tipo)
*
* Como usar:
* - `PadroesEntradas[tipo]` retorna a tipagem completa (value + opcoes) daquele tipo.
* - `TipoEntrada` é a união com todos os tipos suportados (ex.: "texto" | "numero").
* Tipos base para componentes de entrada (EliEntrada*).
* PadroesEntradas[tipo] → tipagem completa (value + opcoes).
* TipoEntrada → união de todos os tipos suportados.
*/
import type { zCoordenadas } from "p-comuns"
@ -221,13 +213,19 @@ export type PadroesEntradas = {
>
coordenadas: tipoPadraoEntrada<
z.infer<typeof zCoordenadas>,
z.infer<typeof zCoordenadas> | null | undefined,
{
/** Nível de zoom inicial do mapa Leaflet (default: 4) */
zoom?: number
/** Altura do container do mapa em pixels (default: 300) */
alturaMapa?: number
/** Camadas vetoriais GeoJSON (contornos do IBGE) */
camadas?: {
/** Exibir contornos dos estados */
estados?: boolean
/** Exibir contornos dos municípios (carregados por UF conforme viewport) */
municipios?: boolean
}
}
>
}

View file

@ -0,0 +1,160 @@
import L from "leaflet"
/** URL base do bucket MinIO para arquivos GeoJSON */
const URL_BASE = "https://paiol.idz.one/geo"
/** Zoom mínimo para exibir municípios */
const ZOOM_MUNICIPIOS = 7
/** Zoom a partir do qual usa resolução intermediária */
const ZOOM_HD = 10
/** Zoom a partir do qual usa resolução máxima */
const ZOOM_UHD = 13
/**
* Diretórios no MinIO por nível de detalhe:
* - municipios/ → qualidade mínima (zoom 7-9)
* - municipios_hd/ → qualidade média (zoom 10-12)
* - municipios_uhd/ → qualidade máxima (zoom 13+)
*/
type NivelDetalhe = "municipios" | "municipios_hd" | "municipios_uhd"
/** Cache global — evita re-download entre instâncias do componente */
const cacheGeoJSON = new Map<string, GeoJSON.GeoJsonObject>()
export interface OpcoesCamadas {
estados?: boolean
municipios?: boolean
}
interface InfoEstado {
codigo: string
bounds: L.LatLngBounds
}
async function buscarGeoJSON(url: string): Promise<GeoJSON.GeoJsonObject | null> {
const cached = cacheGeoJSON.get(url)
if (cached) return cached
try {
const resp = await fetch(url)
if (!resp.ok) return null
const dados = (await resp.json()) as GeoJSON.GeoJsonObject
cacheGeoJSON.set(url, dados)
return dados
} catch {
return null
}
}
function estiloEstados(): L.PathOptions {
return {
color: "#1565C0",
weight: 1.5,
fillColor: "#42A5F5",
fillOpacity: 0.05,
dashArray: "4 4",
}
}
function estiloMunicipios(): L.PathOptions {
return {
color: "#78909C",
weight: 0.8,
fillColor: "#B0BEC5",
fillOpacity: 0.03,
}
}
function nivelPorZoom(zoom: number): NivelDetalhe {
if (zoom >= ZOOM_UHD) return "municipios_uhd"
if (zoom >= ZOOM_HD) return "municipios_hd"
return "municipios"
}
/**
* Inicializa camadas vetoriais GeoJSON no mapa Leaflet.
* - Estados: overlay imediato
* - Municípios: sob demanda por UF, com 3 níveis de detalhe por zoom
*/
export function inicializarCamadas(mapa: L.Map, opcoes: OpcoesCamadas): void {
if (!opcoes.estados && !opcoes.municipios) return
carregarEstados(mapa, opcoes)
}
async function carregarEstados(mapa: L.Map, opcoes: OpcoesCamadas): Promise<void> {
const dados = await buscarGeoJSON(`${URL_BASE}/estados.geojson`)
if (!dados) return
const estadosInfo: InfoEstado[] = []
const camada = L.geoJSON(dados, {
style: estiloEstados,
onEachFeature: (feature, layer) => {
const codigo = String(feature.properties?.codarea ?? "")
if (!codigo) return
estadosInfo.push({
codigo,
bounds: (layer as L.Polygon).getBounds(),
})
},
})
if (opcoes.estados) camada.addTo(mapa)
if (opcoes.municipios) configurarMunicipios(mapa, estadosInfo)
}
function configurarMunicipios(mapa: L.Map, estadosInfo: InfoEstado[]): void {
/** Grupo de layers por nível de detalhe */
const grupos: Record<NivelDetalhe, L.LayerGroup> = {
municipios: L.layerGroup(),
municipios_hd: L.layerGroup(),
municipios_uhd: L.layerGroup(),
}
/** UFs já carregadas por nível */
const carregados: Record<NivelDetalhe, Set<string>> = {
municipios: new Set(),
municipios_hd: new Set(),
municipios_uhd: new Set(),
}
let nivelAtivo: NivelDetalhe | null = null
const verificarViewport = () => {
const zoom = mapa.getZoom()
if (zoom < ZOOM_MUNICIPIOS) {
if (nivelAtivo && mapa.hasLayer(grupos[nivelAtivo])) {
mapa.removeLayer(grupos[nivelAtivo])
}
nivelAtivo = null
return
}
const nivel = nivelPorZoom(zoom)
if (nivel !== nivelAtivo) {
if (nivelAtivo && mapa.hasLayer(grupos[nivelAtivo])) {
mapa.removeLayer(grupos[nivelAtivo])
}
grupos[nivel].addTo(mapa)
nivelAtivo = nivel
}
const bounds = mapa.getBounds()
for (const estado of estadosInfo) {
if (carregados[nivel].has(estado.codigo)) continue
if (!bounds.intersects(estado.bounds)) continue
carregados[nivel].add(estado.codigo)
carregarMunicipiosUF(nivel, estado.codigo, grupos[nivel])
}
}
mapa.on("moveend", verificarViewport)
mapa.on("zoomend", verificarViewport)
verificarViewport()
}
async function carregarMunicipiosUF(
nivel: NivelDetalhe,
codigoUF: string,
grupo: L.LayerGroup,
): Promise<void> {
const dados = await buscarGeoJSON(`${URL_BASE}/${nivel}/${codigoUF}.geojson`)
if (!dados) return
grupo.addLayer(L.geoJSON(dados, { style: estiloMunicipios }))
}

View file

@ -0,0 +1,205 @@
import L from "leaflet"
import { computed, onBeforeUnmount, onMounted, type Ref, ref, watch } from "vue"
import type { PadroesEntradas } from "../tiposEntradas"
import { inicializarCamadas } from "./camadas"
import {
ALTURA_MAPA_PADRAO,
COORDENADAS_PADRAO,
coordenadaValida,
formatarCoordenada,
obterLocalizacao,
parseCoordenada,
ZOOM_PADRAO,
} from "./coordenadas"
type EntradaCoordenadas = PadroesEntradas["coordenadas"]
type EmitFn = (event: string, ...args: unknown[]) => void
interface OpcoesComposable {
value: () => EntradaCoordenadas["value"]
opcoes: () => EntradaCoordenadas["opcoes"]
emit: EmitFn
mapaRef: Ref<HTMLElement | null>
}
export function useCoordenadas({ value, opcoes, emit, mapaRef }: OpcoesComposable) {
const geolocalizando = ref(false)
let mapa: L.Map | null = null
let marcador: L.Marker | null = null
const alturaEfetiva = computed(() => opcoes()?.alturaMapa ?? ALTURA_MAPA_PADRAO)
const zoomEfetivo = computed(() => opcoes()?.zoom ?? ZOOM_PADRAO)
const somenteLeitura = computed(() => Boolean(opcoes()?.apenasLeitura))
const possuiValor = computed(() => value() != null)
const latDisplay = computed(() => {
const v = value()
return v ? formatarCoordenada(v.latitude) : ""
})
const lngDisplay = computed(() => {
const v = value()
return v ? formatarCoordenada(v.longitude) : ""
})
function emitirValor(lat: number, lng: number) {
if (!coordenadaValida(lat, lng)) return
const novoValor: NonNullable<EntradaCoordenadas["value"]> = {
formato: "decimal",
datum: "EPSG:4326",
latitude: Number.parseFloat(lat.toFixed(6)),
longitude: Number.parseFloat(lng.toFixed(6)),
}
emit("update:value", novoValor)
emit("input", novoValor)
emit("change", novoValor)
}
function limpar() {
emit("update:value", null)
emit("input", null)
emit("change", null)
removerMarcador()
}
function removerMarcador() {
if (marcador && mapa) {
mapa.removeLayer(marcador)
marcador = null
}
}
function adicionarMarcador(lat: number, lng: number) {
if (!mapa) return
marcador = L.marker([lat, lng], { draggable: !somenteLeitura.value }).addTo(mapa)
marcador.on("dragend", () => {
if (!marcador) return
const pos = marcador.getLatLng()
emitirValor(pos.lat, pos.lng)
})
mapa.setView([lat, lng], mapa.getZoom())
}
function onLatChange(v: string | number) {
const lat = parseCoordenada(v)
if (lat === null) return
emitirValor(lat, value()?.longitude ?? COORDENADAS_PADRAO.longitude)
}
function onLngChange(v: string | number) {
const lng = parseCoordenada(v)
if (lng === null) return
emitirValor(value()?.latitude ?? COORDENADAS_PADRAO.latitude, lng)
}
function atualizarMarcador(lat: number, lng: number) {
if (!mapa) return
if (!marcador) {
adicionarMarcador(lat, lng)
return
}
const posAtual = marcador.getLatLng()
if (posAtual.lat === lat && posAtual.lng === lng) return
marcador.setLatLng([lat, lng])
mapa.setView([lat, lng], mapa.getZoom())
}
async function usarMinhaLocalizacao() {
geolocalizando.value = true
try {
const pos = await obterLocalizacao()
emitirValor(pos.latitude, pos.longitude)
} finally {
geolocalizando.value = false
}
}
async function inicializarMapa() {
if (!mapaRef.value) return
const v = value()
const temValor = v != null
let centroLat: number
let centroLng: number
let zoomInicial = zoomEfetivo.value
if (temValor) {
centroLat = v.latitude
centroLng = v.longitude
zoomInicial = Math.max(zoomInicial, 13)
} else {
try {
const pos = await obterLocalizacao()
centroLat = pos.latitude
centroLng = pos.longitude
zoomInicial = 13
} catch {
centroLat = COORDENADAS_PADRAO.latitude
centroLng = COORDENADAS_PADRAO.longitude
}
}
mapa = L.map(mapaRef.value).setView([centroLat, centroLng], zoomInicial)
const camadaMapa = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
})
const camadaSatelite = L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{ attribution: "© Esri", maxZoom: 19 },
)
camadaMapa.addTo(mapa)
L.control.layers({ Mapa: camadaMapa, Satélite: camadaSatelite }).addTo(mapa)
if (temValor) adicionarMarcador(v.latitude, v.longitude)
mapa.on("click", (e: L.LeafletMouseEvent) => {
if (somenteLeitura.value) return
emitirValor(e.latlng.lat, e.latlng.lng)
})
const cfgCamadas = opcoes()?.camadas
if (cfgCamadas) inicializarCamadas(mapa, cfgCamadas)
}
watch(
value,
(v) => {
if (!v) {
removerMarcador()
return
}
atualizarMarcador(v.latitude, v.longitude)
},
{ deep: true },
)
onMounted(() => {
inicializarMapa()
if (mapaRef.value) {
const observer = new ResizeObserver(() => mapa?.invalidateSize())
observer.observe(mapaRef.value)
onBeforeUnmount(() => observer.disconnect())
}
})
onBeforeUnmount(() => {
if (mapa) {
mapa.remove()
mapa = null
marcador = null
}
})
return {
alturaEfetiva,
geolocalizando,
possuiValor,
latDisplay,
lngDisplay,
onLatChange,
onLngChange,
usarMinhaLocalizacao,
limpar,
}
}

View file

@ -130,7 +130,7 @@
<EliEntradaCoordenadas
v-model:value="coordenadas"
:opcoes="{
rotulo: 'Localização',
rotulo: 'Localização (com valor)',
zoom: 5,
alturaMapa: 350,
}"
@ -140,6 +140,22 @@
<strong>v-model:value</strong>:
{{ coordenadas ? `${coordenadas.latitude}, ${coordenadas.longitude}` : '(null)' }}
</div>
<v-divider />
<EliEntradaCoordenadas
v-model:value="coordenadasNulas"
:opcoes="{
rotulo: 'Localização (nula + camadas)',
alturaMapa: 400,
camadas: { estados: true, municipios: true },
}"
/>
<div class="text-caption">
<strong>v-model:value</strong>:
{{ coordenadasNulas ? `${coordenadasNulas.latitude}, ${coordenadasNulas.longitude}` : '(null)' }}
</div>
</section>
</div>
</template>
@ -189,6 +205,7 @@ export default defineComponent({
latitude: -15.7801,
longitude: -47.9292,
} as PadroesEntradas["coordenadas"]["value"],
coordenadasNulas: null as PadroesEntradas["coordenadas"]["value"],
}
},
})

View file

@ -26,7 +26,8 @@ export default defineConfig({
fileName: (format) => `eli-vue.${format}.js`,
},
rollupOptions: {
// não bundle o Vue, Vuetify nem Leaflet
// não bundle Vue, Vuetify nem Leaflet JS
// leaflet/dist/leaflet.css é bundled pelo cssInjectedByJsPlugin
external: (id) => {
if (id === "vue" || id === "leaflet") return true
if (id === "vuetify" || id.startsWith("vuetify/")) return true