"use client"; import { useState, useMemo } from "react"; import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useLanguage } from "@/contexts/LanguageContext"; import type { PriceData, PriceStats } from "@/lib/types"; interface PriceChartProps { priceData: PriceData[]; priceStats: PriceStats; ticker: string; } // Heikin Ashi data structure interface HeikinAshiData extends PriceData { HA_Open: number; HA_Close: number; HA_High: number; HA_Low: number; } // Calculate Heikin Ashi values from regular OHLC data function calculateHeikinAshi(data: PriceData[]): HeikinAshiData[] { const haData: HeikinAshiData[] = []; for (let i = 0; i < data.length; i++) { const current = data[i]; const { Open, High, Low, Close } = current; const adjClose = current["Adj Close"] ?? Close; // HA Close = (Open + High + Low + Close) / 4 const HA_Close = (Open + High + Low + adjClose) / 4; // HA Open = (previous HA Open + previous HA Close) / 2 let HA_Open: number; if (i === 0) { // For the first candle, use regular Open HA_Open = Open; } else { HA_Open = (haData[i - 1].HA_Open + haData[i - 1].HA_Close) / 2; } // HA High = max(High, HA Open, HA Close) const HA_High = Math.max(High, HA_Open, HA_Close); // HA Low = min(Low, HA Open, HA Close) const HA_Low = Math.min(Low, HA_Open, HA_Close); haData.push({ ...current, HA_Open, HA_Close, HA_High, HA_Low, }); } return haData; } export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) { const { t, locale } = useLanguage(); const [chartType, setChartType] = useState<"line" | "candlestick">("line"); // Calculate Heikin Ashi data const heikinAshiData = useMemo(() => calculateHeikinAshi(priceData), [priceData]); // 格式化數字 const formatNumber = (num: number) => { return num.toLocaleString(locale === 'en' ? 'en-US' : 'zh-TW', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; // 格式化日期(只顯示月-日) const formatDate = (dateStr: string) => { const date = new Date(dateStr); return `${date.getMonth() + 1}/${date.getDate()}`; }; // Get the close field to use (prefer Adj Close) const getCloseValue = (data: PriceData) => { return data["Adj Close"] ?? data.Close; }; // 計算價格範圍用於標準化 const priceValues = heikinAshiData.flatMap(d => [d.HA_High, d.HA_Low]); const minPrice = Math.min(...priceValues); const maxPrice = Math.max(...priceValues); const _priceRange = maxPrice - minPrice; // Localized labels const labels = { priceTitle: t.results.priceSection.title, growth: t.results.priceSection.growth, duration: t.results.priceSection.duration, days: t.results.priceSection.days, startPrice: t.results.priceSection.startPrice, endPrice: t.results.priceSection.endPrice, lineChart: t.results.priceSection.lineChart, candlestick: t.results.priceSection.candlestick, volume: t.results.volumeChart, adjClosePrice: locale === 'en' ? 'Adj Close' : '調整後收盤價', closePrice: locale === 'en' ? 'Close' : '收盤價', date: locale === 'en' ? 'Date' : '日期', open: locale === 'en' ? 'Open' : '開', close: locale === 'en' ? 'Close' : '收', adjClose: locale === 'en' ? 'Adj Close' : '調整收', high: locale === 'en' ? 'High' : '高', low: locale === 'en' ? 'Low' : '低', up: locale === 'en' ? '↑ Up' : '↑ 上漲', down: locale === 'en' ? '↓ Down' : '↓ 下跌', noChange: locale === 'en' ? '→ No Change' : '→ 無變化', }; return (
{ticker} {labels.priceTitle} setChartType(v as "line" | "candlestick")}> {labels.lineChart} {labels.candlestick}
{/* 統計資訊 */}

{labels.growth}

= 0 ? 'text-green-600' : 'text-red-600'}`}> {priceStats.growth_rate >= 0 ? '+' : ''}{priceStats.growth_rate}%

{labels.duration}

{priceStats.duration_days} {labels.days}

{labels.startPrice}

${formatNumber(priceStats.start_price)}

{priceStats.start_date}

{labels.endPrice}

${formatNumber(priceStats.end_price)}

{priceStats.end_date}

{/* 價格圖表 */}

{labels.priceTitle}

{chartType === "line" ? ( `$${value.toFixed(0)}`} /> { if (active && payload && payload.length) { const data = payload[0].payload as PriceData; const adjClose = data["Adj Close"]; const close = data.Close; const hasAdjClose = adjClose !== undefined && adjClose !== null; return (

{labels.date}: {data.Date}

{hasAdjClose && (

{labels.adjClosePrice}: ${formatNumber(adjClose)}

)}

{labels.closePrice}: ${formatNumber(close)}

); } return null; }} /> getCloseValue(data)} stroke="#93c5fd" strokeWidth={2} name={labels.adjClosePrice} dot={false} />
) : ( // 平均K線圖(Heikin Ashi) `$${value.toFixed(0)}`} /> { if (active && payload && payload.length) { const data = payload[0].payload as HeikinAshiData; const isUp = data.HA_Close > data.HA_Open; const isDown = data.HA_Close < data.HA_Open; const adjClose = data["Adj Close"]; const close = data.Close; const hasAdjClose = adjClose !== undefined && adjClose !== null; // Trend color coding for the direction indicator const trendColor = isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-gray-600'; const direction = isUp ? labels.up : isDown ? labels.down : labels.noChange; return (

{labels.date}: {data.Date}

{/* 原始價格 - Adj Close 為主 */}

{locale === 'en' ? 'Actual Prices' : '實際價格'}

{hasAdjClose && (

{labels.adjClosePrice}: ${formatNumber(adjClose)}

)}

{labels.closePrice}: ${formatNumber(close)}

{/* Heikin Ashi 值 */}

Heikin Ashi

{labels.open}: ${formatNumber(data.HA_Open)}

{labels.close}: ${formatNumber(data.HA_Close)}

{labels.high}: ${formatNumber(data.HA_High)}

{labels.low}: ${formatNumber(data.HA_Low)}

{direction} ${formatNumber(Math.abs(data.HA_Close - data.HA_Open))}

); } return null; }} /> {/* 使用自定義 shape 來繪製平均蠟燭 */} } />
)}
{/* 交易量圖表 */}

{labels.volume}

{ if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; return value.toString(); }} /> [ value !== undefined ? value.toLocaleString() : '-', labels.volume ]} labelFormatter={(label) => `${labels.date}: ${label}`} />
); } // 自定義平均蠟燭圖形狀組件(Heikin Ashi) interface HeikinAshiCandlestickShapeProps { x?: number; y?: number; width?: number; height?: number; payload?: HeikinAshiData; minPrice: number; maxPrice: number; } const HeikinAshiCandlestickShape: React.FC = (props) => { const { x = 0, y = 0, width = 0, height = 0, payload, minPrice, maxPrice } = props; if (!payload) return null; const { HA_Open, HA_Close, HA_High, HA_Low } = payload; const isUp = HA_Close > HA_Open; const isDown = HA_Close < HA_Open; const _isNeutral = HA_Close === HA_Open; // Color coding: green for up, red for down, gray for neutral let fillColor: string; let strokeColor: string; if (isUp) { fillColor = '#86efac'; // soft pastel green strokeColor = '#22c55e'; // darker green } else if (isDown) { fillColor = '#fca5a5'; // soft pastel pink/red strokeColor = '#ef4444'; // darker red } else { fillColor = '#d1d5db'; // soft gray strokeColor = '#6b7280'; // darker gray } // 計算實際的 Y 坐標位置 const priceRange = maxPrice - minPrice; const pixelsPerPriceUnit = height / priceRange; // Calculate Y coordinates for HA values const highY = y + (maxPrice - HA_High) * pixelsPerPriceUnit; const lowY = y + (maxPrice - HA_Low) * pixelsPerPriceUnit; const openY = y + (maxPrice - HA_Open) * pixelsPerPriceUnit; const closeY = y + (maxPrice - HA_Close) * pixelsPerPriceUnit; // 蠟燭主體 const bodyTop = Math.min(openY, closeY); const bodyBottom = Math.max(openY, closeY); const bodyHeight = Math.max(bodyBottom - bodyTop, 1); // 至少 1px // 蠟燭寬度和影線位置 const candleWidth = width * 0.6; // 蠟燭佔 60% 寬度 const candleX = x + (width - candleWidth) / 2; const wickX = x + width / 2; // 影線在中間 return ( {/* 上影線(從最高價到蠟燭頂部) */} {/* 下影線(從蠟燭底部到最低價) */} {/* 蠟燭主體 */} ); };