229 lines
5.5 KiB
Vue
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>
|