+
+ La información de origen de los productos de este pedido estará disponible
+ próximamente.
+
+
+
+
+ Elementos a graficar:
+
+
+
+
+ Selecciona al menos un elemento para graficar.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue
index efae78d..4f6da26 100644
--- a/src/components/provenance/ProvenanceSection.vue
+++ b/src/components/provenance/ProvenanceSection.vue
@@ -6,19 +6,12 @@
Conoce quiénes producen los productos que compras y de qué territorios provienen.
- Conoce quiénes producen los productos que compras y de qué territorios provienen.
-
+
+
+ Origen de los productos
+
+
+
-
+
+
+
+ Conoce quiénes producen los productos que compras y de qué territorios provienen.
+
+
+
+
+
diff --git a/src/components/provenance/provenance-vis.js b/src/components/provenance/provenance-vis.js
index e17d99a..c83560d 100644
--- a/src/components/provenance/provenance-vis.js
+++ b/src/components/provenance/provenance-vis.js
@@ -25,6 +25,8 @@ const COLUMN_X = {
country: 560,
}
+export const KIND_COLORS = COLORS
+
export function toVisNodes (nodes) {
const products = nodes.filter(node => node.kind === 'product')
const productY = new Map()
diff --git a/tests/unit/components/provenance/ProvenanceGraph.spec.js b/tests/unit/components/provenance/ProvenanceGraph.spec.js
index 186f247..b533934 100644
--- a/tests/unit/components/provenance/ProvenanceGraph.spec.js
+++ b/tests/unit/components/provenance/ProvenanceGraph.spec.js
@@ -44,7 +44,7 @@ function visChart (wrapper) {
}
function checkbox (wrapper, label) {
- return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.props('label') === label)
+ return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.text().trim() === label)
}
async function setKinds (wrapper, kinds) {
@@ -75,10 +75,20 @@ describe('ProvenanceGraph', () => {
it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
const wrapper = mountGraph()
- const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.props('label'))
+ const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.text().trim())
expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
})
+ it('dibuja un círculo de color junto a cada checkbox como leyenda', () => {
+ const wrapper = mountGraph()
+
+ const dots = wrapper.findAll('.legend-dot')
+ expect(dots).toHaveLength(6)
+ expect(dots[0].element.style.backgroundColor).toBe('rgb(38, 166, 154)')
+ expect(dots[1].element.style.backgroundColor).toBe('rgb(66, 165, 245)')
+ expect(dots[2].element.style.backgroundColor).toBe('rgb(255, 183, 77)')
+ })
+
it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
const wrapper = mountGraph()
diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js
index a8f2ef2..497b07a 100644
--- a/tests/unit/components/provenance/ProvenanceSection.spec.js
+++ b/tests/unit/components/provenance/ProvenanceSection.spec.js
@@ -8,7 +8,7 @@ vi.mock('@/components/graph/VisChart.vue', () => ({
default: {
name: 'VisChart',
template: '',
- props: ['nodes', 'edges', 'height', 'options'],
+ props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
},
}))
@@ -47,16 +47,41 @@ describe('ProvenanceSection', () => {
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
- it('muestra el gráfico unificado con su título cuando hay provenance', () => {
+ it('muestra el título y oculta el gráfico por defecto', () => {
const wrapper = mountSection()
- expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
- expect(wrapper.text()).toContain('Origen e historia de los productos')
+ expect(wrapper.text()).toContain('Origen de los productos')
+ expect(wrapper.text()).not.toContain('historia')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
})
- it('pasa el provenance al gráfico', () => {
+ it('muestra el gráfico al hacer clic en el título y lo oculta al volver a hacer clic', async () => {
const wrapper = mountSection()
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
+
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('también despliega y repliega con el botón de chevron', async () => {
+ const wrapper = mountSection()
+
+ const button = wrapper.find('[data-test="provenance-toggle-button"]')
+ expect(button.exists()).toBe(true)
+ await button.trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
+
+ await button.trigger('click')
+ expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
+ })
+
+ it('pasa el provenance al gráfico al desplegarlo', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
+
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
})
})
From 18026617cea1025c22ea62cf58531dcd6d8a7d11 Mon Sep 17 00:00:00 2001
From: monomono
Date: Sun, 16 Aug 2026 04:10:39 -0500
Subject: [PATCH 15/22] #49 fix: marcador de producto sin imagen visible en el
mapa de provenance
---
AGENTS.md | 2 +-
src/components/provenance/ProvenanceMap.vue | 248 ++++++++++++++++++
.../provenance/ProvenanceRelationModal.vue | 126 +++++++++
.../provenance/ProvenanceSection.vue | 31 +++
.../provenance/ProvenanceMap.spec.js | 224 ++++++++++++++++
.../ProvenanceRelationModal.spec.js | 81 ++++++
.../provenance/ProvenanceSection.spec.js | 42 ++-
7 files changed, 752 insertions(+), 2 deletions(-)
create mode 100644 src/components/provenance/ProvenanceMap.vue
create mode 100644 src/components/provenance/ProvenanceRelationModal.vue
create mode 100644 tests/unit/components/provenance/ProvenanceMap.spec.js
create mode 100644 tests/unit/components/provenance/ProvenanceRelationModal.spec.js
diff --git a/AGENTS.md b/AGENTS.md
index 0bf2639..94a637c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
-- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto — patrón reutilizable para futuros bloques como el mapa leaflet. `ProvenanceDetailModal.vue`
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
new file mode 100644
index 0000000..eaab76b
--- /dev/null
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -0,0 +1,248 @@
+
+
+
+ Las coordenadas geográficas de los municipios de origen aún no están disponibles.
+
+
+
+
+
+ La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
+
+
+ Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
+
+ Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda.
+
+
+
+
+
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js
new file mode 100644
index 0000000..e87d58c
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceMap.spec.js
@@ -0,0 +1,224 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue'
+import vuetify from '@/plugins/vuetify'
+
+const leaflet = vi.hoisted(() => {
+ const markers = []
+ const polylines = []
+ const layers = []
+ function makeLayer (type) {
+ const layer = {
+ type,
+ addTo: vi.fn(function () { return this }),
+ on: vi.fn(),
+ bindTooltip: vi.fn(function () { return this }),
+ remove: vi.fn(),
+ }
+ layers.push(layer)
+ return layer
+ }
+ const map = {
+ setView: vi.fn(function () { return this }),
+ fitBounds: vi.fn(),
+ remove: vi.fn(),
+ invalidateSize: vi.fn(),
+ on: vi.fn(),
+ }
+ const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) }
+ return {
+ markers,
+ polylines,
+ layers,
+ map,
+ bounds,
+ L: {
+ map: vi.fn(() => map),
+ tileLayer: vi.fn(() => makeLayer('tileLayer')),
+ marker: vi.fn((pos, opts) => {
+ const marker = makeLayer('marker')
+ marker.pos = pos
+ marker.opts = opts
+ markers.push(marker)
+ return marker
+ }),
+ divIcon: vi.fn(opts => opts),
+ polyline: vi.fn((points, opts) => {
+ const polyline = makeLayer('polyline')
+ polyline.points = points
+ polyline.opts = opts
+ polylines.push(polyline)
+ return polyline
+ }),
+ latLngBounds: vi.fn(() => bounds),
+ },
+ }
+})
+
+vi.mock('leaflet', () => ({ default: leaflet.L }))
+
+const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' }
+
+const provenance = [
+ {
+ product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
+ suppliers: [
+ {
+ supplier: { id: 5, name: 'Asociación La Mesa' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 },
+ },
+ ],
+ },
+ {
+ product: { id: 2, name: 'Arroz blanco', catalogue_images: [] },
+ suppliers: [
+ { supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } },
+ { supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } },
+ ],
+ },
+]
+
+function mountMap (props = {}) {
+ const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ const wrapper = mount(ProvenanceMap, {
+ props: { provenance, ...props },
+ global: {
+ plugins: [pinia, vuetify],
+ provide: { api },
+ },
+ })
+ return { api, wrapper }
+}
+
+function handlerOf (marker, event) {
+ return marker.on.mock.calls.find(args => args[0] === event)[1]
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ leaflet.markers.length = 0
+ leaflet.polylines.length = 0
+ leaflet.layers.length = 0
+ document.body.innerHTML = ''
+})
+
+describe('ProvenanceMap', () => {
+ it('carga la configuración de la tienda al montarse', async () => {
+ const { api } = mountMap()
+ await flushPromises()
+
+ expect(api.getStoreSettings).toHaveBeenCalled()
+ })
+
+ it('crea el mapa con la tienda y un marcador por municipio de origen', async () => {
+ mountMap()
+ await flushPromises()
+
+ expect(leaflet.L.map).toHaveBeenCalledTimes(1)
+ expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1)
+ expect(leaflet.markers).toHaveLength(4)
+ expect(leaflet.map.fitBounds).toHaveBeenCalled()
+ expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled()
+ })
+
+ it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(1)
+ expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]])
+ expect(leaflet.polylines[0].addTo).toHaveBeenCalled()
+ })
+
+ it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[0], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(3)
+ })
+
+ it('al salir del marcador se limpian las líneas', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+ expect(leaflet.polylines).toHaveLength(1)
+ handlerOf(leaflet.markers[1], 'mouseout')()
+
+ expect(leaflet.polylines[0].remove).toHaveBeenCalled()
+ })
+
+ it('al hacer clic en un producto abre el diálogo con el proveedor', async () => {
+ mountMap()
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'click')()
+ await nextTick()
+
+ expect(document.body.textContent).toContain('Asociación La Mesa')
+ expect(document.body.textContent).toContain('Panela regional')
+ })
+
+ it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
+ const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) }
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ mount(ProvenanceMap, {
+ props: { provenance },
+ global: {
+ plugins: [pinia, vuetify],
+ provide: { api },
+ },
+ })
+ await flushPromises()
+
+ expect(leaflet.markers).toHaveLength(3)
+ handlerOf(leaflet.markers[0], 'mouseover')()
+ expect(leaflet.L.polyline).not.toHaveBeenCalled()
+ })
+
+ it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => {
+ const noCoords = [
+ { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] },
+ ]
+ const { wrapper } = mountMap({ provenance: noCoords })
+ await flushPromises()
+
+ expect(leaflet.L.map).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('coordenadas')
+ })
+
+ it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
+ const backendProvenance = [
+ {
+ product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] },
+ suppliers: [
+ {
+ supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' },
+ organization: null,
+ municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' },
+ department: { id: 91, name: 'Cundinamarca' },
+ country: { id: 3, name: 'Colombia', code: 'CO' },
+ },
+ ],
+ },
+ ]
+ mountMap({ provenance: backendProvenance })
+ await flushPromises()
+
+ expect(leaflet.L.map).toHaveBeenCalledTimes(1)
+ expect(leaflet.markers).toHaveLength(2)
+ expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
+ const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback'))
+ expect(productIcon).toBeDefined()
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
new file mode 100644
index 0000000..942c3d6
--- /dev/null
+++ b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue'
+import vuetify from '@/plugins/vuetify'
+
+const relation = {
+ supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' },
+ organization: { id: 3, name: 'Red de Economía Solidaria' },
+ municipality: { id: 7, name: 'Santa Bárbara' },
+ department: { id: 2, name: 'Antioquia' },
+ country: { id: 1, name: 'Colombia', code: 'CO' },
+}
+const product = {
+ id: 1,
+ name: 'Panela regional',
+ catalogue_images: ['http://localhost/media/panela.jpg'],
+}
+
+function mountModal (props = {}) {
+ return mount(ProvenanceRelationModal, {
+ props: { product, relation, visible: true, ...props },
+ global: { plugins: [vuetify] },
+ })
+}
+
+function bodyText () {
+ return document.body.textContent
+}
+
+describe('ProvenanceRelationModal', () => {
+ it('muestra el producto y todas las entidades de la relación', () => {
+ mountModal()
+
+ expect(bodyText()).toContain('Panela regional')
+ expect(bodyText()).toContain('Proveedor')
+ expect(bodyText()).toContain('Asociación La Mesa')
+ expect(bodyText()).toContain('Organización')
+ expect(bodyText()).toContain('Red de Economía Solidaria')
+ expect(bodyText()).toContain('Municipio')
+ expect(bodyText()).toContain('Santa Bárbara')
+ expect(bodyText()).toContain('Departamento')
+ expect(bodyText()).toContain('Antioquia')
+ expect(bodyText()).toContain('País')
+ expect(bodyText()).toContain('Colombia')
+ })
+
+ it('incluye los detalles de contacto del proveedor', () => {
+ mountModal()
+
+ expect(bodyText()).toContain('info@mesa.co')
+ })
+
+ it('muestra la imagen del producto cuando existe', () => {
+ mountModal()
+
+ expect(document.querySelector('img').getAttribute('src')).toBe(
+ 'http://localhost/media/panela.jpg'
+ )
+ })
+
+ it('no muestra las entidades ausentes', () => {
+ mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
+
+ expect(bodyText()).not.toContain('Organización')
+ expect(bodyText()).not.toContain('Municipio')
+ expect(bodyText()).not.toContain('Departamento')
+ expect(bodyText()).not.toContain('País')
+ })
+
+ it('no muestra contenido cuando no hay relación', () => {
+ mountModal({ relation: null })
+
+ expect(bodyText()).not.toContain('Proveedor')
+ })
+
+ it('no muestra el diálogo cuando visible es false', () => {
+ mountModal({ visible: false })
+
+ expect(bodyText()).not.toContain('Asociación La Mesa')
+ })
+})
diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js
index 497b07a..adc5892 100644
--- a/tests/unit/components/provenance/ProvenanceSection.spec.js
+++ b/tests/unit/components/provenance/ProvenanceSection.spec.js
@@ -30,10 +30,14 @@ const provenance = [
function mountSection (props = {}) {
return mount(ProvenanceSection, {
props: { provenance, ...props },
- global: { plugins: [vuetify] },
+ global: { plugins: [vuetify], stubs: { ProvenanceMap: true } },
})
}
+function mapWrapper (wrapper) {
+ return wrapper.findComponent({ name: 'ProvenanceMap' })
+}
+
describe('ProvenanceSection', () => {
it('no renderiza nada cuando no hay provenance', () => {
const wrapper = mountSection({ provenance: null })
@@ -84,4 +88,40 @@ describe('ProvenanceSection', () => {
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
})
+
+ it('oculta el mapa por defecto', () => {
+ const wrapper = mountSection()
+
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(true)
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('también despliega y repliega el mapa con el botón de chevron', async () => {
+ const wrapper = mountSection()
+
+ const button = wrapper.find('[data-test="map-toggle-button"]')
+ expect(button.exists()).toBe(true)
+ await button.trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(true)
+
+ await button.trigger('click')
+ expect(mapWrapper(wrapper).exists()).toBe(false)
+ })
+
+ it('pasa el provenance al mapa al desplegarlo', async () => {
+ const wrapper = mountSection()
+
+ await wrapper.find('[data-test="map-toggle"]').trigger('click')
+
+ expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance)
+ })
})
From a251ae59eb756686e3899584a26c3335213c96ad Mon Sep 17 00:00:00 2001
From: monomono
Date: Sun, 16 Aug 2026 04:19:17 -0500
Subject: [PATCH 16/22] #49 feat: separacion de marcadores de provenance
escalada por zoom en el mapa
---
AGENTS.md | 2 +-
src/components/provenance/ProvenanceMap.vue | 55 ++++++++++++++++---
.../provenance/ProvenanceMap.spec.js | 49 +++++++++++++++++
3 files changed, 97 insertions(+), 9 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 94a637c..7bb2ca8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
-- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload; si varios productos coinciden en el mismo municipio se separan en anillo cuyo radio en grados se recalcula en `zoomend` para mantener ~40px de separación visual en cualquier zoom) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
index eaab76b..829d947 100644
--- a/src/components/provenance/ProvenanceMap.vue
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -67,6 +67,36 @@
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 || []) {
@@ -77,7 +107,7 @@
result.push({
product: entry.product,
relation: rel,
- position: [Number(municipality.latitude), Number(municipality.longitude)],
+ basePosition: [Number(municipality.latitude), Number(municipality.longitude)],
})
}
}
@@ -126,29 +156,37 @@
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
storeMarker.bindTooltip('Tienda')
storeMarker.on('mouseover', () => {
- markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
+ productMarkers.forEach(item => showLine(item.marker.getLatLng(), storePosition.value))
})
storeMarker.on('mouseout', clearLines)
}
function addProductMarkers () {
+ const zoom = map.getZoom()
markers.value.forEach(markerData => {
- const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
+ const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map)
marker.bindTooltip(markerData.product.name)
marker.on('click', () => openDialog(markerData))
marker.on('mouseover', () => {
- if (storePosition.value) showLine(markerData.position, storePosition.value)
+ if (storePosition.value) showLine(marker.getLatLng(), storePosition.value)
})
marker.on('mouseout', clearLines)
- productMarkers.push(marker)
+ productMarkers.push({ marker, data: markerData })
+ })
+ }
+
+ 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.position))
+ markers.value.forEach(markerData => bounds.extend(markerData.basePosition))
if (storePosition.value) bounds.extend(storePosition.value)
- if (bounds.isValid()) map.fitBounds(bounds, { padding: [40, 40] })
+ if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] })
}
function openDialog (markerData) {
@@ -159,7 +197,7 @@
function initMap () {
if (!mapEl.value || map) return
- const center = storePosition.value || markers.value[0].position
+ const center = storePosition.value || markers.value[0].basePosition
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
@@ -167,6 +205,7 @@
}).addTo(map)
addStoreMarker()
addProductMarkers()
+ map.on('zoomend', onZoomEnd)
fitBounds()
}
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js
index e87d58c..adecca0 100644
--- a/tests/unit/components/provenance/ProvenanceMap.spec.js
+++ b/tests/unit/components/provenance/ProvenanceMap.spec.js
@@ -16,6 +16,8 @@ const leaflet = vi.hoisted(() => {
on: vi.fn(),
bindTooltip: vi.fn(function () { return this }),
remove: vi.fn(),
+ getLatLng: vi.fn(function () { return this.pos }),
+ setLatLng: vi.fn(),
}
layers.push(layer)
return layer
@@ -26,6 +28,7 @@ const leaflet = vi.hoisted(() => {
remove: vi.fn(),
invalidateSize: vi.fn(),
on: vi.fn(),
+ getZoom: vi.fn(() => 8),
}
const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) }
return {
@@ -197,6 +200,52 @@ describe('ProvenanceMap', () => {
expect(wrapper.text()).toContain('coordenadas')
})
+ it('separa los productos del mismo municipio para que no se superpongan', async () => {
+ const sameMuni = [
+ {
+ product: { id: 1, name: 'Panela', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ {
+ product: { id: 2, name: 'Arroz', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ ]
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(','))
+ expect(productPositions).toHaveLength(2)
+ expect(new Set(productPositions).size).toBe(2)
+ expect(productPositions).not.toContain('4.631028,-74.461588')
+ })
+
+ it('reduce la separación en grados al hacer zoom in para mantener la distancia visual', async () => {
+ const sameMuni = [
+ {
+ product: { id: 1, name: 'Panela', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ {
+ product: { id: 2, name: 'Arroz', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ ]
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const deg8 = 40 * (360 / (256 * Math.pow(2, 8)))
+ expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5)
+
+ leaflet.map.getZoom.mockReturnValue(9)
+ const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1]
+ zoomHandler()
+
+ const deg9 = 40 * (360 / (256 * Math.pow(2, 9)))
+ expect(leaflet.markers[1].setLatLng).toHaveBeenCalled()
+ expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5)
+ })
+
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
const backendProvenance = [
{
From 6a833a1ea4a2586c4af08842a4f17242f99f4f66 Mon Sep 17 00:00:00 2001
From: monomono
Date: Sun, 16 Aug 2026 12:19:13 -0500
Subject: [PATCH 17/22] #49 feat: recuadro informativo de productos en el mapa
de provenance sin desplazar posiciones
---
AGENTS.md | 2 +-
src/components/provenance/ProvenanceMap.vue | 141 ++++++++++--------
.../provenance/ProvenanceRelationModal.vue | 21 ++-
.../provenance/ProvenanceMap.spec.js | 81 +++++++---
.../ProvenanceRelationModal.spec.js | 17 ++-
5 files changed, 180 insertions(+), 82 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 7bb2ca8..5f6ae54 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
-- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload; si varios productos coinciden en el mismo municipio se separan en anillo cuyo radio en grados se recalcula en `zoomend` para mantener ~40px de separación visual en cualquier zoom) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un marcador abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
index 829d947..a11a4a0 100644
--- a/src/components/provenance/ProvenanceMap.vue
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -1,27 +1,52 @@
-
- Las coordenadas geográficas de los municipios de origen aún no están disponibles.
-
+
+
+
+
+
+
+ mdi-map-marker
+
+
+ mdi-map-marker-off
+
+
+
+
+
-
-
+
+
+
+ La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
+
+
+ Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
+
+
- 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.
-
- Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
-
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 = []
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js
index adecca0..91e3608 100644
--- a/tests/unit/components/provenance/ProvenanceMap.spec.js
+++ b/tests/unit/components/provenance/ProvenanceMap.spec.js
@@ -25,6 +25,7 @@ const leaflet = vi.hoisted(() => {
const map = {
setView: vi.fn(function () { return this }),
fitBounds: vi.fn(),
+ flyTo: vi.fn(),
remove: vi.fn(),
invalidateSize: vi.fn(),
on: vi.fn(),
@@ -200,7 +201,7 @@ describe('ProvenanceMap', () => {
expect(wrapper.text()).toContain('coordenadas')
})
- it('separa los productos del mismo municipio para que no se superpongan', async () => {
+ it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
const sameMuni = [
{
product: { id: 1, name: 'Panela', catalogue_images: [] },
@@ -214,36 +215,78 @@ describe('ProvenanceMap', () => {
mountMap({ provenance: sameMuni })
await flushPromises()
- const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(','))
+ const productPositions = leaflet.markers.slice(1).map(marker => marker.pos)
expect(productPositions).toHaveLength(2)
- expect(new Set(productPositions).size).toBe(2)
- expect(productPositions).not.toContain('4.631028,-74.461588')
+ expect(productPositions[0]).toEqual([4.631028, -74.461588])
+ expect(productPositions[1]).toEqual([4.631028, -74.461588])
+ expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
})
- it('reduce la separación en grados al hacer zoom in para mantener la distancia visual', async () => {
- const sameMuni = [
+ it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
+ const withNoGeo = [
{
- product: { id: 1, name: 'Panela', catalogue_images: [] },
- suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
+ suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
- product: { id: 2, name: 'Arroz', catalogue_images: [] },
- suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
+ },
+ {
+ product: { id: 3, name: 'Miel pura', catalogue_images: [] },
+ suppliers: [],
},
]
- mountMap({ provenance: sameMuni })
+ const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
- const deg8 = 40 * (360 / (256 * Math.pow(2, 8)))
- expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5)
+ const rows = wrapper.findAll('[data-test="map-product-row"]')
+ expect(rows).toHaveLength(3)
+ expect(rows.map(row => row.text().replace(/\s+/g, ' ').trim())).toEqual([
+ expect.stringContaining('Panela regional'),
+ expect.stringContaining('Café de montaña'),
+ expect.stringContaining('Miel pura'),
+ ])
+ expect(rows[1].text()).toContain('Sin geolocalización')
+ expect(rows[2].text()).toContain('Sin geolocalización')
+ expect(rows[0].text()).toContain('Santa Bárbara')
+ })
- leaflet.map.getZoom.mockReturnValue(9)
- const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1]
- zoomHandler()
+ it('al hacer clic en un producto con ubicación centra el mapa y abre el diálogo', async () => {
+ const { wrapper } = mountMap()
+ await flushPromises()
- const deg9 = 40 * (360 / (256 * Math.pow(2, 9)))
- expect(leaflet.markers[1].setLatLng).toHaveBeenCalled()
- expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5)
+ await wrapper.find('[data-test="map-product-row"]').trigger('click')
+ await nextTick()
+
+ expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
+ expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
+ expect(document.body.textContent).toContain('Asociación La Mesa')
+ expect(document.body.textContent).toContain('Panela regional')
+ })
+
+ it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
+ const withNoGeo = [
+ {
+ product: { id: 1, name: 'Panela regional', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
+ },
+ {
+ product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
+ },
+ ]
+ const { wrapper } = mountMap({ provenance: withNoGeo })
+ await flushPromises()
+
+ const noGeoRow = wrapper.findAll('[data-test="map-product-row"]').find(row => row.text().includes('Café de montaña'))
+ await noGeoRow.trigger('click')
+ await nextTick()
+
+ expect(leaflet.map.flyTo).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('Café de montaña')
+ expect(document.body.textContent).toContain('Caficultores del Valle')
+ expect(document.body.textContent).toContain('sin geolocalización')
})
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
diff --git a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
index 942c3d6..575ba3d 100644
--- a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
+++ b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js
@@ -67,10 +67,25 @@ describe('ProvenanceRelationModal', () => {
expect(bodyText()).not.toContain('País')
})
- it('no muestra contenido cuando no hay relación', () => {
+ it('muestra el producto y un aviso cuando no hay proveedor vinculado', () => {
mountModal({ relation: null })
+ expect(bodyText()).toContain('Panela regional')
+ expect(bodyText()).toContain('no se ha vinculado un proveedor')
+ })
+
+ it('no muestra contenido cuando no hay producto ni relación', () => {
+ mountModal({ relation: null, product: null })
+
expect(bodyText()).not.toContain('Proveedor')
+ expect(bodyText()).not.toContain('Producto')
+ })
+
+ it('indica que aún no tiene geolocalización cuando la relación no tiene municipio', () => {
+ mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
+
+ expect(bodyText()).toContain('Asociación La Mesa')
+ expect(bodyText()).toContain('sin geolocalización')
})
it('no muestra el diálogo cuando visible es false', () => {
From f24e7ab2ec8428805f5c0391e9967f2b3806bd89 Mon Sep 17 00:00:00 2001
From: monomono
Date: Sun, 16 Aug 2026 12:24:49 -0500
Subject: [PATCH 18/22] #49 feat: agrupacion de marcadores del mismo punto con
contador y popup de productos en el mapa de provenance
---
AGENTS.md | 2 +-
src/components/provenance/ProvenanceMap.vue | 118 ++++++++++++++++--
.../provenance/ProvenanceMap.spec.js | 82 +++++++++---
3 files changed, 180 insertions(+), 22 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 5f6ae54..52481df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
-- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un marcador abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue`
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
index a11a4a0..1bb8184 100644
--- a/src/components/provenance/ProvenanceMap.vue
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -138,6 +138,16 @@
const hasMarkers = computed(() => markers.value.length > 0)
+ const markerGroups = computed(() => {
+ const groups = new Map()
+ for (const markerData of markers.value) {
+ const key = markerData.position.join(',')
+ if (!groups.has(key)) groups.set(key, [])
+ groups.get(key).push(markerData)
+ }
+ return [...groups.values()].map(items => ({ position: items[0].position, items }))
+ })
+
const personIcon = L.divIcon({
className: 'provenance-store-pin',
html: '',
@@ -158,6 +168,40 @@
})
}
+ function groupIcon (group) {
+ return L.divIcon({
+ className: 'provenance-group-pin',
+ html: `${group.items.length}`,
+ iconSize: [38, 38],
+ iconAnchor: [19, 38],
+ })
+ }
+
+ function buildGroupPopup (items) {
+ const container = document.createElement('div')
+ container.className = 'provenance-popup-list'
+ items.forEach((item, index) => {
+ const row = document.createElement('div')
+ row.className = 'provenance-popup-item'
+ row.setAttribute('role', 'button')
+ row.setAttribute('data-test', `map-popup-item-${index}`)
+ const image = item.product.catalogue_images?.[0]
+ if (image) {
+ const img = document.createElement('img')
+ img.src = image
+ img.className = 'provenance-popup-img'
+ row.appendChild(img)
+ }
+ const name = document.createElement('span')
+ name.className = 'provenance-popup-name'
+ name.textContent = item.product.name
+ row.appendChild(name)
+ row.addEventListener('click', () => openDialog(item))
+ container.appendChild(row)
+ })
+ return container
+ }
+
function showLine (from, to) {
const line = L.polyline([from, to], {
color: '#26a69a',
@@ -177,21 +221,33 @@
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
storeMarker.bindTooltip('Tienda')
storeMarker.on('mouseover', () => {
- productMarkers.forEach(item => showLine(item.marker.getLatLng(), storePosition.value))
+ markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
})
storeMarker.on('mouseout', clearLines)
}
function addProductMarkers () {
- markers.value.forEach(markerData => {
- const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
- marker.bindTooltip(markerData.product.name)
- marker.on('click', () => openDialog(markerData))
+ markerGroups.value.forEach(group => {
+ if (group.items.length === 1) {
+ const item = group.items[0]
+ const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
+ marker.bindTooltip(item.product.name)
+ marker.on('click', () => openDialog(item))
+ marker.on('mouseover', () => {
+ if (storePosition.value) showLine(item.position, storePosition.value)
+ })
+ marker.on('mouseout', clearLines)
+ productMarkers.push({ marker, data: item })
+ return
+ }
+ const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
+ marker.bindTooltip(`${group.items.length} productos en este punto`)
+ marker.bindPopup(buildGroupPopup(group.items))
marker.on('mouseover', () => {
- if (storePosition.value) showLine(marker.getLatLng(), storePosition.value)
+ if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
})
marker.on('mouseout', clearLines)
- productMarkers.push({ marker, data: markerData })
+ productMarkers.push({ marker, data: group.items })
})
}
@@ -302,6 +358,54 @@
background: transparent;
}
+.map-wrapper :deep(.provenance-group-pin) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: #26a69a;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ color: #ffffff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
+ cursor: pointer;
+}
+
+.map-wrapper :deep(.provenance-group-pin .provenance-group-count) {
+ font-weight: 700;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.map-wrapper :deep(.provenance-group-pin .mdi) {
+ font-size: 12px;
+ line-height: 1;
+ margin-top: 2px;
+}
+
+.map-wrapper :deep(.provenance-popup-item) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ cursor: pointer;
+}
+
+.map-wrapper :deep(.provenance-popup-item:hover) {
+ text-decoration: underline;
+}
+
+.map-wrapper :deep(.provenance-popup-img) {
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ object-fit: cover;
+}
+
+.map-wrapper :deep(.provenance-popup-name) {
+ font-weight: 500;
+}
+
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js
index 91e3608..d6bb2e4 100644
--- a/tests/unit/components/provenance/ProvenanceMap.spec.js
+++ b/tests/unit/components/provenance/ProvenanceMap.spec.js
@@ -15,6 +15,7 @@ const leaflet = vi.hoisted(() => {
addTo: vi.fn(function () { return this }),
on: vi.fn(),
bindTooltip: vi.fn(function () { return this }),
+ bindPopup: vi.fn(function () { return this }),
remove: vi.fn(),
getLatLng: vi.fn(function () { return this.pos }),
setLatLng: vi.fn(),
@@ -85,6 +86,17 @@ const provenance = [
},
]
+const sameMuni = [
+ {
+ product: { id: 1, name: 'Panela', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+ {
+ product: { id: 2, name: 'Arroz', catalogue_images: [] },
+ suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
+ },
+]
+
function mountMap (props = {}) {
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
const pinia = createPinia()
@@ -202,26 +214,68 @@ describe('ProvenanceMap', () => {
})
it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
- const sameMuni = [
- {
- product: { id: 1, name: 'Panela', catalogue_images: [] },
- suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
- },
- {
- product: { id: 2, name: 'Arroz', catalogue_images: [] },
- suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
- },
- ]
mountMap({ provenance: sameMuni })
await flushPromises()
- const productPositions = leaflet.markers.slice(1).map(marker => marker.pos)
- expect(productPositions).toHaveLength(2)
- expect(productPositions[0]).toEqual([4.631028, -74.461588])
- expect(productPositions[1]).toEqual([4.631028, -74.461588])
+ expect(leaflet.markers).toHaveLength(2)
+ expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
})
+ it('agrupa los productos del mismo punto en un marcador con contador', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const groupIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].className === 'provenance-group-pin')
+ expect(groupIcon).toBeDefined()
+ expect(groupIcon[0].html).toContain('2')
+ expect(groupIcon[0].html).toContain('mdi-package-variant')
+ const groupMarker = leaflet.markers[1]
+ expect(groupMarker.bindPopup).toHaveBeenCalled()
+ expect(groupMarker.bindTooltip.mock.calls[0][0]).toContain('2 productos')
+ })
+
+ it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
+ expect(popupEl.textContent).toContain('Panela')
+ expect(popupEl.textContent).toContain('Arroz')
+ })
+
+ it('al hacer clic en un producto del popup agrupado abre el diálogo', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
+ const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
+ item.dispatchEvent(new Event('click'))
+ await nextTick()
+
+ expect(document.body.textContent).toContain('Arroz')
+ expect(document.body.textContent).toContain('B')
+ })
+
+ it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ handlerOf(leaflet.markers[0], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
+ })
+
+ it('al pasar sobre el marcador agrupado dibuja las líneas de sus productos', async () => {
+ mountMap({ provenance: sameMuni })
+ await flushPromises()
+
+ handlerOf(leaflet.markers[1], 'mouseover')()
+
+ expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
+ expect(leaflet.polylines[0].points[0]).toEqual([4.631028, -74.461588])
+ })
+
it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
const withNoGeo = [
{
From 798a9bba15985a5b900b5f6c243c6b227637ed0d Mon Sep 17 00:00:00 2001
From: monomono
Date: Sun, 16 Aug 2026 12:36:00 -0500
Subject: [PATCH 19/22] #49 feat: boton para volver al zoom general en el mapa
de provenance con icono fit-to-page
---
AGENTS.md | 2 +-
src/components/provenance/ProvenanceMap.vue | 28 ++++++++++++++++++-
.../provenance/ProvenanceMap.spec.js | 10 +++++++
3 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 52481df..effc113 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
- **Genérico reutilizable:**
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
-- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue`
+- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; un botón flotante (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue
index 1bb8184..b149c27 100644
--- a/src/components/provenance/ProvenanceMap.vue
+++ b/src/components/provenance/ProvenanceMap.vue
@@ -26,7 +26,18 @@
-
+