보조지표 툴바추가
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 차트 우측 세로 툴바 — 캔들·보조지표 pane 과 높이 동기화.
|
||||
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
||||
import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout';
|
||||
|
||||
export interface ChartRightToolbarProps {
|
||||
manager: ChartManager;
|
||||
indicators: IndicatorConfig[];
|
||||
chartHeight: number;
|
||||
paused?: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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;
|
||||
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 = opts.focusedId === item.id;
|
||||
const showExpand = !!(opts.onExpand || (isFocused && opts.onRestore));
|
||||
const done = opts.onDone ?? (() => {});
|
||||
|
||||
const items: MenuItemDef[] = [];
|
||||
|
||||
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,
|
||||
onSplit, onRemove, onDuplicate,
|
||||
onExpand, onRestore, focusedId,
|
||||
onDragStart,
|
||||
}) => {
|
||||
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; }
|
||||
}, []);
|
||||
|
||||
if (paused || chartHeight <= 0) 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;
|
||||
|
||||
return (
|
||||
<aside className="chart-right-toolbar" aria-label="차트 우측 도구">
|
||||
<div
|
||||
className="chart-right-toolbar-inner"
|
||||
style={{ height: Math.max(innerHeight, chartHeight) }}
|
||||
>
|
||||
{paneLayouts.map(layout => (
|
||||
<div
|
||||
key={layout.paneIndex}
|
||||
className="chart-right-toolbar-segment"
|
||||
style={{ top: layout.topY, height: layout.height }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
{openItem && openMenuId && (() => {
|
||||
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
|
||||
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
||||
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;
|
||||
@@ -15,26 +15,15 @@ import { createPortal } from 'react-dom';
|
||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
||||
import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout';
|
||||
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
||||
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
|
||||
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
||||
import {
|
||||
buildPaneItems,
|
||||
resolvePaneItemLayout,
|
||||
type PaneItem,
|
||||
} from './paneLegendLayout';
|
||||
|
||||
// ── 타입 ─────────────────────────────────────────────────────────────────────
|
||||
interface PaneItem {
|
||||
id: string;
|
||||
paneIndex: number;
|
||||
label: string;
|
||||
plotColors: string[];
|
||||
hidden?: boolean;
|
||||
/** 화면에 보이는 sub-pane 위치 (orphan pane 인덱스와 분리) */
|
||||
layoutTopY?: number;
|
||||
layoutHeight?: number;
|
||||
}
|
||||
|
||||
const MIN_SUB_PANE_HEIGHT = 12;
|
||||
|
||||
interface PaneCapture {
|
||||
dataUrl: string;
|
||||
cssWidth: number;
|
||||
@@ -66,18 +55,8 @@ export interface PaneLegendProps {
|
||||
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
|
||||
/** 보조지표 pane 병합 */
|
||||
onMerge?: (fromId: string, intoId: string) => void;
|
||||
/** 병합 pane 분리 */
|
||||
onSplit?: (hostId: string) => void;
|
||||
/** 지표 단독 전체화면 확장 */
|
||||
onExpand?: (id: string) => void;
|
||||
/** 지표 제거 (X 버튼) */
|
||||
onRemove?: (id: string) => void;
|
||||
/** 보조지표 pane 복사 */
|
||||
onDuplicate?: (id: string) => void;
|
||||
/** 현재 단독 전체화면 중인 지표 id */
|
||||
focusedId?: string | null;
|
||||
/** 전체화면 → 전체 보기 복원 */
|
||||
onRestore?: () => void;
|
||||
/** 드래그 핸들 — ChartRightToolbar 에서 호출 */
|
||||
dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>;
|
||||
/** 크로스헤어 이동 시 보조지표 수치 표시 */
|
||||
crosshairInfoVisible?: boolean;
|
||||
}
|
||||
@@ -159,138 +138,7 @@ function computeShiftY(
|
||||
return 0;
|
||||
}
|
||||
|
||||
function makeLabel(config: IndicatorConfig): string {
|
||||
if (config.type === 'BollingerBands') {
|
||||
return formatBbLegendLabel(config.params ?? {});
|
||||
}
|
||||
const name = getIndicatorChartTitle(config.type);
|
||||
const def = getIndicatorDef(config.type);
|
||||
const params = (config.params ?? def?.defaultParams ?? {}) as Record<string, number | string | boolean>;
|
||||
const plots = config.plots ?? def?.plots ?? [];
|
||||
|
||||
const orderedKeys: string[] = [];
|
||||
for (let i = 0; i < plots.length; i++) {
|
||||
for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) {
|
||||
if (!orderedKeys.includes(k)) orderedKeys.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of getGlobalParamKeys(config.type, params, plots)) {
|
||||
if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k);
|
||||
}
|
||||
|
||||
const nums = orderedKeys
|
||||
.map(k => params[k])
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
.slice(0, 3);
|
||||
return nums.length ? `${name} ${nums.join(', ')}` : name;
|
||||
}
|
||||
|
||||
function applyPaneLayoutToItem(
|
||||
item: PaneItem,
|
||||
layout: { topY: number; height: number },
|
||||
overwrite = false,
|
||||
): void {
|
||||
if (!overwrite && item.layoutTopY != null) return;
|
||||
item.layoutTopY = layout.topY;
|
||||
item.layoutHeight = layout.height;
|
||||
}
|
||||
|
||||
function buildPaneItems(
|
||||
manager: ChartManager,
|
||||
indicators: IndicatorConfig[],
|
||||
): PaneItem[] {
|
||||
const wantedIds = new Set(
|
||||
indicators
|
||||
.filter(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
return def && !def.overlay && !def.returnsMarkers;
|
||||
})
|
||||
.map(ind => ind.id),
|
||||
);
|
||||
|
||||
const configById = new Map(indicators.map(ind => [ind.id, ind]));
|
||||
|
||||
// ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외
|
||||
const items: PaneItem[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const info of manager.getIndicatorPaneInfo()) {
|
||||
if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue;
|
||||
seenIds.add(info.id);
|
||||
const ind = configById.get(info.id) ?? info.config;
|
||||
const def = getIndicatorDef(ind.type);
|
||||
if (!def || def.overlay || def.returnsMarkers) continue;
|
||||
|
||||
let chartPane = info.paneIndex;
|
||||
if (ind.mergedWith) {
|
||||
const host = manager.getIndicatorPaneIndex(ind.mergedWith);
|
||||
if (host >= 2) chartPane = host;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: info.id,
|
||||
paneIndex: chartPane,
|
||||
label: makeLabel(ind),
|
||||
plotColors: info.plotColors.map(c => c.slice(0, 7)),
|
||||
hidden: ind.hidden === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return [];
|
||||
|
||||
items.sort((a, b) => a.paneIndex - b.paneIndex);
|
||||
|
||||
for (const item of items) {
|
||||
const lay = manager.getIndicatorPaneScreenLayout(item.id);
|
||||
if (lay) applyPaneLayoutToItem(item, lay, true);
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const ay = a.layoutTopY ?? 1e9;
|
||||
const by = b.layoutTopY ?? 1e9;
|
||||
if (ay !== by) return ay - by;
|
||||
return a.paneIndex - b.paneIndex;
|
||||
});
|
||||
|
||||
return items.filter(
|
||||
item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */
|
||||
function resolvePaneItemLayout(
|
||||
manager: ChartManager,
|
||||
item: PaneItem,
|
||||
): { topY: number; height: number } | null {
|
||||
return manager.getIndicatorPaneScreenLayout(item.id);
|
||||
}
|
||||
|
||||
// ── 아이콘 SVG ────────────────────────────────────────────────────────────────
|
||||
/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */
|
||||
const IconExpand = () => (
|
||||
<svg width="13" height="13" 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="13" height="13" 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 IconMerge = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
@@ -299,37 +147,11 @@ const IconMerge = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 그래프 복사 — 겹친 사각형 */
|
||||
const IconCopy = () => (
|
||||
<svg width="13" height="13" 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="13" height="13" 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="11" height="11" 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 PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
manager, indicators, containerEl, paused = false,
|
||||
onHoverName, onLeaveName, onDoubleClick,
|
||||
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
|
||||
onReorder, onMerge, dragHandleRef,
|
||||
crosshairInfoVisible = true,
|
||||
}) => {
|
||||
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
|
||||
@@ -350,8 +172,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
const containerElRef = useRef(containerEl);
|
||||
const onReorderRef = useRef(onReorder);
|
||||
const onMergeRef = useRef(onMerge);
|
||||
const onSplitRef = useRef(onSplit);
|
||||
const onDuplicateRef = useRef(onDuplicate);
|
||||
const indicatorsRef = useRef(indicators);
|
||||
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
|
||||
|
||||
@@ -373,8 +193,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
|
||||
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
|
||||
useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]);
|
||||
useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]);
|
||||
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
||||
|
||||
const syncPaneItems = useCallback(() => {
|
||||
@@ -587,10 +405,15 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
finishDrag,
|
||||
);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}, [manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragHandleRef) return;
|
||||
dragHandleRef.current = (id, e) => handleDragHandlePointerDown(e, id);
|
||||
return () => { dragHandleRef.current = null; };
|
||||
}, [dragHandleRef, handleDragHandlePointerDown]);
|
||||
|
||||
// ── 렌더 ─────────────────────────────────────────────────────────────────
|
||||
const canReorder = !!onReorder && paneItems.length > 1;
|
||||
const isDragging = dragState !== null;
|
||||
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
|
||||
? getDropLineY(dragState!.dropBeforeIndex)
|
||||
@@ -625,23 +448,13 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
const pl = resolvePaneItemLayout(manager, item);
|
||||
if (!pl) return null;
|
||||
|
||||
const paneBottomY = pl.topY + pl.height;
|
||||
const itemVals = item.hidden ? [] : (values[item.id] ?? []);
|
||||
const itemHostId = getPaneHostId(item.id, indicators);
|
||||
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
|
||||
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
|
||||
const isBeingDragged = isDragging && itemHostId === draggingHostId;
|
||||
const isFocused = focusedId === item.id;
|
||||
const isMergedPane = samePaneItems.length > 1;
|
||||
const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit;
|
||||
const PANE_BTN_STEP = 24;
|
||||
const paneBtnRight = (slot: number) => 28 + slot * PANE_BTN_STEP;
|
||||
const hoverScreenX = containerRect.left;
|
||||
const hoverScreenY = containerRect.top + pl.topY;
|
||||
let btnSlot = 0;
|
||||
const closeSlot = btnSlot++;
|
||||
const copySlot = onDuplicate ? btnSlot++ : -1;
|
||||
const splitSlot = showSplitBtn ? btnSlot++ : -1;
|
||||
const isPaneHidden = item.hidden === true;
|
||||
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
|
||||
|
||||
@@ -711,126 +524,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 우측 상단: 분리 / 복사 / 닫기 ─────────────────────── */}
|
||||
{showSplitBtn && splitSlot >= 0 && (
|
||||
<button
|
||||
className="pane-btn pane-btn-split"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: paneBtnRight(splitSlot),
|
||||
top: pl.topY + 5,
|
||||
zIndex: 505,
|
||||
opacity: isBeingDragged ? 0 : undefined,
|
||||
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
|
||||
transition: shiftTrans,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onSplitRef.current?.(itemHostId);
|
||||
}}
|
||||
title="보조지표 분리"
|
||||
>
|
||||
<IconSplit />
|
||||
</button>
|
||||
)}
|
||||
{copySlot >= 0 && (
|
||||
<button
|
||||
className="pane-btn pane-btn-copy"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: paneBtnRight(copySlot),
|
||||
top: pl.topY + 5,
|
||||
zIndex: 505,
|
||||
opacity: isBeingDragged ? 0 : undefined,
|
||||
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
|
||||
transition: shiftTrans,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDuplicateRef.current?.(item.id);
|
||||
}}
|
||||
title="보조지표 복사"
|
||||
>
|
||||
<IconCopy />
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="pane-btn pane-btn-close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: paneBtnRight(closeSlot),
|
||||
top: pl.topY + 5,
|
||||
zIndex: 504,
|
||||
opacity: isBeingDragged ? 0 : undefined,
|
||||
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
|
||||
transition: shiftTrans,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); onRemove(item.id); }}
|
||||
title="지표 제거"
|
||||
>
|
||||
<IconClose />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── 좌측 하단: 전체화면 버튼 (위) + 드래그 핸들 (아래) ── */}
|
||||
{/* 드래그 핸들: pane 하단에서 30px 위 */}
|
||||
{canReorder && (
|
||||
<div
|
||||
className="pane-drag-handle"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 4,
|
||||
top: paneBottomY - 30,
|
||||
zIndex: 510,
|
||||
opacity: isBeingDragged ? 0 : undefined,
|
||||
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
|
||||
transition: shiftTrans,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onPointerDown={e => handleDragHandlePointerDown(e, item.id)}
|
||||
onMouseEnter={onLeaveName}
|
||||
title="드래그하여 순서 변경"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전체화면 / 복원 버튼: 드래그 핸들 위에 배치 */}
|
||||
{(onExpand || (isFocused && onRestore)) && (
|
||||
<button
|
||||
className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 4,
|
||||
top: paneBottomY - 56,
|
||||
zIndex: 504,
|
||||
opacity: isBeingDragged ? 0 : undefined,
|
||||
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
|
||||
transition: shiftTrans,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (isFocused) onRestore?.();
|
||||
else onExpand?.(item.id);
|
||||
}}
|
||||
title={isFocused ? '전체 보기로 복원' : '이 지표만 보기'}
|
||||
>
|
||||
{isFocused ? <IconRestore /> : <IconExpand />}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||||
import { classifyPaneBoundary } from '../types/chartPaneSeparator';
|
||||
import { plotColorCss } from '../utils/plotColorUtils';
|
||||
|
||||
interface Props {
|
||||
@@ -8,12 +9,29 @@ interface Props {
|
||||
options: ChartPaneSeparatorOptions;
|
||||
}
|
||||
|
||||
function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) {
|
||||
function applyLineDash(ctx: CanvasRenderingContext2D, style: PaneSeparatorStyle['lineStyle']) {
|
||||
if (style === 'dashed') ctx.setLineDash([6, 4]);
|
||||
else if (style === 'dotted') ctx.setLineDash([2, 3]);
|
||||
else ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function strokeBoundary(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cssW: number,
|
||||
y: number,
|
||||
style: PaneSeparatorStyle,
|
||||
) {
|
||||
if (!style.visible) return;
|
||||
ctx.strokeStyle = plotColorCss(style.color);
|
||||
ctx.lineWidth = style.width;
|
||||
applyLineDash(ctx, style.lineStyle);
|
||||
const lineY = y + style.width / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, lineY);
|
||||
ctx.lineTo(cssW, lineY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
@@ -37,24 +55,22 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
if (!options.visible) return;
|
||||
|
||||
const layouts = manager.getPaneLayouts()
|
||||
.filter(l => l.height > 8)
|
||||
.sort((a, b) => a.topY - b.topY);
|
||||
if (layouts.length < 2) return;
|
||||
|
||||
ctx.strokeStyle = plotColorCss(options.color);
|
||||
ctx.lineWidth = options.width;
|
||||
applyLineDash(ctx, options.lineStyle);
|
||||
|
||||
for (let i = 0; i < layouts.length - 1; i++) {
|
||||
const y = layouts[i].topY + layouts[i].height;
|
||||
const lineY = y + options.width / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, lineY);
|
||||
ctx.lineTo(cssW, lineY);
|
||||
ctx.stroke();
|
||||
const lower = layouts[i];
|
||||
const upper = layouts[i + 1];
|
||||
const kind = classifyPaneBoundary(lower.paneIndex, upper.paneIndex);
|
||||
if (kind === 'other') continue;
|
||||
|
||||
const style = kind === 'mainToIndicator'
|
||||
? options.mainToIndicator
|
||||
: options.indicatorToIndicator;
|
||||
const y = lower.topY + lower.height;
|
||||
strokeBoundary(ctx, cssW, y, style);
|
||||
}
|
||||
}, [manager, options]);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
CHART_LEGEND_SETTING_ITEMS,
|
||||
type ChartLegendVisibility,
|
||||
} from '../types/chartLegend';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||||
import {
|
||||
LINE_STYLE_LABELS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
@@ -661,6 +661,72 @@ const GeneralPanel: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
function PaneSeparatorStyleFields({
|
||||
style,
|
||||
onChange,
|
||||
}: {
|
||||
style: PaneSeparatorStyle;
|
||||
onChange: (next: PaneSeparatorStyle) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SettingRow label="구분선 표시">
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={style.visible}
|
||||
onChange={e => onChange({ ...style, visible: e.target.checked })}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
{style.visible && (
|
||||
<>
|
||||
<SettingRow label="선 색상">
|
||||
<div className="stg-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="stg-color"
|
||||
value={style.color.startsWith('#') ? style.color.slice(0, 7) : '#2a2e3a'}
|
||||
onChange={e => onChange({ ...style, color: e.target.value })}
|
||||
/>
|
||||
<span className="stg-color-label">{style.color}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 굵기">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={style.width}
|
||||
onChange={e => onChange({
|
||||
...style,
|
||||
width: Number(e.target.value) as PaneSeparatorStyle['width'],
|
||||
})}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}px</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 유형">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={style.lineStyle}
|
||||
onChange={e => onChange({
|
||||
...style,
|
||||
lineStyle: e.target.value as PaneSeparatorStyle['lineStyle'],
|
||||
})}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartPanelProps {
|
||||
chartRealtimeSource?: string;
|
||||
onChartRealtimeSource?: (v: string) => void;
|
||||
@@ -820,70 +886,28 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
|
||||
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
||||
<SettingSection title="차트 영역 구분선">
|
||||
<SettingRow
|
||||
label="구분선 표시"
|
||||
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chartPaneSeparator.visible}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
visible: e.target.checked,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
{chartPaneSeparator.visible && (
|
||||
<>
|
||||
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
|
||||
<div className="stg-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="stg-color"
|
||||
value={chartPaneSeparator.color.startsWith('#')
|
||||
? chartPaneSeparator.color.slice(0, 7)
|
||||
: '#2a2e3a'}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
color: e.target.value,
|
||||
})}
|
||||
/>
|
||||
<span className="stg-color-label">{chartPaneSeparator.color}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 굵기" desc="구분선 두께(px)입니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.width}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
width: Number(e.target.value) as ChartPaneSeparatorOptions['width'],
|
||||
})}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}px</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="선 스타일" desc="실선·점선·도트 중 선택합니다.">
|
||||
<select
|
||||
className="stg-select"
|
||||
value={chartPaneSeparator.lineStyle}
|
||||
onChange={e => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
lineStyle: e.target.value as ChartPaneSeparatorOptions['lineStyle'],
|
||||
})}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<div className="stg-subsection-label">
|
||||
캔들·보조지표
|
||||
<span className="stg-subsection-desc">캔들(·거래량) 영역과 첫 보조지표 pane 사이</span>
|
||||
</div>
|
||||
<PaneSeparatorStyleFields
|
||||
style={chartPaneSeparator.mainToIndicator}
|
||||
onChange={mainToIndicator => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
mainToIndicator,
|
||||
})}
|
||||
/>
|
||||
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||||
보조지표 간
|
||||
<span className="stg-subsection-desc">보조지표 pane 사이</span>
|
||||
</div>
|
||||
<PaneSeparatorStyleFields
|
||||
style={chartPaneSeparator.indicatorToIndicator}
|
||||
onChange={indicatorToIndicator => onChartPaneSeparatorChange({
|
||||
...chartPaneSeparator,
|
||||
indicatorToIndicator,
|
||||
})}
|
||||
/>
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function canApplyChartBars(
|
||||
}
|
||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||
import ChartRightToolbar from './ChartRightToolbar';
|
||||
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
|
||||
import ChartHoverToolbar from './ChartHoverToolbar';
|
||||
import ChartMagnifier from './ChartMagnifier';
|
||||
@@ -225,6 +226,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||
const indicatorSyncInFlightRef = useRef(false);
|
||||
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
|
||||
const [chartBodyHeight, setChartBodyHeight] = useState(0);
|
||||
const pendingIndicatorResyncRef = useRef(false);
|
||||
const indicatorReloadGenRef = useRef(0);
|
||||
const prevMarket = useRef<string>(market);
|
||||
@@ -242,6 +244,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (barsMarket === market) {
|
||||
@@ -259,6 +262,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
chartVisibleRef.current = chartVisible;
|
||||
}, [chartVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setChartBodyHeight(el.clientHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [chartMgr]);
|
||||
|
||||
const toggleCandleOnly = useCallback(() => {
|
||||
setCandleOnlyMode(v => !v);
|
||||
}, []);
|
||||
@@ -1360,10 +1373,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
style={{ flex: 1, position: 'relative', minHeight: 0 }}
|
||||
onPointerMove={handleWrapperMouseMove}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="chart-container"
|
||||
/>
|
||||
<div className="chart-main-row">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="chart-container"
|
||||
/>
|
||||
{chartMgr && !candleOnlyMode && showPaneLegend && (
|
||||
<ChartRightToolbar
|
||||
manager={chartMgr}
|
||||
indicators={indicators}
|
||||
chartHeight={chartBodyHeight}
|
||||
paused={paneLegendPaused}
|
||||
onSplit={onSplitIndicatorPane}
|
||||
onRemove={onRemoveIndicator}
|
||||
onDuplicate={onDuplicateIndicator}
|
||||
onExpand={onExpandIndicator}
|
||||
onRestore={onRestoreIndicators}
|
||||
focusedId={focusedIndicatorId}
|
||||
onDragStart={(id, e) => paneDragRef.current?.(id, e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{ctxMenu && (
|
||||
<ChartContextMenu
|
||||
x={ctxMenu.x}
|
||||
@@ -1423,12 +1453,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
|
||||
onReorder={onReorderIndicators}
|
||||
onMerge={onMergeIndicators}
|
||||
onSplit={onSplitIndicatorPane}
|
||||
onExpand={onExpandIndicator}
|
||||
onRemove={onRemoveIndicator}
|
||||
onDuplicate={onDuplicateIndicator}
|
||||
focusedId={focusedIndicatorId}
|
||||
onRestore={onRestoreIndicators}
|
||||
dragHandleRef={paneDragRef}
|
||||
crosshairInfoVisible={crosshairInfoVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* PaneLegend / ChartRightToolbar 공통 — pane 항목·좌표 계산
|
||||
*/
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
||||
import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout';
|
||||
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
||||
|
||||
export interface PaneItem {
|
||||
id: string;
|
||||
paneIndex: number;
|
||||
label: string;
|
||||
plotColors: string[];
|
||||
hidden?: boolean;
|
||||
layoutTopY?: number;
|
||||
layoutHeight?: number;
|
||||
}
|
||||
|
||||
export const MIN_SUB_PANE_HEIGHT = 12;
|
||||
|
||||
function makeLabel(config: IndicatorConfig): string {
|
||||
if (config.type === 'BollingerBands') {
|
||||
return formatBbLegendLabel(config.params ?? {});
|
||||
}
|
||||
const name = getIndicatorChartTitle(config.type);
|
||||
const def = getIndicatorDef(config.type);
|
||||
const params = (config.params ?? def?.defaultParams ?? {}) as Record<string, number | string | boolean>;
|
||||
const plots = config.plots ?? def?.plots ?? [];
|
||||
|
||||
const orderedKeys: string[] = [];
|
||||
for (let i = 0; i < plots.length; i++) {
|
||||
for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) {
|
||||
if (!orderedKeys.includes(k)) orderedKeys.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of getGlobalParamKeys(config.type, params, plots)) {
|
||||
if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k);
|
||||
}
|
||||
|
||||
const nums = orderedKeys
|
||||
.map(k => params[k])
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
.slice(0, 3);
|
||||
return nums.length ? `${name} ${nums.join(', ')}` : name;
|
||||
}
|
||||
|
||||
function applyPaneLayoutToItem(
|
||||
item: PaneItem,
|
||||
layout: { topY: number; height: number },
|
||||
overwrite = false,
|
||||
): void {
|
||||
if (!overwrite && item.layoutTopY != null) return;
|
||||
item.layoutTopY = layout.topY;
|
||||
item.layoutHeight = layout.height;
|
||||
}
|
||||
|
||||
export function buildPaneItems(
|
||||
manager: ChartManager,
|
||||
indicators: IndicatorConfig[],
|
||||
): PaneItem[] {
|
||||
const wantedIds = new Set(
|
||||
indicators
|
||||
.filter(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
return def && !def.overlay && !def.returnsMarkers;
|
||||
})
|
||||
.map(ind => ind.id),
|
||||
);
|
||||
|
||||
const configById = new Map(indicators.map(ind => [ind.id, ind]));
|
||||
|
||||
const items: PaneItem[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const info of manager.getIndicatorPaneInfo()) {
|
||||
if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue;
|
||||
seenIds.add(info.id);
|
||||
const ind = configById.get(info.id) ?? info.config;
|
||||
const def = getIndicatorDef(ind.type);
|
||||
if (!def || def.overlay || def.returnsMarkers) continue;
|
||||
|
||||
let chartPane = info.paneIndex;
|
||||
if (ind.mergedWith) {
|
||||
const host = manager.getIndicatorPaneIndex(ind.mergedWith);
|
||||
if (host >= 2) chartPane = host;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: info.id,
|
||||
paneIndex: chartPane,
|
||||
label: makeLabel(ind),
|
||||
plotColors: info.plotColors.map(c => c.slice(0, 7)),
|
||||
hidden: ind.hidden === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) return [];
|
||||
|
||||
items.sort((a, b) => a.paneIndex - b.paneIndex);
|
||||
|
||||
for (const item of items) {
|
||||
const lay = manager.getIndicatorPaneScreenLayout(item.id);
|
||||
if (lay) applyPaneLayoutToItem(item, lay, true);
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const ay = a.layoutTopY ?? 1e9;
|
||||
const by = b.layoutTopY ?? 1e9;
|
||||
if (ay !== by) return ay - by;
|
||||
return a.paneIndex - b.paneIndex;
|
||||
});
|
||||
|
||||
return items.filter(
|
||||
item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePaneItemLayout(
|
||||
manager: ChartManager,
|
||||
item: PaneItem,
|
||||
): { topY: number; height: number } | null {
|
||||
return manager.getIndicatorPaneScreenLayout(item.id);
|
||||
}
|
||||
Reference in New Issue
Block a user