chore: initial import
This commit is contained in:
182
apps/visualizer/src/features/chart/ChartIcons.tsx
Normal file
182
apps/visualizer/src/features/chart/ChartIcons.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
type IconProps = SVGAttributes<SVGSVGElement> & { title?: string };
|
||||
|
||||
function Svg({ title, children, ...rest }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={title ? undefined : true}
|
||||
role={title ? 'img' : 'presentation'}
|
||||
{...rest}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCursor(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Cursor'} {...props}>
|
||||
<path d="M4 2.5L13 9.2L9.1 10.2L11.3 15.5L9.6 16.3L7.4 11L4.8 14.3L4 2.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCrosshair(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Crosshair'} {...props}>
|
||||
<path d="M9 2.2V5.0M9 13.0V15.8M2.2 9H5.0M13.0 9H15.8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<circle cx="9" cy="9" r="2.3" stroke="currentColor" strokeWidth="1.3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Plus'} {...props}>
|
||||
<path d="M9 3.2V14.8M3.2 9H14.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrendline(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Trendline'} {...props}>
|
||||
<path d="M3 12.8L7.2 9.1L10.2 11L15 5.2" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<circle cx="3" cy="12.8" r="1" fill="currentColor" />
|
||||
<circle cx="7.2" cy="9.1" r="1" fill="currentColor" />
|
||||
<circle cx="10.2" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="5.2" r="1" fill="currentColor" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFib(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Fibonacci'} {...props}>
|
||||
<path
|
||||
d="M3 13.8C5.4 13.8 6.9 12.5 7.8 10.9C8.8 9.2 9.9 7.2 15 7.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M3.2 10.4H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.8" />
|
||||
<path d="M3.2 7.0H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.65" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBrush(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Brush'} {...props}>
|
||||
<path
|
||||
d="M12.8 3.6C13.9 4.7 13.9 6.4 12.8 7.5L8.5 11.8L6.2 12.2L6.6 9.9L10.9 5.6C12 4.5 12 3.6 12.8 3.6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M5.8 12.2C5.8 14.0 4.6 15.2 2.8 15.2C4.0 14.6 4.6 14.0 4.6 13.2C4.6 12.4 5.1 12.0 5.8 12.2Z" fill="currentColor" opacity="0.9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconText(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Text'} {...props}>
|
||||
<path d="M4 4.2H14M9 4.2V14.2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSmile(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Emoji'} {...props}>
|
||||
<circle cx="9" cy="9" r="6.2" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M6.6 7.6H6.7M11.3 7.6H11.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M6.4 10.5C7.2 11.8 8.2 12.4 9 12.4C9.8 12.4 10.8 11.8 11.6 10.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRuler(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Ruler'} {...props}>
|
||||
<path d="M4.2 12.8L12.8 4.2L14.8 6.2L6.2 14.8L4.2 12.8Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
<path d="M7.0 12.2L5.8 11.0M8.6 10.6L7.8 9.8M10.2 9.0L9.4 8.2M11.8 7.4L10.6 6.2" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" opacity="0.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconZoom(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Zoom'} {...props}>
|
||||
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M8 6.3V9.7M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconZoomOut(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Zoom Out'} {...props}>
|
||||
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLock(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Lock'} {...props}>
|
||||
<path d="M5.6 8.0V6.6C5.6 4.7 7.1 3.2 9 3.2C10.9 3.2 12.4 4.7 12.4 6.6V8.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M5.0 8.0H13.0V14.8H5.0V8.0Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEye(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Visibility'} {...props}>
|
||||
<path
|
||||
d="M1.8 9.0C3.5 6.0 6.1 4.2 9.0 4.2C11.9 4.2 14.5 6.0 16.2 9.0C14.5 12.0 11.9 13.8 9.0 13.8C6.1 13.8 3.5 12.0 1.8 9.0Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="9" r="2.1" stroke="currentColor" strokeWidth="1.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||
<path d="M5.2 5.6H12.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M7.0 5.6V4.6C7.0 3.8 7.6 3.2 8.4 3.2H9.6C10.4 3.2 11.0 3.8 11.0 4.6V5.6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M6.0 6.6L6.6 14.6H11.4L12.0 6.6" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
<path d="M7.8 8.3V13.0M10.2 8.3V13.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.85" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconResetView(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Reset View'} {...props}>
|
||||
<path
|
||||
d="M13.8 8.0A5.8 5.8 0 1 0 9.0 14.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M13.9 3.8V8.0H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
177
apps/visualizer/src/features/chart/ChartPanel.tsx
Normal file
177
apps/visualizer/src/features/chart/ChartPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||
import Card from '../../ui/Card';
|
||||
import ChartSideToolbar from './ChartSideToolbar';
|
||||
import ChartToolbar from './ChartToolbar';
|
||||
import TradingChart from './TradingChart';
|
||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||
import type { IChartApi } from 'lightweight-charts';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
timeframe: string;
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
seriesLabel: string;
|
||||
};
|
||||
|
||||
export default function ChartPanel({
|
||||
candles,
|
||||
indicators,
|
||||
timeframe,
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
seriesLabel,
|
||||
}: Props) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const chartApiRef = useRef<IChartApi | null>(null);
|
||||
const activeToolRef = useRef(activeTool);
|
||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
||||
return () => document.body.classList.remove('chartFullscreen');
|
||||
}, [isFullscreen]);
|
||||
|
||||
const cardClassName = useMemo(() => {
|
||||
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
if (activeTool !== 'fib-retracement') {
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
}
|
||||
}, [activeTool]);
|
||||
|
||||
useEffect(() => {
|
||||
fibStartRef.current = fibStart;
|
||||
}, [fibStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function zoomTime(factor: number) {
|
||||
const chart = chartApiRef.current;
|
||||
if (!chart) return;
|
||||
const ts = chart.timeScale();
|
||||
const range = ts.getVisibleLogicalRange();
|
||||
if (!range) return;
|
||||
const from = range.from as number;
|
||||
const to = range.to as number;
|
||||
const span = Math.max(5, (to - from) * factor);
|
||||
const center = (from + to) / 2;
|
||||
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||
<Card className={cardClassName}>
|
||||
<div className="chartCard__toolbar">
|
||||
<ChartToolbar
|
||||
timeframe={timeframe}
|
||||
onTimeframeChange={onTimeframeChange}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={onToggleIndicators}
|
||||
seriesLabel={seriesLabel}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="chartCard__content">
|
||||
<ChartSideToolbar
|
||||
timeframe={timeframe}
|
||||
activeTool={activeTool}
|
||||
hasFib={fib != null || fibDraft != null}
|
||||
onToolChange={setActiveTool}
|
||||
onZoomIn={() => zoomTime(0.8)}
|
||||
onZoomOut={() => zoomTime(1.25)}
|
||||
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
||||
onClearFib={() => {
|
||||
setFib(null);
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
}}
|
||||
/>
|
||||
<div className="chartCard__chart">
|
||||
<TradingChart
|
||||
candles={candles}
|
||||
oracle={indicators.oracle}
|
||||
sma20={indicators.sma20}
|
||||
ema20={indicators.ema20}
|
||||
bb20={indicators.bb20}
|
||||
showIndicators={showIndicators}
|
||||
fib={fibDraft ?? fib}
|
||||
onReady={({ chart }) => {
|
||||
chartApiRef.current = chart;
|
||||
}}
|
||||
onChartClick={(p) => {
|
||||
if (activeTool !== 'fib-retracement') return;
|
||||
if (!fibStartRef.current) {
|
||||
fibStartRef.current = p;
|
||||
setFibStart(p);
|
||||
setFibDraft({ a: p, b: p });
|
||||
return;
|
||||
}
|
||||
setFib({ a: fibStartRef.current, b: p });
|
||||
setFibStart(null);
|
||||
fibStartRef.current = null;
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
}}
|
||||
onChartCrosshairMove={(p) => {
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
const start = fibStartRef.current;
|
||||
if (!start) return;
|
||||
pendingMoveRef.current = p;
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const move = pendingMoveRef.current;
|
||||
const start2 = fibStartRef.current;
|
||||
if (!move || !start2) return;
|
||||
setFibDraft({ a: start2, b: move });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
220
apps/visualizer/src/features/chart/ChartSideToolbar.tsx
Normal file
220
apps/visualizer/src/features/chart/ChartSideToolbar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ChartToolMenu, { type ToolMenuSection } from './ChartToolMenu';
|
||||
import {
|
||||
IconBrush,
|
||||
IconCrosshair,
|
||||
IconCursor,
|
||||
IconEye,
|
||||
IconFib,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRuler,
|
||||
IconSmile,
|
||||
IconText,
|
||||
IconResetView,
|
||||
IconTrash,
|
||||
IconTrendline,
|
||||
IconZoom,
|
||||
IconZoomOut,
|
||||
} from './ChartIcons';
|
||||
|
||||
type ActiveTool = 'cursor' | 'fib-retracement';
|
||||
|
||||
type Props = {
|
||||
timeframe: string;
|
||||
activeTool: ActiveTool;
|
||||
hasFib: boolean;
|
||||
onToolChange: (tool: ActiveTool) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
onClearFib: () => void;
|
||||
};
|
||||
|
||||
export default function ChartSideToolbar({
|
||||
timeframe,
|
||||
activeTool,
|
||||
hasFib,
|
||||
onToolChange,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
onClearFib,
|
||||
}: Props) {
|
||||
const [openMenu, setOpenMenu] = useState<'fib' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenMenu(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [openMenu]);
|
||||
|
||||
const fibMenuSections: ToolMenuSection[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'fib',
|
||||
title: 'FIBONACCI',
|
||||
items: [
|
||||
{ id: 'fib-retracement', label: 'Fib Retracement', icon: <IconFib />, shortcut: 'Click 2 points' },
|
||||
{ id: 'fib-tb-ext', label: 'Trend-Based Fib Extension', icon: <IconTrendline /> },
|
||||
{ id: 'fib-channel', label: 'Fib Channel', icon: <IconFib /> },
|
||||
{ id: 'fib-time-zone', label: 'Fib Time Zone', icon: <IconFib /> },
|
||||
{ id: 'fib-speed-fan', label: 'Fib Speed Resistance Fan', icon: <IconFib /> },
|
||||
{ id: 'fib-tb-time', label: 'Trend-Based Fib Time', icon: <IconTrendline /> },
|
||||
{ id: 'fib-circles', label: 'Fib Circles', icon: <IconFib /> },
|
||||
{ id: 'fib-spiral', label: 'Fib Spiral', icon: <IconFib /> },
|
||||
{ id: 'fib-speed-arcs', label: 'Fib Speed Resistance Arcs', icon: <IconFib /> },
|
||||
{ id: 'fib-wedge', label: 'Fib Wedge', icon: <IconFib /> },
|
||||
{ id: 'pitchfan', label: 'Pitchfan', icon: <IconTrendline /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gann',
|
||||
title: 'GANN',
|
||||
items: [
|
||||
{ id: 'gann-box', label: 'Gann Box', icon: <IconTrendline /> },
|
||||
{ id: 'gann-square-fixed', label: 'Gann Square Fixed', icon: <IconTrendline /> },
|
||||
{ id: 'gann-fan', label: 'Gann Fan', icon: <IconTrendline /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chartTools">
|
||||
<div className="chartSideToolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={['chartToolBtn', activeTool === 'cursor' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||
title="Cursor"
|
||||
aria-label="Cursor"
|
||||
onClick={() => {
|
||||
onToolChange('cursor');
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconCursor />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Crosshair" aria-label="Crosshair" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconCrosshair />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Add" aria-label="Add" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconPlus />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Trendline" aria-label="Trendline" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconTrendline />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={['chartToolBtn', activeTool === 'fib-retracement' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||
title="Fibonacci"
|
||||
aria-label="Fibonacci"
|
||||
onClick={() => setOpenMenu((m) => (m === 'fib' ? null : 'fib'))}
|
||||
>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconFib />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Brush" aria-label="Brush" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconBrush />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Text" aria-label="Text" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconText />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Emoji" aria-label="Emoji" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconSmile />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Ruler" aria-label="Ruler" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconRuler />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="chartToolBtn__spacer" />
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Zoom In" aria-label="Zoom In" onClick={onZoomIn}>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconZoom />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Zoom Out" aria-label="Zoom Out" onClick={onZoomOut}>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconZoomOut />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Reset View" aria-label="Reset View" onClick={onResetView}>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconResetView />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Lock" aria-label="Lock" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconLock />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="chartToolBtn"
|
||||
title="Clear Fib"
|
||||
aria-label="Clear Fib"
|
||||
onClick={onClearFib}
|
||||
disabled={!hasFib}
|
||||
>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconTrash />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconEye />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openMenu === 'fib' ? (
|
||||
<>
|
||||
<div className="chartToolMenuBackdrop" onClick={() => setOpenMenu(null)} />
|
||||
<ChartToolMenu
|
||||
timeframeLabel={timeframe}
|
||||
sections={fibMenuSections}
|
||||
onSelectItem={(id) => {
|
||||
if (id === 'fib-retracement') onToolChange('fib-retracement');
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/visualizer/src/features/chart/ChartToolMenu.tsx
Normal file
54
apps/visualizer/src/features/chart/ChartToolMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ToolMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
export type ToolMenuSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: ToolMenuItem[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
timeframeLabel: string;
|
||||
sections: ToolMenuSection[];
|
||||
onSelectItem?: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function ChartToolMenu({ timeframeLabel, sections, onSelectItem }: Props) {
|
||||
return (
|
||||
<div className="chartToolMenu" role="dialog" aria-label="Chart tools">
|
||||
<div className="chartToolMenu__top">
|
||||
<div className="chartToolMenu__tf">{timeframeLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="chartToolMenu__body">
|
||||
{sections.map((s) => (
|
||||
<div key={s.id} className="chartToolMenu__section">
|
||||
<div className="chartToolMenu__sectionTitle">{s.title}</div>
|
||||
<div className="chartToolMenu__items">
|
||||
{s.items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
className="chartToolMenuItem"
|
||||
type="button"
|
||||
onClick={() => onSelectItem?.(it.id)}
|
||||
>
|
||||
<span className="chartToolMenuItem__icon" aria-hidden="true">
|
||||
{it.icon}
|
||||
</span>
|
||||
<span className="chartToolMenuItem__label">{it.label}</span>
|
||||
{it.shortcut ? <span className="chartToolMenuItem__shortcut">{it.shortcut}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/visualizer/src/features/chart/ChartToolbar.tsx
Normal file
51
apps/visualizer/src/features/chart/ChartToolbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Button from '../../ui/Button';
|
||||
|
||||
type Props = {
|
||||
timeframe: string;
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
seriesLabel: string;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
|
||||
export default function ChartToolbar({
|
||||
timeframe,
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
seriesLabel,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="chartToolbar">
|
||||
<div className="chartToolbar__group">
|
||||
{timeframes.map((tf) => (
|
||||
<Button
|
||||
key={tf}
|
||||
size="sm"
|
||||
variant={tf === timeframe ? 'primary' : 'ghost'}
|
||||
onClick={() => onTimeframeChange(tf)}
|
||||
type="button"
|
||||
>
|
||||
{tf}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chartToolbar__group">
|
||||
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||
Indicators
|
||||
</Button>
|
||||
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||
</Button>
|
||||
<div className="chartToolbar__meta">{seriesLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
apps/visualizer/src/features/chart/FibRetracementPrimitive.ts
Normal file
199
apps/visualizer/src/features/chart/FibRetracementPrimitive.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
IPrimitivePaneRenderer,
|
||||
IPrimitivePaneView,
|
||||
ISeriesApi,
|
||||
ISeriesPrimitive,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export type FibAnchor = {
|
||||
logical: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type FibRetracement = {
|
||||
a: FibAnchor;
|
||||
b: FibAnchor;
|
||||
};
|
||||
|
||||
type FibLevel = {
|
||||
ratio: number;
|
||||
line: string;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
const LEVELS: FibLevel[] = [
|
||||
{ ratio: 4.236, line: 'rgba(255, 45, 85, 0.95)', fill: 'rgba(255, 45, 85, 0.16)' },
|
||||
{ ratio: 3.618, line: 'rgba(192, 132, 252, 0.95)', fill: 'rgba(192, 132, 252, 0.14)' },
|
||||
{ ratio: 2.618, line: 'rgba(239, 68, 68, 0.92)', fill: 'rgba(239, 68, 68, 0.14)' },
|
||||
{ ratio: 1.618, line: 'rgba(59, 130, 246, 0.92)', fill: 'rgba(59, 130, 246, 0.14)' },
|
||||
{ ratio: 1.0, line: 'rgba(148, 163, 184, 0.92)', fill: 'rgba(59, 130, 246, 0.10)' },
|
||||
{ ratio: 0.786, line: 'rgba(96, 165, 250, 0.92)', fill: 'rgba(96, 165, 250, 0.10)' },
|
||||
{ ratio: 0.618, line: 'rgba(6, 182, 212, 0.92)', fill: 'rgba(6, 182, 212, 0.10)' },
|
||||
{ ratio: 0.5, line: 'rgba(34, 197, 94, 0.92)', fill: 'rgba(34, 197, 94, 0.09)' },
|
||||
{ ratio: 0.382, line: 'rgba(245, 158, 11, 0.92)', fill: 'rgba(245, 158, 11, 0.10)' },
|
||||
{ ratio: 0.236, line: 'rgba(249, 115, 22, 0.92)', fill: 'rgba(249, 115, 22, 0.10)' },
|
||||
{ ratio: 0.0, line: 'rgba(163, 163, 163, 0.85)', fill: 'rgba(163, 163, 163, 0.06)' },
|
||||
];
|
||||
|
||||
function formatRatio(r: number): string {
|
||||
if (Number.isInteger(r)) return String(r);
|
||||
const s = r.toFixed(3);
|
||||
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatPrice(p: number): string {
|
||||
if (!Number.isFinite(p)) return '—';
|
||||
if (Math.abs(p) >= 1000) return p.toFixed(0);
|
||||
if (Math.abs(p) >= 1) return p.toFixed(2);
|
||||
return p.toPrecision(4);
|
||||
}
|
||||
|
||||
type State = {
|
||||
fib: FibRetracement | null;
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
};
|
||||
|
||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
private readonly _getState: () => State;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._getState = getState;
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { fib, series, chart } = this._getState();
|
||||
if (!fib || !series || !chart) return;
|
||||
|
||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||
if (x1 == null || x2 == null) return;
|
||||
|
||||
const xLeftMedia = Math.min(x1, x2);
|
||||
const xRightMedia = Math.max(x1, x2);
|
||||
const p0 = fib.a.price;
|
||||
const p1 = fib.b.price;
|
||||
const delta = p1 - p0;
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||
const w = xEnd - xStart;
|
||||
|
||||
const points = LEVELS.map((l) => {
|
||||
const price = p0 + delta * l.ratio;
|
||||
const y = series.priceToCoordinate(price);
|
||||
return { ...l, price, y };
|
||||
}).filter((p) => p.y != null) as Array<FibLevel & { price: number; y: number }>;
|
||||
|
||||
if (!points.length) return;
|
||||
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
const ya = Math.round(a.y * verticalPixelRatio);
|
||||
const yb = Math.round(b.y * verticalPixelRatio);
|
||||
const top = Math.min(ya, yb);
|
||||
const h = Math.abs(yb - ya);
|
||||
if (h <= 0) continue;
|
||||
context.fillStyle = a.fill;
|
||||
context.fillRect(xStart, top, w, h);
|
||||
}
|
||||
|
||||
const lineW = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.lineWidth = lineW;
|
||||
|
||||
const labelX = Math.round(Math.max(6, xLeftMedia - 8) * horizontalPixelRatio);
|
||||
context.font = `${Math.max(10, Math.round(11 * verticalPixelRatio))}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif`;
|
||||
context.textAlign = 'right';
|
||||
context.textBaseline = 'middle';
|
||||
|
||||
for (const pt of points) {
|
||||
const y = Math.round(pt.y * verticalPixelRatio);
|
||||
context.strokeStyle = pt.line;
|
||||
context.beginPath();
|
||||
context.moveTo(xStart, y);
|
||||
context.lineTo(xEnd, y);
|
||||
context.stroke();
|
||||
|
||||
const label = `${formatRatio(pt.ratio)} (${formatPrice(pt.price)})`;
|
||||
context.fillStyle = pt.line;
|
||||
context.fillText(label, labelX, y);
|
||||
}
|
||||
|
||||
const y0 = series.priceToCoordinate(p0);
|
||||
const y1 = series.priceToCoordinate(p1);
|
||||
if (y0 != null && y1 != null) {
|
||||
const ax = Math.round(x1 * horizontalPixelRatio);
|
||||
const ay = Math.round(y0 * verticalPixelRatio);
|
||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||
const by = Math.round(y1 * verticalPixelRatio);
|
||||
|
||||
context.strokeStyle = 'rgba(226,232,240,0.55)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
|
||||
context.fillStyle = 'rgba(147,197,253,0.95)';
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.beginPath();
|
||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FibPaneView implements IPrimitivePaneView {
|
||||
private readonly _renderer: FibPaneRenderer;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._renderer = new FibPaneRenderer(getState);
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return this._renderer;
|
||||
}
|
||||
}
|
||||
|
||||
export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _fib: FibRetracement | null = null;
|
||||
private readonly _paneView: FibPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>) {
|
||||
this._param = param;
|
||||
this._series = param.series as ISeriesApi<'Candlestick', Time>;
|
||||
}
|
||||
|
||||
detached() {
|
||||
this._param = null;
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return this._paneViews;
|
||||
}
|
||||
|
||||
setFib(next: FibRetracement | null) {
|
||||
this._fib = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal file
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
HistogramSeries,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
LineStyle,
|
||||
LineSeries,
|
||||
createChart,
|
||||
type UTCTimestamp,
|
||||
type CandlestickData,
|
||||
type HistogramData,
|
||||
type LineData,
|
||||
type WhitespaceData,
|
||||
} from 'lightweight-charts';
|
||||
import type { Candle, SeriesPoint } from '../../lib/api';
|
||||
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
oracle?: SeriesPoint[];
|
||||
sma20?: SeriesPoint[];
|
||||
ema20?: SeriesPoint[];
|
||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||
showIndicators: boolean;
|
||||
fib?: FibRetracement | null;
|
||||
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
|
||||
onChartClick?: (p: FibAnchor) => void;
|
||||
onChartCrosshairMove?: (p: FibAnchor) => void;
|
||||
};
|
||||
|
||||
type LinePoint = LineData | WhitespaceData;
|
||||
|
||||
function toTime(t: number): UTCTimestamp {
|
||||
return t as UTCTimestamp;
|
||||
}
|
||||
|
||||
function toCandleData(candles: Candle[]): CandlestickData[] {
|
||||
return candles.map((c) => ({
|
||||
time: toTime(c.time),
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
}));
|
||||
}
|
||||
|
||||
function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||
return candles.map((c) => {
|
||||
const up = c.close >= c.open;
|
||||
return {
|
||||
time: toTime(c.time),
|
||||
value: c.volume ?? 0,
|
||||
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
||||
if (!points?.length) return [];
|
||||
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
||||
}
|
||||
|
||||
export default function TradingChart({
|
||||
candles,
|
||||
oracle,
|
||||
sma20,
|
||||
ema20,
|
||||
bb20,
|
||||
showIndicators,
|
||||
fib,
|
||||
onReady,
|
||||
onChartClick,
|
||||
onChartCrosshairMove,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
|
||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||
const seriesRef = useRef<{
|
||||
candles?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
oracle?: ISeriesApi<'Line'>;
|
||||
sma20?: ISeriesApi<'Line'>;
|
||||
ema20?: ISeriesApi<'Line'>;
|
||||
bbUpper?: ISeriesApi<'Line'>;
|
||||
bbLower?: ISeriesApi<'Line'>;
|
||||
bbMid?: ISeriesApi<'Line'>;
|
||||
}>({});
|
||||
|
||||
const candleData = useMemo(() => toCandleData(candles), [candles]);
|
||||
const volumeData = useMemo(() => toVolumeData(candles), [candles]);
|
||||
const oracleData = useMemo(() => toLineSeries(oracle), [oracle]);
|
||||
const smaData = useMemo(() => toLineSeries(sma20), [sma20]);
|
||||
const emaData = useMemo(() => toLineSeries(ema20), [ema20]);
|
||||
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
|
||||
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
|
||||
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyRef.current = onReady;
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
onChartClickRef.current = onChartClick;
|
||||
}, [onChartClick]);
|
||||
|
||||
useEffect(() => {
|
||||
onChartCrosshairMoveRef.current = onChartCrosshairMove;
|
||||
}, [onChartCrosshairMove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
|
||||
textColor: '#e6e9ef',
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(255,255,255,0.06)' },
|
||||
horzLines: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
|
||||
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
|
||||
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
|
||||
handleScale: { mouseWheel: true, pinch: true },
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#22c55e',
|
||||
downColor: '#ef4444',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#22c55e',
|
||||
wickDownColor: '#ef4444',
|
||||
});
|
||||
|
||||
const fibPrimitive = new FibRetracementPrimitive();
|
||||
candleSeries.attachPrimitive(fibPrimitive);
|
||||
fibPrimitiveRef.current = fibPrimitive;
|
||||
fibPrimitive.setFib(fib ?? null);
|
||||
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: '',
|
||||
color: 'rgba(255,255,255,0.15)',
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
});
|
||||
|
||||
const oracleSeries = chart.addSeries(LineSeries, {
|
||||
color: 'rgba(251,146,60,0.9)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
});
|
||||
|
||||
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
|
||||
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
|
||||
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
|
||||
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
|
||||
const bbMidSeries = chart.addSeries(LineSeries, {
|
||||
color: 'rgba(250,204,21,0.35)',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
});
|
||||
|
||||
seriesRef.current = {
|
||||
candles: candleSeries,
|
||||
volume: volumeSeries,
|
||||
oracle: oracleSeries,
|
||||
sma20: smaSeries,
|
||||
ema20: emaSeries,
|
||||
bbUpper: bbUpperSeries,
|
||||
bbLower: bbLowerSeries,
|
||||
bbMid: bbMidSeries,
|
||||
};
|
||||
|
||||
onReadyRef.current?.({ chart, candles: candleSeries as ISeriesApi<'Candlestick', UTCTimestamp> });
|
||||
|
||||
const onClick = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
if (price == null) return;
|
||||
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
|
||||
};
|
||||
chart.subscribeClick(onClick);
|
||||
|
||||
const onCrosshairMove = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
if (price == null) return;
|
||||
onChartCrosshairMoveRef.current?.({ logical: Number(logical), price: Number(price) });
|
||||
};
|
||||
chart.subscribeCrosshairMove(onCrosshairMove);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
chart.unsubscribeClick(onClick);
|
||||
chart.unsubscribeCrosshairMove(onCrosshairMove);
|
||||
ro.disconnect();
|
||||
if (fibPrimitiveRef.current) {
|
||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||
fibPrimitiveRef.current = null;
|
||||
}
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles || !s.volume) return;
|
||||
s.candles.setData(candleData);
|
||||
s.volume.setData(volumeData);
|
||||
s.oracle?.setData(oracleData);
|
||||
s.sma20?.setData(smaData);
|
||||
s.ema20?.setData(emaData);
|
||||
s.bbUpper?.setData(bbUpper);
|
||||
s.bbLower?.setData(bbLower);
|
||||
s.bbMid?.setData(bbMid);
|
||||
|
||||
s.sma20?.applyOptions({ visible: showIndicators });
|
||||
s.ema20?.applyOptions({ visible: showIndicators });
|
||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
||||
|
||||
useEffect(() => {
|
||||
fibPrimitiveRef.current?.setFib(fib ?? null);
|
||||
}, [fib]);
|
||||
|
||||
return <div className="tradingChart" ref={containerRef} />;
|
||||
}
|
||||
57
apps/visualizer/src/features/chart/useChartData.ts
Normal file
57
apps/visualizer/src/features/chart/useChartData.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||
import { fetchChart } from '../../lib/api';
|
||||
import { useInterval } from '../../app/hooks/useInterval';
|
||||
|
||||
type Params = {
|
||||
symbol: string;
|
||||
source?: string;
|
||||
tf: string;
|
||||
limit: number;
|
||||
pollMs: number;
|
||||
};
|
||||
|
||||
type Result = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const fetchOnce = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchChart({ symbol, source, tf, limit });
|
||||
setCandles(res.candles);
|
||||
setIndicators(res.indicators);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [symbol, source, tf, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOnce();
|
||||
}, [fetchOnce]);
|
||||
|
||||
useInterval(() => void fetchOnce(), pollMs);
|
||||
|
||||
return useMemo(
|
||||
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
|
||||
[candles, indicators, loading, error, fetchOnce]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user