349 lines
11 KiB
Vue
349 lines
11 KiB
Vue
<template>
|
|
<UCard class="brand-card border border-transparent">
|
|
<template #header>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-lucide-trending-up" class="size-5 text-[#c08040]" />
|
|
<h3 class="text-base font-semibold text-[var(--brand-text)]">Evolución Temporal Comparada</h3>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<label class="text-xs text-[var(--brand-text-muted)]">Métrica:</label>
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="metrica = 'acumulado'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
:class="metrica === 'acumulado'
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
Acumulado
|
|
</button>
|
|
<button
|
|
@click="metrica = 'diario'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
:class="metrica === 'diario'
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
Diario
|
|
</button>
|
|
</div>
|
|
|
|
<label class="text-xs text-[var(--brand-text-muted)] ml-4">Tipo:</label>
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="tipoSeleccionado = 'todos'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
:class="tipoSeleccionado === 'todos'
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
v-for="tipo in ['uva', 'verde', 'oreado', 'mojado']"
|
|
:key="tipo"
|
|
@click="tipoSeleccionado = tipo"
|
|
class="px-3 py-1 rounded text-xs transition-all capitalize"
|
|
:class="tipoSeleccionado === tipo
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
{{ tipo }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="w-full">
|
|
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-96">
|
|
<!-- Grid horizontal -->
|
|
<g v-for="i in 5" :key="`grid-${i}`">
|
|
<line
|
|
:x1="padding.left"
|
|
:y1="padding.top + (chartHeight / 5) * i"
|
|
:x2="svgWidth - padding.right"
|
|
:y2="padding.top + (chartHeight / 5) * i"
|
|
stroke="var(--brand-border)"
|
|
stroke-width="0.5"
|
|
opacity="0.3"
|
|
/>
|
|
<text
|
|
:x="padding.left - 10"
|
|
:y="padding.top + (chartHeight / 5) * i + 5"
|
|
text-anchor="end"
|
|
class="text-xs fill-[var(--brand-text-muted)]"
|
|
>
|
|
{{ formatValue(maxValue * (1 - i / 5)) }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- Eje X -->
|
|
<line
|
|
:x1="padding.left"
|
|
:y1="svgHeight - padding.bottom"
|
|
:x2="svgWidth - padding.right"
|
|
:y2="svgHeight - padding.bottom"
|
|
stroke="var(--brand-border)"
|
|
stroke-width="1"
|
|
/>
|
|
|
|
<!-- Eje Y -->
|
|
<line
|
|
:x1="padding.left"
|
|
:y1="padding.top"
|
|
:x2="padding.left"
|
|
:y2="svgHeight - padding.bottom"
|
|
stroke="var(--brand-border)"
|
|
stroke-width="1"
|
|
/>
|
|
|
|
<!-- Líneas de evolución -->
|
|
<g v-for="(cosecha, index) in evolucionCosechas" :key="cosecha.id">
|
|
<path
|
|
:d="getLinePath(cosecha.puntos)"
|
|
:stroke="getCosechaColor(index)"
|
|
stroke-width="2"
|
|
fill="none"
|
|
class="transition-all duration-300"
|
|
/>
|
|
|
|
<!-- Puntos -->
|
|
<circle
|
|
v-for="(punto, pIndex) in cosecha.puntos.filter((_, i) => i % Math.ceil(cosecha.puntos.length / 30) === 0)"
|
|
:key="`${cosecha.id}-${pIndex}`"
|
|
:cx="getX(punto.diaRelativo)"
|
|
:cy="getY(punto.valor)"
|
|
r="3"
|
|
:fill="getCosechaColor(index)"
|
|
class="transition-all duration-300 hover:r-5"
|
|
>
|
|
<title>{{ cosecha.label }} - Día {{ punto.diaRelativo }}: {{ punto.valor.toFixed(1) }} qq</title>
|
|
</circle>
|
|
</g>
|
|
|
|
<!-- Etiquetas eje X -->
|
|
<g v-for="i in 6" :key="`x-label-${i}`">
|
|
<text
|
|
:x="padding.left + (chartWidth / 5) * (i - 1)"
|
|
:y="svgHeight - padding.bottom + 20"
|
|
text-anchor="middle"
|
|
class="text-xs fill-[var(--brand-text-muted)]"
|
|
>
|
|
Día {{ Math.floor(maxDias * (i - 1) / 5) }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- Leyenda -->
|
|
<g v-for="(cosecha, index) in evolucionCosechas" :key="`legend-${cosecha.id}`">
|
|
<line
|
|
:x1="svgWidth - padding.right - 150"
|
|
:y1="20 + index * 20"
|
|
:x2="svgWidth - padding.right - 130"
|
|
:y2="20 + index * 20"
|
|
:stroke="getCosechaColor(index)"
|
|
stroke-width="2"
|
|
/>
|
|
<text
|
|
:x="svgWidth - padding.right - 125"
|
|
:y="24 + index * 20"
|
|
class="text-xs fill-[var(--brand-text)]"
|
|
>
|
|
{{ cosecha.label }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- Etiqueta eje Y -->
|
|
<text
|
|
:x="20"
|
|
:y="padding.top - 10"
|
|
class="text-xs fill-[var(--brand-text-muted)]"
|
|
>
|
|
Peso (qq)
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
|
|
|
interface Props {
|
|
ingresos: IngresoRecord[]
|
|
cosechasSeleccionadas: string[]
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
// Obtener definiciones de cosechas
|
|
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
|
|
|
|
// Obtener configuración de estilos
|
|
const estilosGraficas = inject<any>('estilosGraficas', ref({
|
|
coloresCosechas: ['#c08040', '#d99a56', '#8b6f47', '#a0826e', '#b89968', '#f0c07c']
|
|
}))
|
|
|
|
// Dimensiones del SVG
|
|
const svgWidth = 900
|
|
const svgHeight = 450
|
|
const padding = { top: 40, right: 180, bottom: 50, left: 80 }
|
|
const chartWidth = svgWidth - padding.left - padding.right
|
|
const chartHeight = svgHeight - padding.top - padding.bottom
|
|
|
|
// Selección de métrica y tipo
|
|
const metrica = ref<'acumulado' | 'diario'>('acumulado')
|
|
const tipoSeleccionado = ref<string>('todos')
|
|
|
|
// Calcular evolución por cosecha
|
|
const evolucionCosechas = computed(() => {
|
|
return props.cosechasSeleccionadas.map(cosechaId => {
|
|
const cosechaDef = cosechasDisponibles.find(c => c.id === cosechaId)
|
|
if (!cosechaDef) return null
|
|
|
|
const ingresosEnCosecha = props.ingresos.filter(ingreso => {
|
|
if (!ingreso.created_at) return false
|
|
const fecha = new Date(ingreso.created_at)
|
|
const inicio = new Date(cosechaDef.fechaInicio)
|
|
const fin = new Date(cosechaDef.fechaFin)
|
|
|
|
// Filtrar por tipo si no es "todos"
|
|
const cumpleTipo = tipoSeleccionado.value === 'todos' || ingreso.tipo === tipoSeleccionado.value
|
|
|
|
return fecha >= inicio && fecha <= fin && cumpleTipo
|
|
})
|
|
|
|
// Ordenar por fecha
|
|
const ingresosOrdenados = ingresosEnCosecha.sort((a, b) => {
|
|
return new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime()
|
|
})
|
|
|
|
// Calcular día relativo para cada ingreso
|
|
const fechaInicio = new Date(cosechaDef.fechaInicio)
|
|
const puntosPorDia: Record<number, number> = {}
|
|
|
|
ingresosOrdenados.forEach(ingreso => {
|
|
const fecha = new Date(ingreso.created_at!)
|
|
const diaRelativo = Math.floor((fecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
if (!puntosPorDia[diaRelativo]) {
|
|
puntosPorDia[diaRelativo] = 0
|
|
}
|
|
puntosPorDia[diaRelativo] += ingreso.peso_neto
|
|
})
|
|
|
|
// Convertir a array de puntos
|
|
const puntos: Array<{ diaRelativo: number, valor: number }> = []
|
|
let acumulado = 0
|
|
|
|
const maxDia = Math.max(...Object.keys(puntosPorDia).map(Number))
|
|
for (let dia = 0; dia <= maxDia; dia++) {
|
|
const valorDia = puntosPorDia[dia] || 0
|
|
|
|
if (metrica.value === 'acumulado') {
|
|
acumulado += valorDia
|
|
puntos.push({ diaRelativo: dia, valor: acumulado })
|
|
} else {
|
|
puntos.push({ diaRelativo: dia, valor: valorDia })
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: cosechaId,
|
|
label: cosechaDef.label.replace('Cosecha ', ''),
|
|
puntos
|
|
}
|
|
}).filter(Boolean) as Array<{
|
|
id: string
|
|
label: string
|
|
puntos: Array<{ diaRelativo: number, valor: number }>
|
|
}>
|
|
})
|
|
|
|
// Máximos para escala
|
|
const maxDias = computed(() => {
|
|
let max = 0
|
|
evolucionCosechas.value.forEach(cosecha => {
|
|
cosecha.puntos.forEach(punto => {
|
|
if (punto.diaRelativo > max) {
|
|
max = punto.diaRelativo
|
|
}
|
|
})
|
|
})
|
|
return max || 365
|
|
})
|
|
|
|
const maxValue = computed(() => {
|
|
let max = 0
|
|
evolucionCosechas.value.forEach(cosecha => {
|
|
cosecha.puntos.forEach(punto => {
|
|
if (punto.valor > max) {
|
|
max = punto.valor
|
|
}
|
|
})
|
|
})
|
|
return max || 1
|
|
})
|
|
|
|
// Funciones de mapeo
|
|
function getX(diaRelativo: number): number {
|
|
return padding.left + (diaRelativo / maxDias.value) * chartWidth
|
|
}
|
|
|
|
function getY(valor: number): number {
|
|
const normalized = valor / maxValue.value
|
|
return padding.top + chartHeight * (1 - normalized)
|
|
}
|
|
|
|
function getLinePath(puntos: Array<{ diaRelativo: number, valor: number }>): string {
|
|
if (puntos.length === 0) return ''
|
|
|
|
const path = puntos.map((punto, index) => {
|
|
const x = getX(punto.diaRelativo)
|
|
const y = getY(punto.valor)
|
|
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`
|
|
}).join(' ')
|
|
|
|
return path
|
|
}
|
|
|
|
// Mapeo de cosechas a índices fijos
|
|
const cosechaColorMap: Record<string, number> = {
|
|
'cosecha-20-21': 0,
|
|
'cosecha-21-22': 1,
|
|
'cosecha-22-23': 2,
|
|
'cosecha-23-24': 3,
|
|
'cosecha-24-25': 4,
|
|
'cosecha-25-26': 5
|
|
}
|
|
|
|
// Función para obtener color fijo de cosecha según su ID (no su orden de selección)
|
|
function getCosechaColor(cosechaIdOrIndex: string | number): string {
|
|
const colores = estilosGraficas.value.coloresCosechas
|
|
|
|
// Si es un string, es un ID de cosecha
|
|
if (typeof cosechaIdOrIndex === 'string') {
|
|
const index = cosechaColorMap[cosechaIdOrIndex] ?? 0
|
|
return colores[index % colores.length]
|
|
}
|
|
|
|
// Si es un número, buscar el ID de la cosecha en evolucionCosechas
|
|
const cosecha = evolucionCosechas.value[cosechaIdOrIndex]
|
|
if (cosecha?.id) {
|
|
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
|
|
return colores[index % colores.length]
|
|
}
|
|
|
|
// Fallback al índice directo
|
|
return colores[cosechaIdOrIndex % colores.length]
|
|
}
|
|
|
|
function formatValue(value: number): string {
|
|
if (value >= 1000) {
|
|
return `${(value / 1000).toFixed(1)}K`
|
|
}
|
|
return value.toFixed(0)
|
|
}
|
|
</script>
|