보조지표 툴바추가
This commit is contained in:
@@ -953,6 +953,91 @@ html.theme-blue {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 차트 + 우측 툴바 행 */
|
||||||
|
.chart-main-row {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우측 세로 툴바 — 가격축 라벨 오른쪽 전용 영역 */
|
||||||
|
.chart-right-toolbar {
|
||||||
|
width: var(--sidebar-w, 44px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg2);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-segment {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-cluster {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-btn--menu {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-btn--drag {
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-right-toolbar-btn--drag:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우측 툴바 플라이아웃 — 버튼 왼쪽으로 펼침 */
|
||||||
|
.tv-side-flyout--right-anchor {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: flyout-in-right 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flyout-in-right {
|
||||||
|
from { opacity: 0; transform: translateX(calc(-100% + 6px)); }
|
||||||
|
to { opacity: 1; transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-side-flyout-item--danger:hover {
|
||||||
|
color: #ff6b6b !important;
|
||||||
|
background: rgba(255, 107, 107, 0.15) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PaneLegend — portal 호스트 + 레이어 (React createPortal 전용, 수동 DOM 제거 금지) */
|
/* PaneLegend — portal 호스트 + 레이어 (React createPortal 전용, 수동 DOM 제거 금지) */
|
||||||
@@ -9060,6 +9145,28 @@ html.theme-blue {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stg-subsection-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 16px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stg-subsection-label--spaced {
|
||||||
|
margin-top: 6px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stg-subsection-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 설정 행 ── */
|
/* ── 설정 행 ── */
|
||||||
.stg-row {
|
.stg-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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 { MouseEventParams, Time } from 'lightweight-charts';
|
||||||
import type { IndicatorConfig } from '../types';
|
import type { IndicatorConfig } from '../types';
|
||||||
import { ChartManager } from '../utils/ChartManager';
|
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 { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
|
||||||
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
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 {
|
interface PaneCapture {
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
cssWidth: number;
|
cssWidth: number;
|
||||||
@@ -66,18 +55,8 @@ export interface PaneLegendProps {
|
|||||||
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
|
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
|
||||||
/** 보조지표 pane 병합 */
|
/** 보조지표 pane 병합 */
|
||||||
onMerge?: (fromId: string, intoId: string) => void;
|
onMerge?: (fromId: string, intoId: string) => void;
|
||||||
/** 병합 pane 분리 */
|
/** 드래그 핸들 — ChartRightToolbar 에서 호출 */
|
||||||
onSplit?: (hostId: string) => void;
|
dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>;
|
||||||
/** 지표 단독 전체화면 확장 */
|
|
||||||
onExpand?: (id: string) => void;
|
|
||||||
/** 지표 제거 (X 버튼) */
|
|
||||||
onRemove?: (id: string) => void;
|
|
||||||
/** 보조지표 pane 복사 */
|
|
||||||
onDuplicate?: (id: string) => void;
|
|
||||||
/** 현재 단독 전체화면 중인 지표 id */
|
|
||||||
focusedId?: string | null;
|
|
||||||
/** 전체화면 → 전체 보기 복원 */
|
|
||||||
onRestore?: () => void;
|
|
||||||
/** 크로스헤어 이동 시 보조지표 수치 표시 */
|
/** 크로스헤어 이동 시 보조지표 수치 표시 */
|
||||||
crosshairInfoVisible?: boolean;
|
crosshairInfoVisible?: boolean;
|
||||||
}
|
}
|
||||||
@@ -159,138 +138,7 @@ function computeShiftY(
|
|||||||
return 0;
|
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 ────────────────────────────────────────────────────────────────
|
// ── 아이콘 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 = () => (
|
const IconMerge = () => (
|
||||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
|
||||||
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||||
@@ -299,37 +147,11 @@ const IconMerge = () => (
|
|||||||
</svg>
|
</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> = ({
|
const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||||
manager, indicators, containerEl, paused = false,
|
manager, indicators, containerEl, paused = false,
|
||||||
onHoverName, onLeaveName, onDoubleClick,
|
onHoverName, onLeaveName, onDoubleClick,
|
||||||
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
|
onReorder, onMerge, dragHandleRef,
|
||||||
crosshairInfoVisible = true,
|
crosshairInfoVisible = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
|
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
|
||||||
@@ -350,8 +172,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
|||||||
const containerElRef = useRef(containerEl);
|
const containerElRef = useRef(containerEl);
|
||||||
const onReorderRef = useRef(onReorder);
|
const onReorderRef = useRef(onReorder);
|
||||||
const onMergeRef = useRef(onMerge);
|
const onMergeRef = useRef(onMerge);
|
||||||
const onSplitRef = useRef(onSplit);
|
|
||||||
const onDuplicateRef = useRef(onDuplicate);
|
|
||||||
const indicatorsRef = useRef(indicators);
|
const indicatorsRef = useRef(indicators);
|
||||||
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
|
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -373,8 +193,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
|||||||
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
|
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
|
||||||
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||||
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
|
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
|
||||||
useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]);
|
|
||||||
useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]);
|
|
||||||
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
||||||
|
|
||||||
const syncPaneItems = useCallback(() => {
|
const syncPaneItems = useCallback(() => {
|
||||||
@@ -587,10 +405,15 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
|||||||
finishDrag,
|
finishDrag,
|
||||||
);
|
);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
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 isDragging = dragState !== null;
|
||||||
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
|
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
|
||||||
? getDropLineY(dragState!.dropBeforeIndex)
|
? getDropLineY(dragState!.dropBeforeIndex)
|
||||||
@@ -625,23 +448,13 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
|||||||
const pl = resolvePaneItemLayout(manager, item);
|
const pl = resolvePaneItemLayout(manager, item);
|
||||||
if (!pl) return null;
|
if (!pl) return null;
|
||||||
|
|
||||||
const paneBottomY = pl.topY + pl.height;
|
|
||||||
const itemVals = item.hidden ? [] : (values[item.id] ?? []);
|
const itemVals = item.hidden ? [] : (values[item.id] ?? []);
|
||||||
const itemHostId = getPaneHostId(item.id, indicators);
|
const itemHostId = getPaneHostId(item.id, indicators);
|
||||||
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
|
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
|
||||||
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
|
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
|
||||||
const isBeingDragged = isDragging && itemHostId === draggingHostId;
|
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 hoverScreenX = containerRect.left;
|
||||||
const hoverScreenY = containerRect.top + pl.topY;
|
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 isPaneHidden = item.hidden === true;
|
||||||
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
|
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
|
||||||
|
|
||||||
@@ -711,126 +524,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { ChartManager } from '../utils/ChartManager';
|
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';
|
import { plotColorCss } from '../utils/plotColorUtils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,12 +9,29 @@ interface Props {
|
|||||||
options: ChartPaneSeparatorOptions;
|
options: ChartPaneSeparatorOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) {
|
function applyLineDash(ctx: CanvasRenderingContext2D, style: PaneSeparatorStyle['lineStyle']) {
|
||||||
if (style === 'dashed') ctx.setLineDash([6, 4]);
|
if (style === 'dashed') ctx.setLineDash([6, 4]);
|
||||||
else if (style === 'dotted') ctx.setLineDash([2, 3]);
|
else if (style === 'dotted') ctx.setLineDash([2, 3]);
|
||||||
else ctx.setLineDash([]);
|
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 PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, cssW, cssH);
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
if (!options.visible) return;
|
|
||||||
|
|
||||||
const layouts = manager.getPaneLayouts()
|
const layouts = manager.getPaneLayouts()
|
||||||
.filter(l => l.height > 8)
|
.filter(l => l.height > 8)
|
||||||
.sort((a, b) => a.topY - b.topY);
|
.sort((a, b) => a.topY - b.topY);
|
||||||
if (layouts.length < 2) return;
|
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++) {
|
for (let i = 0; i < layouts.length - 1; i++) {
|
||||||
const y = layouts[i].topY + layouts[i].height;
|
const lower = layouts[i];
|
||||||
const lineY = y + options.width / 2;
|
const upper = layouts[i + 1];
|
||||||
ctx.beginPath();
|
const kind = classifyPaneBoundary(lower.paneIndex, upper.paneIndex);
|
||||||
ctx.moveTo(0, lineY);
|
if (kind === 'other') continue;
|
||||||
ctx.lineTo(cssW, lineY);
|
|
||||||
ctx.stroke();
|
const style = kind === 'mainToIndicator'
|
||||||
|
? options.mainToIndicator
|
||||||
|
: options.indicatorToIndicator;
|
||||||
|
const y = lower.topY + lower.height;
|
||||||
|
strokeBoundary(ctx, cssW, y, style);
|
||||||
}
|
}
|
||||||
}, [manager, options]);
|
}, [manager, options]);
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
CHART_LEGEND_SETTING_ITEMS,
|
CHART_LEGEND_SETTING_ITEMS,
|
||||||
type ChartLegendVisibility,
|
type ChartLegendVisibility,
|
||||||
} from '../types/chartLegend';
|
} from '../types/chartLegend';
|
||||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
|
||||||
import {
|
import {
|
||||||
LINE_STYLE_LABELS,
|
LINE_STYLE_LABELS,
|
||||||
LINE_STYLE_OPTIONS,
|
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 {
|
interface ChartPanelProps {
|
||||||
chartRealtimeSource?: string;
|
chartRealtimeSource?: string;
|
||||||
onChartRealtimeSource?: (v: string) => void;
|
onChartRealtimeSource?: (v: string) => void;
|
||||||
@@ -820,70 +886,28 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
|||||||
|
|
||||||
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
{chartPaneSeparator && onChartPaneSeparatorChange && (
|
||||||
<SettingSection title="차트 영역 구분선">
|
<SettingSection title="차트 영역 구분선">
|
||||||
<SettingRow
|
<div className="stg-subsection-label">
|
||||||
label="구분선 표시"
|
캔들·보조지표
|
||||||
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
|
<span className="stg-subsection-desc">캔들(·거래량) 영역과 첫 보조지표 pane 사이</span>
|
||||||
>
|
</div>
|
||||||
<label className="stg-toggle">
|
<PaneSeparatorStyleFields
|
||||||
<input
|
style={chartPaneSeparator.mainToIndicator}
|
||||||
type="checkbox"
|
onChange={mainToIndicator => onChartPaneSeparatorChange({
|
||||||
checked={chartPaneSeparator.visible}
|
...chartPaneSeparator,
|
||||||
onChange={e => onChartPaneSeparatorChange({
|
mainToIndicator,
|
||||||
...chartPaneSeparator,
|
})}
|
||||||
visible: e.target.checked,
|
/>
|
||||||
})}
|
<div className="stg-subsection-label stg-subsection-label--spaced">
|
||||||
/>
|
보조지표 간
|
||||||
<span className="stg-toggle-slider" />
|
<span className="stg-subsection-desc">보조지표 pane 사이</span>
|
||||||
</label>
|
</div>
|
||||||
</SettingRow>
|
<PaneSeparatorStyleFields
|
||||||
{chartPaneSeparator.visible && (
|
style={chartPaneSeparator.indicatorToIndicator}
|
||||||
<>
|
onChange={indicatorToIndicator => onChartPaneSeparatorChange({
|
||||||
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
|
...chartPaneSeparator,
|
||||||
<div className="stg-color-row">
|
indicatorToIndicator,
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function canApplyChartBars(
|
|||||||
}
|
}
|
||||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||||
|
import ChartRightToolbar from './ChartRightToolbar';
|
||||||
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
|
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
|
||||||
import ChartHoverToolbar from './ChartHoverToolbar';
|
import ChartHoverToolbar from './ChartHoverToolbar';
|
||||||
import ChartMagnifier from './ChartMagnifier';
|
import ChartMagnifier from './ChartMagnifier';
|
||||||
@@ -225,6 +226,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
|
||||||
const indicatorSyncInFlightRef = useRef(false);
|
const indicatorSyncInFlightRef = useRef(false);
|
||||||
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
|
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
|
||||||
|
const [chartBodyHeight, setChartBodyHeight] = useState(0);
|
||||||
const pendingIndicatorResyncRef = useRef(false);
|
const pendingIndicatorResyncRef = useRef(false);
|
||||||
const indicatorReloadGenRef = useRef(0);
|
const indicatorReloadGenRef = useRef(0);
|
||||||
const prevMarket = useRef<string>(market);
|
const prevMarket = useRef<string>(market);
|
||||||
@@ -242,6 +244,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||||
|
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (barsMarket === market) {
|
if (barsMarket === market) {
|
||||||
@@ -259,6 +262,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
chartVisibleRef.current = chartVisible;
|
chartVisibleRef.current = chartVisible;
|
||||||
}, [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(() => {
|
const toggleCandleOnly = useCallback(() => {
|
||||||
setCandleOnlyMode(v => !v);
|
setCandleOnlyMode(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1360,10 +1373,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
style={{ flex: 1, position: 'relative', minHeight: 0 }}
|
style={{ flex: 1, position: 'relative', minHeight: 0 }}
|
||||||
onPointerMove={handleWrapperMouseMove}
|
onPointerMove={handleWrapperMouseMove}
|
||||||
>
|
>
|
||||||
<div
|
<div className="chart-main-row">
|
||||||
ref={containerRef}
|
<div
|
||||||
className="chart-container"
|
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 && (
|
{ctxMenu && (
|
||||||
<ChartContextMenu
|
<ChartContextMenu
|
||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
@@ -1423,12 +1453,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
|
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
|
||||||
onReorder={onReorderIndicators}
|
onReorder={onReorderIndicators}
|
||||||
onMerge={onMergeIndicators}
|
onMerge={onMergeIndicators}
|
||||||
onSplit={onSplitIndicatorPane}
|
dragHandleRef={paneDragRef}
|
||||||
onExpand={onExpandIndicator}
|
|
||||||
onRemove={onRemoveIndicator}
|
|
||||||
onDuplicate={onDuplicateIndicator}
|
|
||||||
focusedId={focusedIndicatorId}
|
|
||||||
onRestore={onRestoreIndicators}
|
|
||||||
crosshairInfoVisible={crosshairInfoVisible}
|
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);
|
||||||
|
}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { Theme } from '../types';
|
import type { Theme } from '../types';
|
||||||
import type { HLineStyle } from '../utils/indicatorRegistry';
|
import type { HLineStyle } from '../utils/indicatorRegistry';
|
||||||
|
|
||||||
/** 캔들·거래량·보조지표 pane 사이 구분선 */
|
/** 단일 pane 구분선 스타일 */
|
||||||
export interface ChartPaneSeparatorOptions {
|
export interface PaneSeparatorStyle {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
width: 1 | 2 | 3 | 4;
|
width: 1 | 2 | 3 | 4;
|
||||||
lineStyle: HLineStyle;
|
lineStyle: HLineStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = {
|
/** 캔들·보조지표 / 보조지표 간 구분선 설정 */
|
||||||
visible: true,
|
export interface ChartPaneSeparatorOptions {
|
||||||
color: '#2a2e3a',
|
/** 캔들(·거래량) 영역 ↔ 첫 보조지표 pane */
|
||||||
width: 1,
|
mainToIndicator: PaneSeparatorStyle;
|
||||||
lineStyle: 'solid',
|
/** 보조지표 pane 사이 */
|
||||||
};
|
indicatorToIndicator: PaneSeparatorStyle;
|
||||||
|
}
|
||||||
|
|
||||||
const THEME_DEFAULT_COLORS: Record<Theme, string> = {
|
const THEME_DEFAULT_COLORS: Record<Theme, string> = {
|
||||||
dark: 'rgba(122, 162, 247, 0.15)',
|
dark: 'rgba(122, 162, 247, 0.15)',
|
||||||
@@ -26,11 +27,23 @@ export function themeDefaultPaneSeparatorColor(theme: Theme): string {
|
|||||||
return THEME_DEFAULT_COLORS[theme] ?? THEME_DEFAULT_COLORS.dark;
|
return THEME_DEFAULT_COLORS[theme] ?? THEME_DEFAULT_COLORS.dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveChartPaneSeparatorOptions(
|
export const DEFAULT_PANE_SEPARATOR_STYLE: PaneSeparatorStyle = {
|
||||||
raw?: Partial<ChartPaneSeparatorOptions> | null,
|
visible: true,
|
||||||
|
color: '#2a2e3a',
|
||||||
|
width: 1,
|
||||||
|
lineStyle: 'solid',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CHART_PANE_SEPARATOR: ChartPaneSeparatorOptions = {
|
||||||
|
mainToIndicator: { ...DEFAULT_PANE_SEPARATOR_STYLE },
|
||||||
|
indicatorToIndicator: { ...DEFAULT_PANE_SEPARATOR_STYLE },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePaneSeparatorStyle(
|
||||||
|
raw?: Partial<PaneSeparatorStyle> | null,
|
||||||
theme: Theme = 'dark',
|
theme: Theme = 'dark',
|
||||||
): ChartPaneSeparatorOptions {
|
): PaneSeparatorStyle {
|
||||||
const base = { ...DEFAULT_CHART_PANE_SEPARATOR };
|
const base = { ...DEFAULT_PANE_SEPARATOR_STYLE };
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
return { ...base, color: themeDefaultPaneSeparatorColor(theme) };
|
return { ...base, color: themeDefaultPaneSeparatorColor(theme) };
|
||||||
}
|
}
|
||||||
@@ -45,8 +58,52 @@ export function resolveChartPaneSeparatorOptions(
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 이전 단일 구분선 형식 — 하위 호환 */
|
||||||
|
function isLegacyFlatSeparator(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): raw is Partial<PaneSeparatorStyle> {
|
||||||
|
return 'visible' in raw && !('mainToIndicator' in raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChartPaneSeparatorOptions(
|
||||||
|
raw?: Partial<ChartPaneSeparatorOptions> | Record<string, unknown> | null,
|
||||||
|
theme: Theme = 'dark',
|
||||||
|
): ChartPaneSeparatorOptions {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
const style = resolvePaneSeparatorStyle(null, theme);
|
||||||
|
return {
|
||||||
|
mainToIndicator: { ...style },
|
||||||
|
indicatorToIndicator: { ...style },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isLegacyFlatSeparator(raw)) {
|
||||||
|
const legacy = resolvePaneSeparatorStyle(raw, theme);
|
||||||
|
return {
|
||||||
|
mainToIndicator: { ...legacy },
|
||||||
|
indicatorToIndicator: { ...legacy },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const r = raw as Partial<ChartPaneSeparatorOptions>;
|
||||||
|
return {
|
||||||
|
mainToIndicator: resolvePaneSeparatorStyle(r.mainToIndicator, theme),
|
||||||
|
indicatorToIndicator: resolvePaneSeparatorStyle(r.indicatorToIndicator, theme),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaneBoundaryKind = 'mainToIndicator' | 'indicatorToIndicator' | 'other';
|
||||||
|
|
||||||
|
/** 인접 pane 경계 종류 (pane 0=캔들, 1=거래량, 2+=보조지표) */
|
||||||
|
export function classifyPaneBoundary(
|
||||||
|
lowerPaneIndex: number,
|
||||||
|
upperPaneIndex: number,
|
||||||
|
): PaneBoundaryKind {
|
||||||
|
if (upperPaneIndex >= 2 && lowerPaneIndex < 2) return 'mainToIndicator';
|
||||||
|
if (lowerPaneIndex >= 2 && upperPaneIndex >= 2) return 'indicatorToIndicator';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
/** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */
|
/** LWC pane 구분선 — 커스텀 오버레이 사용 시 투명 처리 */
|
||||||
export function applyPaneSeparatorToChartOptions(opts: ChartPaneSeparatorOptions) {
|
export function applyPaneSeparatorToChartOptions(_opts: ChartPaneSeparatorOptions) {
|
||||||
return {
|
return {
|
||||||
layout: {
|
layout: {
|
||||||
panes: {
|
panes: {
|
||||||
|
|||||||
@@ -2717,27 +2717,28 @@ export class ChartManager {
|
|||||||
/** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */
|
/** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */
|
||||||
isOnPriceAxis(chartX: number, chartY: number): boolean {
|
isOnPriceAxis(chartX: number, chartY: number): boolean {
|
||||||
const paneIndex = this.getPaneIndexAtChartY(chartY);
|
const paneIndex = this.getPaneIndexAtChartY(chartY);
|
||||||
|
const scaleW = this.getPriceScaleWidth(paneIndex);
|
||||||
|
if (scaleW <= 0) return false;
|
||||||
|
return chartX >= this.container.clientWidth - scaleW;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pane 우측 가격축(값 라벨) 너비 — PaneLegend 버튼 배치용 */
|
||||||
|
getPriceScaleWidth(paneIndex: number): number {
|
||||||
try {
|
try {
|
||||||
const ps = paneIndex === 1
|
const ps = paneIndex === 1
|
||||||
? this.chart.priceScale('volume', 1)
|
? this.chart.priceScale('volume', 1)
|
||||||
: this.chart.priceScale('right', paneIndex);
|
: this.chart.priceScale('right', paneIndex);
|
||||||
const scaleW = ps.width();
|
const scaleW = ps.width();
|
||||||
if (scaleW <= 0) return false;
|
return scaleW > 0 ? scaleW : 56;
|
||||||
return chartX >= this.container.clientWidth - scaleW;
|
|
||||||
} catch {
|
} catch {
|
||||||
return chartX >= this.container.clientWidth - 56;
|
return 56;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
||||||
private _candlePlotWidth(): number {
|
private _candlePlotWidth(): number {
|
||||||
try {
|
const scaleW = this.getPriceScaleWidth(0);
|
||||||
const ps = this.mainSeries?.priceScale() ?? this.chart.priceScale('right', 0);
|
return Math.max(40, this.container.clientWidth - scaleW);
|
||||||
const scaleW = ps.width();
|
|
||||||
return Math.max(40, this.container.clientWidth - scaleW);
|
|
||||||
} catch {
|
|
||||||
return Math.max(40, this.container.clientWidth - 56);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -396,8 +396,21 @@ export interface AppSettingsDto {
|
|||||||
chartCrosshairInfoVisible?: boolean;
|
chartCrosshairInfoVisible?: boolean;
|
||||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||||
chartLegendOptions?: Record<string, boolean> | null;
|
chartLegendOptions?: Record<string, boolean> | null;
|
||||||
/** 차트 pane(캔들·거래량·보조지표) 구분선 */
|
/** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */
|
||||||
chartPaneSeparator?: {
|
chartPaneSeparator?: {
|
||||||
|
mainToIndicator?: {
|
||||||
|
visible?: boolean;
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
lineStyle?: string;
|
||||||
|
};
|
||||||
|
indicatorToIndicator?: {
|
||||||
|
visible?: boolean;
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
lineStyle?: string;
|
||||||
|
};
|
||||||
|
/** @deprecated 이전 단일 형식 */
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|||||||
@@ -478,8 +478,20 @@ export interface AppSettingsDto {
|
|||||||
chartLiveReceiveHighlight?: boolean;
|
chartLiveReceiveHighlight?: boolean;
|
||||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||||
chartLegendOptions?: Record<string, boolean> | null;
|
chartLegendOptions?: Record<string, boolean> | null;
|
||||||
/** 차트 pane(캔들·거래량·보조지표) 구분선 */
|
/** 차트 pane 구분선 — mainToIndicator / indicatorToIndicator */
|
||||||
chartPaneSeparator?: {
|
chartPaneSeparator?: {
|
||||||
|
mainToIndicator?: {
|
||||||
|
visible?: boolean;
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
lineStyle?: string;
|
||||||
|
};
|
||||||
|
indicatorToIndicator?: {
|
||||||
|
visible?: boolean;
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
lineStyle?: string;
|
||||||
|
};
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user