#49 feat: grafico unificado de provenance con filtro por niveles y re-estabilizacion
This commit is contained in:
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
95
src/components/provenance/ProvenanceGraph.vue
Normal file
95
src/components/provenance/ProvenanceGraph.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user