1473 lines
47 KiB
TypeScript
1473 lines
47 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Card } from '@/components/ui/card';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import {
|
|
ChevronDown,
|
|
TrendingUp,
|
|
CalendarDays,
|
|
Crosshair,
|
|
Ruler,
|
|
PenLine,
|
|
Settings,
|
|
Bell,
|
|
StickyNote,
|
|
Minus,
|
|
MoveRight,
|
|
MoreHorizontal,
|
|
X,
|
|
Minimize2,
|
|
Maximize2,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import type { CandlestickGraphProps, Candle, ChartTheme, ChartSize, MeasurePoint, ChartNote, DrawLine, ChartAnnotations, ChartIndicator, IndicatorType } from './chartTypes';
|
|
import { DEFAULT_INDICATOR_PARAMS, INDICATOR_COLORS } from './chartTypes';
|
|
import { getIndicatorDisplayName, getIndicatorParamLabel } from './indicatorUtils';
|
|
import { useCandlestickData } from './useCandlestickData';
|
|
import { useCandlestickViewModel } from './useCandlestickViewModel';
|
|
import { renderChart, renderCrosshair, renderMeasure } from './chartRenderer';
|
|
import { DEFAULT_CHART_THEME, getChartTheme } from './chartTypes';
|
|
|
|
const ANNOTATIONS_API_BASE = 'https://api.dongfeng-systems.org';
|
|
|
|
// Chart size presets
|
|
const SIZE_CLASSES: Record<ChartSize, string> = {
|
|
sm: 'h-[300px]',
|
|
md: 'h-[400px]',
|
|
lg: 'h-[500px]',
|
|
xl: 'h-[600px]',
|
|
full: 'h-full',
|
|
};
|
|
|
|
// Indicator options
|
|
const INDICATORS = [
|
|
{ id: 'sma', label: 'SMA (Simple Moving Average)' },
|
|
{ id: 'ema', label: 'EMA (Exponential Moving Average)' },
|
|
{ id: 'boll', label: 'Bollinger Bands' },
|
|
{ id: 'rsi', label: 'RSI' },
|
|
{ id: 'macd', label: 'MACD' },
|
|
{ id: 'volume', label: 'Volume Profile' },
|
|
];
|
|
|
|
// Event options
|
|
const EVENTS = [
|
|
{ id: 'earnings', label: 'Earnings Reports' },
|
|
{ id: 'dividends', label: 'Dividend Dates' },
|
|
{ id: 'splits', label: 'Stock Splits' },
|
|
{ id: 'news', label: 'Major News' },
|
|
];
|
|
|
|
// Action rail buttons
|
|
const ACTION_BUTTONS = [
|
|
{ id: 'crosshair', icon: Crosshair, label: 'Crosshair' },
|
|
{ id: 'measure', icon: Ruler, label: 'Measure' },
|
|
{ id: 'draw', icon: PenLine, label: 'Draw' },
|
|
{ id: 'note', icon: StickyNote, label: 'Insert Note' },
|
|
{ id: 'alert', icon: Bell, label: 'Alerts' },
|
|
{ id: 'settings', icon: Settings, label: 'Settings' },
|
|
];
|
|
|
|
// Draw line type options
|
|
const LINE_TYPES = [
|
|
{ id: 'line', icon: Minus, label: 'Line' },
|
|
{ id: 'arrow', icon: MoveRight, label: 'Arrow' },
|
|
{ id: 'dotted', icon: MoreHorizontal, label: 'Dotted Line' },
|
|
];
|
|
|
|
// Draw color options
|
|
const LINE_COLORS = [
|
|
{ id: 'red', color: '#ef4444', label: 'Red' },
|
|
{ id: 'green', color: '#22c55e', label: 'Green' },
|
|
{ id: 'blue', color: '#3b82f6', label: 'Blue' },
|
|
{ id: 'yellow', color: '#eab308', label: 'Yellow' },
|
|
];
|
|
|
|
export function CandlestickGraph({
|
|
symbol,
|
|
user,
|
|
startDate,
|
|
endDate,
|
|
barSize,
|
|
size = 'lg',
|
|
className,
|
|
}: CandlestickGraphProps) {
|
|
// Refs for canvas elements
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
|
|
// Container dimensions
|
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
|
|
|
// Theme that responds to dark/light mode
|
|
const [chartTheme, setChartTheme] = useState<ChartTheme>(DEFAULT_CHART_THEME);
|
|
|
|
// Mouse state (using ref to avoid re-renders)
|
|
const mouseStateRef = useRef({ x: 0, y: 0, isInChart: false });
|
|
|
|
// Dropdown states
|
|
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
|
|
|
|
// Active indicators with full configuration
|
|
const [activeIndicators, setActiveIndicators] = useState<ChartIndicator[]>([]);
|
|
const [indicatorsLoaded, setIndicatorsLoaded] = useState(false);
|
|
const [activeAction, setActiveAction] = useState<string | null>('crosshair');
|
|
|
|
// Draw tool state
|
|
const [selectedLineType, setSelectedLineType] = useState<string>('line');
|
|
const [selectedLineColor, setSelectedLineColor] = useState<string>('#3b82f6');
|
|
|
|
// Measure tool state
|
|
const [measurePoints, setMeasurePoints] = useState<MeasurePoint[]>([]);
|
|
|
|
// Notes state
|
|
const [notes, setNotes] = useState<ChartNote[]>([]);
|
|
|
|
// Draw lines state
|
|
const [drawLines, setDrawLines] = useState<DrawLine[]>([]);
|
|
const [pendingDrawPoint, setPendingDrawPoint] = useState<{ time: number; price: number } | null>(null);
|
|
|
|
// Load indicators from localStorage on mount (client-side only)
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem('chart-indicators');
|
|
if (stored) {
|
|
setActiveIndicators(JSON.parse(stored));
|
|
}
|
|
} catch {
|
|
// localStorage might be unavailable
|
|
}
|
|
setIndicatorsLoaded(true);
|
|
}, []);
|
|
|
|
// Save indicators to localStorage when they change (shared across all symbols)
|
|
useEffect(() => {
|
|
if (!indicatorsLoaded) return; // Don't save on initial load
|
|
try {
|
|
localStorage.setItem('chart-indicators', JSON.stringify(activeIndicators));
|
|
} catch {
|
|
// localStorage might be full or unavailable
|
|
}
|
|
}, [activeIndicators, indicatorsLoaded]);
|
|
|
|
// Fetch annotations on mount
|
|
useEffect(() => {
|
|
const fetchAnnotations = async () => {
|
|
try {
|
|
const response = await fetch(
|
|
`${ANNOTATIONS_API_BASE}/api/annotations/fetch?user=${encodeURIComponent(user)}&symbol=${encodeURIComponent(symbol)}`
|
|
);
|
|
if (response.ok) {
|
|
const data: ChartAnnotations = await response.json();
|
|
if (data.notes && Array.isArray(data.notes)) {
|
|
setNotes(data.notes);
|
|
}
|
|
if (data.drawLines && Array.isArray(data.drawLines)) {
|
|
setDrawLines(data.drawLines);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch annotations:', err);
|
|
}
|
|
};
|
|
fetchAnnotations();
|
|
}, [user, symbol]);
|
|
|
|
// Save annotations
|
|
const saveAnnotations = useCallback(async (updatedNotes: ChartNote[], updatedDrawLines: DrawLine[]) => {
|
|
try {
|
|
const annotations: ChartAnnotations = {
|
|
notes: updatedNotes,
|
|
drawLines: updatedDrawLines,
|
|
};
|
|
await fetch(
|
|
`${ANNOTATIONS_API_BASE}/api/annotations/save?user=${encodeURIComponent(user)}&symbol=${encodeURIComponent(symbol)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(annotations),
|
|
}
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to save annotations:', err);
|
|
}
|
|
}, [user, symbol]);
|
|
|
|
// Note handlers
|
|
const handleAddNote = useCallback((time: number, price: number) => {
|
|
const newNote: ChartNote = {
|
|
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
time,
|
|
price,
|
|
text: '',
|
|
isMinimized: false,
|
|
createdAt: Date.now(),
|
|
};
|
|
const updatedNotes = [...notes, newNote];
|
|
setNotes(updatedNotes);
|
|
saveAnnotations(updatedNotes, drawLines);
|
|
}, [notes, drawLines, saveAnnotations]);
|
|
|
|
const handleUpdateNote = useCallback((id: string, updates: Partial<ChartNote>) => {
|
|
const updatedNotes = notes.map(note =>
|
|
note.id === id ? { ...note, ...updates } : note
|
|
);
|
|
setNotes(updatedNotes);
|
|
saveAnnotations(updatedNotes, drawLines);
|
|
}, [notes, drawLines, saveAnnotations]);
|
|
|
|
const handleDeleteNote = useCallback((id: string) => {
|
|
const updatedNotes = notes.filter(note => note.id !== id);
|
|
setNotes(updatedNotes);
|
|
saveAnnotations(updatedNotes, drawLines);
|
|
}, [notes, drawLines, saveAnnotations]);
|
|
|
|
// Draw line handlers
|
|
const handleAddDrawLine = useCallback((startTime: number, startPrice: number, endTime: number, endPrice: number) => {
|
|
const newLine: DrawLine = {
|
|
id: `draw-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
startTime,
|
|
startPrice,
|
|
endTime,
|
|
endPrice,
|
|
lineType: selectedLineType as 'line' | 'arrow' | 'dotted',
|
|
color: selectedLineColor,
|
|
createdAt: Date.now(),
|
|
};
|
|
const updatedDrawLines = [...drawLines, newLine];
|
|
setDrawLines(updatedDrawLines);
|
|
saveAnnotations(notes, updatedDrawLines);
|
|
}, [drawLines, notes, selectedLineType, selectedLineColor, saveAnnotations]);
|
|
|
|
const handleDeleteDrawLine = useCallback((id: string) => {
|
|
const updatedDrawLines = drawLines.filter(line => line.id !== id);
|
|
setDrawLines(updatedDrawLines);
|
|
saveAnnotations(notes, updatedDrawLines);
|
|
}, [drawLines, notes, saveAnnotations]);
|
|
|
|
// Indicator handlers
|
|
const handleAddIndicator = useCallback((type: IndicatorType) => {
|
|
const colorIndex = activeIndicators.length % INDICATOR_COLORS.length;
|
|
const newIndicator: ChartIndicator = {
|
|
id: `ind-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
type,
|
|
params: { ...DEFAULT_INDICATOR_PARAMS[type] },
|
|
color: INDICATOR_COLORS[colorIndex],
|
|
enabled: true,
|
|
};
|
|
setActiveIndicators(prev => [...prev, newIndicator]);
|
|
}, [activeIndicators.length]);
|
|
|
|
const handleUpdateIndicator = useCallback((id: string, updates: Partial<ChartIndicator>) => {
|
|
setActiveIndicators(prev =>
|
|
prev.map(ind => ind.id === id ? { ...ind, ...updates } : ind)
|
|
);
|
|
}, []);
|
|
|
|
const handleDeleteIndicator = useCallback((id: string) => {
|
|
setActiveIndicators(prev => prev.filter(ind => ind.id !== id));
|
|
}, []);
|
|
|
|
// Data layer
|
|
const { candles, visibleStartOffset, isLoading, error } = useCandlestickData({
|
|
symbol,
|
|
user,
|
|
startDate,
|
|
endDate,
|
|
barSize,
|
|
});
|
|
|
|
// View model layer
|
|
const {
|
|
viewModel,
|
|
setHoveredIndex,
|
|
getXForIndex,
|
|
getYForPrice,
|
|
getYForVolume,
|
|
getIndexForX,
|
|
getPriceForY,
|
|
getTimeForX,
|
|
getXForTime,
|
|
} = useCandlestickViewModel({
|
|
candles,
|
|
containerWidth: dimensions.width,
|
|
containerHeight: dimensions.height,
|
|
visibleStartOffset,
|
|
});
|
|
|
|
// Handle resize
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (entry) {
|
|
const { width, height } = entry.contentRect;
|
|
setDimensions({ width, height });
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
|
|
// Initial measurement
|
|
const rect = container.getBoundingClientRect();
|
|
setDimensions({ width: rect.width, height: rect.height });
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, []);
|
|
|
|
// Observe dark/light mode changes
|
|
useEffect(() => {
|
|
const updateTheme = () => {
|
|
setChartTheme(getChartTheme());
|
|
};
|
|
|
|
// Initial theme
|
|
updateTheme();
|
|
|
|
// Watch for class changes on document element (dark mode toggle)
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.attributeName === 'class') {
|
|
updateTheme();
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(document.documentElement, { attributes: true });
|
|
|
|
// Also listen for media query changes
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
mediaQuery.addEventListener('change', updateTheme);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
mediaQuery.removeEventListener('change', updateTheme);
|
|
};
|
|
}, []);
|
|
|
|
// Set up canvas DPR
|
|
useEffect(() => {
|
|
const mainCanvas = mainCanvasRef.current;
|
|
const overlayCanvas = overlayCanvasRef.current;
|
|
if (!mainCanvas || !overlayCanvas) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const { width, height } = dimensions;
|
|
|
|
// Size canvases
|
|
mainCanvas.width = width * dpr;
|
|
mainCanvas.height = height * dpr;
|
|
mainCanvas.style.width = `${width}px`;
|
|
mainCanvas.style.height = `${height}px`;
|
|
|
|
overlayCanvas.width = width * dpr;
|
|
overlayCanvas.height = height * dpr;
|
|
overlayCanvas.style.width = `${width}px`;
|
|
overlayCanvas.style.height = `${height}px`;
|
|
}, [dimensions]);
|
|
|
|
// Main render loop
|
|
const render = useCallback(() => {
|
|
const mainCanvas = mainCanvasRef.current;
|
|
const overlayCanvas = overlayCanvasRef.current;
|
|
if (!mainCanvas || !overlayCanvas) return;
|
|
|
|
const mainCtx = mainCanvas.getContext('2d');
|
|
const overlayCtx = overlayCanvas.getContext('2d');
|
|
if (!mainCtx || !overlayCtx) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
// Render main chart
|
|
renderChart(
|
|
mainCtx,
|
|
viewModel,
|
|
chartTheme,
|
|
dpr,
|
|
getXForIndex,
|
|
getYForPrice,
|
|
getYForVolume,
|
|
activeIndicators
|
|
);
|
|
|
|
// Clear overlay canvas
|
|
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
|
|
const { x, y, isInChart } = mouseStateRef.current;
|
|
|
|
// Always render measure points if they exist (persistent across tool switches)
|
|
if (measurePoints.length > 0) {
|
|
renderMeasure(
|
|
overlayCtx,
|
|
viewModel,
|
|
measurePoints,
|
|
activeAction === 'measure' && isInChart ? { x, y } : null,
|
|
chartTheme,
|
|
dpr,
|
|
getYForPrice
|
|
);
|
|
}
|
|
|
|
// Render crosshair overlay when not in measure mode
|
|
if (isInChart && activeAction !== 'measure') {
|
|
renderCrosshair(
|
|
overlayCtx,
|
|
viewModel,
|
|
x,
|
|
y,
|
|
chartTheme,
|
|
dpr,
|
|
getXForIndex,
|
|
getYForPrice
|
|
);
|
|
}
|
|
}, [viewModel, chartTheme, activeAction, measurePoints, getXForIndex, getYForPrice, getYForVolume, activeIndicators]);
|
|
|
|
// Schedule render with RAF
|
|
const scheduleRender = useCallback(() => {
|
|
if (rafRef.current !== null) return;
|
|
rafRef.current = requestAnimationFrame(() => {
|
|
rafRef.current = null;
|
|
render();
|
|
});
|
|
}, [render]);
|
|
|
|
// Render when view model changes
|
|
useEffect(() => {
|
|
scheduleRender();
|
|
}, [scheduleRender, viewModel]);
|
|
|
|
// Mouse handlers
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = overlayCanvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
mouseStateRef.current = { x, y, isInChart: true };
|
|
|
|
// Find nearest candle
|
|
const index = getIndexForX(x);
|
|
setHoveredIndex(index);
|
|
|
|
// Re-render overlay
|
|
scheduleRender();
|
|
},
|
|
[getIndexForX, setHoveredIndex, scheduleRender]
|
|
);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
mouseStateRef.current = { ...mouseStateRef.current, isInChart: false };
|
|
setHoveredIndex(null);
|
|
scheduleRender();
|
|
}, [setHoveredIndex, scheduleRender]);
|
|
|
|
// Handle click for measure tool and note insertion
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = overlayCanvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// Get price and time for this point
|
|
const price = getPriceForY(y);
|
|
const index = getIndexForX(x);
|
|
|
|
if (index === null || index < 0 || index >= candles.length) return;
|
|
|
|
const candle = candles[index];
|
|
|
|
if (activeAction === 'measure') {
|
|
const newPoint: MeasurePoint = {
|
|
x,
|
|
y,
|
|
price,
|
|
time: candle.time,
|
|
index,
|
|
};
|
|
setMeasurePoints((prev) => [...prev, newPoint]);
|
|
scheduleRender();
|
|
} else if (activeAction === 'note') {
|
|
handleAddNote(candle.time, price);
|
|
} else if (activeAction === 'draw') {
|
|
// For draw, use precise time based on x position (not snapped to candle)
|
|
const preciseTime = getTimeForX(x);
|
|
if (preciseTime === null) return;
|
|
|
|
if (pendingDrawPoint === null) {
|
|
// First point - store it with precise time
|
|
setPendingDrawPoint({ time: preciseTime, price });
|
|
} else {
|
|
// Second point - create the line with precise times
|
|
handleAddDrawLine(pendingDrawPoint.time, pendingDrawPoint.price, preciseTime, price);
|
|
setPendingDrawPoint(null);
|
|
}
|
|
}
|
|
},
|
|
[activeAction, getPriceForY, getIndexForX, getTimeForX, candles, scheduleRender, handleAddNote, pendingDrawPoint, handleAddDrawLine]
|
|
);
|
|
|
|
// Clear pending draw point when switching away from draw tool
|
|
useEffect(() => {
|
|
if (activeAction !== 'draw') {
|
|
setPendingDrawPoint(null);
|
|
}
|
|
}, [activeAction]);
|
|
|
|
// Get hovered candle for tooltip
|
|
const hoveredCandle =
|
|
viewModel.hoveredIndex !== null && viewModel.hoveredIndex < candles.length
|
|
? candles[viewModel.hoveredIndex]
|
|
: null;
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'flex flex-col overflow-hidden bg-chart-bg border-border',
|
|
SIZE_CLASSES[size],
|
|
className
|
|
)}
|
|
>
|
|
{/* Top Toolbar */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
{/* Left: Symbol + OHLC Info or Measure Info */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
|
<span className="font-semibold text-foreground tracking-wide">{symbol}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{barSize}
|
|
</span>
|
|
</div>
|
|
|
|
{/* OHLC Info Bar - shown when not measuring */}
|
|
{hoveredCandle && measurePoints.length === 0 && (
|
|
<OHLCBar candle={hoveredCandle} />
|
|
)}
|
|
|
|
{/* Measure Info Bar - shown when measuring */}
|
|
{measurePoints.length > 0 && (
|
|
<MeasureBar points={measurePoints} onClear={() => setMeasurePoints([])} />
|
|
)}
|
|
|
|
{/* Draw Info Bar - shown when drawing */}
|
|
{activeAction === 'draw' && (
|
|
<DrawBar
|
|
pendingPoint={pendingDrawPoint}
|
|
lineType={selectedLineType}
|
|
color={selectedLineColor}
|
|
onCancel={() => setPendingDrawPoint(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Dropdowns */}
|
|
<div className="flex items-center gap-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
Indicators
|
|
<span suppressHydrationWarning className="contents">
|
|
{indicatorsLoaded && activeIndicators.length > 0 && (
|
|
<span className="ml-1 px-1.5 py-0.5 text-[10px] bg-primary/20 text-primary rounded-full">
|
|
{activeIndicators.length}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<ChevronDown className="ml-1 h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="bg-popover border-border"
|
|
>
|
|
{INDICATORS.map((indicator) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={indicator.id}
|
|
checked={false}
|
|
onCheckedChange={() => {
|
|
handleAddIndicator(indicator.id as IndicatorType);
|
|
}}
|
|
className="text-sm"
|
|
>
|
|
{indicator.label}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
<CalendarDays className="mr-1 h-3 w-3" />
|
|
Events
|
|
<ChevronDown className="ml-1 h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="bg-popover border-border"
|
|
>
|
|
{EVENTS.map((event) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={event.id}
|
|
checked={selectedEvents.includes(event.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelectedEvents((prev) =>
|
|
checked
|
|
? [...prev, event.id]
|
|
: prev.filter((id) => id !== event.id)
|
|
);
|
|
}}
|
|
className="text-sm"
|
|
>
|
|
{event.label}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Row */}
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* Left Action Rail */}
|
|
<div className="flex flex-col gap-1 p-2 border-r border-border bg-muted/30 overflow-y-auto max-h-full scrollbar-thin">
|
|
{ACTION_BUTTONS.map((action) => {
|
|
const Icon = action.icon;
|
|
const isActive = activeAction === action.id;
|
|
return (
|
|
<Button
|
|
key={action.id}
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
'h-8 w-8 rounded-md transition-colors',
|
|
isActive
|
|
? 'bg-muted text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
)}
|
|
onClick={() => setActiveAction(action.id)}
|
|
title={action.label}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</Button>
|
|
);
|
|
})}
|
|
|
|
{/* Line type options - shown when draw is selected */}
|
|
{activeAction === 'draw' && (
|
|
<>
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
|
{LINE_TYPES.map((lineType) => {
|
|
const Icon = lineType.icon;
|
|
const isSelected = selectedLineType === lineType.id;
|
|
return (
|
|
<Button
|
|
key={lineType.id}
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
'h-7 w-8 rounded-md transition-colors',
|
|
isSelected
|
|
? 'bg-primary/20 text-primary'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
)}
|
|
onClick={() => setSelectedLineType(lineType.id)}
|
|
title={lineType.label}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Color options */}
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
|
{LINE_COLORS.map((colorOption) => {
|
|
const isSelected = selectedLineColor === colorOption.color;
|
|
return (
|
|
<button
|
|
key={colorOption.id}
|
|
className={cn(
|
|
'h-6 w-8 rounded-md transition-all border-2',
|
|
isSelected
|
|
? 'border-foreground scale-110'
|
|
: 'border-transparent hover:scale-105'
|
|
)}
|
|
style={{ backgroundColor: colorOption.color }}
|
|
onClick={() => setSelectedLineColor(colorOption.color)}
|
|
title={colorOption.label}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chart Area */}
|
|
<div className="relative flex-1 min-w-0">
|
|
{/* Loading State */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Spinner className="h-6 w-6 text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground">Loading chart data...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
|
<div className="text-center">
|
|
<p className="text-sm text-destructive">Failed to load chart data</p>
|
|
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!isLoading && !error && candles.length === 0 && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
|
<div className="text-center">
|
|
<p className="text-sm text-muted-foreground">No data available</p>
|
|
<p className="text-xs text-muted-foreground/60 mt-1">
|
|
No candles found for {symbol}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Canvas Container */}
|
|
<div ref={containerRef} className="absolute inset-0">
|
|
<canvas
|
|
ref={mainCanvasRef}
|
|
className="absolute inset-0"
|
|
style={{ imageRendering: 'pixelated' }}
|
|
/>
|
|
<canvas
|
|
ref={overlayCanvasRef}
|
|
className="absolute inset-0"
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={handleClick}
|
|
style={{ cursor: activeAction === 'note' ? 'cell' : 'crosshair' }}
|
|
/>
|
|
|
|
{/* Draw lines SVG overlay */}
|
|
<svg className="absolute inset-0 pointer-events-none z-10" style={{ overflow: 'visible' }}>
|
|
<defs>
|
|
<marker
|
|
id="arrowhead"
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
|
|
</marker>
|
|
</defs>
|
|
{drawLines.map((line) => {
|
|
// Use precise time-based x coordinates
|
|
const x1 = getXForTime(line.startTime);
|
|
const y1 = getYForPrice(line.startPrice);
|
|
const x2 = getXForTime(line.endTime);
|
|
const y2 = getYForPrice(line.endPrice);
|
|
|
|
return (
|
|
<DrawLineComponent
|
|
key={line.id}
|
|
line={line}
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
onDelete={() => handleDeleteDrawLine(line.id)}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Pending draw point indicator */}
|
|
{pendingDrawPoint && (
|
|
<circle
|
|
cx={getXForTime(pendingDrawPoint.time)}
|
|
cy={getYForPrice(pendingDrawPoint.price)}
|
|
r={6}
|
|
fill={selectedLineColor}
|
|
stroke="white"
|
|
strokeWidth={2}
|
|
/>
|
|
)}
|
|
</svg>
|
|
|
|
{/* Notes overlay */}
|
|
{notes.map((note) => {
|
|
// Find the candle index for this note's time
|
|
const candleIndex = candles.findIndex(c => c.time === note.time);
|
|
if (candleIndex === -1) return null;
|
|
|
|
// Check if note is in visible range
|
|
if (candleIndex < viewModel.visibleStartIndex || candleIndex > viewModel.visibleEndIndex) {
|
|
return null;
|
|
}
|
|
|
|
const x = getXForIndex(candleIndex);
|
|
const y = getYForPrice(note.price);
|
|
|
|
return (
|
|
<NoteComponent
|
|
key={note.id}
|
|
note={note}
|
|
x={x}
|
|
y={y}
|
|
onUpdate={(updates) => handleUpdateNote(note.id, updates)}
|
|
onDelete={() => handleDeleteNote(note.id)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Indicator Panel - shown when indicators are active */}
|
|
{activeIndicators.length > 0 && (
|
|
<div className="border-t border-border px-4 py-2 bg-muted/20">
|
|
<div className="flex flex-wrap gap-2">
|
|
{activeIndicators.map((indicator) => (
|
|
<IndicatorPill
|
|
key={indicator.id}
|
|
indicator={indicator}
|
|
onUpdate={(updates) => handleUpdateIndicator(indicator.id, updates)}
|
|
onDelete={() => handleDeleteIndicator(indicator.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Indicator Pill component - editable indicator display
|
|
function IndicatorPill({
|
|
indicator,
|
|
onUpdate,
|
|
onDelete,
|
|
}: {
|
|
indicator: ChartIndicator;
|
|
onUpdate: (updates: Partial<ChartIndicator>) => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editParams, setEditParams] = useState(indicator.params);
|
|
|
|
const handleSave = () => {
|
|
onUpdate({ params: editParams });
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const displayName = getIndicatorDisplayName(indicator.type);
|
|
const paramLabel = getIndicatorParamLabel(indicator);
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<div className="flex items-center gap-2 px-2 py-1 bg-background border border-border rounded-md text-xs">
|
|
<div
|
|
className="w-3 h-3 rounded-full cursor-pointer"
|
|
style={{ backgroundColor: indicator.color }}
|
|
onClick={() => {
|
|
const currentIndex = INDICATOR_COLORS.indexOf(indicator.color);
|
|
const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length;
|
|
onUpdate({ color: INDICATOR_COLORS[nextIndex] });
|
|
}}
|
|
title="Click to change color"
|
|
/>
|
|
<span className="font-medium text-foreground">{displayName}</span>
|
|
|
|
{/* Parameter inputs based on indicator type */}
|
|
{(indicator.type === 'sma' || indicator.type === 'ema' || indicator.type === 'rsi') && (
|
|
<Input
|
|
type="number"
|
|
value={editParams.period || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })}
|
|
className="h-5 w-12 px-1 text-xs"
|
|
min={1}
|
|
/>
|
|
)}
|
|
|
|
{indicator.type === 'boll' && (
|
|
<>
|
|
<Input
|
|
type="number"
|
|
value={editParams.period || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, period: parseInt(e.target.value) || 1 })}
|
|
className="h-5 w-12 px-1 text-xs"
|
|
min={1}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={editParams.stdDev || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, stdDev: parseFloat(e.target.value) || 1 })}
|
|
className="h-5 w-10 px-1 text-xs"
|
|
min={0.1}
|
|
step={0.1}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{indicator.type === 'macd' && (
|
|
<>
|
|
<Input
|
|
type="number"
|
|
value={editParams.fastPeriod || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, fastPeriod: parseInt(e.target.value) || 1 })}
|
|
className="h-5 w-10 px-1 text-xs"
|
|
min={1}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={editParams.slowPeriod || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, slowPeriod: parseInt(e.target.value) || 1 })}
|
|
className="h-5 w-10 px-1 text-xs"
|
|
min={1}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={editParams.signalPeriod || ''}
|
|
onChange={(e) => setEditParams({ ...editParams, signalPeriod: parseInt(e.target.value) || 1 })}
|
|
className="h-5 w-10 px-1 text-xs"
|
|
min={1}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-xs"
|
|
onClick={handleSave}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-xs text-muted-foreground"
|
|
onClick={() => {
|
|
setEditParams(indicator.params);
|
|
setIsEditing(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded-md text-xs group">
|
|
<div
|
|
className="w-3 h-3 rounded-full cursor-pointer"
|
|
style={{ backgroundColor: indicator.color }}
|
|
onClick={() => {
|
|
const currentIndex = INDICATOR_COLORS.indexOf(indicator.color);
|
|
const nextIndex = (currentIndex + 1) % INDICATOR_COLORS.length;
|
|
onUpdate({ color: INDICATOR_COLORS[nextIndex] });
|
|
}}
|
|
title="Click to change color"
|
|
/>
|
|
<span
|
|
className="font-medium text-foreground cursor-pointer hover:underline"
|
|
onClick={() => setIsEditing(true)}
|
|
title="Click to edit parameters"
|
|
>
|
|
{displayName}
|
|
</span>
|
|
<span className="text-muted-foreground">{paramLabel}</span>
|
|
<button
|
|
className="ml-1 p-0.5 text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={onDelete}
|
|
title="Remove indicator"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Draw Bar component - displays inline in the toolbar when drawing
|
|
function DrawBar({
|
|
pendingPoint,
|
|
lineType,
|
|
color,
|
|
onCancel
|
|
}: {
|
|
pendingPoint: { time: number; price: number } | null;
|
|
lineType: string;
|
|
color: string;
|
|
onCancel: () => void;
|
|
}) {
|
|
const lineTypeLabel = lineType === 'line' ? 'Line' : lineType === 'arrow' ? 'Arrow' : 'Dotted';
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-sm"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span className="text-muted-foreground">{lineTypeLabel}</span>
|
|
</div>
|
|
{pendingPoint ? (
|
|
<>
|
|
<span className="text-foreground">
|
|
Point 1: ${pendingPoint.price.toFixed(2)}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
Click to set end point
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={onCancel}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">
|
|
Click to set start point
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Measure Bar component - displays inline in the toolbar
|
|
function MeasureBar({
|
|
points,
|
|
onClear
|
|
}: {
|
|
points: MeasurePoint[];
|
|
onClear: () => void;
|
|
}) {
|
|
if (points.length < 2) {
|
|
return (
|
|
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
|
|
<span className="text-muted-foreground">
|
|
Click to add measure points ({points.length}/2+)
|
|
</span>
|
|
{points.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={onClear}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Calculate total difference from first to last point
|
|
const firstPrice = points[0].price;
|
|
const lastPrice = points[points.length - 1].price;
|
|
const totalDiff = lastPrice - firstPrice;
|
|
const totalPercent = (totalDiff / firstPrice) * 100;
|
|
const isPositive = totalDiff >= 0;
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">Points</span>
|
|
<span className="font-mono text-foreground">{points.length}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">Start</span>
|
|
<span className="font-mono text-foreground">${firstPrice.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">End</span>
|
|
<span className="font-mono text-foreground">${lastPrice.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">Change</span>
|
|
<span
|
|
className={cn(
|
|
'font-mono font-medium',
|
|
isPositive ? 'text-emerald-500' : 'text-red-500'
|
|
)}
|
|
>
|
|
{isPositive ? '+' : ''}${totalDiff.toFixed(2)} ({isPositive ? '+' : ''}{totalPercent.toFixed(2)}%)
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={onClear}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// OHLC Bar component - displays inline in the toolbar
|
|
function OHLCBar({ candle }: { candle: Candle }) {
|
|
const isBullish = candle.close >= candle.open;
|
|
const changePercent = ((candle.close - candle.open) / candle.open) * 100;
|
|
|
|
const formatTime = (timestamp: number) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
};
|
|
|
|
const formatNumber = (num: number) => {
|
|
return num.toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
});
|
|
};
|
|
|
|
const formatVolume = (num: number) => {
|
|
if (num >= 1000000) return (num / 1000000).toFixed(2) + 'M';
|
|
if (num >= 1000) return (num / 1000).toFixed(2) + 'K';
|
|
return num.toString();
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 text-xs border-l border-border pl-4">
|
|
<span className="text-muted-foreground">{formatTime(candle.time)}</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">O</span>
|
|
<span className="font-mono text-foreground">{formatNumber(candle.open)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">H</span>
|
|
<span className="font-mono text-foreground">{formatNumber(candle.high)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">L</span>
|
|
<span className="font-mono text-foreground">{formatNumber(candle.low)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">C</span>
|
|
<span
|
|
className={cn(
|
|
'font-mono',
|
|
isBullish ? 'text-emerald-500' : 'text-red-500'
|
|
)}
|
|
>
|
|
{formatNumber(candle.close)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">Vol</span>
|
|
<span className="font-mono text-foreground">{formatVolume(candle.volume)}</span>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'font-mono font-medium',
|
|
isBullish ? 'text-emerald-500' : 'text-red-500'
|
|
)}
|
|
>
|
|
{isBullish ? '+' : ''}{changePercent.toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Note component - floating editable text box on the chart
|
|
function NoteComponent({
|
|
note,
|
|
x,
|
|
y,
|
|
onUpdate,
|
|
onDelete,
|
|
}: {
|
|
note: ChartNote;
|
|
x: number;
|
|
y: number;
|
|
onUpdate: (updates: Partial<ChartNote>) => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [text, setText] = useState(note.text);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isEditing && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.select();
|
|
}
|
|
}, [isEditing]);
|
|
|
|
const handleSave = () => {
|
|
onUpdate({ text });
|
|
setIsEditing(false);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSave();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setText(note.text);
|
|
setIsEditing(false);
|
|
}
|
|
};
|
|
|
|
if (note.isMinimized) {
|
|
return (
|
|
<div
|
|
className="absolute z-20 pointer-events-auto"
|
|
style={{ left: x, top: y, transform: 'translate(-50%, -50%)' }}
|
|
>
|
|
<button
|
|
className="flex items-center justify-center h-6 w-6 rounded-full bg-yellow-500 hover:bg-yellow-400 text-yellow-950 shadow-md transition-colors"
|
|
onClick={() => onUpdate({ isMinimized: false })}
|
|
title="Expand note"
|
|
>
|
|
<StickyNote className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="absolute z-20 pointer-events-auto"
|
|
style={{ left: x, top: y, transform: 'translate(-8px, -8px)' }}
|
|
>
|
|
<div className="bg-yellow-100 dark:bg-yellow-900/90 border border-yellow-300 dark:border-yellow-700 rounded-md shadow-lg min-w-[160px] max-w-[240px]">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-2 py-1 border-b border-yellow-300 dark:border-yellow-700 bg-yellow-200/50 dark:bg-yellow-800/50 rounded-t-md">
|
|
<span className="text-[10px] text-yellow-800 dark:text-yellow-200 font-medium">
|
|
${note.price.toFixed(2)}
|
|
</span>
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
className="p-0.5 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
|
|
onClick={() => onUpdate({ isMinimized: true })}
|
|
title="Minimize"
|
|
>
|
|
<Minimize2 className="h-3 w-3 text-yellow-700 dark:text-yellow-300" />
|
|
</button>
|
|
<button
|
|
className="p-0.5 hover:bg-red-300 dark:hover:bg-red-700 rounded transition-colors"
|
|
onClick={onDelete}
|
|
title="Delete"
|
|
>
|
|
<X className="h-3 w-3 text-yellow-700 dark:text-yellow-300" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-2">
|
|
{isEditing ? (
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={handleSave}
|
|
onKeyDown={handleKeyDown}
|
|
className="w-full min-h-[60px] p-1 text-xs bg-yellow-50 dark:bg-yellow-950 border border-yellow-300 dark:border-yellow-600 rounded resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500 text-yellow-900 dark:text-yellow-100"
|
|
placeholder="Enter note..."
|
|
/>
|
|
) : (
|
|
<div
|
|
className="text-xs text-yellow-900 dark:text-yellow-100 min-h-[40px] cursor-text whitespace-pre-wrap"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
{note.text || (
|
|
<span className="text-yellow-600 dark:text-yellow-400 italic">
|
|
Click to add note...
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Draw Line component - renders a line/arrow/dotted line on the chart
|
|
function DrawLineComponent({
|
|
line,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
onDelete,
|
|
}: {
|
|
line: DrawLine;
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
onDelete: () => void;
|
|
}) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
const getStrokeDasharray = () => {
|
|
if (line.lineType === 'dotted') return '4,4';
|
|
return 'none';
|
|
};
|
|
|
|
const getMarkerEnd = () => {
|
|
if (line.lineType === 'arrow') return `url(#arrowhead-${line.id})`;
|
|
return undefined;
|
|
};
|
|
|
|
// Calculate midpoint for delete button
|
|
const midX = (x1 + x2) / 2;
|
|
const midY = (y1 + y2) / 2;
|
|
|
|
return (
|
|
<g
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
style={{ pointerEvents: 'auto' }}
|
|
>
|
|
{/* Arrow marker definition for this line */}
|
|
{line.lineType === 'arrow' && (
|
|
<defs>
|
|
<marker
|
|
id={`arrowhead-${line.id}`}
|
|
markerWidth="10"
|
|
markerHeight="7"
|
|
refX="9"
|
|
refY="3.5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={line.color} />
|
|
</marker>
|
|
</defs>
|
|
)}
|
|
|
|
{/* Invisible wider line for easier hover */}
|
|
<line
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke="transparent"
|
|
strokeWidth={12}
|
|
style={{ cursor: 'pointer' }}
|
|
/>
|
|
|
|
{/* Visible line */}
|
|
<line
|
|
x1={x1}
|
|
y1={y1}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke={line.color}
|
|
strokeWidth={2}
|
|
strokeDasharray={getStrokeDasharray()}
|
|
markerEnd={getMarkerEnd()}
|
|
style={{ cursor: 'pointer' }}
|
|
/>
|
|
|
|
{/* Start point */}
|
|
<circle
|
|
cx={x1}
|
|
cy={y1}
|
|
r={4}
|
|
fill={line.color}
|
|
stroke="white"
|
|
strokeWidth={1}
|
|
/>
|
|
|
|
{/* End point */}
|
|
<circle
|
|
cx={x2}
|
|
cy={y2}
|
|
r={4}
|
|
fill={line.color}
|
|
stroke="white"
|
|
strokeWidth={1}
|
|
/>
|
|
|
|
{/* Delete button on hover */}
|
|
{isHovered && (
|
|
<g
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<circle
|
|
cx={midX}
|
|
cy={midY}
|
|
r={10}
|
|
fill="#ef4444"
|
|
stroke="white"
|
|
strokeWidth={1}
|
|
/>
|
|
<line
|
|
x1={midX - 4}
|
|
y1={midY - 4}
|
|
x2={midX + 4}
|
|
y2={midY + 4}
|
|
stroke="white"
|
|
strokeWidth={2}
|
|
/>
|
|
<line
|
|
x1={midX + 4}
|
|
y1={midY - 4}
|
|
x2={midX - 4}
|
|
y2={midY + 4}
|
|
stroke="white"
|
|
strokeWidth={2}
|
|
/>
|
|
</g>
|
|
)}
|
|
</g>
|
|
);
|
|
}
|