Files
boursecu_user/src/utils/chartPriceAxis.ts
T
safavimohadeseh78andCursor 59cceaf581 Make boursecu branch boursecu-only and simplify app loading.
Remove multi-tenant branches and default views, show logo-only boot and splash screens, and polish boursecu transactions tab styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:35:42 +02:00

307 lines
8.9 KiB
TypeScript

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<string, DayBucket> {
const buckets = new Map<string, DayBucket>()
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),
}
}