Files
boursecu_user/src/views/base/home/components/PriceChart.vue
T
2026-06-24 11:52:09 +03:30

364 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="bg-gray-50 w-full rounded-lg py-4"
:class="[
chartContainerClass,
variant === 'desktop' ? 'mt-0' : 'mt-4',
layout === 'boursecu' && variant !== 'desktop' ? 'price-chart-box--boursecu' : '',
]"
>
<div class="w-full text-center pt-2">
<div v-if="showMetalSelector" class="chart-metal-segment mb-3">
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'katod' }"
@click="emit('update:selectedMetal', 'katod')"
>
کاتد مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'maftol' }"
@click="emit('update:selectedMetal', 'maftol')"
>
مفتول مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'shamsh' }"
@click="emit('update:selectedMetal', 'shamsh')"
>
شمش مس
</button>
</div>
<a-radio-group v-model:value="selectedValue" button-style="solid" class="chart-timeline-group">
<a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button>
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
type="area"
width="100%"
:height="chartHeight"
:options="chartOptions"
:series="displayChartSeries"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { usePrice } from '@/utils/usePrice'
import { getBoursecuChartColor, getProjectFontFamily } from '@/utils/config'
import {
computeKatodMesVisualYBounds,
prepareKatodMesChartPoints,
shouldAmplifyKatodMesChart,
shouldUseKatodVisualChartTransform,
} from '@/utils/chartPriceAxis'
import { useSettingsStore } from '@/store/settingsStore'
import { useElementSize } from '@vueuse/core'
import { price } from '@/utils/price'
import type { ChartMetalType } from '../composables/usePriceChart'
import { resolveMetalDayRangePrices, usePriceChart } from '../composables/usePriceChart'
const props = withDefaults(
defineProps<{
priceChartSeries: any[]
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
chartSelection?: string
selectedMetal?: ChartMetalType
}>(),
{
layout: 'default',
variant: 'mobile',
chartSelection: 'DAILY',
selectedMetal: 'katod',
}
)
const chartHeight = computed(() => (props.variant === 'desktop' ? 320 : 200))
const chartContainerClass = computed(() => {
if (props.variant === 'desktop') {
return 'min-h-[22rem]'
}
return props.layout === 'default' ? 'min-h-[15rem] h-[15rem]' : 'min-h-[15rem]'
})
const chartInnerClass = computed(() =>
props.variant === 'desktop' ? 'min-h-[320px]' : 'min-h-[200px]'
)
const showMetalSelector = computed(() => props.layout === 'boursecu')
const chartColor = computed(() => {
if (props.layout === 'boursecu') {
return getBoursecuChartColor()
}
return config.config?.color ?? '#0A1B37'
})
const emit = defineEmits<{
updateData: [timeline: string]
'update:selectedMetal': [metal: ChartMetalType]
}>()
const config = useConfigStore()
const settingsStore = useSettingsStore()
const { selectedMetalChartRatio } = usePriceChart()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const selectedValue = ref(props.chartSelection)
const { width: chartWidth } = useElementSize(chartContainerRef)
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const amplifyKatodChart = computed(() =>
shouldAmplifyKatodMesChart({
isBoursecu: props.layout === 'boursecu',
metalTypeSetting: settingsStore.metalTypeSettingValue,
})
)
const katodUsesVisualTransform = computed(
() => amplifyKatodChart.value && shouldUseKatodVisualChartTransform(props.chartSelection)
)
const katodDayRange = computed(() => {
if (!amplifyKatodChart.value) {
return undefined
}
return resolveMetalDayRangePrices({
price: price.value,
metal: props.selectedMetal,
isBoursecuLayout: true,
metalRatio: selectedMetalChartRatio.value,
})
})
const displayChartSeries = computed(() => {
const baseSeries = props.priceChartSeries?.[0]
if (!baseSeries?.data?.length || !amplifyKatodChart.value) {
return props.priceChartSeries
}
const originalData = baseSeries.data as [number, number][]
const { data: displayData, tooltipPrices, dayMeta, pointMeta } = prepareKatodMesChartPoints(originalData, {
timeline: props.chartSelection,
dayRange: katodDayRange.value,
})
return [
{
...baseSeries,
data: displayData,
meta: { tooltipPrices, dayMeta, pointMeta },
},
]
})
const chartYBounds = computed(() => {
if (!katodUsesVisualTransform.value) {
return {}
}
const prices =
displayChartSeries.value?.[0]?.data
?.map((point: [number, number]) => Number(point?.[1]))
.filter((value: number) => Number.isFinite(value)) ?? []
return computeKatodMesVisualYBounds(prices)
})
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
)
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
}
)
onMounted(async () => {
await nextTick()
chartMounted.value = true
})
const chartOptions = computed(() => {
const fontFamily = getProjectFontFamily()
const yBounds = chartYBounds.value
return {
chart: {
id: 'نوسانات قیمت',
type: 'area',
height: 200,
toolbar: { show: false },
zoom: { autoScaleYaxis: !katodUsesVisualTransform.value },
redrawOnParentResize: true,
},
grid: {
padding: { top: 0, right: 0, bottom: 0, left: 0 },
yaxis: { lines: { show: false } },
},
stroke: {
width: katodUsesVisualTransform.value ? 2.5 : 2,
curve: 'straight',
},
dataLabels: {
enabled: false,
style: {
fontFamily,
},
},
yaxis: {
tickAmount: 8,
show: false,
min: yBounds.min,
max: yBounds.max,
labels: {
formatter: (val: number) => usePrice(val, true, 'ه.ریال'),
style: { fontSize: '13px', fontFamily },
},
},
xaxis: {
show: false,
type: 'datetime',
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { show: false },
},
tooltip: {
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
},
},
...(katodUsesVisualTransform.value
? {
custom: ({ dataPointIndex, w }: { dataPointIndex: number; w: { globals: { seriesX: number[][] } } }) => {
const timestamp = w.globals.seriesX[0]?.[dataPointIndex]
const seriesMeta = displayChartSeries.value?.[0]?.meta as
| {
tooltipPrices?: number[]
dayMeta?: { high: number; low: number; avg: number }[]
pointMeta?: { kind: 'high' | 'low' | 'avg'; dayHigh: number; dayLow: number }[]
}
| undefined
const pointMeta = seriesMeta?.pointMeta?.[dataPointIndex]
const dayMeta = seriesMeta?.dayMeta?.[dataPointIndex]
const realPrice = seriesMeta?.tooltipPrices?.[dataPointIndex] ?? dayMeta?.avg
if (timestamp == null || realPrice == null) {
return ''
}
const date = new Date(timestamp)
const timeLabel = `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
const priceLine = usePrice(realPrice, true, 'ه.ریال')
let valueLabel = priceLine
if (pointMeta?.kind === 'high') {
valueLabel = `بالاترین روز: ${priceLine}`
} else if (pointMeta?.kind === 'low') {
valueLabel = `پایین‌ترین روز: ${priceLine}`
} else if (dayMeta) {
valueLabel = `میانگین روز: ${priceLine}`
}
const rangeHigh = pointMeta?.dayHigh ?? dayMeta?.high
const rangeLow = pointMeta?.dayLow ?? dayMeta?.low
const rangeLine =
rangeHigh != null && rangeLow != null
? `<div style="font-family:${fontFamily};font-size:12px;opacity:0.85;margin-top:4px">
بازه روز: ${usePrice(rangeHigh, true, 'ه.ریال')} — ${usePrice(rangeLow, true, 'ه.ریال')}
</div>`
: ''
return `<div class="apexcharts-tooltip-title" style="font-family:${fontFamily}">${timeLabel}</div>
<div class="apexcharts-tooltip-series-group apexcharts-active" style="order:1;display:flex">
<span class="apexcharts-tooltip-text" style="font-family:${fontFamily}">
<span class="apexcharts-tooltip-text-y-value">${valueLabel}</span>
</span>
</div>${rangeLine}`
},
}
: {
formatter: (val: number) => usePrice(val, true, 'ه.ریال'),
}),
style: { fontFamily },
},
colors: [chartColor.value],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.9,
stops: [0, 100],
},
},
}
})
const handleUpdate = (timeline: string) => {
emit('updateData', timeline)
}
</script>
<style scoped lang="less">
@boursecu-surface-border: #d4e3de;
.price-chart-box--boursecu {
border: 2px solid @boursecu-surface-border;
}
.chart-metal-segment {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #dfe8e5;
}
.chart-metal-segment__item {
appearance: none;
cursor: pointer;
min-width: 5.5rem;
padding: 0.4rem 0.6rem;
border: none;
border-radius: 8px;
background: transparent;
color: @App-primary-color;
opacity: 0.65;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.chart-metal-segment__item--active {
background: #fff;
color: @App-primary-color;
opacity: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(10, 27, 55, 0.12);
}
</style>