#49 feat: grafico unificado de provenance con filtro por niveles y re-estabilizacion

This commit is contained in:
2026-08-16 03:10:57 -05:00
parent 5c91e66429
commit ad0bbb579d
14 changed files with 508 additions and 747 deletions

View File

@@ -27,6 +27,10 @@
type: Object,
default: () => ({}),
},
minVerticalSpacing: {
type: Number,
default: null,
},
})
const emit = defineEmits(['select'])
@@ -87,7 +91,28 @@
network.setData({ nodes: nodesDataSet, edges: new DataSet(edges) })
}
onMounted(() => {
function applyMinVerticalSpacing (minSpacing) {
if (!minSpacing || minSpacing <= 0) return
const columns = new Map()
for (const [id, position] of Object.entries(network.getPositions())) {
const x = Math.round(position.x)
if (!columns.has(x)) columns.set(x, [])
columns.get(x).push({ id, x, y: position.y })
}
for (const column of columns.values()) {
column.sort((a, b) => a.y - b.y)
let previousY = null
for (const item of column) {
if (previousY !== null && item.y - previousY < minSpacing) {
network.moveNode(item.id, item.x, previousY + minSpacing)
}
previousY = network.getPositions()[item.id].y
}
}
}
function setupNetwork () {
if (network) network.destroy()
network = new Network(containerRef.value, { nodes: [], edges: [] }, mergeOptions())
render(props.nodes, props.edges)
@@ -100,15 +125,18 @@
network.once('stabilizationIterationsDone', () => {
network.setOptions({ physics: { enabled: false } })
applyMinVerticalSpacing(props.minVerticalSpacing)
network.fit()
})
})
}
onMounted(setupNetwork)
watch(
() => [props.nodes, props.edges],
([nodes, edges]) => {
if (!network) return
render(nodes, edges)
() => {
if (!containerRef.value) return
setupNetwork()
},
{ deep: true }
)

View File

@@ -1,59 +0,0 @@
<template>
<div>
<v-alert
v-if="!hasRelations"
class="my-4"
type="info"
variant="tonal"
>
La información de proveedores y organizaciones de este pedido estará
disponible próximamente.
</v-alert>
<VisChart
v-else
data-test="supplier-org-chart"
:edges="visEdges"
:height="height"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildSupplierOrganizationGraph, hasAnySupplier } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const graph = computed(() => buildSupplierOrganizationGraph(props.provenance))
const hasRelations = computed(() => hasAnySupplier(props.provenance))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(560, graph.value.nodes.length * 72)))
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -1,59 +0,0 @@
<template>
<div>
<v-alert
v-if="!hasRelations"
class="my-4"
type="info"
variant="tonal"
>
La información de municipio, departamento y país de este pedido estará
disponible próximamente.
</v-alert>
<VisChart
v-else
data-test="territory-chart"
:edges="visEdges"
:height="height"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildTerritoryGraph, hasAnyTerritory } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const graph = computed(() => buildTerritoryGraph(props.provenance))
const hasRelations = computed(() => hasAnyTerritory(props.provenance))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(560, graph.value.nodes.length * 72)))
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div>
<v-alert
v-if="!hasSuppliers"
class="my-4"
type="info"
variant="tonal"
>
La información de origen de los productos de este pedido estará disponible
próximamente.
</v-alert>
<template v-else>
<div class="d-flex flex-wrap align-center ga-4 mb-2">
<span class="text-body-2 font-weight-medium">Elementos a graficar:</span>
<v-checkbox
v-for="option in options"
:key="option.value"
v-model="selectedKinds"
density="compact"
:label="option.label"
:value="option.value"
/>
</div>
<v-alert
v-if="visNodes.length === 0"
class="my-2"
type="warning"
variant="tonal"
>
Selecciona al menos un elemento para graficar.
</v-alert>
<VisChart
v-if="visNodes.length > 0"
data-test="provenance-chart"
:edges="visEdges"
:height="height"
:min-vertical-spacing="minVerticalSpacing"
:nodes="visNodes"
:options="chartOptions"
@select="onSelect"
/>
</template>
<ProvenanceDetailModal
:selected="selected"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import VisChart from '@/components/graph/VisChart.vue'
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
import { buildProvenanceGraph, hasAnySupplier } from './provenance-graph'
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const options = [
{ value: 'product', label: 'Productos' },
{ value: 'supplier', label: 'Proveedores' },
{ value: 'organization', label: 'Organizaciones' },
{ value: 'municipality', label: 'Municipios' },
{ value: 'department', label: 'Departamentos' },
{ value: 'country', label: 'País' },
]
const selectedKinds = ref(['product', 'supplier'])
const hasSuppliers = computed(() => hasAnySupplier(props.provenance))
const graph = computed(() => buildProvenanceGraph(props.provenance, selectedKinds.value))
const visNodes = computed(() => toVisNodes(graph.value.nodes))
const visEdges = computed(() => toVisEdges(graph.value.edges))
const height = computed(() => Math.max(280, Math.min(700, graph.value.nodes.length * 64)))
const minVerticalSpacing = 2 * 24 + 22
const selected = ref(null)
const modalVisible = ref(false)
function onSelect (node) {
if (!node || node.kind === 'junction') return
selected.value = node
modalVisible.value = true
}
</script>

View File

@@ -6,19 +6,12 @@
Conoce quiénes producen los productos que compras y de qué territorios provienen.
</p>
<h4 class="text-subtitle-1 font-weight-medium mb-2">Proveedores y organizaciones</h4>
<ProductSupplierOrganizationChart :provenance="provenance" />
<v-divider class="my-4" />
<h4 class="text-subtitle-1 font-weight-medium mb-2">Departamento y municipio</h4>
<ProductTerritoryChart :provenance="provenance" />
<ProvenanceGraph :provenance="provenance" />
</div>
</template>
<script setup>
import ProductSupplierOrganizationChart from './ProductSupplierOrganizationChart.vue'
import ProductTerritoryChart from './ProductTerritoryChart.vue'
import ProvenanceGraph from './ProvenanceGraph.vue'
defineProps({
provenance: {

View File

@@ -8,11 +8,19 @@
* - Con varios proveedores para un producto se inserta un nodo de disyunción
* (junction): sólida hasta él y discontinua hacia cada proveedor.
*
* `buildProvenanceGraph` permite elegir los niveles a graficar
* (product, supplier, organization, municipality, department, country);
* los niveles no solicitados se omiten conectando el nivel previo con el
* siguiente.
*
* El modelo devuelto es agnóstico de la herramienta de renderizado:
* node: { id, kind, label, image, entity }
* edge: { from, to, certain }
*/
const DEFAULT_KINDS = ['product', 'supplier']
const TERRITORY_ORDER = ['municipality', 'department', 'country']
function makeCollector () {
const nodes = []
const edges = []
@@ -57,65 +65,76 @@ function distinctValues (items) {
return [...seen.values()]
}
function connectSuppliers (collector, product, relations) {
if (relations.length === 1) {
const supplier = collector.addNode('supplier', relations[0].supplier)
collector.addEdge(product.id, supplier.id, true)
return
}
const junction = collector.addNode('junction', { id: product.entity.id, name: '' })
collector.addEdge(product.id, junction.id, true)
for (const rel of relations) {
const supplier = collector.addNode('supplier', rel.supplier)
collector.addEdge(junction.id, supplier.id, false)
}
}
function isCertain (values) {
return values.length <= 1
}
export function buildSupplierOrganizationGraph (provenance) {
const collector = makeCollector()
for (const entry of provenance || []) {
if (!entry.product) continue
const product = collector.addNode('product', entry.product)
const relations = (entry.suppliers || []).filter(rel => rel.supplier)
if (relations.length === 0) continue
connectSuppliers(collector, product, relations)
const organizationCertain = isCertain(distinctValues(relations.map(rel => rel.organization)))
for (const rel of relations) {
if (!rel.organization) continue
const organization = collector.addNode('organization', rel.organization)
collector.addEdge(`supplier:${rel.supplier.id}`, organization.id, organizationCertain)
}
function wirePath (collector, path, certainties) {
for (let index = 0; index < path.length - 1; index++) {
const to = path[index + 1]
const kind = to.split(':')[0]
collector.addEdge(path[index], to, certainties[kind])
}
return { nodes: collector.nodes, edges: collector.edges }
}
export function buildTerritoryGraph (provenance) {
export function buildProvenanceGraph (provenance, kinds = DEFAULT_KINDS) {
const active = new Set(kinds)
const collector = makeCollector()
for (const entry of provenance || []) {
if (!entry.product) continue
const product = collector.addNode('product', entry.product)
const relations = (entry.suppliers || []).filter(rel => rel.supplier)
if (relations.length === 0) continue
connectSuppliers(collector, product, relations)
const municipalityCertain = isCertain(distinctValues(relations.map(rel => rel.municipality)))
const departmentCertain = isCertain(distinctValues(relations.map(rel => rel.department)))
const product = active.has('product') ? collector.addNode('product', entry.product) : null
const suppliersActive = active.has('supplier')
for (const rel of relations) {
if (!rel.municipality) continue
const municipality = collector.addNode('municipality', rel.municipality)
collector.addEdge(`supplier:${rel.supplier.id}`, municipality.id, municipalityCertain)
const certainties = {
municipality: isCertain(distinctValues(relations.map(rel => rel.municipality))),
department: isCertain(distinctValues(relations.map(rel => rel.department))),
country: isCertain(distinctValues(relations.map(rel => rel.country))),
organization: isCertain(distinctValues(relations.map(rel => rel.organization))),
}
for (const rel of relations) {
if (!rel.municipality || !rel.department) continue
const municipality = collector.addNode('municipality', rel.municipality)
const department = collector.addNode('department', rel.department)
collector.addEdge(municipality.id, department.id, departmentCertain)
if (suppliersActive) collector.addNode('supplier', rel.supplier)
if (active.has('municipality') && rel.municipality) collector.addNode('municipality', rel.municipality)
if (active.has('department') && rel.department) collector.addNode('department', rel.department)
if (active.has('country') && rel.country) collector.addNode('country', rel.country)
if (active.has('organization') && rel.organization) collector.addNode('organization', rel.organization)
}
if (suppliersActive && product) {
if (relations.length === 1) {
collector.addEdge(product.id, `supplier:${relations[0].supplier.id}`, true)
} else {
const junction = collector.addNode('junction', { id: entry.product.id, name: '' })
collector.addEdge(product.id, junction.id, true)
for (const rel of relations) {
collector.addEdge(junction.id, `supplier:${rel.supplier.id}`, false)
}
}
}
for (const rel of relations) {
const path = []
if (suppliersActive) {
path.push(`supplier:${rel.supplier.id}`)
} else if (product) {
path.push(product.id)
}
for (const level of TERRITORY_ORDER) {
if (active.has(level) && rel[level]) path.push(`${level}:${rel[level].id}`)
}
wirePath(collector, path, certainties)
}
if (active.has('organization')) {
for (const rel of relations) {
if (!rel.organization) continue
const source = suppliersActive ? `supplier:${rel.supplier.id}` : (product ? product.id : null)
if (source) collector.addEdge(source, `organization:${rel.organization.id}`, certainties.organization)
}
}
}
return { nodes: collector.nodes, edges: collector.edges }
@@ -124,11 +143,3 @@ export function buildTerritoryGraph (provenance) {
export function hasAnySupplier (provenance) {
return (provenance || []).some(entry => (entry.suppliers || []).length > 0)
}
export function hasAnyTerritory (provenance) {
return (provenance || []).some(entry =>
(entry.suppliers || []).some(rel =>
rel.municipality || rel.department
)
)
}

View File

@@ -17,11 +17,12 @@ const COLORS = {
const PRODUCT_SPACING = 110
const COLUMN_X = {
product: -240,
junction: -120,
supplier: 0,
organization: 240,
junction: -160,
supplier: -80,
organization: 80,
municipality: 240,
department: 480,
department: 400,
country: 560,
}
export function toVisNodes (nodes) {