677 lines
24 KiB
TypeScript
677 lines
24 KiB
TypeScript
/**
|
|
* 차트 우측 세로 툴바 — 캔들·보조지표 pane 과 높이 동기화.
|
|
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
|
|
*/
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { IndicatorConfig } from '../types';
|
|
import { ChartManager } from '../utils/ChartManager';
|
|
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
|
import { isIndicatorPaneFocused, resolveFocusedPaneLayoutId } from '../utils/indicatorPaneFocus';
|
|
import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout';
|
|
import type { ChartOverlayToggleKey } from '../utils/chartOverlayVisibility';
|
|
|
|
export interface CandleOverlayToggleItem {
|
|
key: ChartOverlayToggleKey;
|
|
label: string;
|
|
visible: boolean;
|
|
}
|
|
|
|
export interface ChartRightToolbarProps {
|
|
manager: ChartManager;
|
|
indicators: IndicatorConfig[];
|
|
chartHeight: number;
|
|
paused?: boolean;
|
|
/** 캔들 pane 상단 — 이동평균·일목·볼린저 표시/숨김 (투자관리 차트 탭) */
|
|
candleOverlayToggles?: CandleOverlayToggleItem[];
|
|
onToggleCandleOverlay?: (key: ChartOverlayToggleKey) => void;
|
|
onSplit?: (hostId: string) => void;
|
|
onRemove?: (id: string) => void;
|
|
onDuplicate?: (id: string) => void;
|
|
onExpand?: (id: string) => void;
|
|
onRestore?: () => void;
|
|
focusedId?: string | null;
|
|
onDragStart?: (id: string, e: React.PointerEvent) => void;
|
|
/** 지표명 툴바 — 표시/숨기기 */
|
|
onToggleHidden?: (id: string) => void;
|
|
/** 지표명 툴바 — 설정 모달 */
|
|
onSettings?: (id: string) => void;
|
|
/** Custom / Custom1 오버레이 — 적용 여부 (동시 적용 불가, 설정은 슬롯별 분리) */
|
|
customOverlayActive?: boolean;
|
|
onCustomOverlayActiveChange?: (active: boolean) => void;
|
|
onOpenCustomOverlaySettings?: () => void;
|
|
custom1OverlayActive?: boolean;
|
|
onCustom1OverlayActiveChange?: (active: boolean) => void;
|
|
onOpenCustom1OverlaySettings?: () => void;
|
|
/** true — 캔들 pane 상단 오버레이 토글만 (미니 차트 등) */
|
|
overlayControlsOnly?: boolean;
|
|
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트) */
|
|
priceExtremeLabelsVisible?: boolean;
|
|
onTogglePriceExtremeLabels?: () => void;
|
|
}
|
|
|
|
const BTN_SIZE = 32;
|
|
const BTN_GAP = 2;
|
|
|
|
const IconCopy = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="4.5" y="1.5" width="8" height="8" rx="1"/>
|
|
<path d="M1.5 4.5v8h8"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconSplit = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="1,7 5,3 5,11"/>
|
|
<polyline points="13,7 9,3 9,11"/>
|
|
<line x1="5" y1="7" x2="9" y2="7"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconClose = () => (
|
|
<svg width="12" height="12" viewBox="0 0 11 11" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<line x1="1.5" y1="1.5" x2="9.5" y2="9.5"/>
|
|
<line x1="9.5" y1="1.5" x2="1.5" y2="9.5"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconExpand = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="1,9 1,13 5,13"/>
|
|
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
|
|
<polyline points="13,5 13,1 9,1"/>
|
|
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconRestore = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="5,13 1,13 1,9"/>
|
|
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
|
|
<polyline points="9,1 13,1 13,5"/>
|
|
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconDrag = () => (
|
|
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
|
|
<circle cx="2.5" cy="2.5" r="1.5"/>
|
|
<circle cx="7.5" cy="2.5" r="1.5"/>
|
|
<circle cx="2.5" cy="7" r="1.5"/>
|
|
<circle cx="7.5" cy="7" r="1.5"/>
|
|
<circle cx="2.5" cy="11.5" r="1.5"/>
|
|
<circle cx="7.5" cy="11.5" r="1.5"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconMore = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
<circle cx="7" cy="3" r="1.3"/>
|
|
<circle cx="7" cy="7" r="1.3"/>
|
|
<circle cx="7" cy="11" r="1.3"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconMa = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
|
<path d="M1 10 L5 4 L8 7 L13 2"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconBollinger = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
|
<path d="M1 4 Q7 1 13 4"/>
|
|
<path d="M1 7 Q7 4 13 7"/>
|
|
<path d="M1 10 Q7 13 13 10"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconCandle = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
|
<line x1="4" y1="2" x2="4" y2="12"/>
|
|
<rect x="2.5" y="5" width="3" height="4" rx="0.5" fill="currentColor" stroke="none"/>
|
|
<line x1="10" y1="3" x2="10" y2="11"/>
|
|
<rect x="8.5" y="4" width="3" height="5" rx="0.5" fill="currentColor" stroke="none"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconIchimoku = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1 9 L7 5 L13 9" fill="rgba(239,83,80,0.25)"/>
|
|
<path d="M1 5 L7 9 L13 5" fill="rgba(38,166,154,0.25)"/>
|
|
<path d="M1 9 L7 5 L13 9"/>
|
|
<path d="M1 5 L7 9 L13 5"/>
|
|
</svg>
|
|
);
|
|
|
|
/** 신고가(상향)·신저가(하향) */
|
|
const IconPriceExtreme = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
|
|
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
|
|
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
|
|
</svg>
|
|
);
|
|
|
|
const OVERLAY_ICONS: Record<ChartOverlayToggleKey, React.ReactNode> = {
|
|
ma: <IconMa />,
|
|
bollinger: <IconBollinger />,
|
|
ichimoku: <IconIchimoku />,
|
|
candle: <IconCandle />,
|
|
};
|
|
|
|
const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
|
|
<line x1="2" y1="2" x2="12" y2="12"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
|
|
<circle cx="7" cy="7" r="2"/>
|
|
</svg>
|
|
);
|
|
|
|
const CUSTOM_MENU_ID = '__custom_overlay__';
|
|
const CUSTOM1_MENU_ID = '__custom1_overlay__';
|
|
|
|
const IconCustom = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M2 4h10M2 7h10M2 10h6"/>
|
|
<circle cx="10.5" cy="10" r="1.2" fill="currentColor" stroke="none"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconCheck = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="2,7.5 5.5,11 12,3"/>
|
|
</svg>
|
|
);
|
|
|
|
const IconGear = () => (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
|
<circle cx="7" cy="7" r="2.3"/>
|
|
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
|
</svg>
|
|
);
|
|
|
|
const CustomOverlayFlyoutMenu: React.FC<{
|
|
title: string;
|
|
flyoutPos: { top: number; left: number };
|
|
overlayActive: boolean;
|
|
onToggleActive: () => void;
|
|
onOpenSettings: () => void;
|
|
onCancelClose: () => void;
|
|
onScheduleClose: () => void;
|
|
onCloseFlyout: () => void;
|
|
}> = ({
|
|
title,
|
|
flyoutPos,
|
|
overlayActive,
|
|
onToggleActive,
|
|
onOpenSettings,
|
|
onCancelClose,
|
|
onScheduleClose,
|
|
onCloseFlyout,
|
|
}) => createPortal(
|
|
<div
|
|
className="tv-side-flyout tv-side-flyout--right-anchor tv-side-flyout--custom-overlay"
|
|
style={{ top: flyoutPos.top, left: flyoutPos.left }}
|
|
onMouseEnter={onCancelClose}
|
|
onMouseLeave={onScheduleClose}
|
|
>
|
|
<div className="tv-side-flyout-header">{title}</div>
|
|
<button
|
|
type="button"
|
|
className={`tv-side-flyout-item${overlayActive ? ' active' : ''}`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onToggleActive();
|
|
onCloseFlyout();
|
|
}}
|
|
>
|
|
<span className="tv-side-flyout-icon"><IconCheck /></span>
|
|
<span className="tv-side-flyout-title">
|
|
{overlayActive ? '미적용' : '적용'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="tv-side-flyout-item"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onOpenSettings();
|
|
onCloseFlyout();
|
|
}}
|
|
>
|
|
<span className="tv-side-flyout-icon"><IconGear /></span>
|
|
<span className="tv-side-flyout-title">설정</span>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
|
|
interface MenuItemDef {
|
|
key: string;
|
|
title: string;
|
|
icon: React.ReactNode;
|
|
active?: boolean;
|
|
danger?: boolean;
|
|
onClick: () => void;
|
|
}
|
|
|
|
function buildMenuItems(
|
|
item: ReturnType<typeof buildPaneItems>[number],
|
|
indicators: IndicatorConfig[],
|
|
paneItems: ReturnType<typeof buildPaneItems>,
|
|
opts: {
|
|
focusedId?: string | null;
|
|
onSplit?: (hostId: string) => void;
|
|
onDuplicate?: (id: string) => void;
|
|
onExpand?: (id: string) => void;
|
|
onRestore?: () => void;
|
|
onRemove?: (id: string) => void;
|
|
onToggleHidden?: (id: string) => void;
|
|
onSettings?: (id: string) => void;
|
|
onDone?: () => void;
|
|
},
|
|
): MenuItemDef[] {
|
|
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
|
|
const itemHostId = getPaneHostId(item.id, indicators);
|
|
const isMergedPane = samePaneItems.length > 1;
|
|
const showSplit = isMergedPane && item.id === itemHostId && !!opts.onSplit;
|
|
const isFocused = isIndicatorPaneFocused(item.id, opts.focusedId, indicators);
|
|
const showExpand = !!(opts.onExpand || (opts.focusedId && opts.onRestore));
|
|
const done = opts.onDone ?? (() => {});
|
|
const cfg = indicators.find(ind => ind.id === item.id);
|
|
const isHidden = cfg?.hidden === true;
|
|
|
|
const items: MenuItemDef[] = [];
|
|
|
|
if (opts.onToggleHidden) {
|
|
items.push({
|
|
key: 'visibility',
|
|
title: isHidden ? '표시' : '숨기기',
|
|
icon: <IconEye hidden={isHidden} />,
|
|
active: isHidden,
|
|
onClick: () => { opts.onToggleHidden?.(item.id); done(); },
|
|
});
|
|
}
|
|
if (opts.onSettings) {
|
|
items.push({
|
|
key: 'settings',
|
|
title: '지표 설정',
|
|
icon: <IconGear />,
|
|
onClick: () => { opts.onSettings?.(item.id); done(); },
|
|
});
|
|
}
|
|
if (showSplit) {
|
|
items.push({
|
|
key: 'split',
|
|
title: '보조지표 분리',
|
|
icon: <IconSplit />,
|
|
onClick: () => { opts.onSplit?.(itemHostId); done(); },
|
|
});
|
|
}
|
|
if (opts.onDuplicate) {
|
|
items.push({
|
|
key: 'copy',
|
|
title: '보조지표 복사',
|
|
icon: <IconCopy />,
|
|
onClick: () => { opts.onDuplicate?.(item.id); done(); },
|
|
});
|
|
}
|
|
if (showExpand) {
|
|
items.push({
|
|
key: 'expand',
|
|
title: isFocused ? '전체 보기로 복원' : '이 지표만 보기',
|
|
icon: isFocused ? <IconRestore /> : <IconExpand />,
|
|
active: isFocused,
|
|
onClick: () => {
|
|
if (isFocused) opts.onRestore?.();
|
|
else opts.onExpand?.(item.id);
|
|
done();
|
|
},
|
|
});
|
|
}
|
|
if (opts.onRemove) {
|
|
items.push({
|
|
key: 'remove',
|
|
title: '지표 제거',
|
|
icon: <IconClose />,
|
|
danger: true,
|
|
onClick: () => { opts.onRemove?.(item.id); done(); },
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|
manager, indicators, chartHeight, paused = false,
|
|
candleOverlayToggles,
|
|
onToggleCandleOverlay,
|
|
customOverlayActive = false,
|
|
onCustomOverlayActiveChange,
|
|
onOpenCustomOverlaySettings,
|
|
custom1OverlayActive = false,
|
|
onCustom1OverlayActiveChange,
|
|
onOpenCustom1OverlaySettings,
|
|
onSplit, onRemove, onDuplicate,
|
|
onExpand, onRestore, focusedId,
|
|
onDragStart, onToggleHidden, onSettings,
|
|
overlayControlsOnly = false,
|
|
priceExtremeLabelsVisible = false,
|
|
onTogglePriceExtremeLabels,
|
|
}) => {
|
|
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
|
|
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
|
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
|
const [flyoutPos, setFlyoutPos] = useState({ top: 0, left: 0 });
|
|
const indicatorsRef = useRef(indicators);
|
|
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const sync = useCallback(() => {
|
|
setPaneLayouts(manager.getPaneLayouts());
|
|
setPaneItems(buildPaneItems(manager, indicatorsRef.current));
|
|
}, [manager]);
|
|
|
|
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
|
|
|
useEffect(() => {
|
|
if (paused) {
|
|
setPaneItems([]);
|
|
return;
|
|
}
|
|
sync();
|
|
const unsub = manager.subscribePaneLayout(() => sync());
|
|
return unsub;
|
|
}, [manager, paused, sync, indicators]);
|
|
|
|
useEffect(() => () => {
|
|
if (openTimer.current) clearTimeout(openTimer.current);
|
|
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
}, []);
|
|
|
|
const closeFlyout = useCallback(() => {
|
|
setOpenMenuId(null);
|
|
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
|
|
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
|
}, []);
|
|
|
|
const openFlyout = useCallback((itemId: string, el: HTMLElement) => {
|
|
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
|
if (openTimer.current) clearTimeout(openTimer.current);
|
|
openTimer.current = setTimeout(() => {
|
|
const rect = el.getBoundingClientRect();
|
|
setFlyoutPos({ top: rect.top, left: rect.left - 4 });
|
|
setOpenMenuId(itemId);
|
|
}, 80);
|
|
}, []);
|
|
|
|
const scheduleClose = useCallback(() => {
|
|
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
|
|
closeTimer.current = setTimeout(() => setOpenMenuId(null), 200);
|
|
}, []);
|
|
|
|
const cancelClose = useCallback(() => {
|
|
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
|
}, []);
|
|
|
|
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
|
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
|
|
const showPriceExtremeBtn = !!(candlePane && onTogglePriceExtremeLabels);
|
|
const showOverlayCluster = showOverlayIcons || showPriceExtremeBtn;
|
|
|
|
if (paused || chartHeight <= 0) return null;
|
|
if (overlayControlsOnly && !showOverlayCluster) return null;
|
|
|
|
const innerHeight = paneLayouts.reduce(
|
|
(max, l) => Math.max(max, l.topY + l.height),
|
|
chartHeight,
|
|
);
|
|
|
|
const allowDrag = !!(onDragStart && paneItems.length > 1);
|
|
const clusterHeight = (allowDrag ? BTN_SIZE + BTN_GAP : 0) + BTN_SIZE;
|
|
|
|
const openItem = openMenuId
|
|
? paneItems.find(p => p.id === openMenuId)
|
|
: null;
|
|
|
|
const showCustomBtn = !!(showOverlayIcons && !overlayControlsOnly && onCustomOverlayActiveChange && onOpenCustomOverlaySettings);
|
|
const showCustom1Btn = !!(showOverlayIcons && !overlayControlsOnly && onCustom1OverlayActiveChange && onOpenCustom1OverlaySettings);
|
|
const customMenuOpen = openMenuId === CUSTOM_MENU_ID;
|
|
const custom1MenuOpen = openMenuId === CUSTOM1_MENU_ID;
|
|
|
|
const focusedLayoutId = focusedId
|
|
? resolveFocusedPaneLayoutId(focusedId, indicators)
|
|
: null;
|
|
const focusedPaneLayout = focusedLayoutId
|
|
? (() => {
|
|
const item = paneItems.find(p => isIndicatorPaneFocused(p.id, focusedId, indicators));
|
|
if (item) return resolvePaneItemLayout(manager, item);
|
|
return manager.getIndicatorPaneScreenLayout(focusedLayoutId);
|
|
})()
|
|
: null;
|
|
|
|
return (
|
|
<aside
|
|
className={`chart-right-toolbar${overlayControlsOnly ? ' chart-right-toolbar--overlay-only' : ''}`}
|
|
aria-label="차트 우측 도구"
|
|
>
|
|
<div
|
|
className="chart-right-toolbar-inner"
|
|
style={{ height: Math.max(innerHeight, chartHeight) }}
|
|
>
|
|
{focusedId && onRestore && focusedPaneLayout && (
|
|
<div
|
|
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--focus-restore"
|
|
style={{ top: focusedPaneLayout.topY + 4 }}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--restore active"
|
|
title="기본 보기로 복원 (모든 보조지표 표시)"
|
|
onClick={e => { e.stopPropagation(); onRestore(); }}
|
|
>
|
|
<IconRestore />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showOverlayCluster && (
|
|
<div
|
|
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--overlay-icons"
|
|
style={{ top: candlePane!.topY + 4 }}
|
|
>
|
|
{candleOverlayToggles?.map((item, idx) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
className={`tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--overlay${item.visible ? '' : ' chart-right-toolbar-btn--overlay-off'}`}
|
|
title={item.visible ? `${item.label} 숨기기` : `${item.label} 표시`}
|
|
style={{ marginTop: idx > 0 ? BTN_GAP : 0 }}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onToggleCandleOverlay?.(item.key);
|
|
}}
|
|
>
|
|
{OVERLAY_ICONS[item.key]}
|
|
</button>
|
|
))}
|
|
{showPriceExtremeBtn && (
|
|
<button
|
|
type="button"
|
|
className={`tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--overlay chart-right-toolbar-btn--price-extreme${priceExtremeLabelsVisible ? '' : ' chart-right-toolbar-btn--overlay-off'}`}
|
|
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
|
|
style={{ marginTop: (candleOverlayToggles?.length ?? 0) > 0 ? BTN_GAP : 0 }}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onTogglePriceExtremeLabels?.();
|
|
}}
|
|
>
|
|
<IconPriceExtreme />
|
|
</button>
|
|
)}
|
|
{showCustomBtn && (
|
|
<button
|
|
type="button"
|
|
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom${customOverlayActive ? ' active' : ''}${customMenuOpen ? ' active' : ''}`}
|
|
title="Custom 표시"
|
|
style={{ marginTop: BTN_GAP }}
|
|
onMouseEnter={e => openFlyout(CUSTOM_MENU_ID, e.currentTarget)}
|
|
onMouseLeave={scheduleClose}
|
|
>
|
|
<IconCustom />
|
|
<span className="tv-side-expand-arrow" />
|
|
</button>
|
|
)}
|
|
{showCustom1Btn && (
|
|
<button
|
|
type="button"
|
|
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom1${custom1OverlayActive ? ' active' : ''}${custom1MenuOpen ? ' active' : ''}`}
|
|
title="Custom1 표시"
|
|
style={{ marginTop: BTN_GAP }}
|
|
onMouseEnter={e => openFlyout(CUSTOM1_MENU_ID, e.currentTarget)}
|
|
onMouseLeave={scheduleClose}
|
|
>
|
|
<IconCustom />
|
|
<span className="chart-right-toolbar-custom1-badge">1</span>
|
|
<span className="tv-side-expand-arrow" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!overlayControlsOnly && paneLayouts.map(layout => (
|
|
<div
|
|
key={layout.paneIndex}
|
|
className="chart-right-toolbar-segment"
|
|
style={{ top: layout.topY, height: layout.height }}
|
|
/>
|
|
))}
|
|
|
|
{!overlayControlsOnly && paneItems.map(item => {
|
|
const pl = resolvePaneItemLayout(manager, item);
|
|
if (!pl) return null;
|
|
|
|
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
|
|
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
|
|
const paneBottom = pl.topY + pl.height;
|
|
const clusterTop = paneBottom - 4 - clusterHeight - stackIdx * (clusterHeight + 4);
|
|
const menuItems = buildMenuItems(item, indicators, paneItems, {
|
|
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
|
onToggleHidden, onSettings,
|
|
});
|
|
|
|
if (menuItems.length === 0 && !allowDrag) return null;
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className="chart-right-toolbar-cluster"
|
|
style={{ top: clusterTop }}
|
|
>
|
|
{allowDrag && (
|
|
<button
|
|
type="button"
|
|
className="tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--drag"
|
|
title="드래그하여 순서 변경"
|
|
onPointerDown={e => {
|
|
e.stopPropagation();
|
|
onDragStart?.(item.id, e);
|
|
}}
|
|
>
|
|
<IconDrag />
|
|
</button>
|
|
)}
|
|
{menuItems.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--menu${openMenuId === item.id ? ' active' : ''}`}
|
|
title="보조지표 메뉴"
|
|
onMouseEnter={e => openFlyout(item.id, e.currentTarget)}
|
|
onMouseLeave={scheduleClose}
|
|
>
|
|
<IconMore />
|
|
<span className="tv-side-expand-arrow" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{customMenuOpen && showCustomBtn && (
|
|
<CustomOverlayFlyoutMenu
|
|
title="Custom"
|
|
flyoutPos={flyoutPos}
|
|
overlayActive={customOverlayActive}
|
|
onToggleActive={() => onCustomOverlayActiveChange?.(!customOverlayActive)}
|
|
onOpenSettings={() => onOpenCustomOverlaySettings?.()}
|
|
onCancelClose={cancelClose}
|
|
onScheduleClose={scheduleClose}
|
|
onCloseFlyout={closeFlyout}
|
|
/>
|
|
)}
|
|
|
|
{custom1MenuOpen && showCustom1Btn && (
|
|
<CustomOverlayFlyoutMenu
|
|
title="Custom1"
|
|
flyoutPos={flyoutPos}
|
|
overlayActive={custom1OverlayActive}
|
|
onToggleActive={() => onCustom1OverlayActiveChange?.(!custom1OverlayActive)}
|
|
onOpenSettings={() => onOpenCustom1OverlaySettings?.()}
|
|
onCancelClose={cancelClose}
|
|
onScheduleClose={scheduleClose}
|
|
onCloseFlyout={closeFlyout}
|
|
/>
|
|
)}
|
|
|
|
{openItem && openMenuId && openMenuId !== CUSTOM_MENU_ID && openMenuId !== CUSTOM1_MENU_ID && (() => {
|
|
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
|
|
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
|
onToggleHidden, onSettings,
|
|
onDone: closeFlyout,
|
|
});
|
|
if (menuItems.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className="tv-side-flyout tv-side-flyout--right-anchor"
|
|
style={{ top: flyoutPos.top, left: flyoutPos.left }}
|
|
onMouseEnter={cancelClose}
|
|
onMouseLeave={scheduleClose}
|
|
>
|
|
<div className="tv-side-flyout-header">{openItem.label}</div>
|
|
{menuItems.map(mi => (
|
|
<button
|
|
key={mi.key}
|
|
type="button"
|
|
className={`tv-side-flyout-item${mi.active ? ' active' : ''}${mi.danger ? ' tv-side-flyout-item--danger' : ''}`}
|
|
onClick={e => { e.stopPropagation(); mi.onClick(); }}
|
|
>
|
|
<span className="tv-side-flyout-icon">{mi.icon}</span>
|
|
<span className="tv-side-flyout-title">{mi.title}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default ChartRightToolbar;
|