#49 feat: recuadro informativo de productos en el mapa de provenance sin desplazar posiciones
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user