"use client"; import { useState, useMemo } from "react"; import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Rectangle, } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 [chartType, setChartType] = useState<"line" | "candlestick">("line"); // Calculate Heikin Ashi data const heikinAshiData = useMemo(() => calculateHeikinAshi(priceData), [priceData]); // 格式化數字 const formatNumber = (num: number) => { return num.toLocaleString('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; return (
{ticker} 價格走勢 setChartType(v as "line" | "candlestick")}> 折線圖 平均K線圖
{/* 統計資訊 */}

增長率

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

時長

{priceStats.duration_days} 天

起始價格

${formatNumber(priceStats.start_price)}

{priceStats.start_date}

結束價格

${formatNumber(priceStats.end_price)}

{priceStats.end_date}

{/* 價格圖表 */}

價格走勢

{chartType === "line" ? ( `$${value.toFixed(0)}`} /> [`$${formatNumber(value)}`, '收盤價']} labelFormatter={(label) => `日期: ${label}`} /> getCloseValue(data)} stroke="#93c5fd" strokeWidth={2} name="收盤價" 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 isNeutral = data.HA_Close === data.HA_Open; // Trend color coding for the direction indicator const trendColor = isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-gray-600'; const direction = isUp ? '↑ 上漲' : isDown ? '↓ 下跌' : '→ 無變化'; return (

日期: {data.Date}

開: ${formatNumber(data.HA_Open)}

收: ${formatNumber(data.HA_Close)}

高: ${formatNumber(data.HA_High)}

低: ${formatNumber(data.HA_Low)}

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

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

交易量

{ if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; return value.toString(); }} /> [value.toLocaleString(), '交易量']} labelFormatter={(label) => `日期: ${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 ( {/* 上影線(從最高價到蠟燭頂部) */} {/* 下影線(從蠟燭底部到最低價) */} {/* 蠟燭主體 */} ); };