This commit is contained in:
Luiz Silva 2026-06-06 12:12:24 -03:00
parent 9b2651d5c7
commit 05f5f950cb
31 changed files with 4997 additions and 4176 deletions

View file

@ -3,7 +3,7 @@
v-model="localValue"
:label="rotuloExibido"
:color="opcoes?.cor ?? 'primary'"
:disabled="Boolean(opcoes?.desabilitado)"
:disabled="Boolean(opcoes?.desabilitado) || Boolean(opcoes?.apenasLeitura)"
:density="opcoes?.densidade ?? 'comfortable'"
hide-details
v-bind="attrs"

View file

@ -0,0 +1,30 @@
.eli-coordenadas {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.eli-coordenadas__mapa-container {
width: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
position: relative;
}
.eli-coordenadas__mapa-container .leaflet-container {
width: 100%;
height: 100%;
z-index: 0;
}
.eli-coordenadas__campos {
display: flex;
gap: 12px;
align-items: center;
}
.eli-coordenadas__campo {
flex: 1;
}

View file

@ -0,0 +1,229 @@
<template>
<div class="eli-coordenadas">
<div v-if="opcoes?.rotulo" class="v-label" style="margin-bottom: 4px">
{{ opcoes.rotulo }}
</div>
<div
class="eli-coordenadas__mapa-container"
:style="{ height: `${alturaEfetiva}px` }"
>
<div ref="mapaRef" style="width: 100%; height: 100%" />
</div>
<div class="eli-coordenadas__campos">
<v-text-field
:model-value="latDisplay"
label="Latitude"
type="text"
inputmode="decimal"
:readonly="Boolean(opcoes?.apenasLeitura)"
density="comfortable"
variant="outlined"
class="eli-coordenadas__campo"
hide-details
v-bind="attrs"
@update:model-value="onLatChange"
/>
<v-text-field
:model-value="lngDisplay"
label="Longitude"
type="text"
inputmode="decimal"
:readonly="Boolean(opcoes?.apenasLeitura)"
density="comfortable"
variant="outlined"
class="eli-coordenadas__campo"
hide-details
v-bind="attrs"
@update:model-value="onLngChange"
/>
<v-btn
icon
variant="tonal"
color="primary"
size="small"
title="Usar minha localização"
:loading="geolocalizando"
:disabled="Boolean(opcoes?.apenasLeitura)"
@click="usarMinhaLocalizacao"
>
<v-icon>mdi-crosshairs-gps</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 "./EliEntradaCoordenadas.css"
import type { PadroesEntradas } from "./tiposEntradas"
import {
ALTURA_MAPA_PADRAO,
COORDENADAS_PADRAO,
coordenadaValida,
formatarCoordenada,
obterLocalizacao,
parseCoordenada,
ZOOM_PADRAO,
} from "./utils/coordenadas"
type EntradaCoordenadas = PadroesEntradas["coordenadas"]
export default defineComponent({
name: "EliEntradaCoordenadas",
inheritAttrs: false,
props: {
value: {
type: Object as PropType<EntradaCoordenadas["value"]>,
default: () => ({ ...COORDENADAS_PADRAO }),
},
opcoes: {
type: Object as PropType<EntradaCoordenadas["opcoes"]>,
required: true,
},
},
emits: {
"update:value": (_v: EntradaCoordenadas["value"]) => true,
input: (_v: EntradaCoordenadas["value"]) => true,
change: (_v: EntradaCoordenadas["value"]) => true,
focus: () => true,
blur: () => true,
},
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,
mapaRef,
alturaEfetiva,
geolocalizando,
latDisplay,
lngDisplay,
onLatChange,
onLngChange,
usarMinhaLocalizacao,
}
},
})
</script>

View file

@ -15,6 +15,7 @@
:variant="opcoesEfetivas.variante ?? 'outlined'"
:min="minLocal"
:max="maxLocal"
:readonly="Boolean(opcoesEfetivas.apenasLeitura)"
v-bind="attrs"
@focus="emitCompatFocus"
@blur="emitCompatBlur"

View file

@ -6,6 +6,7 @@
:type="isInteiro ? 'number' : 'text'"
:inputmode="isInteiro ? 'numeric' : 'decimal'"
:pattern="isInteiro ? '[0-9]*' : '[0-9.,]*'"
:readonly="Boolean(opcoes?.apenasLeitura)"
v-bind="attrs"
@update:model-value="onUpdateModelValue"
@focus="() => emit('focus')"

View file

@ -13,6 +13,7 @@
:persistent-hint="Boolean(opcoes?.dicaPersistente)"
:density="opcoes?.densidade ?? 'comfortable'"
:variant="opcoes?.variante ?? 'outlined'"
:readonly="Boolean(opcoes?.apenasLeitura)"
auto-grow
v-bind="attrs"
@focus="() => emit('focus')"

View file

@ -16,6 +16,7 @@
:persistent-hint="Boolean(opcoes?.dicaPersistente)"
:density="opcoes?.densidade ?? 'comfortable'"
:variant="opcoes?.variante ?? 'outlined'"
:readonly="Boolean(opcoes?.apenasLeitura)"
v-bind="attrs"
@focus="() => emit('focus')"
@blur="() => emit('blur')"

View file

@ -19,6 +19,7 @@
multiple
chips
closable-chips
:readonly="Boolean(opcoes?.apenasLeitura)"
v-bind="attrs"
@focus="() => emit('focus')"
@blur="() => emit('blur')"

View file

@ -7,6 +7,7 @@
:placeholder="opcoes?.placeholder"
:counter="opcoes?.limiteCaracteres"
:maxlength="opcoes?.limiteCaracteres"
:readonly="Boolean(opcoes?.apenasLeitura)"
v-bind="attrs"
@focus="() => emit('focus')"
@blur="() => emit('blur')"

View file

@ -1,4 +1,5 @@
import EliEntradaConfirmacao from "./EliEntradaConfirmacao.vue"
import EliEntradaCoordenadas from "./EliEntradaCoordenadas.vue"
import EliEntradaDataHora from "./EliEntradaDataHora.vue"
import EliEntradaNumero from "./EliEntradaNumero.vue"
import EliEntradaParagrafo from "./EliEntradaParagrafo.vue"
@ -9,6 +10,7 @@ import EliEntradaTexto from "./EliEntradaTexto.vue"
export type { PadroesEntradas, TipoEntrada } from "./tiposEntradas"
export {
EliEntradaConfirmacao,
EliEntradaCoordenadas,
EliEntradaDataHora,
EliEntradaNumero,
EliEntradaParagrafo,

View file

@ -1,5 +1,6 @@
import type { Component } from "vue"
import EliEntradaConfirmacao from "./EliEntradaConfirmacao.vue"
import EliEntradaCoordenadas from "./EliEntradaCoordenadas.vue"
import EliEntradaDataHora from "./EliEntradaDataHora.vue"
import EliEntradaNumero from "./EliEntradaNumero.vue"
import EliEntradaParagrafo from "./EliEntradaParagrafo.vue"
@ -21,6 +22,7 @@ import type { TipoEntrada } from "./tiposEntradas"
*/
export const registryTabelaCelulas: Record<TipoEntrada, Component> = {
texto: EliEntradaTexto,
coordenadas: EliEntradaCoordenadas,
numero: EliEntradaNumero,
dataHora: EliEntradaDataHora,
paragrafo: EliEntradaParagrafo,

View file

@ -12,6 +12,9 @@
* - `TipoEntrada` é a união com todos os tipos suportados (ex.: "texto" | "numero").
*/
import type { zCoordenadas } from "p-comuns"
import type z from "zod"
/**
* Contrato padrão de uma entrada.
*
@ -26,9 +29,11 @@ export type tipoPadraoEntrada<T, Mais extends Record<string, unknown> = {}> = {
opcoes: {
/** Rótulo exibido ao usuário */
rotulo: string
/** Texto de ajuda dentro do input quando vazio */
placeholder?: string
/** Se true, o campo será somente leitura (não editável) */
apenasLeitura?: boolean
} & Mais
}
@ -82,31 +87,22 @@ export type PadroesEntradas = {
{
/** Define o tipo de entrada. - `dataHora`: datetime-local - `data`: date */
modo?: "data" | "dataHora"
/** Se true, mostra ícone para limpar o valor (Vuetify clearable). */
limpavel?: boolean
/** Estado de erro (visual). */
erro?: boolean
/** Mensagens de erro. */
mensagensErro?: string | string[]
/** Texto de apoio. */
dica?: string
/** Mantém a dica sempre visível. */
dicaPersistente?: boolean
/** Valor mínimo permitido (ISO 8601 - offset ou Z). */
min?: string
/** Valor máximo permitido (ISO 8601 - offset ou Z). */
max?: string
/** Densidade do campo (Vuetify). */
densidade?: import("../../tipos").CampoDensidade
/** Variante do v-text-field (Vuetify). */
variante?: import("../../tipos").CampoVariante
}
@ -117,28 +113,20 @@ export type PadroesEntradas = {
{
/** Quantidade de linhas visíveis no textarea (Vuetify `rows`). */
linhas?: number
/** Limite máximo de caracteres permitidos (se definido). */
limiteCaracteres?: number
/** Se true, mostra ícone para limpar o valor (Vuetify clearable). */
limpavel?: boolean
/** Estado de erro (visual). */
erro?: boolean
/** Mensagens de erro. */
mensagensErro?: string | string[]
/** Texto de apoio. */
dica?: string
/** Mantém a dica sempre visível. */
dicaPersistente?: boolean
/** Densidade do campo (Vuetify). */
densidade?: import("../../tipos").CampoDensidade
/** Variante do v-text-field (Vuetify). */
variante?: import("../../tipos").CampoVariante
}
@ -231,6 +219,17 @@ export type PadroesEntradas = {
densidade?: import("../../tipos").CampoDensidade
}
>
coordenadas: tipoPadraoEntrada<
z.infer<typeof zCoordenadas>,
{
/** Nível de zoom inicial do mapa Leaflet (default: 4) */
zoom?: number
/** Altura do container do mapa em pixels (default: 300) */
alturaMapa?: number
}
>
}
/**

View file

@ -0,0 +1,62 @@
import type { zCoordenadas } from "p-comuns"
import type z from "zod"
export type TipoCoordenadas = z.infer<typeof zCoordenadas>
/** Centro padrão do Brasil (Brasília) */
export const COORDENADAS_PADRAO: TipoCoordenadas = {
formato: "decimal",
datum: "EPSG:4326",
latitude: -15.7801,
longitude: -47.9292,
}
/** Zoom padrão do mapa */
export const ZOOM_PADRAO = 4
/** Altura padrão do mapa em pixels */
export const ALTURA_MAPA_PADRAO = 300
/** Limites válidos para latitude (-90 a 90) e longitude (-180 a 180) */
export function coordenadaValida(lat: number, lng: number): boolean {
return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180
}
/** Formata número de coordenada para exibição com vírgula (padrão BR) */
export function formatarCoordenada(valor: number): string {
return valor.toFixed(6).replace(".", ",")
}
/** Converte texto digitado (aceita vírgula ou ponto) em número. Retorna null se inválido. */
export function parseCoordenada(v: string | number): number | null {
if (typeof v === "number") return Number.isNaN(v) ? null : v
const normalizado = v.replace(",", ".")
const n = Number.parseFloat(normalizado)
return Number.isNaN(n) ? null : n
}
/** Tenta geolocalização do navegador; se falhar, usa GeoIP como fallback */
export async function obterLocalizacao(): Promise<{ latitude: number; longitude: number }> {
try {
return await obterViaBrowser()
} catch {
return await obterViaGeoIp()
}
}
function obterViaBrowser(): Promise<{ latitude: number; longitude: number }> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) return reject(new Error("Geolocation indisponível"))
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),
(err) => reject(err),
{ enableHighAccuracy: true, timeout: 10000 },
)
})
}
async function obterViaGeoIp(): Promise<{ latitude: number; longitude: number }> {
const resp = await fetch("http://ip-api.com/json/?fields=lat,lon")
const dados: { lat: number; lon: number } = await resp.json()
return { latitude: dados.lat, longitude: dados.lon }
}