first commit

This commit is contained in:
David Itehua Xalamihua 2026-01-04 14:50:52 -06:00
commit d1c1e7fca4
25 changed files with 1538 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
cobertura.mbtiles
hex_mapa2025-10-01_2025-12-14.gpkg

13
INFO_DEPURAR.txt Normal file
View File

@ -0,0 +1,13 @@
- descargar imagen del mapa - sección activa
DEPURAR ALERTS
[ ] carga unitaria
la validación de la etiqueta no es correcta
[ ] carga masiva
[ ] descarga plantilla
[ya] permitir descargar geopackage
crt: 19.381885015253673, -99.17663944307311
Por favor ingrese una etiqueta para el marcador

0
README.md Normal file
View File

35
buscar_data_layers.py Normal file
View File

@ -0,0 +1,35 @@
from pymbtiles import MBtiles
import mapbox_vector_tile
import gzip
mb = MBtiles("./static/salida.mbtiles")
found = False
for z in range(
int(mb.meta.get("minzoom", 0)),
int(mb.meta.get("maxzoom", 14)) + 1
):
for x in range(0, 2**z):
for y in range(0, 2**z):
tile = mb.read_tile(z, x, y)
if tile:
try:
# 🔑 DESCOMPRESIÓN
tile = gzip.decompress(tile)
except OSError:
# no estaba comprimido
pass
decoded = mapbox_vector_tile.decode(tile)
print("✅ Tile encontrado")
print("z:", z, "x:", x, "y:", y)
print("Layers:", decoded.keys())
found = True
break
if found:
break
if found:
break

33
help.txt Normal file
View File

@ -0,0 +1,33 @@
1.- Convertir gpkg a geojson (lo hice con geopandas)
2.- Convertir geojson a mbtiles (linux tippercanoe):
tippecanoe \
-o cobertura.mbtiles \
-l municipios \
-Z5 \
-z18 \
--extend-zooms-if-still-dropping \
--drop-densest-as-needed \
--simplification=2 \
--simplification-at-maximum-zoom=1 \
--no-tiny-polygon-reduction \
geo_pandas_limpio.geojson
3.- Renderizar datos con maplibre
tippecanoe \
-o cobertura.mbtiles \
-l municipios \
-Z5 \
-z18 \
--base-zoom=8 \
--simplification=0.5 \
--simplification-at-maximum-zoom=1 \
--no-tiny-polygon-reduction \
--no-feature-limit \
--no-tile-size-limit \
--extend-zooms-if-still-dropping \
geo_pandas_limpio.geojson

0
log/access.log Normal file
View File

0
log/error.log Normal file
View File

97
main.py Normal file
View File

@ -0,0 +1,97 @@
from flask import Flask, Response, abort, render_template, send_file
from flask_cors import CORS
from pymbtiles import MBtiles
import os
from cachetools import LRUCache
tile_cache = LRUCache(maxsize=5000)
app = Flask(__name__)
CORS(app)
MBTILES_PATH = os.path.join(app.root_path, "static", "data-files/cobertura.mbtiles")
def flip_y(z, y):
return (1 << z) - 1 - y
@app.route("/")
def index():
return render_template("index.html")
@app.route("/tiles/<int:z>/<int:x>/<int:y>.pbf")
def tiles(z, x, y):
y = flip_y(z, y)
key = (z, x, y)
if key in tile_cache:
tile = tile_cache[key]
else:
with MBtiles(MBTILES_PATH) as mbtiles:
tile = mbtiles.read_tile(z, x, y)
if tile is None:
abort(404)
if isinstance(tile, tuple):
tile = tile[0]
tile_cache[key] = tile
return Response(
tile,
mimetype="application/x-protobuf",
headers={
"Content-Encoding": "gzip",
"Cache-Control": "public, max-age=86400"
}
)
# return Response(
# tile,
# mimetype="application/x-protobuf",
# headers={
# "Content-Encoding": "gzip",
# "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"
# }
# )
@app.route("/api/poligonos-edos")
def poligonos_edos():
response = send_file(
os.path.join(app.root_path, "static/data-files", "poligonos_edos_mx.json"),
mimetype="application/geo+json",
max_age=86400
)
response.headers["Access-Control-Allow-Origin"] = "*"
return response
# @app.route("/api/poligonos-edos")
# def poligonos_edos():
# response = send_file(
# os.path.join(app.root_path, "static/data-files", "poligonos_edos_mx.json"),
# mimetype="application/geo+json",
# max_age=0
# )
# response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
# response.headers["Access-Control-Allow-Origin"] = "*"
# return response
@app.route("/file/download/<path:filename>")
def download_file(filename):
file_path = os.path.join(app.root_path, "static/data-files", filename)
if not os.path.isfile(file_path):
abort(404)
return send_file(
file_path,
as_attachment=True
)
@app.errorhandler(404)
def page_not_found(error):
return render_template("404.html"), 404
if __name__ == "__main__":
app.run(debug=True, threaded=True)

48
mapa-cobertura.conf Normal file
View File

@ -0,0 +1,48 @@
# sudo apachectl configtest
Listen 8086
<VirtualHost *:8086>
ServerAdmin davidix1991@gmail.com
ServerName mapa-cobertura.temporal.work
ServerAlias mapa-cobertura.temporal.work
DocumentRoot /var/www/mapa-cobertura
WSGIDaemonProcess app_map_cobertura user=www-data group=www-data threads=6 python-home=/var/www/mapa-cobertura/.venv
WSGIScriptAlias / /var/www/mapa-cobertura/mapa-cobertura.wsgi
ErrorLog /var/www/mapa-cobertura/log/error.log
CustomLog /var/www/mapa-cobertura/log/access.log combined
<Directory /var/www/mapa-cobertura>
WSGIProcessGroup app_map_cobertura
WSGIApplicationGroup %{GLOBAL}
Order deny,allow
Require all granted
</Directory>
# Habilitar caché para todas las solicitudes
CacheEnable disk /
# Configuración de caché
<IfModule mod_cache_disk.c>
CacheRoot /var/cache/apache2/mod_cache_disk
CacheDirLevels 2
CacheDirLength 1
# [bytes] Tamaño máximo de archivo a almacenar en caché
CacheMaxFileSize 1000000
# CacheMinFileSize bytes
CacheMinFileSize 1
CacheIgnoreHeaders Set-Cookie
CacheIgnoreNoLastMod On
<FilesMatch "\.(jpg|jpeg|png|gif|css|js)$">
# Indica si el caché está funcionando
Header set X-Cache "HIT from Apache"
# Expiración por defecto (1 hora)
CacheDefaultExpire 3600
# Expiración máxima (1 día)
CacheMaxExpire 86400
CacheLastModifiedFactor 0.5
</FilesMatch>
</IfModule>
</VirtualHost>

14
mapa-cobertura.wsgi Normal file
View File

@ -0,0 +1,14 @@
import sys
import logging
# ruta de linux al proyecto de flask
sys.path.insert(0, '/var/www/mapa-cobertura.temporal.work')
# ruta de linux al ambiente virtual de flask
sys.path.insert(0, '/var/www/mapa-cobertura.temporal.work/.venv/lib/python3.13/site-packages')
# Set up logging
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
# Import and run the Flask app
from main import app as application

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
blinker==1.9.0
cachetools==6.2.4
click==8.3.1
colorama==0.4.6
Flask==3.1.2
flask-cors==6.0.2
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
pymbtiles==0.5.0
Werkzeug==3.1.4

273
static/css/index/index.css Normal file
View File

@ -0,0 +1,273 @@
/* ===============================
CONTAINER Y MAPA
=============================== */
body {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #333;
background: #f5f7fa;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
/* ===============================
CONTENEDOR GENERAL DEL COLLAPSE
=============================== */
#pin-form {
position: absolute;
top: 20px;
left: 20px;
z-index: 1000;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
width: 300px;
max-width: 90vw;
border: 1px solid #e1e5e9;
overflow: hidden;
}
/* ===============================
CABECERA DEL FORMULARIO (No colapsable)
=============================== */
.form-header {
background: #1976d2;
color: white;
padding: 16px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-right {
transition: transform 0.3s ease;
}
/* ===============================
CONTENIDO PRINCIPAL COLAPSABLE
=============================== */
.collapsible-content {
max-height: 0;
overflow: hidden;
opacity: 0;
transition: all 0.3s ease;
}
/* ===============================
SECCIONES COLAPSABLES INTERNAS
=============================== */
.collapse-section {
border-bottom: 1px solid #e1e5e9;
}
.collapse-section:last-child {
border-bottom: none;
}
.collapse-header {
padding: 14px 16px;
background: #f8fafc;
border-bottom: 1px solid #e1e5e9;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: #2d3748;
transition: background 0.2s ease;
}
.collapse-header:hover {
background: #edf2f7;
}
.collapse-icon {
transition: transform 0.3s ease;
color: #718096;
}
.collapse-content {
max-height: 0;
overflow: hidden;
opacity: 0;
transition: all 0.3s ease;
}
/* ===============================
CHECKBOX PARA CONTROLAR COLLAPSE (Truco CSS)
=============================== */
#collapse-toggle,
#collapse-toggle-1,
#collapse-toggle-2,
#collapse-toggle-3 {
display: none;
}
/* Cuando el checkbox está checked, mostramos el contenido */
#collapse-toggle:checked ~ .collapsible-content,
#collapse-toggle-1:checked ~ .collapse-content,
#collapse-toggle-2:checked ~ .collapse-content,
#collapse-toggle-3:checked ~ .collapse-content {
max-height: 1000px;
opacity: 1;
overflow: visible;
}
/* Rotamos el icono cuando está expandido */
#collapse-toggle:checked ~ .form-header .collapse-icon,
#collapse-toggle-1:checked ~ .collapse-header .collapse-icon,
#collapse-toggle-2:checked ~ .collapse-header .collapse-icon,
#collapse-toggle-3:checked ~ .collapse-header .collapse-icon {
transform: rotate(180deg);
}
/* ===============================
FORM ELEMENTS
=============================== */
.form-group {
padding: 12px 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #4a5568;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.95rem;
transition: border 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.button-primary {
width: 100%;
padding: 12px;
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 16px;
width: calc(100% - 32px);
transition: background 0.2s ease;
}
.button-primary:hover {
background: #1565c0;
}
.csv-section {
padding: 16px;
border-top: 1px solid #e1e5e9;
background: #f8fafc;
}
.section-title {
font-weight: 600;
color: #2d3748;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.button-secondary {
display: block;
margin-left: auto;
margin-right: auto;
width: 95%;
padding: 5px;
background: #edf2f7;
color: #2d3748;
border: 1px solid #cbd5e0;
border-radius: 6px;
text-align: center;
text-decoration: none;
margin-bottom: 10px;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.button-secondary:hover {
background: #e2e8f0;
border-color: #a0aec0;
}
.csv-file-input {
display: none;
}
.file-upload-label {
display: block;
margin-left: auto;
margin-right: auto;
width: 95%;
padding: 5px;
background: #f0f9ff;
color: #0369a1;
border: 1px dashed #7dd3fc;
border-radius: 6px;
text-align: center;
cursor: pointer;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.file-upload-label:hover {
background: #e0f2fe;
border-color: #0ea5e9;
}
/* .tooltip {
font-size: 0.85rem;
color: #718096;
line-height: 1.4;
} */
/* ===============================
ESTADO ACTIVO (SIEMPRE VISIBLE)
=============================== */
.active-state-section {
padding: 16px;
background: #fff7ed;
border-top: 1px solid #fed7aa;
display: block !important;
max-height: none !important;
opacity: 1 !important;
}

View File

@ -0,0 +1,53 @@
/* ===============================
INDICADOR DE CARGA
=============================== */
.loading-indicator {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2000;
background: rgba(255, 255, 255, 0.95);
padding: 20px 30px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
text-align: center;
animation: fadeIn 0.3s ease;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #1976d2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.loading-text {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -0,0 +1,48 @@
/* ===============================
NOTIFICACIONES
=============================== */
.notification {
position: absolute;
top: 20px;
right: 20px;
z-index: 1000;
padding: 15px 20px;
border-radius: 8px;
color: white;
font-weight: 500;
font-size: 0.9rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
max-width: 350px;
animation: slideInRight 0.3s ease;
}
.notification.success {
background: linear-gradient(135deg, #4caf50, #66bb6a);
}
.notification.error {
background: linear-gradient(135deg, #f44336, #e53935);
}
.notification.info {
background: linear-gradient(135deg, #2196f3, #1976d2);
}
.notification i {
margin-right: 10px;
font-size: 1.2rem;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@ -0,0 +1,53 @@
/* ===============================
TOOLTIPS
=============================== */
/* Contenedor */
.tooltip {
position: relative;
display: inline-block;
cursor: pointer;
}
/* Texto del tooltip */
.tooltip .tooltip-text {
visibility: hidden;
width: 220px;
background-color: #333;
color: #fff;
text-align: left; /* Alineado a la izquierda para mejor lectura */
border-radius: 6px;
padding: 12px;
position: absolute;
z-index: 1;
bottom: 125%;
/* CAMBIO CLAVE: Alineación a la derecha */
left: 0; /* Empieza desde el icono */
margin-left: 0; /* Eliminamos el margen negativo que lo sacaba de pantalla */
opacity: 0;
transition: opacity 0.3s;
font-size: 0.85rem;
line-height: 1.4;
box-shadow: 0px 4px 10px rgba(0,0,0,0.3);
}
/* Ajuste de la flecha para que coincida con el icono a la izquierda */
.tooltip .tooltip-text::after {
content: "";
position: absolute;
top: 100%;
left: 10px; /* La flecha ahora se queda cerca del inicio del cuadro */
border-width: 6px;
border-style: solid;
border-color: #333 transparent transparent transparent;
}
/* Mostrar al pasar el mouse */
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
Etiqueta_pin,Estado,Municipio,Localidad,Latitud,Longitud,Comentario
1 Etiqueta_pin Estado Municipio Localidad Latitud Longitud Comentario

View File

@ -0,0 +1,137 @@
// ===============================
// CONFIGURACIÓN GENERAL
// ===============================
const TILE_URL = location.href + "/tiles/{z}/{x}/{y}.pbf";
const SOURCE_LAYER = "municipios";
// ===============================
// MAPA
// ===============================
const map = new maplibregl.Map({
container: "map",
center: [-102.20, 23.3646],
zoom: 5,
minZoom: 5,
maxZoom: 17,
style: {
version: 8,
sources: {
// Fondo raster OSM
osm: {
type: "raster",
tiles: [
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png"
],
tileSize: 256,
attribution: "© OpenStreetMap contributors"
},
// Vector tiles (MBTiles)
municipios: {
type: "vector",
tiles: [TILE_URL],
minzoom: 5,
maxzoom: 18
}
},
layers: [
// ===============================
// BASE RASTER
// ===============================
{
id: "osm-base",
type: "raster",
source: "osm",
maxzoom: 18
},
// ===============================
// POLÍGONOS (RELLENO)
// ===============================
{
id: "municipios-fill",
type: "fill",
source: "municipios",
"source-layer": SOURCE_LAYER,
minzoom: 5,
maxzoom: 18,
paint: {
"fill-color": "#1976d2",
"fill-opacity": [
"interpolate",
["linear"],
["zoom"],
5, 0.8,
8, 0.18,
12, 0.45,
18, 0.15
]
}
},
// ===============================
// BORDES DE POLÍGONOS
// ===============================
{
id: "municipios-outline",
type: "line",
source: "municipios",
"source-layer": SOURCE_LAYER,
minzoom: 6,
maxzoom: 18,
paint: {
"line-color": "#0d47a1",
"line-width": [
"interpolate",
["linear"],
["zoom"],
6, 0.2,
12, 1.2,
18, 3
]
}
},
// ===============================
// PUNTOS (DETALLE FINO)
// ===============================
{
id: "municipios-points",
type: "circle",
source: "municipios",
"source-layer": SOURCE_LAYER,
minzoom: 14,
maxzoom: 18,
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
14, 3,
18, 6
],
"circle-color": "#0d47a1",
"circle-opacity": 0.7
}
}
]
}
});
// ===============================
// CONTROLES
// ===============================
map.addControl(new maplibregl.NavigationControl(), "top-right");
// ===============================
// DEBUG (opcional)
// ===============================
// map.on("load", () => {
// console.log("✅ Mapa cargado correctamente");
// });

View File

@ -0,0 +1,84 @@
import {dic_edos} from './99_functions.js';
map.on("load", () => {
if (map.getSource("edos")) return; // 🔐 protección
map.addSource("edos", {
type: "geojson",
data: "/api/poligonos-edos",
generateId: true
});
map.addLayer({
id: "edos-fill",
type: "fill",
source: "edos",
paint: { "fill-opacity": 0 }
});
map.addLayer({
id: "edos-outline",
type: "line",
source: "edos",
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "hover"], false],
"#9b2226",
"#ffb703"
],
"line-width": [
"case",
["boolean", ["feature-state", "hover"], false],
4,
1.5
]
}
});
let hoveredId = null;
map.on("mousemove", "edos-fill", e => {
let id_entidad = e.features[0].properties.cve_ent;
let nombreEstado = dic_edos[id_entidad] || "No identificado";
// ACTUALIZACIÓN: Mostrar en el formulario
const labelEstado = document.getElementById("nombre-estado-activo");
if (labelEstado) {
labelEstado.innerText = nombreEstado;
}
if (e.features.length) {
if (hoveredId !== null) {
map.setFeatureState({ source: "edos", id: hoveredId }, { hover: false });
}
hoveredId = e.features[0].id;
map.setFeatureState({ source: "edos", id: hoveredId }, { hover: true });
map.getCanvas().style.cursor = "pointer";
}
});
// También es buena idea limpiar el texto cuando el mouse sale del mapa
map.on("mouseleave", "edos-fill", () => {
if (hoveredId !== null) {
map.setFeatureState({ source: "edos", id: hoveredId }, { hover: false });
}
// ACTUALIZACIÓN: Limpiar el nombre al salir
const labelEstado = document.getElementById("nombre-estado-activo");
if (labelEstado) {
labelEstado.innerText = "Ninguno";
}
hoveredId = null;
map.getCanvas().style.cursor = "";
});
});

View File

@ -0,0 +1,67 @@
import { showNotification } from './99_functions.js';
// ===============================
// 1. LÓGICA DE PINES UNITARIOS (FORMULARIO)
// ===============================
const allMarkers = [];
// 💡 DESCOMENTADA: Necesaria para que el validador funcione
function isValidCoord(value, min, max) {
return !isNaN(value) && value >= min && value <= max;
}
function hasFourDecimals(value) {
const s = value.toString();
if (!s.includes('.')) return false;
return s.split(".")[1].length >= 4;
}
document.getElementById("add-pin").addEventListener("click", () => {
const labelInput = document.getElementById("pin-label");
const latInput = document.getElementById("pin-lat");
const lngInput = document.getElementById("pin-lng");
const label = labelInput.value.trim();
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);
// Validación unificada mejorada
const isInvalid =
!label ||
!isValidCoord(lat, -90, 90) ||
!isValidCoord(lng, -180, 180) ||
!hasFourDecimals(latInput.value) ||
!hasFourDecimals(lngInput.value);
if (isInvalid) {
showNotification("Todos los campos son obligatorios: Etiqueta, Lat/Lng válidas y mínimo 4 decimales.", "error");
return;
}
// Si pasa la validación, procedemos:
const newMarker = new maplibregl.Marker({ color: "#d32f2f" })
.setLngLat([lng, lat])
.setPopup(
new maplibregl.Popup({ offset: 25 })
.setHTML(`<strong>${label}</strong><br/>Lat: ${lat}<br/>Lng: ${lng}`)
)
.addTo(map);
allMarkers.push(newMarker);
map.flyTo({
center: [lng, lat],
zoom: 12,
essential: true
});
// Limpiar campos
labelInput.value = "";
latInput.value = "";
lngInput.value = "";
showNotification("Marcador agregado con éxito", "success");
});

View File

@ -0,0 +1,95 @@
import { showNotification } from './99_functions.js';
const csvFileInputEl = document.getElementById('csv-file');
const loadingIndicator = document.getElementById('loading-indicator');
document.getElementById('csv-file').addEventListener('change', function (e) {
const file = e.target.files[0];
if (!file) return;
// emular loading de carga de archivo csv
if (this.files.length > 0) {
loadingIndicator.style.display = 'block';
// Simular carga
setTimeout(() => {
loadingIndicator.style.display = 'none';
// showNotification('Archivo CSV cargado exitosamente', 'success');
}, 500);
}
// PapaParse convierte el texto del CSV a objetos JSON automáticamente
Papa.parse(file, {
header: true, // Importante: Usa tus encabezados (Etiqueta_pin, Latitud, etc.)
skipEmptyLines: true,
dynamicTyping: true, // Convierte lat/lng a números automáticamente
complete: function (results) {
const data = results.data;
if (data.length === 0) {
showNotification("El archivo CSV está vacío.");
return;
}
procesarYPintarPines(data);
},
error: function (err) {
console.error("Error al procesar CSV:", err);
showNotification("Hubo un error al leer el archivo.", "error");
}
});
});
function procesarYPintarPines(puntos) {
// Usaremos esto para centrar el mapa en todos los puntos cargados
const bounds = new maplibregl.LngLatBounds();
let puntosValidos = 0;
puntos.forEach(punto => {
// Extraer datos usando tus encabezados exactos
const lat = parseFloat(punto.Latitud);
const lng = parseFloat(punto.Longitud);
const nombre = punto.Etiqueta_pin || "Sin nombre";
if (!isNaN(lat) && !isNaN(lng)) {
// Crear el HTML del Popup con toda tu información
const contenido = `
<div style="font-family: Arial, sans-serif; min-width: 150px;">
<h4 style="margin: 0 0 5px 0; color: #d32f2f;">${nombre}</h4>
<p style="margin: 0; font-size: 11px;">
<b>Estado:</b> ${punto.Estado || 'N/A'}<br>
<b>Municipio:</b> ${punto.Municipio || 'N/A'}<br>
<b>Localidad:</b> ${punto.Localidad || 'N/A'}<br>
<b>Nota:</b> ${punto.Comentario || '...'}
</p>
</div>
`;
// Crear marcador y añadirlo al mapa
const marker = new maplibregl.Marker({ color: "#d32f2f" })
.setLngLat([lng, lat])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(contenido))
.addTo(map);
// Guardar en tu lista global (la que definimos en pasos anteriores)
if (typeof allMarkers !== 'undefined') {
allMarkers.push(marker);
}
// Extender los límites para que el mapa sepa qué área cubrir
bounds.extend([lng, lat]);
puntosValidos++;
}
});
if (puntosValidos > 0) {
// Mover la cámara para encuadrar todos los pines nuevos
map.fitBounds(bounds, { padding: 50, maxZoom: 15 });
showNotification(`Éxito: Se cargaron ${puntosValidos} pines.`, "success");
} else {
showNotification("Error: No se cargaron pines. Verifica el archivo CSV en los valores de 'latitud' y 'longitud' .", "error");
}
}

View File

@ -0,0 +1,97 @@
import { showNotification } from './99_functions.js';
// Script para el funcionamiento del collapse - VERSIÓN MEJORADA
document.addEventListener('DOMContentLoaded', function () {
const collapseHeader = document.getElementById('form-collapse-header');
const collapseContent = document.getElementById('form-collapse-content');
const collapseIcon = document.getElementById('collapse-icon');
// Función mejorada para detectar dispositivo
function isMobileOrTablet() {
return window.innerWidth <= 1024; // Tablets y móviles
}
// Función para detectar tablet grande
function isLargeTablet() {
return window.innerWidth >= 769 && window.innerWidth <= 1024;
}
// Función para detectar móvil o tablet pequeña
function isSmallDevice() {
return window.innerWidth <= 768;
}
// Función para aplicar estado inicial
function applyInitialState() {
if (isMobileOrTablet()) {
collapseContent.classList.add('collapsed');
collapseIcon.classList.remove('rotated');
// Si es tablet grande, mostrar el header azul
if (isLargeTablet()) {
collapseHeader.style.background = 'linear-gradient(135deg, #1976d2, #2196f3)';
collapseHeader.style.color = 'white';
}
} else {
collapseContent.classList.remove('collapsed');
collapseIcon.classList.add('rotated');
}
}
// Aplicar estado inicial
applyInitialState();
// Alternar collapse al hacer clic en el header
collapseHeader.addEventListener('click', function () {
// Solo funciona en dispositivos móviles/tablets
if (!isMobileOrTablet()) return;
collapseContent.classList.toggle('collapsed');
collapseIcon.classList.toggle('rotated');
// Animar el icono
collapseIcon.style.transition = 'transform 0.3s ease';
// Mostrar/ocultar notificación
if (collapseContent.classList.contains('collapsed')) {
showNotification('Formulario colapsado. Toque para expandir.', 'info', 2000);
} else {
showNotification('Formulario expandido', 'info', 2000);
}
});
// Ajustar al cambiar el tamaño de la ventana
window.addEventListener('resize', function () {
applyInitialState();
});
// Colapsar automáticamente al agregar un marcador (solo en móviles/tablets)
const addPinButton = document.getElementById('add-pin');
if (addPinButton) {
addPinButton.addEventListener('click', function () {
if (isMobileOrTablet() && !collapseContent.classList.contains('collapsed')) {
setTimeout(() => {
collapseContent.classList.add('collapsed');
collapseIcon.classList.remove('rotated');
}, 500);
}
});
}
// Colapsar al subir archivo CSV (solo en móviles/tablets)
const csvFileInput = document.getElementById('csv-file');
if (csvFileInput) {
csvFileInput.addEventListener('change', function () {
if (isMobileOrTablet() && !collapseContent.classList.contains('collapsed') && this.files.length > 0) {
setTimeout(() => {
collapseContent.classList.add('collapsed');
collapseIcon.classList.remove('rotated');
}, 1000);
}
});
}
});

View File

@ -0,0 +1,115 @@
let dic_edos = {
'01': 'Aguascalientes',
'02': 'Baja California',
'03': 'Baja California Sur',
'04': 'Campeche',
'05': 'Coahuila',
'06': 'Colima',
'07': 'Chiapas',
'08': 'Chihuahua',
'09': 'Ciudad de México',
'10': 'Durango',
'11': 'Guanajuato',
'12': 'Guerrero',
'13': 'Hidalgo',
'14': 'Jalisco',
'15': 'Estado de México',
'16': 'Michoacán',
'17': 'Morelos',
'18': 'Nayarit',
'19': 'Nuevo León',
'20': 'Oaxaca',
'21': 'Puebla',
'22': 'Querétaro',
'23': 'Quintana Roo',
'24': 'San Luis Potosí',
'25': 'Sinaloa',
'26': 'Sonora',
'27': 'Tabasco',
'28': 'Tamaulipas',
'29': 'Tlaxcala',
'30': 'Veracruz',
'31': 'Yucatán',
'32': 'Zacatecas',
}
function showNotification(message, type = 'info', duration = 5000) {
const notificationArea = document.getElementById('notification-area');
const notification = document.createElement('div');
notification.className = `notification ${type}`;
let icon = 'info-circle';
// por default el tipo es info
if (type === 'success') icon = 'check-circle';
if (type === 'error') icon = 'exclamation-circle';
notification.innerHTML = `
<i class="fas fa-${icon}"></i>
<span>${message}</span>
`;
notificationArea.appendChild(notification);
// Eliminar notificación después de la duración
setTimeout(() => {
notification.style.animation = 'slideInRight 0.3s ease reverse';
setTimeout(() => notification.remove(), 300);
}, duration);
}
// function crearPin({ lat, lng, label = "Sin etiqueta", popupHTML = null }) {
// const map = getMap();
// if (!map) return null;
// const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
// popupHTML ||
// `<strong>${label}</strong><br>Lat: ${lat}<br>Lng: ${lng}`
// );
// const marker = new maplibregl.Marker({ color: "#d32f2f" })
// .setLngLat([lng, lat])
// .setPopup(popup)
// .addTo(map);
// allMarkers.push(marker);
// return marker;
// }
// function crearPin({ lat, lng, label = "Sin etiqueta", popupHTML = null }) {
// const map = getMap(); // Asegúrate de que esta función exista y devuelva el objeto map
// if (!map) return null;
// const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
// popupHTML || `<strong>${label}</strong><br>Lat: ${lat}<br>Lng: ${lng}`
// );
// const marker = new maplibregl.Marker({ color: "#d32f2f" })
// .setLngLat([lng, lat])
// .setPopup(popup)
// .addTo(map);
// return marker; // Importante devolverlo para poder guardarlo en el array del otro archivo
// }
// En 99_functions.js (Sugerencia de limpieza)
function crearPin({ lat, lng, label = "Sin etiqueta", popupHTML = null }) {
const map = getMap();
if (!map) return null;
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
popupHTML || `<strong>${label}</strong><br>Lat: ${lat}<br>Lng: ${lng}`
);
const marker = new maplibregl.Marker({ color: "#d32f2f" })
.setLngLat([lng, lat])
.setPopup(popup)
.addTo(map);
return marker; // <-- Esto es lo que permite que tu allMarkers.push() funcione en el otro archivo
}
export { dic_edos, showNotification, crearPin };

45
templates/404.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>404 - Página no encontrada</title>
<style>
body {
font-family: Arial, sans-serif;
background: #0f172a;
color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
}
h1 {
font-size: 5rem;
margin-bottom: 0.5rem;
}
p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
a {
color: #38bdf8;
text-decoration: none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>404</h1>
<p>La página que buscas no existe.</p>
<a href="/">Volver al mapa</a>
</div>
</body>
</html>

183
templates/index.html Normal file
View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mapa Cobertura Red Móvil 4G/5G</title>
<link href="https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- {# cdn manejo csv #} -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<!-- {# estilos css #} -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/index/tooltip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index/notifications.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index/loading.css') }}">
<link rel="stylesheet" href="{{url_for('static', filename='css/index/index.css')}}">
</head>
<body>
<div id="map"></div>
<!-- FORMULARIO MEJORADO CON COLLAPSE -->
<div id="pin-form">
<!-- COLLAPSE PRINCIPAL DEL FORMULARIO -->
<input type="checkbox" id="collapse-toggle" />
<!-- CABECERA COLAPSABLE -->
<label for="collapse-toggle" class="form-header collapsible-header">
<div class="header-left">
<i class="fas fa-map-pin"></i>
<strong>Marcadores</strong>
</div>
<div class="header-right">
<i class="fas fa-chevron-down collapse-icon" id="collapse-icon" style="color: white !important;"></i>
</div>
</label>
<!-- CONTENIDO COLAPSABLE -->
<div class="collapsible-content">
<!-- COLLAPSE 1: Formulario de carga unitaria -->
<div class="collapse-section">
<input type="checkbox" id="collapse-toggle-1" />
<label for="collapse-toggle-1" class="collapse-header">
<span><i class="fa-solid fa-location-crosshairs"></i> Carga Individual</span>
<i class="fas fa-chevron-down collapse-icon"></i>
</label>
<div class="collapse-content">
<div class="form-group">
<label for="pin-label">Etiqueta</label>
<input type="text" id="pin-label" class="form-input" placeholder="Ej. Sitio A, Torre B, etc." />
</div>
<div class="form-group">
<label for="pin-lat">Latitud</label>
<input type="number" id="pin-lat" class="form-input" step="0.0001" placeholder="19.3818" />
</div>
<div class="form-group">
<label for="pin-lng">Longitud</label>
<input type="number" id="pin-lng" class="form-input" step="0.0001" placeholder="-99.1766" />
</div>
<button id="add-pin" type="button" class="button-primary">
<i class="fas fa-plus-circle"></i> Colocar Marcador
</button>
</div>
</div>
<!-- COLLAPSE 2: Carga masiva -->
<div class="collapse-section">
<input type="checkbox" id="collapse-toggle-2" />
<label for="collapse-toggle-2" class="collapse-header">
<span><i class="fa-solid fa-file-csv"></i> Carga Masiva Marcadores</span>
<i class="fas fa-chevron-down collapse-icon"></i>
</label>
<div class="collapse-content">
<div class="csv-section">
<div class="tooltip">
<i class="fas fa-info-circle" style="color: #1976d2; font-size: 0.9rem;"></i>
<span class="tooltip-text">El archivo CSV debe contener columnas: etiqueta, latitud (eje. 19.3818), longitud (eje. -99.1766 ). Use la
plantilla como referencia.</span>
</div>
<a href="{{url_for('download_file', filename='template_coordenadas_pines.csv')}}" class="button-secondary">
<i class="fas fa-download"></i> Descargar Plantilla
</a>
<input type="file" id="csv-file" class="csv-file-input" accept=".csv" />
<label for="csv-file" class="file-upload-label">
<i class="fas fa-upload"></i> Subir Plantilla
</label>
</div>
</div>
</div>
<!-- COLLAPSE 3: Descarga de capas -->
<div class="collapse-section">
<input type="checkbox" id="collapse-toggle-3" />
<label for="collapse-toggle-3" class="collapse-header">
<span><i class="fa-regular fa-map"></i> Descarga de Capas</span>
<i class="fas fa-chevron-down collapse-icon"></i>
</label>
<div class="collapse-content">
<div style="padding: 16px;">
<a href="{{url_for('download_file', filename='hex_mapa2025-10-01_2025-12-14.gpkg')}}" class="button-secondary">
Descargar Capa de Cobertura (gpkg)
</a>
</div>
</div>
</div>
</div>
<!-- SECCIÓN NO COLAPSABLE: Estado seleccionado -->
<div class="active-state-section">
<div class="form-group" style="padding: 0; margin: 0;">
<label style="color: #9b2226; font-weight: 600;">Estado seleccionado:</label>
<span id="nombre-estado-activo" style="font-weight: bold; color: #9b2226;">
Ninguno
</span>
</div>
</div>
</div>
<!-- INDICADOR DE CARGA -->
<div id="loading-indicator" class="loading-indicator">
<div class="loading-spinner"></div>
<div class="loading-text">Cargando datos...</div>
</div>
<!-- NOTIFICACIÓN (se añadirá dinámicamente) -->
<div id="notification-area"></div>
<!-- {# CDN map libre #} -->
<script src="https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.js"></script>
<!-- {# 1.- database hexagonos cobertura #} -->
<script src="{{url_for('static', filename='js/index/01_cobertura.js')}}"></script>
<!-- {# 2.- poligonos estados #} -->
<script src="{{url_for('static', filename='js/index/02_edos-poligonos.js')}}" type="module"></script>
<!-- carga de pines unitarios -->
<script src="{{url_for('static', filename='js/index/03_carga_pin_unitarios.js')}}" type="module"></script>
<!-- carga de pines masivos -->
<script src="{{url_for('static', filename='js/index/04_carga_pines_masivo.js')}}" type="module"></script>
<!-- {# Inicialización del mapa #} -->
<!-- <script src="{{ url_for('static', filename='js/index/map-init.js') }}"></script> -->
<!-- {# collapse #} -->
<!-- <script src="{{ url_for('static', filename='js/index/05_collapse.js') }}" type="module"></script> -->
</body>
</html>