edit chart for section hero and live in landing
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/** تاریخچه قیمت عمومی بورس کالا (بدون توکن) — کاتد مس */
|
||||
|
||||
export const ZAANBOX_COPPER_CTHD_ID = '8c19e06a-446e-3d56-9cc0-750f7c6cd767'
|
||||
|
||||
export const ZAANBOX_HISTORY_URL = `https://price.zaarbox.ir/api/history/${ZAANBOX_COPPER_CTHD_ID}`
|
||||
|
||||
export type ZaanboxHistoryPoint = {
|
||||
time: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type ZaanboxHistoryResponse = {
|
||||
history?: ZaanboxHistoryPoint[]
|
||||
data?: { history?: ZaanboxHistoryPoint[] }
|
||||
}
|
||||
|
||||
export type LandingHistoryRange = 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
|
||||
/** نقطهٔ خام تاریخچه — قدیم → جدید */
|
||||
export type LandingHistorySample = {
|
||||
timeMs: number
|
||||
price: number
|
||||
}
|
||||
|
||||
const RANGE_MS: Record<LandingHistoryRange, number> = {
|
||||
DAILY: 1 * 24 * 60 * 60 * 1000,
|
||||
WEEKLY: 7 * 24 * 60 * 60 * 1000,
|
||||
MONTHLY: 30 * 24 * 60 * 60 * 1000,
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 1_000_000_000
|
||||
|
||||
function resolveHistoryRows(payload: unknown): ZaanboxHistoryPoint[] {
|
||||
if (!payload || typeof payload !== 'object') return []
|
||||
const body = payload as ZaanboxHistoryResponse
|
||||
if (Array.isArray(body.history)) return body.history
|
||||
if (Array.isArray(body.data?.history)) return body.data!.history!
|
||||
return []
|
||||
}
|
||||
|
||||
/** نقاط خام بازه، مرتب از قدیم → جدید */
|
||||
export async function fetchZaanboxPriceHistory(
|
||||
range: LandingHistoryRange,
|
||||
now = new Date(),
|
||||
): Promise<LandingHistorySample[]> {
|
||||
const end = now
|
||||
const start = new Date(end.getTime() - RANGE_MS[range])
|
||||
const url = new URL(ZAANBOX_HISTORY_URL)
|
||||
url.searchParams.set('start', start.toISOString())
|
||||
url.searchParams.set('end', end.toISOString())
|
||||
url.searchParams.set('limit', String(HISTORY_LIMIT))
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Zaanbox history HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown
|
||||
const rows = resolveHistoryRows(payload)
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const timeMs = Date.parse(String(row.time ?? ''))
|
||||
const price = Number(row.price)
|
||||
return { timeMs, price }
|
||||
})
|
||||
.filter((point) => Number.isFinite(point.timeMs) && Number.isFinite(point.price) && point.price > 0)
|
||||
.sort((a, b) => a.timeMs - b.timeMs)
|
||||
}
|
||||
@@ -201,44 +201,71 @@
|
||||
<span class="dlanding-price-card__change-label">نسبت به ابتدای روز</span>
|
||||
</div>
|
||||
|
||||
<div class="dlanding-chart" aria-hidden="true">
|
||||
<svg
|
||||
v-if="landingChart.hasData"
|
||||
class="dlanding-chart__svg"
|
||||
viewBox="0 0 360 120"
|
||||
preserveAspectRatio="none"
|
||||
<div class="dlanding-chart">
|
||||
<div
|
||||
class="dlanding-chart__plot"
|
||||
@pointermove="onHeroChartPointerMove"
|
||||
@pointerleave="onHeroChartPointerLeave"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="landingChartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#c98a5f" stop-opacity="0.28" />
|
||||
<stop offset="100%" stop-color="#c98a5f" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="dlanding-chart__area"
|
||||
:d="landingChart.areaPath"
|
||||
fill="url(#landingChartFill)"
|
||||
/>
|
||||
<path
|
||||
class="dlanding-chart__path"
|
||||
:d="landingChart.linePath"
|
||||
fill="none"
|
||||
stroke="#c98a5f"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle :cx="landingChart.lastX" :cy="landingChart.lastY" r="5" fill="#c98a5f" />
|
||||
<circle
|
||||
:cx="landingChart.lastX"
|
||||
:cy="landingChart.lastY"
|
||||
r="9"
|
||||
fill="#c98a5f"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</svg>
|
||||
<div v-else class="dlanding-chart__empty">
|
||||
{{ liveChartLoading ? 'در حال بارگذاری نمودار…' : 'نمودار در دسترس نیست' }}
|
||||
<svg
|
||||
v-if="landingChart.hasData"
|
||||
class="dlanding-chart__svg"
|
||||
viewBox="0 0 360 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="landingChartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#c98a5f" stop-opacity="0.28" />
|
||||
<stop offset="100%" stop-color="#c98a5f" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="dlanding-chart__area"
|
||||
:d="landingChart.areaPath"
|
||||
fill="url(#landingChartFill)"
|
||||
/>
|
||||
<path
|
||||
class="dlanding-chart__path"
|
||||
:d="landingChart.linePath"
|
||||
fill="none"
|
||||
stroke="#c98a5f"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle :cx="landingChart.lastX" :cy="landingChart.lastY" r="5" fill="#c98a5f" />
|
||||
<circle
|
||||
:cx="landingChart.lastX"
|
||||
:cy="landingChart.lastY"
|
||||
r="9"
|
||||
fill="#c98a5f"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
</svg>
|
||||
<div v-else class="dlanding-chart__empty">
|
||||
{{ liveChartLoading ? 'در حال بارگذاری نمودار…' : 'نمودار در دسترس نیست' }}
|
||||
</div>
|
||||
<template v-if="heroChartHover">
|
||||
<div
|
||||
class="dlanding-chart__crosshair"
|
||||
:style="{ left: `${heroChartHover.xPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-chart__dot"
|
||||
:style="{
|
||||
left: `${heroChartHover.xPercent}%`,
|
||||
top: `${heroChartHover.yPercent}%`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-chart__tooltip"
|
||||
:class="{ 'is-near-end': heroChartHover.xPercent > 72 }"
|
||||
:style="{ left: `${heroChartHover.xPercent}%` }"
|
||||
>
|
||||
<strong>{{ heroChartHover.priceLabel }}</strong>
|
||||
<span>{{ heroChartHover.timeLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="dlanding-chart__labels">
|
||||
<span>اکنون</span>
|
||||
@@ -531,7 +558,11 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dlanding-live__chart" aria-hidden="true">
|
||||
<div
|
||||
class="dlanding-live__chart"
|
||||
@pointermove="onLiveChartPointerMove"
|
||||
@pointerleave="onLiveChartPointerLeave"
|
||||
>
|
||||
<svg
|
||||
v-if="liveSectionChart.hasData"
|
||||
class="dlanding-live__chart-svg"
|
||||
@@ -572,6 +603,27 @@
|
||||
: 'نمودار در دسترس نیست'
|
||||
}}
|
||||
</div>
|
||||
<template v-if="liveChartHover">
|
||||
<div
|
||||
class="dlanding-live__chart-crosshair"
|
||||
:style="{ left: `${liveChartHover.xPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-live__chart-dot"
|
||||
:style="{
|
||||
left: `${liveChartHover.xPercent}%`,
|
||||
top: `${liveChartHover.yPercent}%`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="dlanding-live__chart-tooltip"
|
||||
:class="{ 'is-near-end': liveChartHover.xPercent > 72 }"
|
||||
:style="{ left: `${liveChartHover.xPercent}%` }"
|
||||
>
|
||||
<strong>{{ liveChartHover.priceLabel }}</strong>
|
||||
<span>{{ liveChartHover.timeLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -859,9 +911,6 @@ import type { Price } from '@/models/home'
|
||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||
import {
|
||||
applyLastPricePayload,
|
||||
getTradeBuyPrice,
|
||||
lastPrice,
|
||||
percentPriceChanged,
|
||||
} from '@/utils/price'
|
||||
import { getPriceDisplayState, hasLivePriceQuote, isStoreClosed } from '@/utils/storeHours'
|
||||
import useAxios from '@/utils/useAxios'
|
||||
@@ -872,10 +921,13 @@ import {
|
||||
} from '@/utils/usePrice'
|
||||
import {
|
||||
CHART_METAL_MARKUP_RATIO,
|
||||
resolveMetalDayRangePrices,
|
||||
usePriceChart,
|
||||
type ChartMetalType,
|
||||
} from '@/views/base/home/composables/usePriceChart'
|
||||
import {
|
||||
fetchZaanboxPriceHistory,
|
||||
type LandingHistoryRange,
|
||||
type LandingHistorySample,
|
||||
} from '@/api/zaarboxPriceHistory'
|
||||
import {
|
||||
PWA_MANIFEST_READY_EVENT,
|
||||
getDeferredPwaInstallPrompt,
|
||||
@@ -886,7 +938,7 @@ import DesktopPwaGuideModal from '@/views/login/desktop/components/DesktopPwaGui
|
||||
import { useAboutUsPageContext } from '../composables/useAboutUsPage'
|
||||
|
||||
const MOBILE_NAV_MQ = '(max-width: 1023.98px)'
|
||||
const PRICE_POLL_MS = 10_000
|
||||
const PRICE_POLL_MS = 30_000
|
||||
const CHART_WIDTH = 360
|
||||
const CHART_HEIGHT = 120
|
||||
const LIVE_CHART_WIDTH = 560
|
||||
@@ -894,7 +946,14 @@ const LIVE_CHART_HEIGHT = 160
|
||||
const CHART_PAD = 10
|
||||
const MAX_CHART_POINTS = 48
|
||||
|
||||
type LiveChartRange = 'DAILY' | 'WEEKLY' | 'MONTHLY'
|
||||
type LiveChartRange = LandingHistoryRange
|
||||
|
||||
type ChartHoverState = {
|
||||
xPercent: number
|
||||
yPercent: number
|
||||
priceLabel: string
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
const LIVE_CHART_RANGE_OPTIONS: Array<{
|
||||
value: LiveChartRange
|
||||
@@ -908,22 +967,17 @@ const LIVE_CHART_RANGE_OPTIONS: Array<{
|
||||
|
||||
const { config } = useAboutUsPageContext()
|
||||
|
||||
/** همان سیستم نمودار Home — فقط مقادیر سری، بدون UI/استایل Home */
|
||||
const {
|
||||
priceChartSeries,
|
||||
chartSelection,
|
||||
chartLoading,
|
||||
updateChartData,
|
||||
fetchPrices,
|
||||
setChartMetal,
|
||||
loadMetalChartOffsets,
|
||||
} = usePriceChart()
|
||||
|
||||
const activeProduct = ref<'wire' | 'cathode'>('wire')
|
||||
const liveChartRange = ref<LiveChartRange>('DAILY')
|
||||
const liveChartRangeOptions = LIVE_CHART_RANGE_OPTIONS
|
||||
const investmentAmount = ref(0)
|
||||
/** اسنپشات DAILY برای اسپارکلاین هیرو وقتی بازه live عوض میشود */
|
||||
const dailyHomeChartValues = ref<number[]>([])
|
||||
/** سری خام ۲۴ ساعت — هیرو + قیمت لحظهای + متریکهای روز */
|
||||
const dailyRawPoints = ref<LandingHistorySample[]>([])
|
||||
/** سری خام بازهٔ فعلی سکشن live */
|
||||
const liveRangeRawPoints = ref<LandingHistorySample[]>([])
|
||||
const liveChartLoading = ref(false)
|
||||
const heroChartHover = ref<ChartHoverState | null>(null)
|
||||
const liveChartHover = ref<ChartHoverState | null>(null)
|
||||
const openFaqIndex = ref(0)
|
||||
const isMobileMenuOpen = ref(false)
|
||||
const showPwaGuide = ref(false)
|
||||
@@ -952,16 +1006,12 @@ const liveProductName = computed(() =>
|
||||
activeProduct.value === 'wire' ? 'مفتول مس' : 'کاتد مس',
|
||||
)
|
||||
|
||||
const liveChartRange = computed(() => chartSelection.value as LiveChartRange)
|
||||
|
||||
const liveChartRangeLabel = computed(
|
||||
() =>
|
||||
LIVE_CHART_RANGE_OPTIONS.find((option) => option.value === liveChartRange.value)?.label ??
|
||||
'نمودار ۲۴ ساعت گذشته',
|
||||
)
|
||||
|
||||
const liveChartLoading = chartLoading
|
||||
|
||||
function selectedMetalType(): ChartMetalType {
|
||||
return activeProduct.value === 'wire' ? 'maftol' : 'katod'
|
||||
}
|
||||
@@ -973,48 +1023,35 @@ function applyProductMarkup(basePrice: number) {
|
||||
return Math.round(basePrice + basePrice * ratio)
|
||||
}
|
||||
|
||||
function applyMarkupToSeries(points: LandingHistorySample[]): LandingHistorySample[] {
|
||||
return points
|
||||
.map((point) => ({
|
||||
timeMs: point.timeMs,
|
||||
price: applyProductMarkup(point.price),
|
||||
}))
|
||||
.filter((point) => Number.isFinite(point.price) && point.price > 0)
|
||||
}
|
||||
|
||||
function setLiveProduct(product: 'wire' | 'cathode') {
|
||||
activeProduct.value = product
|
||||
setChartMetal(product === 'wire' ? 'maftol' : 'katod')
|
||||
heroChartHover.value = null
|
||||
liveChartHover.value = null
|
||||
}
|
||||
|
||||
function setLiveChartRange(range: LiveChartRange) {
|
||||
updateChartData(range)
|
||||
async function setLiveChartRange(range: LiveChartRange) {
|
||||
if (liveChartRange.value === range && liveRangeRawPoints.value.length >= 2) return
|
||||
liveChartRange.value = range
|
||||
liveChartHover.value = null
|
||||
await loadLiveRangeHistory(range)
|
||||
}
|
||||
|
||||
/** مقادیر Y همان سری Home (بعد از مارکآپ فلز) */
|
||||
const homeChartValues = computed(() =>
|
||||
(priceChartSeries.value[0]?.data ?? [])
|
||||
.map((point) => Number(point?.[1]))
|
||||
.filter((value) => Number.isFinite(value) && value > 0),
|
||||
)
|
||||
|
||||
watch(
|
||||
[homeChartValues, chartSelection],
|
||||
([values, selection]) => {
|
||||
if (selection === 'DAILY' && values.length >= 2) {
|
||||
dailyHomeChartValues.value = values
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** پایه قیمت — buy / price_last مثل Home */
|
||||
/** پایه قیمت = آخرین رکورد history روزانه */
|
||||
const baseBuyPrice = computed(() => {
|
||||
const buy = Number(getTradeBuyPrice(lastPrice.value) || 0)
|
||||
if (Number.isFinite(buy) && buy > 0) return buy
|
||||
const lastDaily = dailyRawPoints.value[dailyRawPoints.value.length - 1]?.price
|
||||
if (Number.isFinite(lastDaily) && lastDaily > 0) return lastDaily
|
||||
|
||||
const fromDaily = dailyHomeChartValues.value[dailyHomeChartValues.value.length - 1]
|
||||
if (Number.isFinite(fromDaily) && fromDaily > 0) {
|
||||
const ratio = CHART_METAL_MARKUP_RATIO[selectedMetalType()] || 0
|
||||
return ratio ? Math.round(fromDaily / (1 + ratio)) : fromDaily
|
||||
}
|
||||
|
||||
const fromLive = homeChartValues.value[homeChartValues.value.length - 1]
|
||||
if (Number.isFinite(fromLive) && fromLive > 0) {
|
||||
const ratio = CHART_METAL_MARKUP_RATIO[selectedMetalType()] || 0
|
||||
return ratio ? Math.round(fromLive / (1 + ratio)) : fromLive
|
||||
}
|
||||
const lastLive = liveRangeRawPoints.value[liveRangeRawPoints.value.length - 1]?.price
|
||||
if (Number.isFinite(lastLive) && lastLive > 0) return lastLive
|
||||
|
||||
return 0
|
||||
})
|
||||
@@ -1028,9 +1065,10 @@ const formattedLivePrice = computed(() => {
|
||||
|
||||
/** open | closed | no_quote — برای badge وضعیت بازار */
|
||||
const liveMarketState = computed(() => {
|
||||
if (isStoreClosed()) return 'closed' as const
|
||||
const state = getPriceDisplayState()
|
||||
if (state === 'open') return 'open' as const
|
||||
if (isStoreClosed()) return 'closed' as const
|
||||
if (baseBuyPrice.value > 0) return 'open' as const
|
||||
if (!hasLivePriceQuote()) return 'no_quote' as const
|
||||
return 'closed' as const
|
||||
})
|
||||
@@ -1059,15 +1097,18 @@ const liveUpdateLabel = computed(() => {
|
||||
return 'مظنه دریافت نشده'
|
||||
})
|
||||
|
||||
/** اسپارکلاین هیرو: همیشه سری DAILY از Home */
|
||||
const chartValues = computed(() =>
|
||||
dailyHomeChartValues.value.length >= 2
|
||||
? dailyHomeChartValues.value
|
||||
: homeChartValues.value,
|
||||
)
|
||||
/** هیرو: همیشه ۲۴ ساعت */
|
||||
const chartPoints = computed(() => applyMarkupToSeries(dailyRawPoints.value))
|
||||
const chartValues = computed(() => chartPoints.value.map((point) => point.price))
|
||||
|
||||
/** سکشن live: دقیقاً همان سری فعلی Home برای بازه انتخابشده */
|
||||
const liveSectionChartValues = computed(() => homeChartValues.value)
|
||||
/** live: بازه انتخابشده — محور Y از min تا max همین سری */
|
||||
const liveSectionChartPoints = computed(() => {
|
||||
const raw =
|
||||
liveChartRange.value === 'DAILY' && liveRangeRawPoints.value.length < 2
|
||||
? dailyRawPoints.value
|
||||
: liveRangeRawPoints.value
|
||||
return applyMarkupToSeries(raw)
|
||||
})
|
||||
|
||||
const dayChangePercent = computed(() => {
|
||||
const values = chartValues.value
|
||||
@@ -1076,8 +1117,7 @@ const dayChangePercent = computed(() => {
|
||||
const last = values[values.length - 1]
|
||||
return ((last - first) / first) * 100
|
||||
}
|
||||
if (!liveUnitPrice.value) return 0
|
||||
return percentPriceChanged()
|
||||
return 0
|
||||
})
|
||||
|
||||
const dayChangeAmountLabel = computed(() => {
|
||||
@@ -1090,12 +1130,12 @@ const dayChangeAmountLabel = computed(() => {
|
||||
})
|
||||
|
||||
const dayRangePrices = computed(() => {
|
||||
const metal = selectedMetalType()
|
||||
return resolveMetalDayRangePrices({
|
||||
price: lastPrice.value,
|
||||
metal,
|
||||
metalRatio: CHART_METAL_MARKUP_RATIO[metal],
|
||||
})
|
||||
const values = chartValues.value
|
||||
if (values.length === 0) return { high: 0, low: 0 }
|
||||
return {
|
||||
high: Math.max(...values),
|
||||
low: Math.min(...values),
|
||||
}
|
||||
})
|
||||
|
||||
const liveMetrics = computed(() => {
|
||||
@@ -1151,57 +1191,146 @@ const forecastTrendText = computed(() => {
|
||||
return `بر اساس دادههای ۲۴ ساعت اخیر، قیمت ${product} نسبتاً پایدار بوده است.`
|
||||
})
|
||||
|
||||
function downsampleValues(values: number[], maxPoints: number) {
|
||||
if (values.length <= maxPoints) return values
|
||||
const result: number[] = []
|
||||
const lastIndex = values.length - 1
|
||||
function downsampleSamples(points: LandingHistorySample[], maxPoints: number) {
|
||||
if (points.length <= maxPoints) return points
|
||||
const result: LandingHistorySample[] = []
|
||||
const lastIndex = points.length - 1
|
||||
for (let i = 0; i < maxPoints; i += 1) {
|
||||
const index = Math.round((i / (maxPoints - 1)) * lastIndex)
|
||||
result.push(values[index])
|
||||
result.push(points[index])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatChartTimeLabel(timeMs: number) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(timeMs))
|
||||
} catch {
|
||||
return new Date(timeMs).toLocaleString('fa-IR')
|
||||
}
|
||||
}
|
||||
|
||||
type ChartPlotSample = {
|
||||
x: number
|
||||
y: number
|
||||
price: number
|
||||
timeMs: number
|
||||
}
|
||||
|
||||
function buildLandingChartPaths(
|
||||
values: number[],
|
||||
points: LandingHistorySample[],
|
||||
width = CHART_WIDTH,
|
||||
height = CHART_HEIGHT,
|
||||
) {
|
||||
const empty = { hasData: false, linePath: '', areaPath: '', lastX: 0, lastY: height / 2 }
|
||||
if (values.length < 2) return empty
|
||||
const empty = {
|
||||
hasData: false,
|
||||
linePath: '',
|
||||
areaPath: '',
|
||||
lastX: 0,
|
||||
lastY: height / 2,
|
||||
samples: [] as ChartPlotSample[],
|
||||
}
|
||||
if (points.length < 2) return empty
|
||||
|
||||
const sampled = downsampleValues(values, MAX_CHART_POINTS)
|
||||
const min = Math.min(...sampled)
|
||||
const max = Math.max(...sampled)
|
||||
const sampled = downsampleSamples(points, MAX_CHART_POINTS)
|
||||
// محور Y: از min تا max همان بازه — از صفر شروع نمیشود
|
||||
const prices = sampled.map((point) => point.price)
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
const range = max - min || 1
|
||||
const usableHeight = height - CHART_PAD * 2
|
||||
|
||||
const points = sampled.map((value, index) => {
|
||||
const plotSamples = sampled.map((point, index) => {
|
||||
const x = (index / (sampled.length - 1)) * width
|
||||
const y = CHART_PAD + (1 - (value - min) / range) * usableHeight
|
||||
return [x, y] as const
|
||||
const y = CHART_PAD + (1 - (point.price - min) / range) * usableHeight
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
price: point.price,
|
||||
timeMs: point.timeMs,
|
||||
}
|
||||
})
|
||||
|
||||
const linePath = points
|
||||
.map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`)
|
||||
const linePath = plotSamples
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||||
.join(' ')
|
||||
const last = points[points.length - 1]
|
||||
const last = plotSamples[plotSamples.length - 1]
|
||||
const areaPath = `${linePath} L${width} ${height} L0 ${height} Z`
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
linePath,
|
||||
areaPath,
|
||||
lastX: last[0],
|
||||
lastY: last[1],
|
||||
lastX: last.x,
|
||||
lastY: last.y,
|
||||
samples: plotSamples,
|
||||
}
|
||||
}
|
||||
|
||||
const landingChart = computed(() => buildLandingChartPaths(chartValues.value))
|
||||
const landingChart = computed(() => buildLandingChartPaths(chartPoints.value))
|
||||
const liveSectionChart = computed(() =>
|
||||
buildLandingChartPaths(liveSectionChartValues.value, LIVE_CHART_WIDTH, LIVE_CHART_HEIGHT),
|
||||
buildLandingChartPaths(liveSectionChartPoints.value, LIVE_CHART_WIDTH, LIVE_CHART_HEIGHT),
|
||||
)
|
||||
|
||||
function resolveHoverFromEvent(
|
||||
event: PointerEvent,
|
||||
chart: { samples: ChartPlotSample[]; hasData: boolean },
|
||||
width: number,
|
||||
height: number,
|
||||
): ChartHoverState | null {
|
||||
if (!chart.hasData || chart.samples.length < 2) return null
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
if (!target) return null
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
|
||||
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
|
||||
const index = Math.round(ratio * (chart.samples.length - 1))
|
||||
const sample = chart.samples[index]
|
||||
if (!sample) return null
|
||||
|
||||
return {
|
||||
xPercent: (sample.x / width) * 100,
|
||||
yPercent: (sample.y / height) * 100,
|
||||
priceLabel: `${usePriceWithoutType(sample.price)} ریال`,
|
||||
timeLabel: formatChartTimeLabel(sample.timeMs),
|
||||
}
|
||||
}
|
||||
|
||||
function onHeroChartPointerMove(event: PointerEvent) {
|
||||
heroChartHover.value = resolveHoverFromEvent(
|
||||
event,
|
||||
landingChart.value,
|
||||
CHART_WIDTH,
|
||||
CHART_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
function onHeroChartPointerLeave() {
|
||||
heroChartHover.value = null
|
||||
}
|
||||
|
||||
function onLiveChartPointerMove(event: PointerEvent) {
|
||||
liveChartHover.value = resolveHoverFromEvent(
|
||||
event,
|
||||
liveSectionChart.value,
|
||||
LIVE_CHART_WIDTH,
|
||||
LIVE_CHART_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
function onLiveChartPointerLeave() {
|
||||
liveChartHover.value = null
|
||||
}
|
||||
|
||||
const investmentDisplay = computed(() =>
|
||||
investmentAmount.value > 0 ? usePriceWithoutType(investmentAmount.value) : '',
|
||||
)
|
||||
@@ -1228,7 +1357,8 @@ function onInvestmentInput(event: Event) {
|
||||
investmentAmount.value = raw ? Number(raw) : 0
|
||||
}
|
||||
|
||||
async function loadLandingPrice() {
|
||||
/** وضعیت باز/بسته بازار از API داخلی — اختیاری */
|
||||
async function loadLandingMarketStatus() {
|
||||
try {
|
||||
const data = await useAxios.get<Price>('/api/price/last', {
|
||||
meta: { errorMessage: 'none' },
|
||||
@@ -1237,14 +1367,52 @@ async function loadLandingPrice() {
|
||||
applyLastPricePayload(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Landing price fetch failed:', error)
|
||||
console.error('Landing market status fetch failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyHistory() {
|
||||
try {
|
||||
const series = await fetchZaanboxPriceHistory('DAILY')
|
||||
if (series.length >= 2) {
|
||||
dailyRawPoints.value = series
|
||||
if (liveChartRange.value === 'DAILY') {
|
||||
liveRangeRawPoints.value = series
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Landing daily history fetch failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLiveRangeHistory(range: LiveChartRange) {
|
||||
liveChartLoading.value = true
|
||||
try {
|
||||
const series = await fetchZaanboxPriceHistory(range)
|
||||
liveRangeRawPoints.value = series.length >= 2 ? series : []
|
||||
if (range === 'DAILY' && series.length >= 2) {
|
||||
dailyRawPoints.value = series
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Landing ${range} history fetch failed:`, error)
|
||||
liveRangeRawPoints.value = []
|
||||
} finally {
|
||||
liveChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** همان init نمودار Home: fetchPrices + metal offsets */
|
||||
async function loadLandingChart() {
|
||||
setChartMetal(selectedMetalType())
|
||||
await Promise.all([fetchPrices(true), loadMetalChartOffsets(true)])
|
||||
liveChartLoading.value = true
|
||||
try {
|
||||
await loadDailyHistory()
|
||||
if (liveChartRange.value !== 'DAILY') {
|
||||
await loadLiveRangeHistory(liveChartRange.value)
|
||||
} else if (liveRangeRawPoints.value.length < 2) {
|
||||
liveRangeRawPoints.value = dailyRawPoints.value
|
||||
}
|
||||
} finally {
|
||||
liveChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let mobileNavMq: MediaQueryList | null = null
|
||||
@@ -1293,10 +1461,14 @@ onMounted(() => {
|
||||
syncInstallAvailability()
|
||||
|
||||
setLiveProduct(activeProduct.value)
|
||||
void loadLandingPrice()
|
||||
void loadLandingMarketStatus()
|
||||
void loadLandingChart()
|
||||
pricePollTimer = setInterval(() => {
|
||||
void loadLandingPrice()
|
||||
void loadLandingMarketStatus()
|
||||
void loadDailyHistory()
|
||||
if (liveChartRange.value !== 'DAILY') {
|
||||
void loadLiveRangeHistory(liveChartRange.value)
|
||||
}
|
||||
}, PRICE_POLL_MS)
|
||||
|
||||
void nextTick(() => {
|
||||
|
||||
@@ -584,6 +584,13 @@ body.dlanding-menu-open {
|
||||
height: auto;
|
||||
margin-top: 14px;
|
||||
|
||||
&__plot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
&__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -600,6 +607,64 @@ body.dlanding-menu-open {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__crosshair {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(201, 138, 95, 0.45);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #c98a5f;
|
||||
box-shadow: 0 0 0 4px rgba(201, 138, 95, 0.2);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&__tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 118px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(26, 43, 71, 0.92);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-near-end {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1439,8 +1504,11 @@ body.dlanding-menu-open {
|
||||
}
|
||||
|
||||
&__chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: clamp(120px, 14vw, 160px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
&__chart-svg {
|
||||
@@ -1460,6 +1528,64 @@ body.dlanding-menu-open {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__chart-crosshair {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(201, 138, 95, 0.45);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__chart-dot {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: #c98a5f;
|
||||
box-shadow: 0 0 0 5px rgba(201, 138, 95, 0.2);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&__chart-tooltip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 132px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 12px;
|
||||
background: rgba(26, 43, 71, 0.94);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-near-end {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user