2026-04-05 12:56:40 -04:00

213 lines
7.4 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart.js';
import { getCategoryColor } from '@/lib/schemas/geopolitical.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
import type { GeopoliticalEventRow, OilPriceRow } from '@/actions/conflict.js';
interface OilChartProps {
oilData: SuperJSONResult;
events?: SuperJSONResult;
}
interface PivotedRow {
timestamp: number;
label: string;
wti_crude?: number;
brent_crude?: number;
spread?: number;
}
const chartConfig: ChartConfig = {
brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' },
wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' },
spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' },
};
export function OilChart({ oilData, events }: OilChartProps) {
const [showSpread, setShowSpread] = useState(false);
const rows = useMemo(() => deserialize<OilPriceRow[]>(oilData), [oilData]);
const eventRows = useMemo(() => (events ? deserialize<GeopoliticalEventRow[]>(events) : []), [events]);
const pivoted = useMemo(() => {
const byDay = new Map<string, PivotedRow>();
for (const row of rows) {
const dayKey = row.timestamp.toISOString().slice(0, 10);
if (!byDay.has(dayKey)) {
byDay.set(dayKey, {
timestamp: row.timestamp.getTime(),
label: row.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
});
}
const pivot = byDay.get(dayKey)!;
if (row.commodity === 'wti_crude') pivot.wti_crude = row.price;
if (row.commodity === 'brent_crude') pivot.brent_crude = row.price;
}
const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp);
// Calculate spread
for (const row of result) {
if (row.brent_crude !== undefined && row.wti_crude !== undefined) {
row.spread = row.brent_crude - row.wti_crude;
}
}
return result;
}, [rows]);
// Find event timestamps within data range for annotation markers
const eventMarkers = useMemo(() => {
if (pivoted.length === 0 || eventRows.length === 0) return [];
const minTs = pivoted[0]!.timestamp;
const maxTs = pivoted[pivoted.length - 1]!.timestamp;
return eventRows.filter(e => {
const ts = e.timestamp.getTime();
return ts >= minTs && ts <= maxTs && (e.severity === 'critical' || e.severity === 'high');
});
}, [pivoted, eventRows]);
if (pivoted.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Oil Prices</CardTitle>
<CardDescription>No oil price data available yet.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Oil Prices</CardTitle>
<CardDescription>WTI & Brent crude with geopolitical event annotations</CardDescription>
</div>
<button
onClick={() => setShowSpread(prev => !prev)}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
showSpread
? 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300'
: 'border-border text-muted-foreground',
)}>
Show Spread
</button>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-100 min-h-60 w-full">
{showSpread ? (
<AreaChart data={pivoted} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}`, undefined]} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="spread"
fill="var(--color-spread)"
fillOpacity={0.3}
stroke="var(--color-spread)"
strokeWidth={2}
/>
</AreaChart>
) : (
<LineChart data={pivoted} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}/bbl`, undefined]} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line
type="monotone"
dataKey="brent_crude"
stroke="var(--color-brent_crude)"
strokeWidth={2}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="wti_crude"
stroke="var(--color-wti_crude)"
strokeWidth={2}
dot={false}
connectNulls
/>
</LineChart>
)}
</ChartContainer>
{/* Event annotations below chart */}
{eventMarkers.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{eventMarkers.map(e => {
const catColor = getCategoryColor(e.category);
return (
<span
key={e.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
borderColor: `${catColor}40`,
backgroundColor: `${catColor}15`,
color: catColor,
}}
title={e.description}>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: catColor }} />
{e.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}: {e.title}
</span>
);
})}
</div>
)}
</CardContent>
</Card>
);
}