#49 feat: recuadro informativo de productos en el mapa de provenance sin desplazar posiciones

This commit is contained in:
2026-08-16 12:19:13 -05:00
parent a251ae59eb
commit 6a833a1ea4
5 changed files with 180 additions and 82 deletions

View File

@@ -1,27 +1,52 @@
<template>
<div>
<v-alert
v-if="!hasMarkers"
class="my-4"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
<template v-if="productRows.length">
<v-card class="mb-3" data-test="map-product-box" variant="tonal">
<v-list density="compact">
<v-list-item
v-for="row in productRows"
:key="row.product.id"
data-test="map-product-row"
:prepend-avatar="row.image"
role="button"
:subtitle="row.locationLabel"
:title="row.product.name"
@click="onRowClick(row)"
>
<template #append>
<v-icon v-if="row.position" color="primary" size="small">
mdi-map-marker
</v-icon>
<v-icon v-else color="medium-emphasis" size="small">
mdi-map-marker-off
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
<template v-else>
<div ref="mapEl" class="map-wrapper" />
<template v-if="hasMarkers">
<div ref="mapEl" class="map-wrapper" />
<v-alert
v-if="!storePosition"
class="mt-2"
type="warning"
variant="tonal"
>
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
</v-alert>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
</p>
</template>
<v-alert
v-if="!storePosition"
class="mt-2"
type="warning"
v-else
class="my-2"
type="info"
variant="tonal"
>
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
</p>
</template>
<ProvenanceRelationModal
@@ -67,36 +92,6 @@
return [Number(current.latitude), Number(current.longitude)]
})
const MIN_PIXEL_GAP = 40
function degreesPerPixel (zoom) {
return 360 / (256 * Math.pow(2, zoom))
}
function groupedPositionKeys () {
const counts = new Map()
for (const markerData of markers.value) {
const key = markerData.basePosition.join(',')
counts.set(key, (counts.get(key) || 0) + 1)
}
return new Set(
[...counts.entries()].filter(entry => entry[1] > 1).map(entry => entry[0])
)
}
function positionAtZoom (markerData, zoom) {
const key = markerData.basePosition.join(',')
if (!groupedPositionKeys().has(key)) return markerData.basePosition
const group = markers.value.filter(m => m.basePosition.join(',') === key)
const index = group.findIndex(m => m === markerData)
const angle = (index / group.length) * Math.PI * 2
const offset = MIN_PIXEL_GAP * degreesPerPixel(zoom)
return [
markerData.basePosition[0] + Math.cos(angle) * offset,
markerData.basePosition[1] + Math.sin(angle) * offset,
]
}
const markers = computed(() => {
const result = []
for (const entry of props.provenance || []) {
@@ -107,7 +102,7 @@
result.push({
product: entry.product,
relation: rel,
basePosition: [Number(municipality.latitude), Number(municipality.longitude)],
position: [Number(municipality.latitude), Number(municipality.longitude)],
})
}
}
@@ -115,6 +110,32 @@
return result
})
const productRows = computed(() => {
const rows = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
const suppliers = entry.suppliers || []
const geoRelations = suppliers.filter(rel =>
rel.municipality && rel.municipality.latitude != null && rel.municipality.longitude != null
)
const municipalities = [...new Map(
geoRelations.map(rel => [rel.municipality.id, rel.municipality])
).values()]
rows.push({
product: entry.product,
relation: suppliers[0] || null,
image: entry.product.catalogue_images?.[0] || null,
position: geoRelations.length
? [Number(geoRelations[0].municipality.latitude), Number(geoRelations[0].municipality.longitude)]
: null,
locationLabel: municipalities.length
? municipalities.map(m => m.name).join(', ')
: 'Sin geolocalización',
})
}
return rows
})
const hasMarkers = computed(() => markers.value.length > 0)
const personIcon = L.divIcon({
@@ -162,9 +183,8 @@
}
function addProductMarkers () {
const zoom = map.getZoom()
markers.value.forEach(markerData => {
const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map)
const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
marker.bindTooltip(markerData.product.name)
marker.on('click', () => openDialog(markerData))
marker.on('mouseover', () => {
@@ -175,20 +195,22 @@
})
}
function onZoomEnd () {
const zoom = map.getZoom()
productMarkers.forEach(item => {
item.marker.setLatLng(positionAtZoom(item.data, zoom))
})
}
function fitBounds () {
const bounds = L.latLngBounds()
markers.value.forEach(markerData => bounds.extend(markerData.basePosition))
markers.value.forEach(markerData => bounds.extend(markerData.position))
if (storePosition.value) bounds.extend(storePosition.value)
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] })
}
function onRowClick (row) {
if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
}
selectedProduct.value = row.product
selectedRelation.value = row.relation
modalVisible.value = true
}
function openDialog (markerData) {
selectedProduct.value = markerData.product
selectedRelation.value = markerData.relation
@@ -197,7 +219,7 @@
function initMap () {
if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].basePosition
const center = storePosition.value || markers.value[0].position
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
@@ -205,7 +227,6 @@
}).addTo(map)
addStoreMarker()
addProductMarkers()
map.on('zoomend', onZoomEnd)
fitBounds()
}

View File

@@ -4,7 +4,7 @@
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card v-if="relation">
<v-card v-if="relation || product">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
<span class="font-weight-bold">{{ productName }}</span>
@@ -17,6 +17,20 @@
class="provenance-image rounded-lg border mb-3"
:src="productImage"
>
<div
v-if="!hasLocation"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" color="grey" icon="mdi-map-marker-off" />
<div>
<div class="text-subtitle-2 font-weight-bold">
Geolocalización
</div>
<div class="text-body-1">
{{ locationNote }}
</div>
</div>
</div>
<div
v-for="row in rows"
:key="row.kind"
@@ -78,6 +92,11 @@
const productName = computed(() => props.product?.name || 'Producto')
const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
const hasLocation = computed(() => !!(props.relation?.municipality || props.relation?.department))
const locationNote = computed(() => {
if (props.relation) return 'Aún sin geolocalización registrada.'
return 'Aún no se ha vinculado un proveedor a este producto.'
})
function entityDetails (kind, entity) {
const details = []