This commit is contained in:
MarkLo 2025-12-01 19:52:45 +08:00
parent c17269608f
commit f2d73888a0
6 changed files with 124 additions and 35 deletions

View File

@ -122,7 +122,7 @@ export function AnalysisForm({ onSubmit, loading = false }: AnalysisFormProps) {
} }
return ( return (
<Card className="shadow-lg"> <Card className="shadow-lg gradient-card gradient-shine hover-lift animate-scale-up">
<CardContent className="pt-6"> <CardContent className="pt-6">
<Form {...form}> <Form {...form}>
<form <form

View File

@ -35,7 +35,7 @@ export function AnalystReport({ reports }: AnalystReportProps) {
} }
return ( return (
<Card className="shadow-lg"> <Card className="shadow-lg gradient-card gradient-shine hover-lift animate-scale-up">
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
@ -125,7 +125,7 @@ export function AnalystReport({ reports }: AnalystReportProps) {
function ReportSection({ title, content }: { title: string; content: string }) { function ReportSection({ title, content }: { title: string; content: string }) {
return ( return (
<div className="border rounded-lg p-4"> <div className="border rounded-lg p-4 bg-gradient-to-br from-blue-50/50 to-purple-50/50 dark:from-blue-900/10 dark:to-purple-900/10 hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{title}</h3> <h3 className="font-semibold text-lg mb-2">{title}</h3>
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>

View File

@ -6,15 +6,12 @@ import {
Line, Line,
BarChart, BarChart,
Bar, Bar,
Cell,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
Legend,
ResponsiveContainer, ResponsiveContainer,
ComposedChart, Rectangle,
Area,
} from "recharts"; } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -40,8 +37,14 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
return `${date.getMonth() + 1}/${date.getDate()}`; return `${date.getMonth() + 1}/${date.getDate()}`;
}; };
// 計算價格範圍用於標準化
const priceValues = priceData.flatMap(d => [d.High, d.Low]);
const minPrice = Math.min(...priceValues);
const maxPrice = Math.max(...priceValues);
const priceRange = maxPrice - minPrice;
return ( return (
<Card className="w-full"> <Card className="w-full gradient-card gradient-shine hover-lift animate-scale-up">
<CardHeader> <CardHeader>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<CardTitle className="text-2xl">{ticker} </CardTitle> <CardTitle className="text-2xl">{ticker} </CardTitle>
@ -55,22 +58,22 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
{/* 統計資訊 */} {/* 統計資訊 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="bg-muted p-4 rounded-lg"> <div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 p-4 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
<p className={`text-2xl font-bold ${priceStats.growth_rate >= 0 ? 'text-green-600' : 'text-red-600'}`}> <p className={`text-2xl font-bold ${priceStats.growth_rate >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{priceStats.growth_rate >= 0 ? '+' : ''}{priceStats.growth_rate}% {priceStats.growth_rate >= 0 ? '+' : ''}{priceStats.growth_rate}%
</p> </p>
</div> </div>
<div className="bg-muted p-4 rounded-lg"> <div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{priceStats.duration_days} </p> <p className="text-2xl font-bold">{priceStats.duration_days} </p>
</div> </div>
<div className="bg-muted p-4 rounded-lg"> <div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 p-4 rounded-lg border border-purple-200 dark:border-purple-800">
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">${formatNumber(priceStats.start_price)}</p> <p className="text-lg font-semibold">${formatNumber(priceStats.start_price)}</p>
<p className="text-xs text-muted-foreground">{priceStats.start_date}</p> <p className="text-xs text-muted-foreground">{priceStats.start_date}</p>
</div> </div>
<div className="bg-muted p-4 rounded-lg"> <div className="bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">${formatNumber(priceStats.end_price)}</p> <p className="text-lg font-semibold">${formatNumber(priceStats.end_price)}</p>
<p className="text-xs text-muted-foreground">{priceStats.end_date}</p> <p className="text-xs text-muted-foreground">{priceStats.end_date}</p>
@ -99,19 +102,18 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
formatter={(value: number) => [`$${formatNumber(value)}`, '收盤價']} formatter={(value: number) => [`$${formatNumber(value)}`, '收盤價']}
labelFormatter={(label) => `日期: ${label}`} labelFormatter={(label) => `日期: ${label}`}
/> />
<Legend />
<Line <Line
type="monotone" type="monotone"
dataKey="Close" dataKey="Close"
stroke="#2563eb" stroke="#93c5fd"
strokeWidth={2} strokeWidth={2}
name="收盤價" name="收盤價"
dot={false} dot={false}
/> />
</LineChart> </LineChart>
) : ( ) : (
// K線圖簡化版實現 // K線圖真正的蠟燭圖實現
<BarChart data={priceData}> <BarChart data={priceData} barCategoryGap="20%">
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
dataKey="Date" dataKey="Date"
@ -119,7 +121,7 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
minTickGap={30} minTickGap={30}
/> />
<YAxis <YAxis
domain={['auto', 'auto']} domain={[minPrice * 0.98, maxPrice * 1.02]}
tickFormatter={(value) => `$${value.toFixed(0)}`} tickFormatter={(value) => `$${value.toFixed(0)}`}
/> />
<Tooltip <Tooltip
@ -149,18 +151,11 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
return null; return null;
}} }}
/> />
{/* 使用 Bar 顯示收盤價,顏色根據漲跌決定 */} {/* 使用自定義 shape 來繪製蠟燭 */}
<Bar <Bar
dataKey="Close" dataKey="High"
name="收盤價" shape={(props: any) => <CandlestickShape {...props} minPrice={minPrice} maxPrice={maxPrice} />}
> />
{priceData.map((entry: any, index: number) => (
<Cell
key={`cell-${index}`}
fill={entry.Close >= entry.Open ? '#22c55e' : '#ef4444'}
/>
))}
</Bar>
</BarChart> </BarChart>
)} )}
</ResponsiveContainer> </ResponsiveContainer>
@ -188,7 +183,7 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
formatter={(value: number) => [value.toLocaleString(), '交易量']} formatter={(value: number) => [value.toLocaleString(), '交易量']}
labelFormatter={(label) => `日期: ${label}`} labelFormatter={(label) => `日期: ${label}`}
/> />
<Bar dataKey="Volume" fill="#10b981" name="交易量" /> <Bar dataKey="Volume" fill="#93c5fd" name="交易量" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -196,3 +191,97 @@ export function PriceChart({ priceData, priceStats, ticker }: PriceChartProps) {
</Card> </Card>
); );
} }
// 自定義蠟燭圖形狀組件
interface CandlestickShapeProps {
x?: number;
y?: number;
width?: number;
height?: number;
payload?: PriceData;
minPrice: number;
maxPrice: number;
}
const CandlestickShape: React.FC<CandlestickShapeProps> = (props) => {
const { x = 0, y = 0, width = 0, height = 0, payload, minPrice, maxPrice } = props;
if (!payload) return null;
const { Open, Close, High, Low } = payload;
const isUp = Close >= Open;
// 粉綠色(上漲)和粉紅色(下跌)
const fillColor = isUp ? '#86efac' : '#fca5a5'; // soft pastel green / soft pastel pink
const strokeColor = isUp ? '#22c55e' : '#ef4444'; // darker green / darker red
// 計算實際的 Y 坐標位置
const priceRange = maxPrice - minPrice;
// The height prop passed to shape is the full height of the chart area for the Y-axis.
// We need to calculate the actual chart drawing height based on the Y-axis domain.
// The Y-axis in recharts is typically inverted, so higher values are lower Y coordinates.
// The 'y' prop passed to shape is the y-coordinate of the data point (High in this case).
// We need to adjust calculations based on the actual Y-axis scale.
// Recharts Y-axis is inverted: higher price -> lower Y coordinate.
// The 'y' prop for the Bar is typically the y-coordinate of the dataKey (High).
// The 'height' prop for the Bar is the height of the bar if it were a standard bar chart.
// For a candlestick, we need to map prices to the chart's pixel height.
// Let's assume 'y' is the top of the plotting area and 'y + height' is the bottom.
// The total pixel height available for the price range is 'height'.
// We need to map minPrice to y + height and maxPrice to y.
// Calculate the pixel value per price unit
const pixelsPerPriceUnit = height / priceRange;
// Calculate Y coordinates for Open, Close, High, Low
// Note: Y-axis is inverted, so (maxPrice - price) gives distance from top.
const highY = y + (maxPrice - High) * pixelsPerPriceUnit;
const lowY = y + (maxPrice - Low) * pixelsPerPriceUnit;
const openY = y + (maxPrice - Open) * pixelsPerPriceUnit;
const closeY = y + (maxPrice - 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 (
<g>
{/* 上影線(從最高價到蠟燭頂部) */}
<line
x1={wickX}
y1={highY}
x2={wickX}
y2={bodyTop}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* 下影線(從蠟燭底部到最低價) */}
<line
x1={wickX}
y1={bodyBottom}
x2={wickX}
y2={lowY}
stroke={strokeColor}
strokeWidth={1.5}
/>
{/* 蠟燭主體 */}
<rect
x={candleX}
y={bodyTop}
width={candleWidth}
height={bodyHeight}
fill={fillColor}
stroke={strokeColor}
strokeWidth={1}
/>
</g>
);
};

View File

@ -41,7 +41,7 @@ export function TradingDecision({ result }: TradingDecisionProps) {
}; };
return ( return (
<Card className="shadow-lg border-2 border-blue-500"> <Card className="shadow-lg border-2 border-blue-500 gradient-card gradient-shine hover-lift animate-scale-up">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>

View File

@ -31,7 +31,7 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.1.12" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -9809,9 +9809,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.13", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

View File

@ -32,7 +32,7 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.1.12" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",