367 lines
12 KiB
Vue
367 lines
12 KiB
Vue
<template>
|
|
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-white">{{ $t('users.trend_title') }}</h3>
|
|
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.trend_subtitle') }}</p>
|
|
</div>
|
|
<!-- Period selector -->
|
|
<select
|
|
v-model="selectedPeriod"
|
|
class="px-2.5 py-1.5 text-xs bg-slate-700 border border-slate-600 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
|
|
@change="updateChart"
|
|
>
|
|
<option value="7">{{ $t('users.trend_7d') }}</option>
|
|
<option value="14">{{ $t('users.trend_14d') }}</option>
|
|
<option value="30">{{ $t('users.trend_30d') }}</option>
|
|
<option value="90">{{ $t('users.trend_90d') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
<svg class="w-6 h-6 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="filteredData.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
|
<svg class="w-10 h-10 text-slate-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
</svg>
|
|
<p class="text-slate-500 text-xs">{{ $t('users.trend_no_data') }}</p>
|
|
</div>
|
|
|
|
<!-- Chart Area -->
|
|
<div v-else class="relative">
|
|
<!-- SVG Bar Chart -->
|
|
<svg
|
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
|
class="w-full h-auto overflow-visible"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
>
|
|
<!-- Grid lines -->
|
|
<line
|
|
v-for="(y, yi) in gridLines"
|
|
:key="'grid-' + yi"
|
|
:x1="padding.left"
|
|
:y1="y"
|
|
:x2="svgWidth - padding.right"
|
|
:y2="y"
|
|
stroke="currentColor"
|
|
class="text-slate-700/50"
|
|
stroke-width="1"
|
|
stroke-dasharray="4,4"
|
|
/>
|
|
<!-- Y-axis labels -->
|
|
<text
|
|
v-for="(label, li) in yLabels"
|
|
:key="'ylabel-' + li"
|
|
:x="padding.left - 8"
|
|
:y="label.y + 4"
|
|
text-anchor="end"
|
|
class="fill-slate-500"
|
|
font-size="10"
|
|
>
|
|
{{ label.text }}
|
|
</text>
|
|
|
|
<!-- Bars -->
|
|
<g
|
|
v-for="(item, idx) in visibleBars"
|
|
:key="'bar-' + idx"
|
|
class="chart-bar-group"
|
|
@mouseenter="hoveredBar = idx"
|
|
@mouseleave="hoveredBar = null"
|
|
>
|
|
<rect
|
|
:x="item.x"
|
|
:y="item.y"
|
|
:width="barWidth"
|
|
:height="item.height"
|
|
:rx="3"
|
|
:ry="3"
|
|
:fill="item.color"
|
|
:opacity="hoveredBar === idx ? 0.9 : 0.7"
|
|
class="transition-all duration-200 cursor-pointer"
|
|
/>
|
|
<!-- Tooltip -->
|
|
<g v-if="hoveredBar === idx">
|
|
<rect
|
|
:x="item.tooltipX"
|
|
:y="item.tooltipY - 28"
|
|
:width="item.tooltipWidth"
|
|
height="22"
|
|
rx="4"
|
|
fill="#1e293b"
|
|
stroke="#475569"
|
|
stroke-width="1"
|
|
/>
|
|
<text
|
|
:x="item.tooltipX + item.tooltipWidth / 2"
|
|
:y="item.tooltipY - 13"
|
|
text-anchor="middle"
|
|
fill="#e2e8f0"
|
|
font-size="11"
|
|
font-weight="600"
|
|
>
|
|
{{ item.tooltipText }}
|
|
</text>
|
|
</g>
|
|
</g>
|
|
|
|
<!-- X-axis labels (show every Nth label to avoid crowding) -->
|
|
<text
|
|
v-for="(item, xi) in xLabels"
|
|
:key="'xlabel-' + xi"
|
|
:x="item.x + barWidth / 2"
|
|
:y="svgHeight - padding.bottom + 16"
|
|
text-anchor="middle"
|
|
class="fill-slate-500"
|
|
font-size="9"
|
|
>
|
|
{{ item.label }}
|
|
</text>
|
|
</svg>
|
|
|
|
<!-- Summary Stats Below Chart -->
|
|
<div class="flex items-center justify-between mt-4 pt-3 border-t border-slate-700/50">
|
|
<div class="flex items-center gap-4 text-xs">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2.5 h-2.5 rounded-sm bg-indigo-500/70"></span>
|
|
<span class="text-slate-400">{{ $t('users.trend_daily') }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-slate-400">{{ $t('users.trend_avg') }}:</span>
|
|
<span class="text-white font-semibold ml-1">{{ averageCount }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-slate-400">{{ $t('users.trend_total') }}:</span>
|
|
<span class="text-white font-semibold ml-1">{{ totalCount }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="trendDirection !== 'stable'" class="flex items-center gap-1 text-xs">
|
|
<svg
|
|
v-if="trendDirection === 'up'"
|
|
class="w-3.5 h-3.5 text-emerald-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
</svg>
|
|
<svg
|
|
v-else
|
|
class="w-3.5 h-3.5 text-red-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
|
|
</svg>
|
|
<span :class="trendDirection === 'up' ? 'text-emerald-400' : 'text-red-400'" class="font-medium">
|
|
{{ trendPercent }}%
|
|
</span>
|
|
<span class="text-slate-500">{{ $t('users.trend_vs_prev') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
|
|
interface TrendDataPoint {
|
|
date: string
|
|
count: number
|
|
}
|
|
|
|
const props = defineProps<{
|
|
data: TrendDataPoint[]
|
|
loading?: boolean
|
|
}>()
|
|
|
|
// ── State ──────────────────────────────────────────────────────────
|
|
|
|
const selectedPeriod = ref('30')
|
|
const hoveredBar = ref<number | null>(null)
|
|
|
|
// ── Chart Dimensions ────────────────────────────────────────────────
|
|
|
|
const svgWidth = 800
|
|
const svgHeight = 220
|
|
const padding = { top: 10, right: 16, bottom: 30, left: 36 }
|
|
|
|
// ── Computed ───────────────────────────────────────────────────────
|
|
|
|
const filteredData = computed(() => {
|
|
const days = parseInt(selectedPeriod.value, 10)
|
|
if (!props.data || props.data.length === 0) return []
|
|
// Sort by date ascending
|
|
const sorted = [...props.data].sort(
|
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
)
|
|
const cutoff = new Date()
|
|
cutoff.setDate(cutoff.getDate() - days)
|
|
return sorted.filter((d) => new Date(d.date) >= cutoff)
|
|
})
|
|
|
|
const maxCount = computed(() => {
|
|
if (filteredData.value.length === 0) return 1
|
|
return Math.max(...filteredData.value.map((d) => d.count), 1)
|
|
})
|
|
|
|
const chartHeight = svgHeight - padding.top - padding.bottom
|
|
|
|
// Grid lines (5 horizontal lines)
|
|
const gridLines = computed(() => {
|
|
const lines = []
|
|
for (let i = 0; i <= 4; i++) {
|
|
lines.push(padding.top + (chartHeight / 4) * i)
|
|
}
|
|
return lines
|
|
})
|
|
|
|
const yLabels = computed(() => {
|
|
const labels = []
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = padding.top + (chartHeight / 4) * i
|
|
const value = Math.round(maxCount.value - (maxCount.value / 4) * i)
|
|
labels.push({ y, text: value.toString() })
|
|
}
|
|
return labels
|
|
})
|
|
|
|
const barWidth = computed(() => {
|
|
const count = filteredData.value.length
|
|
if (count === 0) return 0
|
|
const availableWidth = svgWidth - padding.left - padding.right
|
|
const maxBarWidth = 32
|
|
const calculatedWidth = (availableWidth / count) * 0.7
|
|
return Math.min(calculatedWidth, maxBarWidth)
|
|
})
|
|
|
|
const visibleBars = computed(() => {
|
|
const count = filteredData.value.length
|
|
if (count === 0) return []
|
|
|
|
const availableWidth = svgWidth - padding.left - padding.right
|
|
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
|
|
|
|
return filteredData.value.map((item, idx) => {
|
|
const x = padding.left + idx * (barWidth.value + gap)
|
|
const barHeight = maxCount.value > 0 ? (item.count / maxCount.value) * chartHeight : 0
|
|
const y = svgHeight - padding.bottom - barHeight
|
|
|
|
// Color gradient based on value
|
|
const ratio = maxCount.value > 0 ? item.count / maxCount.value : 0
|
|
let color: string
|
|
if (ratio > 0.8) color = '#6366f1' // indigo-500
|
|
else if (ratio > 0.5) color = '#818cf8' // indigo-400
|
|
else if (ratio > 0.3) color = '#a5b4fc' // indigo-300
|
|
else color = '#c7d2fe' // indigo-200
|
|
|
|
// Tooltip
|
|
const tooltipText = `${item.count} ${formatDate(item.date)}`
|
|
const tooltipWidth = tooltipText.length * 7 + 16
|
|
let tooltipX = x + barWidth.value / 2 - tooltipWidth / 2
|
|
if (tooltipX < padding.left) tooltipX = padding.left
|
|
if (tooltipX + tooltipWidth > svgWidth - padding.right) tooltipX = svgWidth - padding.right - tooltipWidth
|
|
const tooltipY = y - 4
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
height: barHeight,
|
|
color,
|
|
tooltipX,
|
|
tooltipY,
|
|
tooltipWidth,
|
|
tooltipText,
|
|
}
|
|
})
|
|
})
|
|
|
|
// X-axis labels (show every Nth)
|
|
const xLabels = computed(() => {
|
|
const count = filteredData.value.length
|
|
if (count === 0) return []
|
|
|
|
const availableWidth = svgWidth - padding.left - padding.right
|
|
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
|
|
|
|
// Determine label interval based on data density
|
|
let interval = 1
|
|
if (count > 14) interval = 2
|
|
if (count > 30) interval = 5
|
|
if (count > 60) interval = 10
|
|
|
|
return filteredData.value
|
|
.map((item, idx) => {
|
|
const x = padding.left + idx * (barWidth.value + gap)
|
|
const d = new Date(item.date)
|
|
const label = `${d.getMonth() + 1}/${d.getDate()}`
|
|
return { x, label, idx }
|
|
})
|
|
.filter((_, i) => i % interval === 0 || i === count - 1)
|
|
})
|
|
|
|
// Summary stats
|
|
const averageCount = computed(() => {
|
|
if (filteredData.value.length === 0) return 0
|
|
const sum = filteredData.value.reduce((acc, d) => acc + d.count, 0)
|
|
return Math.round(sum / filteredData.value.length)
|
|
})
|
|
|
|
const totalCount = computed(() => {
|
|
return filteredData.value.reduce((acc, d) => acc + d.count, 0)
|
|
})
|
|
|
|
const trendDirection = computed(() => {
|
|
const data = filteredData.value
|
|
if (data.length < 4) return 'stable'
|
|
const half = Math.floor(data.length / 2)
|
|
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
|
|
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
|
|
if (secondHalf > firstHalf * 1.05) return 'up'
|
|
if (secondHalf < firstHalf * 0.95) return 'down'
|
|
return 'stable'
|
|
})
|
|
|
|
const trendPercent = computed(() => {
|
|
const data = filteredData.value
|
|
if (data.length < 4) return 0
|
|
const half = Math.floor(data.length / 2)
|
|
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
|
|
if (firstHalf === 0) return 100
|
|
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
|
|
return Math.round(Math.abs((secondHalf - firstHalf) / firstHalf) * 100)
|
|
})
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
|
|
function formatDate(dateStr: string): string {
|
|
try {
|
|
const d = new Date(dateStr)
|
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
} catch {
|
|
return dateStr
|
|
}
|
|
}
|
|
|
|
function updateChart() {
|
|
// Trigger reactivity by clearing and resetting hover
|
|
hoveredBar.value = null
|
|
}
|
|
|
|
// ── Watch for data changes ─────────────────────────────────────────
|
|
|
|
watch(() => props.data, () => {
|
|
updateChart()
|
|
})
|
|
</script>
|