inclusão de mapas
This commit is contained in:
parent
b564c2c302
commit
6b9a9dc705
20 changed files with 4200 additions and 3696 deletions
23
README.md
23
README.md
|
|
@ -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
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
124
dist/eli-vue.umd.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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
|
|
@ -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;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
|
|
|
|||
11
dist/types/componentes/EliEntrada/utils/camadas.d.ts
vendored
Normal file
11
dist/types/componentes/EliEntrada/utils/camadas.d.ts
vendored
Normal 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;
|
||||
22
dist/types/componentes/EliEntrada/utils/useCoordenadas.d.ts
vendored
Normal file
22
dist/types/componentes/EliEntrada/utils/useCoordenadas.d.ts
vendored
Normal 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 {};
|
||||
|
|
@ -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;
|
||||
}, {}, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, {}, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}[]>;
|
||||
|
|
|
|||
|
|
@ -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
52
scripts/baixar-geojson-ibge.sh
Executable 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/"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
|
|
|
|||
160
src/componentes/EliEntrada/utils/camadas.ts
Normal file
160
src/componentes/EliEntrada/utils/camadas.ts
Normal 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 }))
|
||||
}
|
||||
205
src/componentes/EliEntrada/utils/useCoordenadas.ts
Normal file
205
src/componentes/EliEntrada/utils/useCoordenadas.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue