type ChartPoint = [number, number] export type DayPriceRange = { high: number; low: number } export type DayAggregateMeta = { high: number low: number avg: number } export type KatodChartPointMeta = { kind: 'high' | 'low' | 'avg' dayHigh: number dayLow: number } export type KatodMesDisplayResult = { data: ChartPoint[] /** قیمت واقعی (ریال) متناظر هر نقطه نمایشی — برای tooltip */ tooltipPrices: number[] /** متادیتای تجمیع روزانه — ماه */ dayMeta?: DayAggregateMeta[] /** متادیتای هر نقطه — هفته (بالا/پایین روز) */ pointMeta?: KatodChartPointMeta[] } /** مرکز مختصات نمودار — فقط برای رسم؛ tooltip قیمت واقعی را نشان می‌دهد */ const KATOD_VISUAL_CENTER = 50_000_000 /** هر ریال انحراف از میانه ≈ این مقدار در محور Y */ const KATOD_DEVIATION_FACTOR = 200 /** نمودار boursecu وقتی metal_type = کاتد مس */ export function shouldAmplifyKatodMesChart(options: { metalTypeSetting?: string | number | null }): boolean { const metal = String(options.metalTypeSetting ?? '').trim() return metal === 'کاتد مس' } /** ۲۴ ساعت: داده خام API — بدون تغییر بصری */ export function shouldUseKatodRawDailyChart(timeline: string): boolean { return timeline === 'DAILY' } /** هفته/ماه: تجمیع + بزرگ‌نمایی بصری */ export function shouldUseKatodVisualChartTransform(timeline: string): boolean { return timeline === 'WEEKLY' || timeline === 'MONTHLY' } const TEHRAN_TIMEZONE = 'Asia/Tehran' function getTehranDayKey(timestampMs: number): string { return new Intl.DateTimeFormat('en-CA', { timeZone: TEHRAN_TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date(timestampMs)) } type DayBucket = { values: number[]; timestamps: number[] } function groupKatodMesPointsByDay(points: ChartPoint[]): Map { const buckets = new Map() for (const [timestamp, value] of points) { const dayKey = getTehranDayKey(timestamp) const bucket = buckets.get(dayKey) ?? { values: [], timestamps: [] } bucket.values.push(value) bucket.timestamps.push(timestamp) buckets.set(dayKey, bucket) } return buckets } function findTimestampForValue(bucket: DayBucket, target: number): number { let match = bucket.timestamps[0] for (let index = 0; index < bucket.values.length; index += 1) { if (bucket.values[index] === target) { match = bucket.timestamps[index] } } return match } /** هر روز یک نقطه: میانگین بالاترین و پایین‌ترین — برای ماه */ export function aggregateKatodMesPointsByDay(points: ChartPoint[]): { data: ChartPoint[] dayMeta: DayAggregateMeta[] } { if (points.length < 2) { return { data: points, dayMeta: [] } } const buckets = groupKatodMesPointsByDay(points) const sortedDayKeys = [...buckets.keys()].sort() const data: ChartPoint[] = [] const dayMeta: DayAggregateMeta[] = [] for (const dayKey of sortedDayKeys) { const bucket = buckets.get(dayKey)! const high = Math.max(...bucket.values) const low = Math.min(...bucket.values) const avg = Math.round((high + low) / 2) const timestamp = Math.max(...bucket.timestamps) data.push([timestamp, avg]) dayMeta.push({ high, low, avg }) } return { data, dayMeta } } /** هر روز دو نقطه: بالاترین و پایین‌ترین به ترتیب زمان — برای هفته */ export function aggregateKatodMesWeeklyHighLow(points: ChartPoint[]): { data: ChartPoint[] tooltipPrices: number[] pointMeta: KatodChartPointMeta[] } { if (points.length < 2) { return { data: points, tooltipPrices: points.map((point) => point[1]), pointMeta: [], } } const buckets = groupKatodMesPointsByDay(points) const sortedDayKeys = [...buckets.keys()].sort() const data: ChartPoint[] = [] const tooltipPrices: number[] = [] const pointMeta: KatodChartPointMeta[] = [] for (const dayKey of sortedDayKeys) { const bucket = buckets.get(dayKey)! const high = Math.max(...bucket.values) const low = Math.min(...bucket.values) const dayMeta = { dayHigh: high, dayLow: low } if (high === low) { const timestamp = Math.max(...bucket.timestamps) data.push([timestamp, high]) tooltipPrices.push(high) pointMeta.push({ kind: 'avg', ...dayMeta }) continue } const highTimestamp = findTimestampForValue(bucket, high) const lowTimestamp = findTimestampForValue(bucket, low) const dayPoints: ChartPoint[] = [ [highTimestamp, high], [lowTimestamp, low], ].sort((left, right) => left[0] - right[0]) for (const [timestamp, value] of dayPoints) { data.push([timestamp, value]) tooltipPrices.push(value) pointMeta.push({ kind: value === high ? 'high' : 'low', ...dayMeta, }) } } return { data, tooltipPrices, pointMeta } } /** آماده‌سازی نقاط نمودار کاتد مس */ export function prepareKatodMesChartPoints( points: ChartPoint[], options: { timeline: string dayRange?: DayPriceRange } ): KatodMesDisplayResult { if (shouldUseKatodRawDailyChart(options.timeline)) { return { data: points, tooltipPrices: points.map((point) => point[1]), } } if (options.timeline === 'WEEKLY') { const weekly = aggregateKatodMesWeeklyHighLow(points) const display = buildKatodMesDisplaySeries(weekly.data) return { ...display, tooltipPrices: weekly.tooltipPrices, pointMeta: weekly.pointMeta, } } if (options.timeline === 'MONTHLY') { const monthly = aggregateKatodMesPointsByDay(points) const display = buildKatodMesDisplaySeries(monthly.data) return { ...display, dayMeta: monthly.dayMeta, } } return buildKatodMesDisplaySeries(points, options.dayRange) } function median(values: number[]): number { const sorted = [...values].sort((left, right) => left - right) const middle = Math.floor(sorted.length / 2) if (sorted.length % 2 === 0) { return (sorted[middle - 1] + sorted[middle]) / 2 } return sorted[middle] } function toVisualY(value: number, anchor: number): number { return Math.round(KATOD_VISUAL_CENTER + (value - anchor) * KATOD_DEVIATION_FACTOR) } function isKatodChartTooFlat(values: number[], dayRange?: DayPriceRange): boolean { const min = Math.min(...values) const max = Math.max(...values) const mid = (min + max) / 2 || min const span = max - min if (span >= mid * 0.0025) { return false } if (dayRange?.high && dayRange?.low && dayRange.high > dayRange.low) { return dayRange.high - dayRange.low > span * 2 } return span < mid * 0.0015 } /** وقتی API فقط چند قیمت تکراری دارد — منحنی از بالاترین/پایین‌ترین واقعی امروز */ function buildKatodMesNarrativeSeries(points: ChartPoint[], dayRange: DayPriceRange): KatodMesDisplayResult { const start = points[0][0] const end = points[points.length - 1][0] const duration = Math.max(end - start, 1) const lastValue = points[points.length - 1][1] const { high, low } = dayRange const anchor = (high + low) / 2 const lastPrice = lastValue > 0 ? lastValue : anchor const anchors: ChartPoint[] = [ [start, high], [start + duration * 0.18, high], [start + duration * 0.32, low], [start + duration * 0.48, low + (high - low) * 0.18], [start + duration * 0.62, high], [start + duration * 0.78, low + (high - low) * 0.35], [end, lastPrice], ] return { data: anchors.map(([timestamp, value]) => [timestamp, toVisualY(value, anchor)]), tooltipPrices: anchors.map(([, value]) => value), } } /** بزرگ‌نمایی بصری — فقط هفته/ماه */ export function buildKatodMesDisplaySeries( points: ChartPoint[], dayRange?: DayPriceRange ): KatodMesDisplayResult { if (points.length < 2) { return { data: points, tooltipPrices: points.map((point) => point[1]) } } const values = points.map((point) => point[1]) if (isKatodChartTooFlat(values, dayRange) && dayRange?.high && dayRange?.low) { return buildKatodMesNarrativeSeries(points, dayRange) } const anchor = median(values) return { data: points.map(([timestamp, value]) => [timestamp, toVisualY(value, anchor)]), tooltipPrices: values, } } export function computeKatodMesVisualYBounds(prices: number[]): { min?: number; max?: number } { const values = prices.filter((value) => Number.isFinite(value)) if (values.length < 2) { return {} } const dataMin = Math.min(...values) const dataMax = Math.max(...values) const padding = Math.max((dataMax - dataMin) * 0.06, 500_000) return { min: Math.floor(dataMin - padding), max: Math.ceil(dataMax + padding), } }