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

@ -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
}
const coordenadas = useCoordenadas({
value: () => props.value,
opcoes: () => props.opcoes,
emit: emit as (event: string, ...args: unknown[]) => void,
mapaRef,
})
return {
attrs,
emit,
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,
}
}