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 = [ {