Files
fabric_trade_admin/src/components/charts/SalesAreaChart.vue
T

229 lines
5.5 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatNumber } from '@/utils/format'
const props = withDefaults(
defineProps<{
data: number[]
title?: string
subtitle?: string
height?: number
color?: string
unit?: string
labels?: string[]
}>(),
{
height: 160,
color: '#17379f',
unit: 'میلیارد تومان',
},
)
const hoverIndex = ref<number | null>(null)
const chartWidth = 320
const chartHeight = 120
const padding = { top: 12, right: 8, bottom: 8, left: 8 }
const maxValue = computed(() => Math.max(...props.data, 0.001))
const points = computed(() => {
const innerW = chartWidth - padding.left - padding.right
const innerH = chartHeight - padding.top - padding.bottom
const step = innerW / Math.max(props.data.length - 1, 1)
return props.data.map((value, index) => ({
x: padding.left + index * step,
y: padding.top + innerH - (value / maxValue.value) * innerH,
value,
index,
}))
})
const linePoints = computed(() => points.value.map((p) => `${p.x},${p.y}`).join(' '))
const areaPoints = computed(() => {
const bottom = chartHeight - padding.bottom
const first = points.value[0]
const last = points.value[points.value.length - 1]
if (!first || !last) return ''
return `${first.x},${bottom} ${linePoints.value} ${last.x},${bottom}`
})
const gridLines = computed(() => {
const innerH = chartHeight - padding.top - padding.bottom
return [0, 0.5, 1].map((ratio) => padding.top + innerH * ratio)
})
const hoveredPoint = computed(() =>
hoverIndex.value === null ? null : points.value[hoverIndex.value] ?? null,
)
function onMove(event: MouseEvent) {
const svg = event.currentTarget as SVGSVGElement
const rect = svg.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * chartWidth
let nearest = 0
let minDist = Infinity
points.value.forEach((point) => {
const dist = Math.abs(point.x - x)
if (dist < minDist) {
minDist = dist
nearest = point.index
}
})
hoverIndex.value = nearest
}
function onLeave() {
hoverIndex.value = null
}
</script>
<template>
<div class="sales-chart">
<div v-if="title || subtitle" class="sales-chart__header">
<div>
<h3 v-if="title" class="sales-chart__title">{{ title }}</h3>
<p v-if="subtitle" class="sales-chart__subtitle">{{ subtitle }}</p>
</div>
</div>
<div class="sales-chart__body" :style="{ height: `${height}px` }">
<svg
class="sales-chart__svg"
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
preserveAspectRatio="none"
role="img"
:aria-label="title || 'نمودار فروش'"
@mousemove="onMove"
@mouseleave="onLeave"
>
<line
v-for="(y, index) in gridLines"
:key="index"
:x1="padding.left"
:x2="chartWidth - padding.right"
:y1="y"
:y2="y"
class="sales-chart__grid"
/>
<polygon v-if="areaPoints" :points="areaPoints" class="sales-chart__area" :style="{ fill: `${color}22` }" />
<polyline :points="linePoints" class="sales-chart__line" :style="{ stroke: color }" />
<template v-if="hoveredPoint">
<line
:x1="hoveredPoint.x"
:x2="hoveredPoint.x"
:y1="padding.top"
:y2="chartHeight - padding.bottom"
class="sales-chart__cursor"
/>
<circle :cx="hoveredPoint.x" :cy="hoveredPoint.y" r="4" class="sales-chart__dot" :style="{ fill: color }" />
</template>
</svg>
<div
v-if="hoveredPoint"
class="sales-chart__tooltip"
:style="{ left: `${(hoveredPoint.x / chartWidth) * 100}%` }"
>
<span class="sales-chart__tooltip-label">
{{ labels?.[hoveredPoint.index] ?? `روز ${hoveredPoint.index + 1}` }}
</span>
<strong>{{ formatNumber(hoveredPoint.value) }} {{ unit }}</strong>
</div>
</div>
<div v-if="labels?.length" class="sales-chart__axis">
<span v-for="(label, index) in labels" :key="index">{{ label }}</span>
</div>
</div>
</template>
<style scoped>
.sales-chart__header {
margin-bottom: 12px;
}
.sales-chart__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.sales-chart__subtitle {
margin: 4px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.sales-chart__body {
position: relative;
}
.sales-chart__svg {
width: 100%;
height: 100%;
display: block;
}
.sales-chart__grid {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.sales-chart__line {
fill: none;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.sales-chart__cursor {
stroke: var(--color-border);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.sales-chart__dot {
stroke: #fff;
stroke-width: 2;
}
.sales-chart__tooltip {
position: absolute;
top: 8px;
transform: translateX(-50%);
min-width: 120px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
pointer-events: none;
text-align: center;
}
.sales-chart__tooltip-label {
display: block;
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-bottom: 2px;
}
.sales-chart__tooltip strong {
font-size: 0.8125rem;
}
.sales-chart__axis {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
</style>