Add untracked chart and schema files
This commit is contained in:
38
components/charts/hooks/use-chart-export.ts
Normal file
38
components/charts/hooks/use-chart-export.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { getChartColors, getComputedColors } from '../utils/chart-colors';
|
||||
|
||||
export function useChartExport() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const exportChart = useCallback(async (filename: string = 'chart.png') => {
|
||||
if (!chartRef.current) {
|
||||
console.error('Chart ref not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get background color from CSS variable
|
||||
const colors = getChartColors();
|
||||
const computedColors = getComputedColors(colors);
|
||||
const backgroundColor = computedColors.tooltipBg || colors.tooltipBg;
|
||||
|
||||
const dataUrl = await toPng(chartRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // High DPI export
|
||||
backgroundColor: backgroundColor
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} catch (error) {
|
||||
console.error('Failed to export chart:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { chartRef, exportChart };
|
||||
}
|
||||
44
components/charts/hooks/use-chart-zoom.ts
Normal file
44
components/charts/hooks/use-chart-zoom.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ChartZoomState } from '@/lib/types';
|
||||
|
||||
export function useChartZoom(dataLength: number) {
|
||||
const [zoomState, setZoomState] = useState<ChartZoomState>({
|
||||
startIndex: 0,
|
||||
endIndex: Math.max(0, dataLength - 1),
|
||||
isZoomed: false
|
||||
});
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(brushData: { startIndex?: number; endIndex?: number }) => {
|
||||
if (
|
||||
brushData.startIndex === undefined ||
|
||||
brushData.endIndex === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZoomState({
|
||||
startIndex: brushData.startIndex,
|
||||
endIndex: brushData.endIndex,
|
||||
isZoomed:
|
||||
brushData.startIndex !== 0 ||
|
||||
brushData.endIndex !== dataLength - 1
|
||||
});
|
||||
},
|
||||
[dataLength]
|
||||
);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoomState({
|
||||
startIndex: 0,
|
||||
endIndex: Math.max(0, dataLength - 1),
|
||||
isZoomed: false
|
||||
});
|
||||
}, [dataLength]);
|
||||
|
||||
return {
|
||||
zoomState,
|
||||
handleZoomChange,
|
||||
resetZoom
|
||||
};
|
||||
}
|
||||
92
components/charts/interactive-price-chart.tsx
Normal file
92
components/charts/interactive-price-chart.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { InteractivePriceChartProps, ChartType, TimeRange } from '@/lib/types';
|
||||
import { filterByTimeRange, isOHLCVData } from './utils/chart-data-transformers';
|
||||
import { ChartContainer } from './primitives/chart-container';
|
||||
import { ChartToolbar } from './primitives/chart-toolbar';
|
||||
import { LineChartView } from './renderers/line-chart-view';
|
||||
import { CombinationChartView } from './renderers/combination-chart-view';
|
||||
import { VolumeIndicator } from './primitives/volume-indicator';
|
||||
import { useChartExport } from './hooks/use-chart-export';
|
||||
|
||||
export function InteractivePriceChart({
|
||||
data,
|
||||
dataSeries,
|
||||
defaultChartType = 'line',
|
||||
defaultTimeRange = '1Y',
|
||||
showVolume = false,
|
||||
showToolbar = true,
|
||||
height = 400,
|
||||
loading = false,
|
||||
error = null,
|
||||
formatters,
|
||||
onChartTypeChange,
|
||||
onTimeRangeChange
|
||||
}: InteractivePriceChartProps) {
|
||||
const [chartType, setChartType] = useState<ChartType>(defaultChartType);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(defaultTimeRange);
|
||||
const filteredData = useMemo(() => filterByTimeRange(data, timeRange), [data, timeRange]);
|
||||
const filteredDataSeries = useMemo(
|
||||
() => dataSeries?.map((series) => ({
|
||||
...series,
|
||||
data: filterByTimeRange(series.data, timeRange)
|
||||
})),
|
||||
[dataSeries, timeRange]
|
||||
);
|
||||
const { chartRef, exportChart } = useChartExport();
|
||||
|
||||
const handleChartTypeChange = (type: ChartType) => {
|
||||
setChartType(type);
|
||||
onChartTypeChange?.(type);
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (range: TimeRange) => {
|
||||
setTimeRange(range);
|
||||
onTimeRangeChange?.(range);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportChart(`chart-${Date.now()}.png`);
|
||||
};
|
||||
|
||||
const shouldShowVolume = showVolume && filteredData.some(isOHLCVData);
|
||||
|
||||
return (
|
||||
<div ref={chartRef} className="w-full">
|
||||
{showToolbar && (
|
||||
<ChartToolbar
|
||||
chartType={chartType}
|
||||
timeRange={timeRange}
|
||||
onChartTypeChange={handleChartTypeChange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartContainer height={height} loading={loading} error={error}>
|
||||
{chartType === 'line' && (
|
||||
<LineChartView
|
||||
data={filteredData}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartType === 'combination' && filteredDataSeries && (
|
||||
<CombinationChartView
|
||||
dataSeries={filteredDataSeries}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
</ChartContainer>
|
||||
|
||||
{shouldShowVolume && (
|
||||
<VolumeIndicator
|
||||
data={filteredData.filter(isOHLCVData)}
|
||||
height={80}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/charts/primitives/chart-container.tsx
Normal file
45
components/charts/primitives/chart-container.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ChartContainerProps = {
|
||||
children: React.ReactNode;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ChartContainer({
|
||||
children,
|
||||
height = 400,
|
||||
loading = false,
|
||||
error = null,
|
||||
className
|
||||
}: ChartContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)]',
|
||||
className
|
||||
)}
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-sm text-[color:var(--danger)]">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="h-full w-full p-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/charts/primitives/chart-toolbar.tsx
Normal file
76
components/charts/primitives/chart-toolbar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChartType, TimeRange } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type ChartToolbarProps = {
|
||||
chartType: ChartType;
|
||||
timeRange: TimeRange;
|
||||
onChartTypeChange: (type: ChartType) => void;
|
||||
onTimeRangeChange: (range: TimeRange) => void;
|
||||
onExport: () => void;
|
||||
};
|
||||
|
||||
const TIME_RANGES: TimeRange[] = ['1W', '1M', '3M', '1Y', '3Y', '5Y', '10Y', '20Y'];
|
||||
const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||
{ value: 'line', label: 'Line' },
|
||||
{ value: 'combination', label: 'Compare' }
|
||||
];
|
||||
|
||||
export function ChartToolbar({
|
||||
chartType,
|
||||
timeRange,
|
||||
onChartTypeChange,
|
||||
onTimeRangeChange,
|
||||
onExport
|
||||
}: ChartToolbarProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TIME_RANGES.map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
onClick={() => onTimeRangeChange(range)}
|
||||
className={cn(
|
||||
'min-h-9 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
timeRange === range
|
||||
? 'bg-[color:var(--accent)] text-[#16181c]'
|
||||
: 'bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-bright)] hover:text-[color:var(--terminal-bright)]'
|
||||
)}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{CHART_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => onChartTypeChange(type.value)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
chartType === type.value
|
||||
? 'bg-[color:var(--accent)] text-[#16181c]'
|
||||
: 'bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-bright)] hover:text-[color:var(--terminal-bright)]'
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onExport}
|
||||
className="min-h-9 gap-1.5 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
components/charts/primitives/chart-tooltip.tsx
Normal file
79
components/charts/primitives/chart-tooltip.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { TooltipContentProps, TooltipPayloadEntry } from 'recharts';
|
||||
import { formatCurrency, formatCompactCurrency } from '@/lib/format';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type ChartTooltipProps = TooltipContentProps & {
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ChartTooltip(props: ChartTooltipProps) {
|
||||
const { active, payload, label, formatters } = props;
|
||||
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const colors = getChartColors();
|
||||
const formatDate = formatters?.date || ((date: string) => new Date(date).toLocaleDateString());
|
||||
const formatPrice = formatters?.price || formatCurrency;
|
||||
const formatVolume = formatters?.volume || formatCompactCurrency;
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[180px] rounded-xl border p-3 backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
borderColor: colors.tooltipBorder
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 text-xs uppercase tracking-wider" style={{ color: colors.muted }}>
|
||||
{formatDate(label as string)}
|
||||
</div>
|
||||
|
||||
{isOHLCVData(data) ? (
|
||||
<div className="space-y-1.5">
|
||||
<TooltipRow label="Open" value={formatPrice(data.open)} color={colors.text} />
|
||||
<TooltipRow label="High" value={formatPrice(data.high)} color={colors.positive} />
|
||||
<TooltipRow label="Low" value={formatPrice(data.low)} color={colors.negative} />
|
||||
<TooltipRow label="Close" value={formatPrice(data.close)} color={colors.text} />
|
||||
<div className="mt-2 border-t pt-2" style={{ borderColor: colors.tooltipBorder }}>
|
||||
<TooltipRow label="Volume" value={formatVolume(data.volume)} color={colors.muted} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: TooltipPayloadEntry, index: number) => (
|
||||
<TooltipRow
|
||||
key={index}
|
||||
label={String(entry.name || 'Value')}
|
||||
value={formatPrice(entry.value as number)}
|
||||
color={entry.color || colors.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function TooltipRow({ label, value, color }: TooltipRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 text-xs">
|
||||
<span style={{ color: color }}>{label}:</span>
|
||||
<span className="font-mono font-medium" style={{ color: color }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/charts/primitives/volume-indicator.tsx
Normal file
58
components/charts/primitives/volume-indicator.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BarChart, Bar, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { OHLCVDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { formatCompactCurrency } from '@/lib/format';
|
||||
|
||||
type VolumeIndicatorProps = {
|
||||
data: OHLCVDataPoint[];
|
||||
height?: number;
|
||||
formatters?: {
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function VolumeIndicator({
|
||||
data,
|
||||
height = 80,
|
||||
formatters
|
||||
}: VolumeIndicatorProps) {
|
||||
const colors = getChartColors();
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const formatVolume = formatters?.volume || formatCompactCurrency;
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px`, width: '100%' }} className="mt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<YAxis
|
||||
orientation="right"
|
||||
tickFormatter={formatVolume}
|
||||
stroke={colors.muted}
|
||||
fontSize={10}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
formatVolume(typeof value === 'number' ? value : Number(value ?? 0)),
|
||||
'Volume'
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: '0.75rem',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="volume"
|
||||
fill={colors.volume}
|
||||
opacity={0.3}
|
||||
isAnimationActive={data.length <= 500}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/charts/renderers/candlestick-chart-view.tsx
Normal file
71
components/charts/renderers/candlestick-chart-view.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Scatter
|
||||
} from 'recharts';
|
||||
import type { ChartDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { CandlestickShape } from '../utils/candlestick-shapes';
|
||||
import { isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type CandlestickChartViewProps = {
|
||||
data: ChartDataPoint[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CandlestickChartView({
|
||||
data,
|
||||
formatters
|
||||
}: CandlestickChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
const ohlcvData = data.filter(isOHLCVData);
|
||||
|
||||
if (ohlcvData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
||||
Candlestick chart requires OHLCV data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={ohlcvData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.price}
|
||||
width={60}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => <ChartTooltip {...tooltipProps} formatters={formatters} />}
|
||||
cursor={{ stroke: colors.muted, strokeDasharray: '3 3' }}
|
||||
/>
|
||||
<Scatter
|
||||
dataKey="close"
|
||||
shape={<CandlestickShape />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
112
components/charts/renderers/combination-chart-view.tsx
Normal file
112
components/charts/renderers/combination-chart-view.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
import type { DataSeries } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { mergeDataSeries } from '../utils/chart-data-transformers';
|
||||
|
||||
type CombinationChartViewProps = {
|
||||
dataSeries: DataSeries[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CombinationChartView({
|
||||
dataSeries,
|
||||
formatters
|
||||
}: CombinationChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
if (!dataSeries || dataSeries.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
||||
No data series provided
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedData = mergeDataSeries(dataSeries);
|
||||
const visibleSeries = dataSeries.filter(series => series.visible !== false);
|
||||
const baseValues = Object.fromEntries(visibleSeries.map((series) => {
|
||||
const initialPoint = mergedData.find((entry) => typeof entry[series.id] === 'number');
|
||||
const baseValue = typeof initialPoint?.[series.id] === 'number' ? Number(initialPoint[series.id]) : null;
|
||||
return [series.id, baseValue];
|
||||
}));
|
||||
const normalizedData = mergedData.map((point) => {
|
||||
const normalizedPoint: Record<string, string | number | null> = { date: point.date };
|
||||
|
||||
visibleSeries.forEach((series) => {
|
||||
const baseValue = baseValues[series.id];
|
||||
const currentValue = typeof point[series.id] === 'number' ? Number(point[series.id]) : null;
|
||||
|
||||
normalizedPoint[series.id] = baseValue && currentValue
|
||||
? ((currentValue / baseValue) - 1) * 100
|
||||
: null;
|
||||
});
|
||||
|
||||
return normalizedPoint;
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={normalizedData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={(value) => `${Number(value).toFixed(0)}%`}
|
||||
width={60}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => (
|
||||
<ChartTooltip
|
||||
{...tooltipProps}
|
||||
formatters={{
|
||||
...formatters,
|
||||
price: (value: number) => `${value.toFixed(2)}%`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
cursor={{ stroke: colors.muted, strokeDasharray: '3 3' }}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{visibleSeries.map(series => {
|
||||
const seriesColor = series.color || colors.primary;
|
||||
return (
|
||||
<Line
|
||||
key={series.id}
|
||||
type="monotone"
|
||||
dataKey={series.id}
|
||||
name={series.label}
|
||||
stroke={seriesColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={normalizedData.length <= 500}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
100
components/charts/renderers/line-chart-view.tsx
Normal file
100
components/charts/renderers/line-chart-view.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import type { ChartDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { isPriceData, isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type LineChartViewProps = {
|
||||
data: ChartDataPoint[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function LineChartView({
|
||||
data,
|
||||
formatters
|
||||
}: LineChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
const chartData = data.map(point => {
|
||||
if (isOHLCVData(point)) {
|
||||
return {
|
||||
date: point.date,
|
||||
price: point.close,
|
||||
open: point.open,
|
||||
high: point.high,
|
||||
low: point.low,
|
||||
close: point.close,
|
||||
volume: point.volume
|
||||
};
|
||||
} else if (isPriceData(point)) {
|
||||
return {
|
||||
date: point.date,
|
||||
price: point.price
|
||||
};
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={colors.grid}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={50}
|
||||
axisLine={{ stroke: colors.grid }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.price}
|
||||
width={65}
|
||||
domain={['auto', 'auto']}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
orientation="right"
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => <ChartTooltip {...tooltipProps} formatters={formatters} />}
|
||||
cursor={{ stroke: colors.muted, strokeWidth: 1, strokeDasharray: '5 5' }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="price"
|
||||
stroke={colors.primary}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: colors.primary,
|
||||
strokeWidth: 2,
|
||||
fill: colors.tooltipBg
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
connectNulls={true}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
68
components/charts/utils/candlestick-shapes.tsx
Normal file
68
components/charts/utils/candlestick-shapes.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getPriceChangeColor } from './chart-colors';
|
||||
|
||||
type CandlestickShapeProps = {
|
||||
cx?: number;
|
||||
payload?: {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom candlestick shape component for Recharts
|
||||
* Renders candlestick with wick and body
|
||||
*/
|
||||
export function CandlestickShape(props: CandlestickShapeProps) {
|
||||
const { cx, payload } = props;
|
||||
|
||||
if (!payload || !cx) return null;
|
||||
|
||||
const { open, high, low, close } = payload;
|
||||
const isPositive = close >= open;
|
||||
const color = getPriceChangeColor(close - open);
|
||||
|
||||
// Calculate positions
|
||||
const bodyTop = Math.min(open, close);
|
||||
const bodyBottom = Math.max(open, close);
|
||||
const bodyHeight = Math.max(bodyBottom - bodyTop, 1);
|
||||
|
||||
// Candlestick width
|
||||
const width = 8;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Upper wick */}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={high}
|
||||
x2={cx}
|
||||
y2={bodyTop}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<rect
|
||||
x={cx - width / 2}
|
||||
y={bodyTop}
|
||||
width={width}
|
||||
height={bodyHeight}
|
||||
fill={isPositive ? 'transparent' : color}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Lower wick */}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={bodyBottom}
|
||||
x2={cx}
|
||||
y2={low}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
65
components/charts/utils/chart-colors.ts
Normal file
65
components/charts/utils/chart-colors.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ChartColorPalette } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Get chart color palette using CSS variables for theming
|
||||
* These colors match the existing dark theme in the codebase
|
||||
*/
|
||||
export function getChartColors(): ChartColorPalette {
|
||||
return {
|
||||
primary: 'var(--accent)',
|
||||
secondary: 'var(--terminal-muted)',
|
||||
positive: '#96f5bf', // Green - matches existing price-history-card
|
||||
negative: '#ff9f9f', // Red - matches existing price-history-card
|
||||
grid: 'var(--line-weak)',
|
||||
text: 'var(--terminal-bright)',
|
||||
muted: 'var(--terminal-muted)',
|
||||
tooltipBg: 'rgba(31, 34, 39, 0.96)',
|
||||
tooltipBorder: 'var(--line-strong)',
|
||||
volume: 'var(--terminal-muted)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for price change (positive/negative)
|
||||
*/
|
||||
export function getPriceChangeColor(change: number): string {
|
||||
const colors = getChartColors();
|
||||
return change >= 0 ? colors.positive : colors.negative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSS variable to computed color value
|
||||
* Used for chart export since html-to-image can't render CSS variables
|
||||
*/
|
||||
export function cssVarToColor(cssVar: string): string {
|
||||
if (typeof window === 'undefined') return cssVar;
|
||||
|
||||
// If it's already a color value, return as-is
|
||||
if (!cssVar.startsWith('var(')) return cssVar;
|
||||
|
||||
// Extract variable name from var(--name)
|
||||
const varName = cssVar.match(/var\((--[^)]+)\)/)?.[1];
|
||||
if (!varName) return cssVar;
|
||||
|
||||
// Get computed color from CSS variable
|
||||
const computedColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
|
||||
return computedColor || cssVar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entire color palette to computed colors for export
|
||||
*/
|
||||
export function getComputedColors(palette: Partial<ChartColorPalette>): Partial<ChartColorPalette> {
|
||||
const computed: Partial<ChartColorPalette> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(palette)) {
|
||||
if (value) {
|
||||
computed[key as keyof ChartColorPalette] = cssVarToColor(value);
|
||||
}
|
||||
}
|
||||
|
||||
return computed;
|
||||
}
|
||||
157
components/charts/utils/chart-data-transformers.ts
Normal file
157
components/charts/utils/chart-data-transformers.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { subWeeks, subMonths, subYears } from 'date-fns';
|
||||
import type { TimeRange, ChartDataPoint } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Filter chart data by time range
|
||||
*/
|
||||
export function filterByTimeRange<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
range: TimeRange
|
||||
): T[] {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const now = new Date();
|
||||
const cutoffDate = {
|
||||
'1W': subWeeks(now, 1),
|
||||
'1M': subMonths(now, 1),
|
||||
'3M': subMonths(now, 3),
|
||||
'1Y': subYears(now, 1),
|
||||
'3Y': subYears(now, 3),
|
||||
'5Y': subYears(now, 5),
|
||||
'10Y': subYears(now, 10),
|
||||
'20Y': subYears(now, 20)
|
||||
}[range];
|
||||
|
||||
return data.filter(point => new Date(point.date) >= cutoffDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data point has OHLCV fields
|
||||
*/
|
||||
export function isOHLCVData(data: ChartDataPoint): data is {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
} {
|
||||
return 'open' in data && 'high' in data && 'low' in data && 'close' in data && 'volume' in data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data point has simple price field
|
||||
*/
|
||||
export function isPriceData(data: ChartDataPoint): data is { date: string; price: number } {
|
||||
return 'price' in data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize data to ensure consistent structure
|
||||
* Converts price data to OHLCV-like structure for candlestick charts
|
||||
*/
|
||||
export function normalizeChartData<T extends ChartDataPoint>(data: T[]): T[] {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// Sort by date ascending
|
||||
return [...data].sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample data for performance with large datasets
|
||||
* Keeps every Nth point when dataset is too large
|
||||
*/
|
||||
export function sampleData<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
maxPoints: number = 1000
|
||||
): T[] {
|
||||
if (data.length <= maxPoints) return data;
|
||||
|
||||
const samplingRate = Math.ceil(data.length / maxPoints);
|
||||
const sampled: T[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += samplingRate) {
|
||||
sampled.push(data[i]);
|
||||
}
|
||||
|
||||
// Always include the last point
|
||||
if (sampled[sampled.length - 1] !== data[data.length - 1]) {
|
||||
sampled.push(data[data.length - 1]);
|
||||
}
|
||||
|
||||
return sampled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate min/max values for Y-axis domain
|
||||
*/
|
||||
export function calculateYAxisDomain<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
padding: number = 0.1
|
||||
): [number, number] {
|
||||
if (data.length === 0) return [0, 100];
|
||||
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
|
||||
data.forEach(point => {
|
||||
if (isOHLCVData(point)) {
|
||||
min = Math.min(min, point.low);
|
||||
max = Math.max(max, point.high);
|
||||
} else if (isPriceData(point)) {
|
||||
min = Math.min(min, point.price);
|
||||
max = Math.max(max, point.price);
|
||||
}
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const range = max - min;
|
||||
const paddedMin = min - (range * padding);
|
||||
const paddedMax = max + (range * padding);
|
||||
|
||||
return [paddedMin, paddedMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume max for volume indicator Y-axis
|
||||
*/
|
||||
export function calculateVolumeMax<T extends ChartDataPoint>(data: T[]): number {
|
||||
if (data.length === 0 || !isOHLCVData(data[0])) return 0;
|
||||
|
||||
return Math.max(...data.map(d => (isOHLCVData(d) ? d.volume : 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple data series by date for combination charts
|
||||
*/
|
||||
export function mergeDataSeries<T extends ChartDataPoint>(
|
||||
seriesArray: Array<{ id: string; data: T[] }>
|
||||
): Array<T & Record<string, number>> {
|
||||
if (!seriesArray || seriesArray.length === 0) return [];
|
||||
|
||||
// Create map indexed by date
|
||||
const dateMap = new Map<string, T & Record<string, number>>();
|
||||
|
||||
seriesArray.forEach(series => {
|
||||
series.data.forEach(point => {
|
||||
const date = point.date;
|
||||
const existing = dateMap.get(date) || { date } as T & Record<string, number>;
|
||||
|
||||
// Add value for this series
|
||||
if (isOHLCVData(point)) {
|
||||
existing[series.id] = point.close;
|
||||
} else if (isPriceData(point)) {
|
||||
existing[series.id] = point.price;
|
||||
}
|
||||
|
||||
dateMap.set(date, existing);
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Array.from(dateMap.values()).sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user