All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m6s
- Corregir referencia a colorMode (agregar .value) - Optimizar renderizado de aristas usando paths SVG - Mejorar posicionamiento de nodos con agrupación por padre - Evitar solapamiento de nodos hermanos - Ajustar cálculo dinámico del ancho del SVG
374 lines
11 KiB
Vue
374 lines
11 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h4 class="text-lg font-semibold">Grafo de trazabilidad</h4>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Relaciones insumo → resultado por nivel</p>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<UBadge color="blue" variant="subtle" v-if="nodes.length">
|
|
{{ nodes.length }} nodos · {{ edges.length }} aristas
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative w-full overflow-x-auto rounded-xl border border-gray-200 dark:border-slate-800 bg-gradient-to-b" :class="bgGradient">
|
|
<svg
|
|
:width="svgWidth"
|
|
:height="svgHeight"
|
|
class="min-w-full"
|
|
:style="{ background: 'transparent' }"
|
|
>
|
|
<defs>
|
|
<filter id="nodeShadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#0f172a" flood-opacity="0.08" />
|
|
</filter>
|
|
<linearGradient id="edgeGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" :stop-color="edgeColor" stop-opacity="0.8" />
|
|
<stop offset="100%" :stop-color="edgeColor" stop-opacity="0.4" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- Grid horizontal y vertical -->
|
|
<g stroke="#e2e8f0" stroke-width="1" :stroke-opacity="colorMode.value === 'dark' ? 0.1 : 0.35">
|
|
<line
|
|
v-for="depth in depthLines"
|
|
:key="`h-${depth}`"
|
|
:x1="0"
|
|
:x2="svgWidth"
|
|
:y1="paddingY + depth * levelGap"
|
|
:y2="paddingY + depth * levelGap"
|
|
stroke-dasharray="4 4"
|
|
/>
|
|
<line
|
|
v-for="x in verticalGrid"
|
|
:key="`v-${x}`"
|
|
:x1="x"
|
|
:x2="x"
|
|
y1="0"
|
|
:y2="svgHeight"
|
|
stroke-dasharray="6 6"
|
|
/>
|
|
</g>
|
|
|
|
<!-- Aristas con codo (vertical + horizontal) -->
|
|
<g
|
|
stroke="url(#edgeGradient)"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
fill="none"
|
|
>
|
|
<path
|
|
v-for="edge in edges"
|
|
:key="edge.id"
|
|
:d="edge.path"
|
|
class="transition-opacity duration-200"
|
|
:opacity="0.9"
|
|
/>
|
|
</g>
|
|
|
|
<!-- Nodos -->
|
|
<g>
|
|
<g
|
|
v-for="node in nodes"
|
|
:key="node.id"
|
|
class="transition-transform duration-200"
|
|
filter="url(#nodeShadow)"
|
|
>
|
|
<rect
|
|
:x="node.x - nodeBox.width / 2"
|
|
:y="node.y - nodeBox.height / 2"
|
|
:width="nodeBox.width"
|
|
:height="nodeBox.height"
|
|
rx="14"
|
|
:fill="getNodeFill(node.tipo)"
|
|
:stroke="node.depth === 0 ? accentColor : '#0F172A'"
|
|
:stroke-opacity="node.depth === 0 ? 0.6 : 0.05"
|
|
:stroke-width="node.depth === 0 ? 2 : 1"
|
|
/>
|
|
<text
|
|
:x="node.x"
|
|
:y="node.y - 10"
|
|
text-anchor="middle"
|
|
class="font-semibold"
|
|
:fill="textColor"
|
|
>
|
|
{{ node.label }}
|
|
</text>
|
|
<text
|
|
:x="node.x"
|
|
:y="node.y + 10"
|
|
text-anchor="middle"
|
|
class="text-[11px]"
|
|
:fill="subTextColor"
|
|
>
|
|
{{ getTipoLabel(node.tipo) }}
|
|
</text>
|
|
<text
|
|
v-if="node.cantidad_kg"
|
|
:x="node.x"
|
|
:y="node.y + 26"
|
|
text-anchor="middle"
|
|
class="text-[10px]"
|
|
:fill="subTextColor"
|
|
>
|
|
{{ node.cantidad_kg.toLocaleString('es-AR') }} kg
|
|
</text>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 text-xs">
|
|
<UBadge color="violet" variant="subtle">Uva</UBadge>
|
|
<UBadge color="green" variant="subtle">Despulpado</UBadge>
|
|
<UBadge color="orange" variant="subtle">Oreado/Presecado</UBadge>
|
|
<UBadge color="blue" variant="subtle">Reposo</UBadge>
|
|
<UBadge color="emerald" variant="subtle">Secado/Mezcla</UBadge>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useColorMode } from '#imports'
|
|
import type { TrazabilidadRow } from '~/composables/useLotes'
|
|
|
|
const props = defineProps<{
|
|
historial: TrazabilidadRow[]
|
|
}>()
|
|
|
|
const paddingX = 80
|
|
const paddingY = 60
|
|
const levelGap = 140
|
|
const nodeBox = { width: 180, height: 64 }
|
|
const horizontalGap = 40
|
|
const siblingSpacing = nodeBox.width + horizontalGap
|
|
const edgePadding = 12
|
|
const minElbowHeight = 24
|
|
const colorMode = useColorMode()
|
|
const bgGradient = computed(() => colorMode.value === 'dark'
|
|
? 'from-slate-900 to-slate-950'
|
|
: 'from-white to-slate-50')
|
|
const edgeColor = computed(() => colorMode.value === 'dark' ? '#94a3b8' : '#94a3b8')
|
|
const textColor = computed(() => colorMode.value === 'dark' ? '#e2e8f0' : '#0f172a')
|
|
const subTextColor = computed(() => colorMode.value === 'dark' ? '#cbd5e1' : '#334155')
|
|
const accentColor = computed(() => colorMode.value === 'dark' ? '#22d3ee' : '#0ea5e9')
|
|
|
|
type GraphNode = {
|
|
id: string
|
|
label: string
|
|
tipo: string
|
|
cantidad_kg: number | null
|
|
depth: number
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
const groupedByDepth = computed(() => {
|
|
const levels = new Map<number, TrazabilidadRow[]>()
|
|
for (const row of props.historial) {
|
|
const arr = levels.get(row.profundidad) || []
|
|
arr.push(row)
|
|
levels.set(row.profundidad, arr)
|
|
}
|
|
return levels
|
|
})
|
|
|
|
const nodes = computed<GraphNode[]>(() => {
|
|
const result: GraphNode[] = []
|
|
const levels = groupedByDepth.value
|
|
if (levels.size === 0) return result
|
|
|
|
const depthKeys = Array.from(levels.keys()).sort((a, b) => a - b)
|
|
const minCenterX = paddingX + nodeBox.width / 2
|
|
const placedByDepth = new Map<number, GraphNode[]>()
|
|
|
|
for (const depth of depthKeys) {
|
|
const rows = [...(levels.get(depth) || [])]
|
|
|
|
// Nivel raíz: ubicar centrado y con separación básica
|
|
if (depth === 0) {
|
|
rows.forEach((row, idx) => {
|
|
result.push({
|
|
id: row.lote_id,
|
|
label: row.codigo || row.lote_id.slice(0, 8),
|
|
tipo: row.tipo,
|
|
cantidad_kg: row.cantidad_kg,
|
|
depth,
|
|
x: minCenterX + idx * siblingSpacing,
|
|
y: paddingY + depth * levelGap,
|
|
})
|
|
})
|
|
placedByDepth.set(depth, result.filter((n) => n.depth === depth))
|
|
continue
|
|
}
|
|
|
|
const parents = placedByDepth.get(depth - 1) || []
|
|
const childrenByParent = new Map<string, TrazabilidadRow[]>()
|
|
const placedIds = new Set<string>()
|
|
|
|
for (const row of rows) {
|
|
const key = row.parent_lote_id || '__orphan__'
|
|
const list = childrenByParent.get(key) || []
|
|
list.push(row)
|
|
childrenByParent.set(key, list)
|
|
}
|
|
|
|
let cursor = minCenterX
|
|
|
|
// Ubicar hijos agrupados bajo su padre directo
|
|
for (const parent of parents) {
|
|
const children = childrenByParent.get(parent.id)
|
|
if (!children?.length) continue
|
|
|
|
const groupWidth = (children.length - 1) * siblingSpacing
|
|
let startX = parent.x - groupWidth / 2
|
|
|
|
// Evitar solapamiento con grupos previos
|
|
if (startX < cursor) startX = cursor
|
|
|
|
children.forEach((row, idx) => {
|
|
const x = startX + idx * siblingSpacing
|
|
result.push({
|
|
id: row.lote_id,
|
|
label: row.codigo || row.lote_id.slice(0, 8),
|
|
tipo: row.tipo,
|
|
cantidad_kg: row.cantidad_kg,
|
|
depth,
|
|
x,
|
|
y: paddingY + depth * levelGap,
|
|
})
|
|
placedIds.add(row.lote_id)
|
|
cursor = x + siblingSpacing
|
|
})
|
|
}
|
|
|
|
// Cualquier nodo huérfano (sin padre en el nivel superior) se coloca al final
|
|
const orphanRows = rows.filter((row) => !placedIds.has(row.lote_id))
|
|
orphanRows.forEach((row, idx) => {
|
|
const x = cursor + idx * siblingSpacing
|
|
result.push({
|
|
id: row.lote_id,
|
|
label: row.codigo || row.lote_id.slice(0, 8),
|
|
tipo: row.tipo,
|
|
cantidad_kg: row.cantidad_kg,
|
|
depth,
|
|
x,
|
|
y: paddingY + depth * levelGap,
|
|
})
|
|
cursor = x + siblingSpacing
|
|
})
|
|
|
|
placedByDepth.set(depth, result.filter((n) => n.depth === depth))
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
const nodeById = computed(() => {
|
|
const map = new Map<string, GraphNode>()
|
|
for (const n of nodes.value) {
|
|
map.set(n.id, n)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const edges = computed(() => {
|
|
const list: {
|
|
id: string
|
|
from: string
|
|
to: string
|
|
path: string
|
|
midY: number
|
|
}[] = []
|
|
|
|
for (const row of props.historial) {
|
|
if (!row.parent_lote_id) continue
|
|
const fromNode = nodeById.value.get(row.parent_lote_id)
|
|
const toNode = nodeById.value.get(row.lote_id)
|
|
if (fromNode && toNode) {
|
|
const fromY = fromNode.y + nodeBox.height / 2 + edgePadding
|
|
const toY = toNode.y - nodeBox.height / 2 - edgePadding
|
|
const rawMidY = (fromY + toY) / 2
|
|
const midY = Math.max(
|
|
fromY + minElbowHeight,
|
|
Math.min(toY - minElbowHeight, rawMidY),
|
|
)
|
|
|
|
list.push({
|
|
id: `${fromNode.id}-${toNode.id}`,
|
|
from: fromNode.id,
|
|
to: toNode.id,
|
|
midY,
|
|
path: `M ${fromNode.x} ${fromY} L ${fromNode.x} ${midY} L ${toNode.x} ${midY} L ${toNode.x} ${toY}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
return list
|
|
})
|
|
|
|
const svgWidth = computed(() => {
|
|
if (!nodes.value.length) return 600
|
|
const xs = nodes.value.map((n) => n.x)
|
|
const minX = Math.min(...xs)
|
|
const maxX = Math.max(...xs)
|
|
const width = (maxX - minX) + paddingX * 2 + nodeBox.width
|
|
return Math.max(width, 600)
|
|
})
|
|
|
|
const svgHeight = computed(() => {
|
|
if (!nodes.value.length) return paddingY * 2 + nodeBox.height
|
|
const maxDepth = Math.max(...nodes.value.map((n) => n.depth), 0)
|
|
return paddingY * 2 + maxDepth * levelGap + nodeBox.height
|
|
})
|
|
|
|
const depthLines = computed(() => {
|
|
if (!nodes.value.length) return []
|
|
const maxDepth = Math.max(...nodes.value.map((n) => n.depth), 0)
|
|
return Array.from({ length: maxDepth + 1 }, (_, i) => i)
|
|
})
|
|
|
|
const verticalGrid = computed(() => {
|
|
if (!nodes.value.length) return []
|
|
const xs = nodes.value.map((n) => n.x)
|
|
const minX = Math.min(...xs)
|
|
const maxX = Math.max(...xs)
|
|
const step = 180
|
|
const coords: number[] = []
|
|
for (let x = minX - step; x <= maxX + step; x += step) {
|
|
coords.push(x)
|
|
}
|
|
return coords
|
|
})
|
|
|
|
const getNodeFill = (tipo: string): string => {
|
|
const palette: Record<string, string> = {
|
|
uva: colorMode.value === 'dark' ? '#4c1d95' : '#F3E8FF',
|
|
despulpado_primera: colorMode.value === 'dark' ? '#166534' : '#DCFCE7',
|
|
despulpado_segunda: colorMode.value === 'dark' ? '#854d0e' : '#FEF9C3',
|
|
despulpado_rechazos: colorMode.value === 'dark' ? '#7f1d1d' : '#FEE2E2',
|
|
oreado: colorMode.value === 'dark' ? '#9a3412' : '#FFEDD5',
|
|
presecado: colorMode.value === 'dark' ? '#92400e' : '#FDE68A',
|
|
reposo: colorMode.value === 'dark' ? '#1d4ed8' : '#DBEAFE',
|
|
secado: colorMode.value === 'dark' ? '#065f46' : '#D1FAE5',
|
|
}
|
|
return palette[tipo] || '#E2E8F0'
|
|
}
|
|
|
|
const getTipoLabel = (tipo: string) => {
|
|
const map: Record<string, string> = {
|
|
uva: 'Uva',
|
|
despulpado_primera: 'Despulpado 1ra',
|
|
despulpado_segunda: 'Despulpado 2da',
|
|
despulpado_rechazos: 'Rechazos',
|
|
oreado: 'Oreado',
|
|
presecado: 'Presecado',
|
|
reposo: 'Reposo',
|
|
secado: 'Secado',
|
|
}
|
|
return map[tipo] || tipo
|
|
}
|
|
</script>
|