695 lines
21 KiB
Vue

<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, watch } from "vue";
import { Loader } from "@googlemaps/js-api-loader";
const props = defineProps({
counters: { type: Object, required: true },
});
const mapContainer = ref(null);
const loading = ref(true);
const error = ref(null);
const map = ref(null);
const markers = ref([]);
const geocoder = ref(null);
const isMounted = ref(false);
const filters = reactive({
sin_avances: true,
abiertas: true,
finalizadas: true,
});
const searchQuery = ref("");
const searchResults = ref([]);
const showSearchResults = ref(false);
const searchLoading = ref(false);
const geocodingCache = new Map();
const failedGeocodings = ref([]);
const markerColors = {
sin_avances: "#EF4444",
abiertas: "#F59E0B",
finalizadas: "#10B981",
};
const COMALCALCO_CENTER = { lat: 18.26, lng: -93.25 };
const COMALCALCO_BOUNDS = {
south: 18.1,
west: -93.4,
north: 18.4,
east: -93.0,
};
const visibleMarkersCount = computed(
() => markers.value.filter((m) => m.visible).length
);
onMounted(() => {
isMounted.value = true;
initMap();
});
onUnmounted(() => {
isMounted.value = false;
});
watch(
() => props.counters,
() => {
if (map.value && geocoder.value) {
loadMarkers();
}
},
{ deep: true }
);
const initMap = async () => {
loading.value = true;
error.value = null;
try {
const loader = new Loader({
apiKey: import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
version: "weekly",
// Usar solo librerías básicas por compatibilidad
libraries: ["places", "geometry"],
});
window.google = await loader.load();
if (!isMounted.value) return;
map.value = new google.maps.Map(mapContainer.value, {
center: COMALCALCO_CENTER,
zoom: 11,
// Mantener mapId pero para funcionalidad futura
mapId: "OBRAS_MAP_COMALCALCO",
mapTypeControl: true,
streetViewControl: true,
fullscreenControl: true,
zoomControl: true,
// Estilos mejorados
styles: [
{
featureType: "poi",
elementType: "labels.icon",
stylers: [{ visibility: "off" }],
},
{
featureType: "transit",
elementType: "labels.icon",
stylers: [{ visibility: "off" }],
},
],
});
geocoder.value = new google.maps.Geocoder();
await loadMarkers();
} catch (err) {
if (!isMounted.value) return;
console.error("Error loading map:", err);
error.value = err.message || "Error al cargar Google Maps";
} finally {
if (isMounted.value) loading.value = false;
}
};
const loadMarkers = async () => {
if (!map.value || !geocoder.value || !isMounted.value) return;
loading.value = true;
clearMarkers();
failedGeocodings.value = [];
const allObras = getAllObras();
console.log("Total obras a procesar:", allObras.length);
if (allObras.length === 0) {
if (isMounted.value) loading.value = false;
return;
}
const obrasPorGrupo = groupObrasByAddressAndStatus(allObras);
const uniqueAddresses = [
...new Set(Array.from(obrasPorGrupo.values()).map((g) => g.direccion)),
].filter(Boolean);
console.log("Direcciones únicas a geocodificar:", uniqueAddresses.length);
await geocodeUniqueAddresses(uniqueAddresses);
if (!isMounted.value) return;
createMarkersFromGroups(obrasPorGrupo);
loading.value = false;
updateMarkers();
console.log("Marcadores creados exitosamente:", markers.value.length);
};
const normalizeAddressForGeocoding = (address) => {
if (!address) return "";
return address
.replace(/^RA\.\s*/, "RANCHERÍA ")
.replace(/(\d+)\s*DA\s*SECCION/i, "$1 SECCION")
.replace(/\s+/g, " ")
.trim();
};
const geocodeAndCacheAddress = (address) => {
if (!address || geocodingCache.has(address)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const normalizedAddress = normalizeAddressForGeocoding(address);
const request = {
address: normalizedAddress,
componentRestrictions: {
country: "MX",
administrativeArea: "Tabasco",
},
bounds: new google.maps.LatLngBounds(
{ lat: COMALCALCO_BOUNDS.south, lng: COMALCALCO_BOUNDS.west },
{ lat: COMALCALCO_BOUNDS.north, lng: COMALCALCO_BOUNDS.east }
),
};
geocoder.value.geocode(request, (results, status) => {
if (status === "OK" && results?.length) {
// Con la búsqueda sesgada, el primer resultado es ahora mucho más confiable
const bestResult = results[0];
geocodingCache.set(address, {
success: true,
position: {
lat: bestResult.geometry.location.lat(),
lng: bestResult.geometry.location.lng(),
},
formatted_address: bestResult.formatted_address,
});
} else {
geocodingCache.set(address, { success: false, status });
}
resolve();
});
});
};
const groupObrasByAddressAndStatus = (obras) => {
const grouped = new Map();
obras.forEach((obra) => {
const key = `${obra.direccion_obra}|${obra.estado}`;
if (!grouped.has(key)) {
grouped.set(key, {
direccion: obra.direccion_obra,
estado: obra.estado,
obras: [],
});
}
grouped.get(key).obras.push(obra);
});
return grouped;
};
const geocodeUniqueAddresses = async (addresses) => {
const geocodingPromises = addresses.map((addr) =>
geocodeAndCacheAddress(addr)
);
await Promise.all(geocodingPromises);
};
const createMarkersFromGroups = (obrasPorGrupo) => {
const statusOffsets = {
sin_avances: { lat: 0.00008, lng: -0.00005 },
abiertas: { lat: 0, lng: 0.0001 },
finalizadas: { lat: -0.00004, lng: -0.00005 },
};
for (const grupo of obrasPorGrupo.values()) {
const geocodedResult = geocodingCache.get(grupo.direccion);
if (geocodedResult?.success) {
const offset = statusOffsets[grupo.estado] || { lat: 0, lng: 0 };
const position = {
lat: geocodedResult.position.lat + offset.lat,
lng: geocodedResult.position.lng + offset.lng,
};
createMarker(
position,
grupo.estado,
grupo.obras,
geocodedResult.formatted_address
);
} else {
failedGeocodings.value.push({
direccion: grupo.direccion,
motivo: geocodedResult?.status || "NOT_FOUND",
obras: grupo.obras,
});
}
}
};
const createMarker = (position, estado, obras, formatted_address) => {
try {
const svgIcon = createSVGIcon(estado, obras.length);
const marker = new google.maps.Marker({
position: new google.maps.LatLng(position.lat, position.lng),
map: map.value,
title: `${obras.length} obra(s) en ${obras[0].direccion_obra}`,
icon: {
url: svgIcon,
scaledSize: new google.maps.Size(32, 40),
anchor: new google.maps.Point(16, 40),
},
animation: google.maps.Animation.DROP,
optimized: false,
});
const infoWindow = new google.maps.InfoWindow({
content: createMultipleInfoWindowContent(obras, formatted_address),
maxWidth: 400,
});
marker.addListener("click", () => {
markers.value.forEach((m) => m.infoWindow?.close());
infoWindow.open(map.value, marker);
});
markers.value.push({
marker,
infoWindow,
estado,
obras,
visible: filters[estado],
position,
});
} catch (error) {
console.error("Error creando marcador:", error);
}
};
// Crear icono SVG
const createSVGIcon = (estado, count) => {
const color = markerColors[estado];
const svg = `
<svg width="32" height="40" viewBox="0 0 32 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.3"/>
</filter>
</defs>
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 24 16 24s16-12 16-24c0-8.837-7.163-16-16-16z"
fill="${color}"
stroke="white"
stroke-width="2"
filter="url(#shadow)"/>
<circle cx="16" cy="16" r="10" fill="white"/>
<circle cx="16" cy="16" r="7" fill="${color}"/>
<text x="16" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold" font-family="Arial">${count}</text>
</svg>
`;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
};
const updateMarkers = () => {
markers.value.forEach((m) => {
const shouldShow = filters[m.estado];
m.marker.setVisible(shouldShow);
m.visible = shouldShow;
});
console.log("Marcadores actualizados. Visibles:", visibleMarkersCount.value);
};
const clearMarkers = () => {
markers.value.forEach(({ marker, infoWindow }) => {
try {
infoWindow?.close();
marker.setMap(null);
} catch (error) {
console.error("Error limpiando marcador:", error);
}
});
markers.value = [];
// No limpiar cache para mejorar rendimiento
};
const fitMapToMarkers = () => {
if (!map.value || markers.value.length === 0) return;
const bounds = new google.maps.LatLngBounds();
const visibleMarkers = markers.value.filter((m) => m.visible);
if (visibleMarkers.length === 0) return;
visibleMarkers.forEach(({ position }) => {
bounds.extend(new google.maps.LatLng(position.lat, position.lng));
});
map.value.fitBounds(bounds);
const listener = google.maps.event.addListener(map.value, "idle", () => {
if (map.value.getZoom() > 15) map.value.setZoom(15);
google.maps.event.removeListener(listener);
});
};
const getAllObras = () => {
try {
return [
...props.counters.sin_avances.detalles.map((o) => ({
...o,
estado: "sin_avances",
})),
...props.counters.abiertas.detalles.map((o) => ({
...o,
estado: "abiertas",
})),
...props.counters.finalizadas.detalles.map((o) => ({
...o,
estado: "finalizadas",
})),
];
} catch (error) {
console.error("Error obteniendo obras:", error);
return [];
}
};
const searchAddresses = () => {
if (!searchQuery.value.trim()) {
searchResults.value = [];
showSearchResults.value = false;
return;
}
const query = searchQuery.value.toLowerCase();
const allObras = getAllObras();
searchResults.value = allObras
.filter(
(obra) =>
obra.direccion_obra?.toLowerCase().includes(query) ||
obra.num_proyecto?.toLowerCase().includes(query)
)
.slice(0, 10);
showSearchResults.value = true;
};
const goToObra = (obra) => {
searchQuery.value = "";
searchResults.value = [];
showSearchResults.value = false;
const markerData = markers.value.find((m) =>
m.obras.some((o) => o.num_proyecto === obra.num_proyecto)
);
if (markerData) {
map.value.setCenter(markerData.position);
map.value.setZoom(16);
markers.value.forEach((m) => m.infoWindow?.close());
markerData.infoWindow.open(map.value, markerData.marker);
}
};
const clearSearch = () => {
searchQuery.value = "";
searchResults.value = [];
showSearchResults.value = false;
};
const createMultipleInfoWindowContent = (obras, formatted_address) => {
const direccion = obras[0].direccion_obra;
let obrasHtml = obras
.map((obra) => {
const estadoConfig = {
sin_avances: { color: "bg-red-100 text-red-800", label: "Sin avances" },
abiertas: {
color: "bg-yellow-100 text-yellow-800",
label: "En proceso",
},
finalizadas: {
color: "bg-green-100 text-green-800",
label: "Finalizada",
},
};
const config = estadoConfig[obra.estado];
const cumplimiento = obra.cumplimiento_total
? `${Number(obra.cumplimiento_total).toFixed(1)}%`
: "N/A";
return `<div class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:pb-0 last:mb-0"><div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-gray-900">${
obra.num_proyecto || "N/A"
}</h4><span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
config.color
}">${
config.label
}</span></div><div class="flex items-center space-x-2"><div class="flex-1 bg-gray-200 rounded-full h-1.5"><div class="h-1.5 rounded-full" style="width: ${
Number(obra.cumplimiento_total) || 0
}%; background-color: ${
markerColors[obra.estado]
};"></div></div><span class="text-xs font-medium text-gray-700 min-w-[35px]">${cumplimiento}</span></div></div>`;
})
.join("");
return `<div class="p-4 max-w-sm"><div class="mb-3"><h3 class="font-bold text-gray-900 text-lg mb-1">${
obras.length
} Obra(s) en esta ubicación</h3><p class="text-xs text-gray-600">${direccion}</p>${
formatted_address
? `<p class="text-xs text-gray-500 mt-1">${formatted_address}</p>`
: ""
}</div><div class="space-y-3 text-sm max-h-60 overflow-y-auto">${obrasHtml}</div></div>`;
};
</script>
<template>
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<div
class="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">Mapa de Obras</h3>
<p class="text-sm text-gray-600 mt-1">
Ubicación geográfica de proyectos en Comalcalco, Tabasco
</p>
</div>
<div class="flex items-center space-x-4">
<div class="relative">
<div class="relative">
<input
v-model="searchQuery"
@input="searchAddresses"
@focus="showSearchResults = true"
type="text"
placeholder="Buscar obra o dirección..."
class="w-64 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<svg
v-if="searchLoading"
class="animate-spin h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else-if="searchQuery"
@click="clearSearch"
class="h-4 w-4 text-gray-400 cursor-pointer hover:text-gray-600 pointer-events-auto"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<svg
v-else
class="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<div
v-if="showSearchResults && searchResults.length > 0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
>
<div
v-for="obra in searchResults"
:key="obra.num_proyecto"
@click="goToObra(obra)"
class="px-3 py-2 hover:bg-gray-50 cursor-pointer"
>
<p class="text-sm font-medium text-gray-900 truncate">
{{ obra.num_proyecto || "N/A" }}
</p>
<p class="text-xs text-gray-500 truncate">
{{ obra.direccion_obra }}
</p>
</div>
</div>
<div
v-else-if="
showSearchResults &&
searchResults.length === 0 &&
searchQuery.trim()
"
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg"
>
<div class="px-3 py-2 text-sm text-gray-500 text-center">
No se encontraron resultados
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<input
id="sin-avances"
v-model="filters.sin_avances"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<label
for="sin-avances"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
Sin avances ({{ counters.sin_avances.count }})
</label>
</div>
<div class="flex items-center space-x-2">
<input
id="abiertas"
v-model="filters.abiertas"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
/>
<label
for="abiertas"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
Abiertas ({{ counters.abiertas.count }})
</label>
</div>
<div class="flex items-center space-x-2">
<input
id="finalizadas"
v-model="filters.finalizadas"
@change="updateMarkers"
type="checkbox"
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<label
for="finalizadas"
class="flex items-center text-sm text-gray-700"
>
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
Finalizadas ({{ counters.finalizadas.count }})
</label>
</div>
</div>
</div>
<div class="flex items-center justify-between mt-3">
<div class="text-sm text-gray-600">
Marcadores visibles: {{ visibleMarkersCount }} de {{ markers.length }}
</div>
<div class="flex items-center space-x-2">
<button
@click="fitMapToMarkers"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Ajustar Vista
</button>
<button
@click="loadMarkers"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Recargar
</button>
</div>
</div>
</div>
<div ref="mapContainer" class="w-full h-[600px] relative">
<div
v-if="loading"
class="absolute inset-0 bg-gray-100/80 flex items-center justify-center z-10 backdrop-blur-sm"
>
<div class="text-center">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"
></div>
<p class="mt-2 text-sm text-gray-600">
Cargando mapa y geocodificando direcciones...
</p>
</div>
</div>
<div
v-if="error"
class="absolute inset-0 bg-red-50 flex items-center justify-center z-10"
>
<div class="text-center">
<h3 class="text-sm font-medium text-red-800">
Error al cargar el mapa
</h3>
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
<button
@click="initMap"
class="mt-4 bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700"
>
Reintentar
</button>
</div>
</div>
</div>
<div
v-if="!loading && failedGeocodings.length > 0"
class="px-6 py-4 bg-yellow-50 border-t border-yellow-200"
>
<h4 class="text-sm font-semibold text-yellow-800">
{{ failedGeocodings.flatMap((f) => f.obras).length }} obra(s) no
pudieron ser ubicadas
</h4>
<p class="text-xs text-yellow-700 mb-2">
Las siguientes direcciones no fueron encontradas o la coincidencia era
de baja calidad. Considera revisarlas en la base de datos.
</p>
<ul class="text-xs text-gray-700 list-disc pl-5 max-h-32 overflow-y-auto">
<li v-for="item in failedGeocodings" :key="item.direccion">
<strong>{{ item.direccion }}</strong> ({{ item.obras.length }} obra/s)
- Motivo:
<span class="font-mono text-red-600">{{ item.motivo }}</span>
</li>
</ul>
</div>
</div>
</template>