213 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|