feat(lme): add color legend, dual Y-axis scaling for price vs stocks, and full Persian localization
This commit is contained in:
parent
380605daa4
commit
8ec51d92a8
|
|
@ -35,17 +35,17 @@ FIELD_MAP = {
|
|||
}
|
||||
|
||||
FA_NAMES = {
|
||||
"Copper": "مس (Copper)",
|
||||
"Tin": "قلع (Tin)",
|
||||
"Lead": "سرب (Lead)",
|
||||
"Zinc": "روی (Zinc)",
|
||||
"Aluminium": "آلومینیوم (Aluminium)",
|
||||
"Nickel": "نیکل (Nickel)",
|
||||
"WM_Cu_low": "مس کاتد آلمان (WM-Notiz کف)",
|
||||
"WM_Cu_high": "مس کاتد آلمان (WM-Notiz سقف)",
|
||||
"WI_Cu": "مس ویلند (Wieland Copper)",
|
||||
"Copper": "مس",
|
||||
"Tin": "قلع",
|
||||
"Lead": "سرب",
|
||||
"Zinc": "روی",
|
||||
"Aluminium": "آلومینیوم",
|
||||
"Nickel": "نیکل",
|
||||
"WM_Cu_low": "مس کاتد آلمان (کف قیمت)",
|
||||
"WM_Cu_high": "مس کاتد آلمان (سقف قیمت)",
|
||||
"WI_Cu": "مس ویلند (Wieland)",
|
||||
"ACI": "شاخص مس پیشرفته (ACI)",
|
||||
"MB_bronze_94_6": "برنز 94/6",
|
||||
"MB_bronze_94_6": "برنز ۹۴/۶",
|
||||
"MB_MS_58_1": "برنج MS 58 (مرحله ۱)",
|
||||
"MB_MS_58_2": "برنج MS 58 (مرحله ۲)",
|
||||
"MB_MS_63_37": "برنج MS 63/37",
|
||||
|
|
@ -57,6 +57,28 @@ FA_NAMES = {
|
|||
"Ag_processed": "نقره ساختهشده در اروپا",
|
||||
}
|
||||
|
||||
SERIES_FA_NAMES = {
|
||||
"LME Copper Cash-Settlement": "قیمت تسویه نقدی مس",
|
||||
"LME Copper stock": "موجودی انبار مس",
|
||||
"LME Aluminium Cash-Settlement": "قیمت تسویه نقدی آلومینیوم",
|
||||
"LME Aluminium stock": "موجودی انبار آلومینیوم",
|
||||
"LME Zinc Cash-Settlement": "قیمت تسویه نقدی روی",
|
||||
"LME Zinc stock": "موجودی انبار روی",
|
||||
"LME Nickel Cash-Settlement": "قیمت تسویه نقدی نیکل",
|
||||
"LME Nickel stock": "موجودی انبار نیکل",
|
||||
"LME Lead Cash-Settlement": "قیمت تسویه نقدی سرب",
|
||||
"LME Lead stock": "موجودی انبار سرب",
|
||||
"LME Tin Cash-Settlement": "قیمت تسویه نقدی قلع",
|
||||
"LME Tin stock": "موجودی انبار قلع",
|
||||
"WM_Cu_low": "کف قیمت مس کاتد آلمان",
|
||||
"WM_Cu_high": "سقف قیمت مس کاتد آلمان",
|
||||
"Gold London Fixing": "انس طلای لندن",
|
||||
"Fine Silver in Euro/kg": "نقره خالص در اروپا",
|
||||
"Gold in Euro/kg": "طلای شمش در اروپا",
|
||||
"Advanced Copper Index (ACI)": "شاخص مس پیشرفته (ACI)",
|
||||
"Wieland-Copper": "مس ویلند",
|
||||
}
|
||||
|
||||
def clean_num(val: str) -> float:
|
||||
if not val:
|
||||
return 0.0
|
||||
|
|
@ -98,7 +120,7 @@ def fetch_raw():
|
|||
"cash_str": cells[1],
|
||||
"three_months": clean_num(cells[2]),
|
||||
"three_months_str": cells[2],
|
||||
"unit": "USD/mt"
|
||||
"unit": "دلار بر تن"
|
||||
})
|
||||
|
||||
# Table 1: LME Stocks
|
||||
|
|
@ -118,7 +140,7 @@ def fetch_raw():
|
|||
"stocks_str": cells[1],
|
||||
"change": clean_num(cells[2]),
|
||||
"change_str": cells[2],
|
||||
"unit": "mt"
|
||||
"unit": "تن"
|
||||
})
|
||||
|
||||
# Combine Prices + Stocks by metal
|
||||
|
|
@ -142,7 +164,7 @@ def fetch_raw():
|
|||
"stocks_str": s_data.get("stocks_str", "-"),
|
||||
"stocks_change": s_data.get("change", 0),
|
||||
"stocks_change_str": s_data.get("change_str", "-"),
|
||||
"unit": "USD/mt"
|
||||
"unit": "دلار بر تن"
|
||||
})
|
||||
|
||||
# Table 2: FX Rates
|
||||
|
|
@ -171,7 +193,7 @@ def fetch_raw():
|
|||
"field": field,
|
||||
"price_str": cells[1],
|
||||
"prev_str": cells[2],
|
||||
"unit": "EUR/100kg"
|
||||
"unit": "یورو بر ۱۰۰ کیلوگرم"
|
||||
})
|
||||
|
||||
# Table 4: Precious Metals
|
||||
|
|
@ -218,12 +240,7 @@ def get_lme_data(ttl_seconds: int = 180):
|
|||
return _cache["data"]
|
||||
|
||||
def get_entire_diagram_data(field: str, ttl_seconds: int = 3600):
|
||||
"""
|
||||
Fetches the ENTIRE multi-year historical dataset for any symbol
|
||||
from the official XML marketdata API.
|
||||
"""
|
||||
field_clean = field.strip()
|
||||
# Resolve aliases (e.g. "copper" -> "LME_Cu_cash")
|
||||
field_clean = FIELD_MAP.get(field_clean.lower(), field_clean)
|
||||
|
||||
now = time.time()
|
||||
|
|
@ -245,17 +262,35 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600):
|
|||
lines = []
|
||||
for line in series_elem:
|
||||
raw_name = line.attrib.get('name', field_clean)
|
||||
fa_line_name = SERIES_FA_NAMES.get(raw_name, raw_name)
|
||||
color = line.attrib.get('lineColor', '#D02E00')
|
||||
unit = line.attrib.get('yUnit', '')
|
||||
raw_unit = line.attrib.get('yUnit', '')
|
||||
precision = int(line.attrib.get('yPrecision', '2'))
|
||||
|
||||
# Persian unit & type
|
||||
is_stock = "stock" in raw_name.lower() or "bestände" in raw_name.lower() or "انبار" in fa_line_name
|
||||
if is_stock:
|
||||
unit = "تن"
|
||||
series_type = "stocks"
|
||||
color = "#f97316" # vibrant orange for stocks
|
||||
elif "€" in raw_unit or "euro" in raw_unit.lower():
|
||||
unit = "یورو"
|
||||
series_type = "price"
|
||||
color = "#D02E00" # red for primary price
|
||||
elif "$" in raw_unit or "usd" in raw_unit.lower():
|
||||
unit = "دلار"
|
||||
series_type = "price"
|
||||
color = "#D02E00"
|
||||
else:
|
||||
unit = raw_unit
|
||||
series_type = "price"
|
||||
|
||||
pts = []
|
||||
for val in line:
|
||||
raw_x = val.attrib.get('x', '') # YYYY/MM/DD
|
||||
date_iso = raw_x.replace('/', '-')
|
||||
# Also format DD/MM/YYYY for tooltips as shown in user screenshot
|
||||
parts = date_iso.split('-')
|
||||
date_display = f"{parts[2]}/{parts[1]}/{parts[0]}" if len(parts) == 3 else date_iso
|
||||
date_display = f"{parts[0]}/{parts[1]}/{parts[2]}" if len(parts) == 3 else date_iso
|
||||
|
||||
y_str = val.attrib.get('y', '0').replace(',', '')
|
||||
try:
|
||||
|
|
@ -270,7 +305,9 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600):
|
|||
})
|
||||
|
||||
lines.append({
|
||||
"name": raw_name,
|
||||
"name": fa_line_name,
|
||||
"raw_name": raw_name,
|
||||
"series_type": series_type,
|
||||
"color": color,
|
||||
"unit": unit,
|
||||
"precision": precision,
|
||||
|
|
@ -290,6 +327,5 @@ def get_entire_diagram_data(field: str, ttl_seconds: int = 3600):
|
|||
|
||||
return result
|
||||
|
||||
# Backward compatibility alias
|
||||
def get_metal_history(metal_key: str, ttl_seconds: int = 300):
|
||||
return get_entire_diagram_data(metal_key, ttl_seconds)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import {
|
|||
Globe,
|
||||
X,
|
||||
LineChart as LineChartIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
Calendar,
|
||||
Sparkles
|
||||
Eye,
|
||||
EyeOff
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
YAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
Legend
|
||||
} from "recharts";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
|
|
@ -83,12 +81,14 @@ export interface LmeData {
|
|||
|
||||
export interface DiagramPoint {
|
||||
x: string; // YYYY-MM-DD
|
||||
date_display: string; // DD/MM/YYYY
|
||||
date_display: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DiagramLine {
|
||||
name: string;
|
||||
raw_name: string;
|
||||
series_type: "price" | "stocks";
|
||||
color: string;
|
||||
unit: string;
|
||||
precision: number;
|
||||
|
|
@ -105,21 +105,6 @@ export interface DiagramResponse {
|
|||
|
||||
type Timeframe = "3M" | "6M" | "1Y" | "2Y" | "ENTIRE";
|
||||
|
||||
const METAL_COLORS: Record<string, string> = {
|
||||
Copper: "#D02E00",
|
||||
WM_Cu_low: "#D02E00",
|
||||
WM_Cu_high: "#D02E00",
|
||||
Aluminium: "#0ea5e9",
|
||||
Zinc: "#10b981",
|
||||
Nickel: "#8b5cf6",
|
||||
Lead: "#64748b",
|
||||
Tin: "#06b6d4",
|
||||
ACI: "#f59e0b",
|
||||
USD_ozt_London: "#eab308",
|
||||
Au: "#eab308",
|
||||
Ag: "#94a3b8",
|
||||
};
|
||||
|
||||
export function LmeView() {
|
||||
const [data, setData] = useState<LmeData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -140,6 +125,10 @@ export function LmeView() {
|
|||
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
|
||||
const [offsetIndex, setOffsetIndex] = useState<number>(0);
|
||||
|
||||
// Visibility toggles for dual-series charts
|
||||
const [showPrice, setShowPrice] = useState<boolean>(true);
|
||||
const [showStocks, setShowStocks] = useState<boolean>(true);
|
||||
|
||||
// Load overview catalog
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
|
@ -169,6 +158,8 @@ export function LmeView() {
|
|||
let alive = true;
|
||||
setDiagramLoading(true);
|
||||
setOffsetIndex(0);
|
||||
setShowPrice(true);
|
||||
setShowStocks(true);
|
||||
|
||||
fetch(`${API}/api/lme/diagram?field=${encodeURIComponent(selectedSymbol.field)}`, {
|
||||
credentials: "include"
|
||||
|
|
@ -199,11 +190,20 @@ export function LmeView() {
|
|||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Line separation: price line vs stock line
|
||||
const priceLine = useMemo(() => {
|
||||
return diagramData?.lines?.find((l) => l.series_type === "price") || diagramData?.lines?.[0];
|
||||
}, [diagramData]);
|
||||
|
||||
const stockLine = useMemo(() => {
|
||||
return diagramData?.lines?.find((l) => l.series_type === "stocks");
|
||||
}, [diagramData]);
|
||||
|
||||
// Filter main series by timeframe & offset
|
||||
const chartData = useMemo(() => {
|
||||
if (!diagramData?.lines?.length) return [];
|
||||
const mainLine = diagramData.lines[0];
|
||||
const total = mainLine.points.length;
|
||||
const baseLine = priceLine || diagramData.lines[0];
|
||||
const total = baseLine.points.length;
|
||||
if (!total) return [];
|
||||
|
||||
let sliceCount = total;
|
||||
|
|
@ -213,38 +213,36 @@ export function LmeView() {
|
|||
else if (timeframe === "2Y") sliceCount = 520;
|
||||
else if (timeframe === "ENTIRE") sliceCount = total;
|
||||
|
||||
// Windowing with offsetIndex
|
||||
const maxOffset = Math.max(0, total - sliceCount);
|
||||
const effectiveOffset = Math.min(offsetIndex, maxOffset);
|
||||
const end = total - effectiveOffset;
|
||||
const start = Math.max(0, end - sliceCount);
|
||||
|
||||
const windowPoints = mainLine.points.slice(start, end);
|
||||
const windowPoints = baseLine.points.slice(start, end);
|
||||
|
||||
// Merge secondary lines (e.g. stock line in LME_Cu_cash)
|
||||
if (diagramData.lines.length > 1) {
|
||||
const secondLine = diagramData.lines[1];
|
||||
const secondMap = new Map(secondLine.points.map((p) => [p.x, p.y]));
|
||||
if (stockLine) {
|
||||
const stockMap = new Map(stockLine.points.map((p) => [p.x, p.y]));
|
||||
return windowPoints.map((p) => ({
|
||||
x: p.x,
|
||||
date_display: p.date_display,
|
||||
[mainLine.name]: p.y,
|
||||
[secondLine.name]: secondMap.get(p.x) ?? null,
|
||||
priceVal: p.y,
|
||||
stockVal: stockMap.get(p.x) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
return windowPoints.map((p) => ({
|
||||
x: p.x,
|
||||
date_display: p.date_display,
|
||||
[mainLine.name]: p.y,
|
||||
priceVal: p.y,
|
||||
}));
|
||||
}, [diagramData, timeframe, offsetIndex]);
|
||||
}, [diagramData, priceLine, stockLine, timeframe, offsetIndex]);
|
||||
|
||||
// Statistics calculation for the active window
|
||||
const stats = useMemo(() => {
|
||||
if (!chartData.length || !diagramData?.lines?.[0]) return null;
|
||||
const mainName = diagramData.lines[0].name;
|
||||
const values = chartData.map((d: any) => d[mainName]).filter((v): v is number => typeof v === "number" && v > 0);
|
||||
if (!chartData.length) return null;
|
||||
const values = chartData
|
||||
.map((d) => d.priceVal)
|
||||
.filter((v): v is number => typeof v === "number" && v > 0);
|
||||
if (!values.length) return null;
|
||||
|
||||
const max = Math.max(...values);
|
||||
|
|
@ -256,14 +254,14 @@ export function LmeView() {
|
|||
const pct = first > 0 ? (diff / first) * 100 : 0;
|
||||
|
||||
return { max, min, avg, diff, pct, first, last };
|
||||
}, [chartData, diagramData]);
|
||||
}, [chartData]);
|
||||
|
||||
const canStepPrev = useMemo(() => {
|
||||
if (timeframe === "ENTIRE") return false;
|
||||
const total = diagramData?.lines?.[0]?.points?.length || 0;
|
||||
const total = (priceLine || diagramData?.lines?.[0])?.points?.length || 0;
|
||||
const count = timeframe === "3M" ? 65 : timeframe === "6M" ? 130 : timeframe === "1Y" ? 260 : 520;
|
||||
return offsetIndex + count < total;
|
||||
}, [timeframe, diagramData, offsetIndex]);
|
||||
}, [timeframe, priceLine, diagramData, offsetIndex]);
|
||||
|
||||
const canStepNext = useMemo(() => {
|
||||
return offsetIndex > 0 && timeframe !== "ENTIRE";
|
||||
|
|
@ -319,7 +317,7 @@ export function LmeView() {
|
|||
|
||||
{data?.date && (
|
||||
<div className="self-start sm:self-auto rounded-xl border border-border/80 bg-card/60 px-3.5 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
تاریخ: <span className="font-semibold text-foreground">{data.date}</span>
|
||||
تاریخ بازار: <span className="font-semibold text-foreground">{data.date}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -389,7 +387,7 @@ export function LmeView() {
|
|||
field: m.field,
|
||||
title: m.metal,
|
||||
name_fa: m.name_fa,
|
||||
unit: "USD/mt",
|
||||
unit: "دلار بر تن",
|
||||
currentPrice: m.cash_str,
|
||||
})
|
||||
}
|
||||
|
|
@ -400,7 +398,7 @@ export function LmeView() {
|
|||
<div className="text-base font-bold text-foreground group-hover:text-primary transition-colors">
|
||||
{m.name_fa}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono mt-0.5">{m.metal} • USD/mt</div>
|
||||
<div className="text-xs text-muted-foreground font-mono mt-0.5">{m.metal} • دلار بر تن</div>
|
||||
</div>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-lg bg-primary/10 text-primary font-medium flex items-center gap-1 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
||||
<span>نمودار کامل</span>
|
||||
|
|
@ -410,13 +408,13 @@ export function LmeView() {
|
|||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 pt-3 border-t border-border/40">
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">تسویه نقدی (Cash)</div>
|
||||
<div className="text-[11px] text-muted-foreground">تسویه نقدی</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
${m.cash_str}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">۳ ماهه (3-Month)</div>
|
||||
<div className="text-[11px] text-muted-foreground">قرارداد ۳ ماهه</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
${m.three_months_str}
|
||||
</div>
|
||||
|
|
@ -479,7 +477,7 @@ export function LmeView() {
|
|||
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/40">
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">آخرین مظنه (EUR/100kg)</div>
|
||||
<div className="text-[11px] text-muted-foreground">آخرین مظنه (یورو بر ۱۰۰ کیلو)</div>
|
||||
<div className="text-xl font-bold font-mono text-foreground mt-0.5">
|
||||
{s.price_str} €
|
||||
</div>
|
||||
|
|
@ -565,15 +563,13 @@ export function LmeView() {
|
|||
{selectedSymbol && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/75 backdrop-blur-md animate-in fade-in duration-200">
|
||||
<div
|
||||
className="relative w-full max-w-5xl max-h-[92vh] overflow-y-auto rounded-3xl border border-border/80 bg-background/95 p-5 sm:p-7 shadow-2xl backdrop-blur-2xl space-y-6"
|
||||
className="relative w-full max-w-5xl max-h-[92vh] overflow-y-auto rounded-3xl border border-border/80 bg-background/95 p-5 sm:p-7 shadow-2xl backdrop-blur-2xl space-y-5"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-border/60 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl grid place-items-center border shrink-0 bg-primary/10 border-primary/30"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-2xl grid place-items-center border shrink-0 bg-primary/10 border-primary/30">
|
||||
<LineChartIcon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -586,7 +582,7 @@ export function LmeView() {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
نمودار کامل تاریخی • {diagramData?.total_points ? `${diagramData.total_points.toLocaleString()} روز معاملاتی ثبتشده` : "در حال بارگذاری..."}
|
||||
نمودار کامل تاریخی • {diagramData?.total_points ? `${diagramData.total_points.toLocaleString()} روز کاری ثبتشده` : "در حال بارگذاری..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -600,29 +596,29 @@ export function LmeView() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stat Pill */}
|
||||
{/* Quick Stat Pills (100% Persian) */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground">آخرین قیمت (Latest)</div>
|
||||
<div className="text-[11px] text-muted-foreground">آخرین قیمت</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.last.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""}
|
||||
{stats.last.toLocaleString()} {priceLine?.unit || ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground">بالاترین دوره (Max)</div>
|
||||
<div className="text-[11px] text-muted-foreground">بالاترین قیمت دوره</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.max.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""}
|
||||
{stats.max.toLocaleString()} {priceLine?.unit || ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground">پایینترین دوره (Min)</div>
|
||||
<div className="text-[11px] text-muted-foreground">پایینترین قیمت دوره</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
{stats.min.toLocaleString()} {diagramData?.lines?.[0]?.unit || ""}
|
||||
{stats.min.toLocaleString()} {priceLine?.unit || ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground">تغییر بازه انتخابی</div>
|
||||
<div className="text-[11px] text-muted-foreground">تغییر در بازه انتخابی</div>
|
||||
<div
|
||||
className={`text-lg font-bold font-mono mt-0.5 ${
|
||||
stats.diff >= 0 ? "text-emerald-500" : "text-rose-500"
|
||||
|
|
@ -634,12 +630,59 @@ export function LmeView() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/40 p-4 sm:p-5">
|
||||
{/* Chart Container */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/40 p-4 sm:p-5 space-y-4">
|
||||
{/* LEGEND & TOGGLE BAR (راهنمای خطوط و رنگها) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/40 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-foreground">راهنمای رنگها:</span>
|
||||
|
||||
{/* Price Line Toggle */}
|
||||
{priceLine && (
|
||||
<button
|
||||
onClick={() => setShowPrice((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-medium border transition ${
|
||||
showPrice
|
||||
? "bg-[#D02E00]/15 border-[#D02E00]/40 text-[#D02E00]"
|
||||
: "bg-muted/40 border-border/60 text-muted-foreground opacity-50"
|
||||
}`}
|
||||
title="کلیک برای نمایش/مخفیسازی خط قیمت"
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#D02E00] shrink-0" />
|
||||
<span>{priceLine.name} ({priceLine.unit})</span>
|
||||
{showPrice ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Stock Line Toggle (if exists) */}
|
||||
{stockLine && (
|
||||
<button
|
||||
onClick={() => setShowStocks((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-medium border transition ${
|
||||
showStocks
|
||||
? "bg-[#f97316]/15 border-[#f97316]/40 text-[#f97316]"
|
||||
: "bg-muted/40 border-border/60 text-muted-foreground opacity-50"
|
||||
}`}
|
||||
title="کلیک برای نمایش/مخفیسازی خط موجودی انبار"
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#f97316] shrink-0" />
|
||||
<span>{stockLine.name} ({stockLine.unit})</span>
|
||||
{showStocks ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stockLine && (
|
||||
<span className="text-[11px] text-muted-foreground font-medium hidden sm:inline">
|
||||
* محور راست: قیمت ({priceLine?.unit}) • محور چپ: موجودی انبار ({stockLine?.unit})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diagramLoading ? (
|
||||
<div className="h-80 flex flex-col items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span>در حال بارگذاری دیتای کامل تاریخی (Entire Data)...</span>
|
||||
<span>در حال بارگذاری تمام دادههای تاریخی...</span>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-80 flex items-center justify-center text-muted-foreground text-sm">
|
||||
|
|
@ -648,14 +691,20 @@ export function LmeView() {
|
|||
) : (
|
||||
<div className="h-80 sm:h-96 w-full" dir="ltr">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 15, left: 10, bottom: 0 }}>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 15, left: 15, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient id="chartGradientPrice" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#D02E00" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#D02E00" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="chartGradientStocks" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.18} />
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="2 2" opacity={0.15} vertical={true} />
|
||||
|
||||
{/* X Axis */}
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
|
|
@ -666,64 +715,112 @@ export function LmeView() {
|
|||
}}
|
||||
minTickGap={40}
|
||||
/>
|
||||
|
||||
{/* Right Y-Axis for Price */}
|
||||
<YAxis
|
||||
domain={["auto", "auto"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(v) => v.toLocaleString()}
|
||||
yAxisId="price"
|
||||
orientation="right"
|
||||
domain={["auto", "auto"]}
|
||||
tick={{ fontSize: 10, fill: "#D02E00" }}
|
||||
tickFormatter={(v) => v.toLocaleString()}
|
||||
hide={!showPrice}
|
||||
/>
|
||||
{/* Tooltip styled exactly like Westmetall screenshot */}
|
||||
|
||||
{/* Left Y-Axis for Warehouse Stocks (Scaled properly!) */}
|
||||
{stockLine && (
|
||||
<YAxis
|
||||
yAxisId="stocks"
|
||||
orientation="left"
|
||||
domain={["auto", "auto"]}
|
||||
tick={{ fontSize: 10, fill: "#f97316" }}
|
||||
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
|
||||
hide={!showStocks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Persian Tooltip */}
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const pt = payload[0];
|
||||
const ptData = pt.payload;
|
||||
const lineName = pt.name;
|
||||
const val = pt.value;
|
||||
const unit = diagramData?.lines?.[0]?.unit || "";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-600/40 bg-[#D02E00] px-3.5 py-2.5 text-white shadow-xl">
|
||||
<div className="text-[11px] font-semibold tracking-wider font-mono opacity-90">
|
||||
{lineName}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono opacity-80 mt-0.5">
|
||||
{ptData.date_display || label}
|
||||
</div>
|
||||
<div className="text-sm font-bold font-mono mt-1">
|
||||
{typeof val === "number" ? val.toLocaleString(undefined, { minimumFractionDigits: 2 }) : val} {unit}
|
||||
<div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1.5 min-w-[170px]" dir="rtl">
|
||||
<div className="text-[11px] font-medium text-muted-foreground border-b border-border/40 pb-1 font-mono">
|
||||
تاریخ: {ptData.date_display || label}
|
||||
</div>
|
||||
|
||||
{showPrice && ptData.priceVal !== undefined && (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#D02E00]" />
|
||||
<span>{priceLine?.name || "قیمت"}:</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-[#D02E00]">
|
||||
{Number(ptData.priceVal).toLocaleString(undefined, { minimumFractionDigits: 2 })} {priceLine?.unit || ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showStocks && ptData.stockVal !== null && ptData.stockVal !== undefined && (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#f97316]" />
|
||||
<span>{stockLine?.name || "موجودی انبار"}:</span>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-[#f97316]">
|
||||
{Number(ptData.stockVal).toLocaleString()} {stockLine?.unit || "تن"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{diagramData?.lines?.map((line, idx) => (
|
||||
|
||||
{/* Price Curve */}
|
||||
{showPrice && (
|
||||
<Area
|
||||
key={line.name}
|
||||
yAxisId="price"
|
||||
type="monotone"
|
||||
dataKey={line.name}
|
||||
stroke={line.color || "#D02E00"}
|
||||
strokeWidth={1.8}
|
||||
dataKey="priceVal"
|
||||
name={priceLine?.name || "قیمت"}
|
||||
stroke="#D02E00"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill={idx === 0 ? "url(#chartGradient)" : "transparent"}
|
||||
fill="url(#chartGradientPrice)"
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Stock Curve (Mapped to Left Y-Axis!) */}
|
||||
{showStocks && stockLine && (
|
||||
<Line
|
||||
yAxisId="stocks"
|
||||
type="monotone"
|
||||
dataKey="stockVal"
|
||||
name={stockLine.name}
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TIMEFRAME BAR — Exactly matching user's screenshot */}
|
||||
<div className="mt-4 pt-4 border-t border-border/40 flex flex-wrap items-center justify-center gap-1.5" dir="ltr">
|
||||
{/* TIMEFRAME BAR (100% Persian) */}
|
||||
<div className="pt-3 border-t border-border/40 flex flex-wrap items-center justify-center gap-2" dir="rtl">
|
||||
{/* Step Backwards */}
|
||||
<button
|
||||
onClick={() => stepWindow("prev")}
|
||||
disabled={!canStepPrev}
|
||||
className="rounded-lg border border-border/80 bg-background/80 px-2.5 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="rounded-xl border border-border/80 bg-background/80 px-3 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="نمایش دادههای قدیمیتر"
|
||||
>
|
||||
<
|
||||
> قبلی
|
||||
</button>
|
||||
|
||||
{/* 3 months */}
|
||||
|
|
@ -732,13 +829,13 @@ export function LmeView() {
|
|||
setTimeframe("3M");
|
||||
setOffsetIndex(0);
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
|
||||
className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "3M"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
3 months
|
||||
۳ ماه
|
||||
</button>
|
||||
|
||||
{/* 6 months */}
|
||||
|
|
@ -747,13 +844,13 @@ export function LmeView() {
|
|||
setTimeframe("6M");
|
||||
setOffsetIndex(0);
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
|
||||
className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "6M"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
6 months
|
||||
۶ ماه
|
||||
</button>
|
||||
|
||||
{/* 1 year */}
|
||||
|
|
@ -762,13 +859,13 @@ export function LmeView() {
|
|||
setTimeframe("1Y");
|
||||
setOffsetIndex(0);
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
|
||||
className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "1Y"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
1 year
|
||||
۱ سال
|
||||
</button>
|
||||
|
||||
{/* 2 year */}
|
||||
|
|
@ -777,13 +874,13 @@ export function LmeView() {
|
|||
setTimeframe("2Y");
|
||||
setOffsetIndex(0);
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${
|
||||
className={`rounded-xl border px-3.5 py-1.5 text-xs font-semibold transition ${
|
||||
timeframe === "2Y"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
2 year
|
||||
۲ سال
|
||||
</button>
|
||||
|
||||
{/* entire data (ACTIVE HIGHLIGHT) */}
|
||||
|
|
@ -792,23 +889,23 @@ export function LmeView() {
|
|||
setTimeframe("ENTIRE");
|
||||
setOffsetIndex(0);
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
|
||||
className={`rounded-xl border px-4 py-1.5 text-xs font-bold transition ${
|
||||
timeframe === "ENTIRE"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-md ring-2 ring-primary/20"
|
||||
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
entire data
|
||||
کل دادهها (تاریخچه کامل)
|
||||
</button>
|
||||
|
||||
{/* Step Forwards */}
|
||||
<button
|
||||
onClick={() => stepWindow("next")}
|
||||
disabled={!canStepNext}
|
||||
className="rounded-lg border border-border/80 bg-background/80 px-2.5 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="rounded-xl border border-border/80 bg-background/80 px-3 py-1.5 text-xs text-foreground transition hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="نمایش دادههای جدیدتر"
|
||||
>
|
||||
>
|
||||
بعدی <
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue