custom 툴발 적용
This commit is contained in:
@@ -1103,6 +1103,34 @@ html.theme-blue {
|
||||
animation: flyout-in-right 0.1s ease;
|
||||
}
|
||||
|
||||
/* Custom / Custom1 — body 포털, 차트 레이어 위·항목 색상 통일 */
|
||||
.tv-side-flyout--custom-overlay {
|
||||
z-index: 10050;
|
||||
background: var(--bg2);
|
||||
isolation: isolate;
|
||||
}
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-item {
|
||||
color: var(--text2);
|
||||
background: transparent;
|
||||
}
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-item:hover,
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-item:focus-visible {
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
}
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-item.active {
|
||||
background: rgba(122, 162, 247, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-item.active:hover {
|
||||
background: rgba(122, 162, 247, 0.22);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-icon,
|
||||
.tv-side-flyout--custom-overlay .tv-side-flyout-title {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@keyframes flyout-in-right {
|
||||
from { opacity: 0; transform: translateX(calc(-100% + 6px)); }
|
||||
to { opacity: 1; transform: translateX(-100%); }
|
||||
@@ -2561,6 +2589,74 @@ html.theme-blue {
|
||||
border-color: rgba(122, 162, 247, 0.65);
|
||||
background: rgba(122, 162, 247, 0.22);
|
||||
}
|
||||
.chart-right-toolbar-btn--custom1 {
|
||||
position: relative;
|
||||
}
|
||||
.chart-right-toolbar-custom1-badge {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-muted, #787b86);
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-right-toolbar-btn--custom1.active .chart-right-toolbar-custom1-badge {
|
||||
color: var(--accent, #2962ff);
|
||||
}
|
||||
.chart-right-toolbar-btn--custom.active,
|
||||
.chart-right-toolbar-btn--custom1.active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(122, 162, 247, 0.65);
|
||||
background: rgba(122, 162, 247, 0.22);
|
||||
}
|
||||
|
||||
/* Custom 오버레이 설정 모달 (보조지표 설정 ibsm 스타일) */
|
||||
.ccos-dialog {
|
||||
max-height: 88vh;
|
||||
}
|
||||
.ccos-hint {
|
||||
margin: 0;
|
||||
padding: 8px 14px 0;
|
||||
}
|
||||
.ccos-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.ccos-item-grid {
|
||||
display: grid;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.ccos-item-grid--2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ccos-item-grid--3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.ccos-grid-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ccos-grid-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.ccos-grid-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ccos-grid-label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */
|
||||
.pane-legend-item {
|
||||
|
||||
+102
-1
@@ -54,6 +54,15 @@ import {
|
||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||
listChartOverlayToggleItems,
|
||||
} from './utils/chartOverlayVisibility';
|
||||
import {
|
||||
type ChartCustomOverlaySelection,
|
||||
type ChartCustomOverlaySlotId,
|
||||
createDefaultChartCustomOverlaySelection,
|
||||
isCustomOverlaySlotActive,
|
||||
resolveActiveCustomOverlaySlot,
|
||||
syncChartManagerCustomOverlaysByActiveSlot,
|
||||
} from './utils/chartCustomOverlay';
|
||||
import ChartCustomOverlaySettingsModal from './components/ChartCustomOverlaySettingsModal';
|
||||
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
|
||||
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
|
||||
@@ -1455,6 +1464,73 @@ function App() {
|
||||
[overlayVisibility],
|
||||
);
|
||||
|
||||
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
|
||||
() => createDefaultChartCustomOverlaySelection(),
|
||||
);
|
||||
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
|
||||
() => createDefaultChartCustomOverlaySelection(),
|
||||
);
|
||||
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
||||
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
||||
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
|
||||
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
|
||||
|
||||
const syncCustomOverlaysToManager = useCallback((mgr: import('./utils/ChartManager').ChartManager) => {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
activeCustomOverlaySlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
|
||||
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
|
||||
setActiveCustomOverlaySlot(nextSlot);
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
nextSlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
|
||||
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
|
||||
setActiveCustomOverlaySlot(nextSlot);
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
nextSlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleSaveCustomOverlaySelection = useCallback((
|
||||
slot: ChartCustomOverlaySlotId,
|
||||
selection: ChartCustomOverlaySelection,
|
||||
) => {
|
||||
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
|
||||
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
|
||||
if (slot === 'custom') setCustomOverlaySelection(selection);
|
||||
else setCustom1OverlaySelection(selection);
|
||||
setCustomOverlaySettingsSlot(null);
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
activeCustomOverlaySlot,
|
||||
nextCustomSel,
|
||||
nextCustom1Sel,
|
||||
);
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
||||
setOverlayVisibility(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
@@ -2451,7 +2527,9 @@ function App() {
|
||||
onCrosshair={setLegend}
|
||||
onTradeOrderRequest={applyTradeFill}
|
||||
onDataLoaded={() => {
|
||||
managerRef.current?.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||
const mgr = managerRef.current;
|
||||
mgr?.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||
if (mgr) syncCustomOverlaysToManager(mgr);
|
||||
syncBacktestMarkersRef.current();
|
||||
}}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
@@ -2474,6 +2552,7 @@ function App() {
|
||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||
syncCustomOverlaysToManager(mgr);
|
||||
syncBacktestMarkersRef.current();
|
||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||
mgr.subscribeVisibleLogicalRange(r => {
|
||||
@@ -2520,8 +2599,30 @@ function App() {
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
candleOverlayToggles={candleOverlayToggles}
|
||||
onToggleCandleOverlay={handleToggleCandleOverlay}
|
||||
customOverlayActive={customOverlayActive}
|
||||
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
|
||||
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
|
||||
custom1OverlayActive={custom1OverlayActive}
|
||||
onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange}
|
||||
onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')}
|
||||
showHoverToolbar={chartHoverToolbarVisible}
|
||||
/>
|
||||
{customOverlaySettingsSlot === 'custom' && (
|
||||
<ChartCustomOverlaySettingsModal
|
||||
title="Custom 표시 설정"
|
||||
selection={customOverlaySelection}
|
||||
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
|
||||
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
||||
/>
|
||||
)}
|
||||
{customOverlaySettingsSlot === 'custom1' && (
|
||||
<ChartCustomOverlaySettingsModal
|
||||
title="Custom1 표시 설정"
|
||||
selection={custom1OverlaySelection}
|
||||
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
|
||||
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
||||
/>
|
||||
)}
|
||||
{isSingleLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
type ChartCustomOverlaySelection,
|
||||
type CustomOverlaySectionId,
|
||||
cloneChartCustomOverlaySelection,
|
||||
smaMaLabel,
|
||||
BB_CUSTOM_LABELS,
|
||||
ICHIMOKU_CUSTOM_ROWS,
|
||||
isCustomSectionAllSelected,
|
||||
setCustomSectionAllSelected,
|
||||
} from '../utils/chartCustomOverlay';
|
||||
import { SMA_MA_COUNT, smaPlotId } from '../utils/smaConfig';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
interface ChartCustomOverlaySettingsModalProps {
|
||||
title?: string;
|
||||
selection: ChartCustomOverlaySelection;
|
||||
onSave: (selection: ChartCustomOverlaySelection) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SECTION_META: Array<{
|
||||
id: CustomOverlaySectionId;
|
||||
ko: string;
|
||||
en: string;
|
||||
cols: 2 | 3;
|
||||
}> = [
|
||||
{ id: 'sma', ko: '이동평균선', en: 'Simple Moving Average (SMA)', cols: 3 },
|
||||
{ id: 'bollinger', ko: '볼린저 밴드', en: 'Bollinger Bands', cols: 2 },
|
||||
{ id: 'ichimoku', ko: '일목균형표', en: 'Ichimoku Cloud', cols: 3 },
|
||||
{ id: 'candle', ko: '캔들차트', en: 'Candlestick', cols: 2 },
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = new Set<CustomOverlaySectionId>(SECTION_META.map(s => s.id));
|
||||
|
||||
const GridToggle: React.FC<{
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}> = ({ label, checked, onChange }) => (
|
||||
<div className="ccos-grid-item">
|
||||
<label className="ibsm-toggle ccos-grid-toggle" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ccos-grid-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChartCustomOverlaySettingsModal: React.FC<ChartCustomOverlaySettingsModalProps> = ({
|
||||
title = 'Custom 표시 설정',
|
||||
selection,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const draftRef = useRef(cloneChartCustomOverlaySelection(selection));
|
||||
const [, bump] = useState(0);
|
||||
const refresh = useCallback(() => bump(n => n + 1), []);
|
||||
const [expanded, setExpanded] = useState<Set<CustomOverlaySectionId>>(
|
||||
() => new Set(ALL_SECTIONS),
|
||||
);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown,
|
||||
headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = cloneChartCustomOverlaySelection(selection);
|
||||
setExpanded(new Set(ALL_SECTIONS));
|
||||
refresh();
|
||||
}, [selection, refresh]);
|
||||
|
||||
const draft = draftRef.current;
|
||||
|
||||
const patch = useCallback((patchFn: (d: ChartCustomOverlaySelection) => void) => {
|
||||
patchFn(draftRef.current);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const toggleSectionExpand = useCallback((id: CustomOverlaySectionId) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSectionAll = useCallback((id: CustomOverlaySectionId) => {
|
||||
patch(d => {
|
||||
const allOn = isCustomSectionAllSelected(d, id);
|
||||
setCustomSectionAllSelected(d, id, !allOn);
|
||||
});
|
||||
}, [patch]);
|
||||
|
||||
const renderSectionItems = (sectionId: CustomOverlaySectionId) => {
|
||||
switch (sectionId) {
|
||||
case 'sma':
|
||||
return Array.from({ length: SMA_MA_COUNT }, (_, i) => {
|
||||
const pid = smaPlotId(i);
|
||||
return (
|
||||
<GridToggle
|
||||
key={pid}
|
||||
label={smaMaLabel(i)}
|
||||
checked={draft.smaPlots[pid] !== false}
|
||||
onChange={v => patch(d => { d.smaPlots[pid] = v; })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
case 'bollinger':
|
||||
return (
|
||||
<>
|
||||
<GridToggle
|
||||
label={BB_CUSTOM_LABELS.plot0}
|
||||
checked={draft.bollinger.basis}
|
||||
onChange={v => patch(d => { d.bollinger.basis = v; })}
|
||||
/>
|
||||
<GridToggle
|
||||
label={BB_CUSTOM_LABELS.plot1}
|
||||
checked={draft.bollinger.upper}
|
||||
onChange={v => patch(d => { d.bollinger.upper = v; })}
|
||||
/>
|
||||
<GridToggle
|
||||
label={BB_CUSTOM_LABELS.plot2}
|
||||
checked={draft.bollinger.lower}
|
||||
onChange={v => patch(d => { d.bollinger.lower = v; })}
|
||||
/>
|
||||
<GridToggle
|
||||
label="백그라운드 그리기"
|
||||
checked={draft.bollinger.background}
|
||||
onChange={v => patch(d => { d.bollinger.background = v; })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'ichimoku':
|
||||
return ICHIMOKU_CUSTOM_ROWS.map(row => (
|
||||
<GridToggle
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
checked={draft.ichimoku[row.key]}
|
||||
onChange={v => patch(d => { d.ichimoku[row.key] = v; })}
|
||||
/>
|
||||
));
|
||||
case 'candle':
|
||||
return (
|
||||
<GridToggle
|
||||
label="캔들차트"
|
||||
checked={draft.candle}
|
||||
onChange={v => patch(d => { d.candle = v; })}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ibsm-overlay"
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ibsm-dialog ibsm-dialog-wide ccos-dialog"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 5002,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header ibsm-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title ibsm-header-title">{title}</span>
|
||||
<div className="ibsm-header-actions">
|
||||
<button type="button" className="gc-popup-close ibsm-icon-btn" onClick={onCancel} title="닫기">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="ibsm-hint ccos-hint">
|
||||
Custom <strong>적용</strong> 시 아래에서 켠 항목만 차트에 표시됩니다. 각 지표 설정의 on/off와는 별개이며,
|
||||
지표 우측 스위치는 해당 지표의 모든 항목을 한 번에 선택·해제합니다.
|
||||
</p>
|
||||
|
||||
<div className="ibsm-body ccos-body">
|
||||
{SECTION_META.map(meta => {
|
||||
const isExpanded = expanded.has(meta.id);
|
||||
const allOn = isCustomSectionAllSelected(draft, meta.id);
|
||||
return (
|
||||
<div
|
||||
key={meta.id}
|
||||
className={`ibsm-card ibsm-main-row${allOn ? ' ibsm-main-row--active' : ''}${isExpanded ? ' ibsm-card-expanded' : ''}`}
|
||||
>
|
||||
<div className="ibsm-card-main ibsm-main-row-head">
|
||||
<div className="ibsm-main-row-info">
|
||||
<span className="ibsm-main-row-ko">{meta.ko}</span>
|
||||
<span className="ibsm-main-row-en">{meta.en}</span>
|
||||
</div>
|
||||
<div className="ibsm-main-row-badges">
|
||||
<span className={allOn ? 'ibsm-main-badge ibsm-main-badge--on' : 'ibsm-main-badge'}>
|
||||
{allOn ? '전체 선택' : '일부 선택'}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
className="ibsm-toggle"
|
||||
title={allOn ? '전체 해제' : '전체 선택'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOn}
|
||||
onChange={() => toggleSectionAll(meta.id)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="ibsm-expand"
|
||||
onClick={() => toggleSectionExpand(meta.id)}
|
||||
aria-expanded={isExpanded}
|
||||
title={isExpanded ? '접기' : '펼치기'}
|
||||
>
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className={`ibsm-card-expand ccos-item-grid ccos-item-grid--${meta.cols}`}>
|
||||
{renderSectionItems(meta.id)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ibsm-footer">
|
||||
<button type="button" className="ism-btn-cancel" onClick={onCancel}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ism-btn-ok"
|
||||
onClick={() => onSave(cloneChartCustomOverlaySelection(draftRef.current))}
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartCustomOverlaySettingsModal;
|
||||
@@ -3,6 +3,7 @@
|
||||
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
||||
@@ -35,6 +36,13 @@ export interface ChartRightToolbarProps {
|
||||
onToggleHidden?: (id: string) => void;
|
||||
/** 지표명 툴바 — 설정 모달 */
|
||||
onSettings?: (id: string) => void;
|
||||
/** Custom / Custom1 오버레이 — 적용 여부 (동시 적용 불가, 설정은 슬롯별 분리) */
|
||||
customOverlayActive?: boolean;
|
||||
onCustomOverlayActiveChange?: (active: boolean) => void;
|
||||
onOpenCustomOverlaySettings?: () => void;
|
||||
custom1OverlayActive?: boolean;
|
||||
onCustom1OverlayActiveChange?: (active: boolean) => void;
|
||||
onOpenCustom1OverlaySettings?: () => void;
|
||||
}
|
||||
|
||||
const BTN_SIZE = 32;
|
||||
@@ -159,6 +167,24 @@ const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CUSTOM_MENU_ID = '__custom_overlay__';
|
||||
const CUSTOM1_MENU_ID = '__custom1_overlay__';
|
||||
|
||||
const IconCustom = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M2 4h10M2 7h10M2 10h6"/>
|
||||
<circle cx="10.5" cy="10" r="1.2" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2,7.5 5.5,11 12,3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconGear = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<circle cx="7" cy="7" r="2.3"/>
|
||||
@@ -166,6 +192,62 @@ const IconGear = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CustomOverlayFlyoutMenu: React.FC<{
|
||||
title: string;
|
||||
flyoutPos: { top: number; left: number };
|
||||
overlayActive: boolean;
|
||||
onToggleActive: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onCancelClose: () => void;
|
||||
onScheduleClose: () => void;
|
||||
onCloseFlyout: () => void;
|
||||
}> = ({
|
||||
title,
|
||||
flyoutPos,
|
||||
overlayActive,
|
||||
onToggleActive,
|
||||
onOpenSettings,
|
||||
onCancelClose,
|
||||
onScheduleClose,
|
||||
onCloseFlyout,
|
||||
}) => createPortal(
|
||||
<div
|
||||
className="tv-side-flyout tv-side-flyout--right-anchor tv-side-flyout--custom-overlay"
|
||||
style={{ top: flyoutPos.top, left: flyoutPos.left }}
|
||||
onMouseEnter={onCancelClose}
|
||||
onMouseLeave={onScheduleClose}
|
||||
>
|
||||
<div className="tv-side-flyout-header">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`tv-side-flyout-item${overlayActive ? ' active' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggleActive();
|
||||
onCloseFlyout();
|
||||
}}
|
||||
>
|
||||
<span className="tv-side-flyout-icon"><IconCheck /></span>
|
||||
<span className="tv-side-flyout-title">
|
||||
{overlayActive ? '미적용' : '적용'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tv-side-flyout-item"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings();
|
||||
onCloseFlyout();
|
||||
}}
|
||||
>
|
||||
<span className="tv-side-flyout-icon"><IconGear /></span>
|
||||
<span className="tv-side-flyout-title">설정</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
interface MenuItemDef {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -266,6 +348,12 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
||||
manager, indicators, chartHeight, paused = false,
|
||||
candleOverlayToggles,
|
||||
onToggleCandleOverlay,
|
||||
customOverlayActive = false,
|
||||
onCustomOverlayActiveChange,
|
||||
onOpenCustomOverlaySettings,
|
||||
custom1OverlayActive = false,
|
||||
onCustom1OverlayActiveChange,
|
||||
onOpenCustom1OverlaySettings,
|
||||
onSplit, onRemove, onDuplicate,
|
||||
onExpand, onRestore, focusedId,
|
||||
onDragStart, onToggleHidden, onSettings,
|
||||
@@ -341,6 +429,10 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
||||
|
||||
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
||||
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
|
||||
const showCustomBtn = !!(showOverlayIcons && onCustomOverlayActiveChange && onOpenCustomOverlaySettings);
|
||||
const showCustom1Btn = !!(showOverlayIcons && onCustom1OverlayActiveChange && onOpenCustom1OverlaySettings);
|
||||
const customMenuOpen = openMenuId === CUSTOM_MENU_ID;
|
||||
const custom1MenuOpen = openMenuId === CUSTOM1_MENU_ID;
|
||||
|
||||
const focusedLayoutId = focusedId
|
||||
? resolveFocusedPaneLayoutId(focusedId, indicators)
|
||||
@@ -395,6 +487,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
||||
{OVERLAY_ICONS[item.key]}
|
||||
</button>
|
||||
))}
|
||||
{showCustomBtn && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom${customOverlayActive ? ' active' : ''}${customMenuOpen ? ' active' : ''}`}
|
||||
title="Custom 표시"
|
||||
style={{ marginTop: BTN_GAP }}
|
||||
onMouseEnter={e => openFlyout(CUSTOM_MENU_ID, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<IconCustom />
|
||||
<span className="tv-side-expand-arrow" />
|
||||
</button>
|
||||
)}
|
||||
{showCustom1Btn && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom1${custom1OverlayActive ? ' active' : ''}${custom1MenuOpen ? ' active' : ''}`}
|
||||
title="Custom1 표시"
|
||||
style={{ marginTop: BTN_GAP }}
|
||||
onMouseEnter={e => openFlyout(CUSTOM1_MENU_ID, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<IconCustom />
|
||||
<span className="chart-right-toolbar-custom1-badge">1</span>
|
||||
<span className="tv-side-expand-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -457,7 +576,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openItem && openMenuId && (() => {
|
||||
{customMenuOpen && showCustomBtn && (
|
||||
<CustomOverlayFlyoutMenu
|
||||
title="Custom"
|
||||
flyoutPos={flyoutPos}
|
||||
overlayActive={customOverlayActive}
|
||||
onToggleActive={() => onCustomOverlayActiveChange?.(!customOverlayActive)}
|
||||
onOpenSettings={() => onOpenCustomOverlaySettings?.()}
|
||||
onCancelClose={cancelClose}
|
||||
onScheduleClose={scheduleClose}
|
||||
onCloseFlyout={closeFlyout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{custom1MenuOpen && showCustom1Btn && (
|
||||
<CustomOverlayFlyoutMenu
|
||||
title="Custom1"
|
||||
flyoutPos={flyoutPos}
|
||||
overlayActive={custom1OverlayActive}
|
||||
onToggleActive={() => onCustom1OverlayActiveChange?.(!custom1OverlayActive)}
|
||||
onOpenSettings={() => onOpenCustom1OverlaySettings?.()}
|
||||
onCancelClose={cancelClose}
|
||||
onScheduleClose={scheduleClose}
|
||||
onCloseFlyout={closeFlyout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openItem && openMenuId && openMenuId !== CUSTOM_MENU_ID && openMenuId !== CUSTOM1_MENU_ID && (() => {
|
||||
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
|
||||
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
||||
onToggleHidden, onSettings,
|
||||
|
||||
@@ -51,6 +51,15 @@ import {
|
||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||
listChartOverlayToggleItems,
|
||||
} from '../utils/chartOverlayVisibility';
|
||||
import {
|
||||
type ChartCustomOverlaySelection,
|
||||
type ChartCustomOverlaySlotId,
|
||||
createDefaultChartCustomOverlaySelection,
|
||||
isCustomOverlaySlotActive,
|
||||
resolveActiveCustomOverlaySlot,
|
||||
syncChartManagerCustomOverlaysByActiveSlot,
|
||||
} from '../utils/chartCustomOverlay';
|
||||
import ChartCustomOverlaySettingsModal from './ChartCustomOverlaySettingsModal';
|
||||
import { saveSlot } from '../utils/backendApi';
|
||||
import type {
|
||||
OHLCVBar, ChartType, Theme, Timeframe,
|
||||
@@ -672,6 +681,73 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
[overlayVisibility],
|
||||
);
|
||||
|
||||
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
|
||||
() => createDefaultChartCustomOverlaySelection(),
|
||||
);
|
||||
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
|
||||
() => createDefaultChartCustomOverlaySelection(),
|
||||
);
|
||||
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
||||
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
|
||||
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
|
||||
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
|
||||
|
||||
const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
activeCustomOverlaySlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
|
||||
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
|
||||
setActiveCustomOverlaySlot(nextSlot);
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
nextSlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
|
||||
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
|
||||
setActiveCustomOverlaySlot(nextSlot);
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) {
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
nextSlot,
|
||||
customOverlaySelection,
|
||||
custom1OverlaySelection,
|
||||
);
|
||||
}
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleSaveCustomOverlaySelection = useCallback((
|
||||
slot: ChartCustomOverlaySlotId,
|
||||
selection: ChartCustomOverlaySelection,
|
||||
) => {
|
||||
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
|
||||
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
|
||||
if (slot === 'custom') setCustomOverlaySelection(selection);
|
||||
else setCustom1OverlaySelection(selection);
|
||||
setCustomOverlaySettingsSlot(null);
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr,
|
||||
activeCustomOverlaySlot,
|
||||
nextCustomSel,
|
||||
nextCustom1Sel,
|
||||
);
|
||||
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
|
||||
|
||||
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
||||
setOverlayVisibility(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
@@ -937,6 +1013,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
mgr.setVolumeVisible(chartVolumeVisible);
|
||||
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||
syncCustomOverlaysToManager(mgr);
|
||||
// Time sync: 가시 시간 범위 변화 구독
|
||||
mgr.subscribeVisibleTimeRange(r => {
|
||||
if (!r || isSyncingTimeRef.current) return;
|
||||
@@ -962,6 +1039,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||
syncCustomOverlaysToManager(mgr);
|
||||
if (skipSyncApplyRef.current) {
|
||||
skipSyncApplyRef.current = false;
|
||||
const fb = mgr.hasIchimoku() ? 28 : 0;
|
||||
@@ -1016,8 +1094,31 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
|
||||
candleOverlayToggles={compactMode ? undefined : candleOverlayToggles}
|
||||
onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay}
|
||||
customOverlayActive={compactMode ? undefined : customOverlayActive}
|
||||
onCustomOverlayActiveChange={compactMode ? undefined : handleCustomOverlayActiveChange}
|
||||
onOpenCustomOverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom')}
|
||||
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
|
||||
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
|
||||
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
|
||||
/>
|
||||
|
||||
{customOverlaySettingsSlot === 'custom' && !compactMode && (
|
||||
<ChartCustomOverlaySettingsModal
|
||||
title="Custom 표시 설정"
|
||||
selection={customOverlaySelection}
|
||||
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
|
||||
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
||||
/>
|
||||
)}
|
||||
{customOverlaySettingsSlot === 'custom1' && !compactMode && (
|
||||
<ChartCustomOverlaySettingsModal
|
||||
title="Custom1 표시 설정"
|
||||
selection={custom1OverlaySelection}
|
||||
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
|
||||
onCancel={() => setCustomOverlaySettingsSlot(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컨텍스트 툴바 */}
|
||||
{ctxToolbar && (() => {
|
||||
const cfg = ctxToolbar.entryId !== '__main__'
|
||||
|
||||
@@ -165,6 +165,12 @@ interface TradingChartProps {
|
||||
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
|
||||
candleOverlayToggles?: CandleOverlayToggleItem[];
|
||||
onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void;
|
||||
customOverlayActive?: boolean;
|
||||
onCustomOverlayActiveChange?: (active: boolean) => void;
|
||||
onOpenCustomOverlaySettings?: () => void;
|
||||
custom1OverlayActive?: boolean;
|
||||
onCustom1OverlayActiveChange?: (active: boolean) => void;
|
||||
onOpenCustom1OverlaySettings?: () => void;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -209,6 +215,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
showCandlePaneControls = true,
|
||||
candleOverlayToggles,
|
||||
onToggleCandleOverlay,
|
||||
customOverlayActive,
|
||||
onCustomOverlayActiveChange,
|
||||
onOpenCustomOverlaySettings,
|
||||
custom1OverlayActive,
|
||||
onCustom1OverlayActiveChange,
|
||||
onOpenCustom1OverlaySettings,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -1609,6 +1621,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
paused={paneLegendPaused}
|
||||
candleOverlayToggles={candleOverlayToggles}
|
||||
onToggleCandleOverlay={onToggleCandleOverlay}
|
||||
customOverlayActive={customOverlayActive}
|
||||
onCustomOverlayActiveChange={onCustomOverlayActiveChange}
|
||||
onOpenCustomOverlaySettings={onOpenCustomOverlaySettings}
|
||||
custom1OverlayActive={custom1OverlayActive}
|
||||
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
|
||||
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
|
||||
onSplit={onSplitIndicatorPane}
|
||||
onRemove={onRemoveIndicator}
|
||||
onDuplicate={onDuplicateIndicator}
|
||||
|
||||
@@ -64,7 +64,15 @@ import {
|
||||
type ChartOverlayVisibility,
|
||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||
chartOverlayKeyForType,
|
||||
isMovingAverageOverlayType,
|
||||
} from './chartOverlayVisibility';
|
||||
import {
|
||||
type ChartCustomOverlaySelection,
|
||||
type ChartCustomOverlaySlotId,
|
||||
createDefaultChartCustomOverlaySelection,
|
||||
isCustomIchimokuCloudVisible,
|
||||
isCustomPlotSelected,
|
||||
} from './chartCustomOverlay';
|
||||
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
||||
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
||||
import {
|
||||
@@ -171,8 +179,13 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void {
|
||||
if (!shouldShowBbBandFill(config)) {
|
||||
function attachBbBandFill(
|
||||
entry: IndicatorEntry,
|
||||
config: IndicatorConfig,
|
||||
showFill?: boolean,
|
||||
): void {
|
||||
const show = showFill ?? shouldShowBbBandFill(config);
|
||||
if (!show) {
|
||||
detachBbBandFill(entry);
|
||||
return;
|
||||
}
|
||||
@@ -240,6 +253,36 @@ export class ChartManager {
|
||||
private _chartOverlayVisibility: ChartOverlayVisibility = {
|
||||
...DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||
};
|
||||
private _customOverlaySlots: Record<
|
||||
ChartCustomOverlaySlotId,
|
||||
{ active: boolean; selection: ChartCustomOverlaySelection }
|
||||
> = {
|
||||
custom: {
|
||||
active: false,
|
||||
selection: createDefaultChartCustomOverlaySelection(),
|
||||
},
|
||||
custom1: {
|
||||
active: false,
|
||||
selection: createDefaultChartCustomOverlaySelection(),
|
||||
},
|
||||
};
|
||||
|
||||
private _appliedCustomOverlaySlot(): ChartCustomOverlaySlotId | null {
|
||||
if (this._customOverlaySlots.custom1.active) return 'custom1';
|
||||
if (this._customOverlaySlots.custom.active) return 'custom';
|
||||
return null;
|
||||
}
|
||||
|
||||
private _isCustomOverlayApplied(): boolean {
|
||||
return this._appliedCustomOverlaySlot() !== null;
|
||||
}
|
||||
|
||||
private _getAppliedCustomOverlaySelection(): ChartCustomOverlaySelection {
|
||||
const slot = this._appliedCustomOverlaySlot();
|
||||
return slot
|
||||
? this._customOverlaySlots[slot].selection
|
||||
: createDefaultChartCustomOverlaySelection();
|
||||
}
|
||||
|
||||
// ── 인디케이터 실시간 갱신 스로틀 ─────────────────────────────────────────
|
||||
private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -731,7 +774,10 @@ export class ChartManager {
|
||||
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
||||
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
||||
} else {
|
||||
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
|
||||
const isPlotVisible = !indicatorHidden
|
||||
&& (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud')
|
||||
? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id)
|
||||
: config.plotVisibility?.[plotDef.id] !== false);
|
||||
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane);
|
||||
const showSeriesTitle = pane < 2 && showPriceLabel;
|
||||
series = this.chart.addSeries(LineSeries, {
|
||||
@@ -804,7 +850,7 @@ export class ChartManager {
|
||||
this.indicators.set(config.id, entry);
|
||||
this.applyIndicatorStyle(config);
|
||||
if (config.type === 'BollingerBands' && !indicatorHidden) {
|
||||
attachBbBandFill(entry, config);
|
||||
attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config));
|
||||
entry.bbFillPrimitive?.requestRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1790,13 +1836,21 @@ export class ChartManager {
|
||||
|
||||
// bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고
|
||||
// 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지.
|
||||
const bullishVis = colors.bullishVisible !== false;
|
||||
const bearishVis = colors.bearishVisible !== false;
|
||||
let bullishVis = colors.bullishVisible !== false;
|
||||
let bearishVis = colors.bearishVisible !== false;
|
||||
if (this._isCustomOverlayApplied()) {
|
||||
const sel = this._getAppliedCustomOverlaySelection();
|
||||
bullishVis = isCustomIchimokuCloudVisible(sel, 'bullish');
|
||||
bearishVis = isCustomIchimokuCloudVisible(sel, 'bearish');
|
||||
} else {
|
||||
const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis;
|
||||
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis;
|
||||
bullishVis = effectiveBullish;
|
||||
bearishVis = effectiveBearish;
|
||||
}
|
||||
|
||||
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
||||
plugin.setVisibility(effectiveBullish, effectiveBearish);
|
||||
plugin.setVisibility(bullishVis, bearishVis);
|
||||
|
||||
if (!this._isIndicatorEffectivelyVisible(config)) {
|
||||
plugin.setCloudData([]);
|
||||
@@ -2379,16 +2433,87 @@ export class ChartManager {
|
||||
return config.hidden !== true;
|
||||
}
|
||||
|
||||
getChartCustomOverlayActive(slot: ChartCustomOverlaySlotId = 'custom'): boolean {
|
||||
return this._customOverlaySlots[slot].active;
|
||||
}
|
||||
|
||||
getChartCustomOverlaySelection(slot: ChartCustomOverlaySlotId = 'custom'): ChartCustomOverlaySelection {
|
||||
const sel = this._customOverlaySlots[slot].selection;
|
||||
return {
|
||||
smaPlots: { ...sel.smaPlots },
|
||||
bollinger: { ...sel.bollinger },
|
||||
ichimoku: { ...sel.ichimoku },
|
||||
candle: sel.candle,
|
||||
};
|
||||
}
|
||||
|
||||
setChartCustomOverlaySelection(
|
||||
selection: ChartCustomOverlaySelection,
|
||||
slot: ChartCustomOverlaySlotId = 'custom',
|
||||
): void {
|
||||
this._customOverlaySlots[slot].selection = {
|
||||
smaPlots: { ...selection.smaPlots },
|
||||
bollinger: { ...selection.bollinger },
|
||||
ichimoku: { ...selection.ichimoku },
|
||||
candle: selection.candle,
|
||||
};
|
||||
if (this._customOverlaySlots[slot].active) {
|
||||
this.syncChartOverlayVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/** 슬롯 적용 시 다른 슬롯은 자동 미적용 (Custom ↔ Custom1 상호 배타) */
|
||||
setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId = 'custom'): void {
|
||||
if (this._customOverlaySlots[slot].active === active) return;
|
||||
this._customOverlaySlots[slot].active = active;
|
||||
if (active) {
|
||||
const other: ChartCustomOverlaySlotId = slot === 'custom' ? 'custom1' : 'custom';
|
||||
this._customOverlaySlots[other].active = false;
|
||||
}
|
||||
this.syncChartOverlayVisibility();
|
||||
}
|
||||
|
||||
/** custom 미적용 시 지표 설정 plotVisibility, 적용 시 custom 선택만 반영 */
|
||||
private _resolveSeriesVisible(config: IndicatorConfig, plotId: string | undefined): boolean {
|
||||
if (!plotId || !this._isIndicatorEffectivelyVisible(config)) return false;
|
||||
if (!this._isCustomOverlayApplied()) {
|
||||
return config.plotVisibility?.[plotId] !== false;
|
||||
}
|
||||
if (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') {
|
||||
return isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotId);
|
||||
}
|
||||
if (isMovingAverageOverlayType(config.type)) {
|
||||
return config.plotVisibility?.[plotId] !== false;
|
||||
}
|
||||
return config.plotVisibility?.[plotId] !== false;
|
||||
}
|
||||
|
||||
private _resolveCandleVisible(): boolean {
|
||||
if (!this._chartOverlayVisibility.candle) return false;
|
||||
if (!this._isCustomOverlayApplied()) return true;
|
||||
return this._getAppliedCustomOverlaySelection().candle;
|
||||
}
|
||||
|
||||
private _shouldShowBbBandFillForConfig(config: IndicatorConfig): boolean {
|
||||
if (!this._isIndicatorEffectivelyVisible(config)) return false;
|
||||
if (!this._isCustomOverlayApplied()) {
|
||||
const bg = resolveBbBandBackground(config.bandBackground);
|
||||
if (!bg.visible) return false;
|
||||
if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
|
||||
return true;
|
||||
}
|
||||
const b = this._getAppliedCustomOverlaySelection().bollinger;
|
||||
return b.background && b.upper && b.lower;
|
||||
}
|
||||
|
||||
/** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */
|
||||
private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void {
|
||||
const config = entry.config;
|
||||
if (!config) return;
|
||||
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
|
||||
|
||||
entry.seriesList.forEach((series, i) => {
|
||||
const plotId = entry.seriesMeta[i]?.plotId;
|
||||
const visible = indicatorVisible
|
||||
&& (plotId == null || config.plotVisibility?.[plotId] !== false);
|
||||
const visible = this._resolveSeriesVisible(config, plotId);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
series.applyOptions({ visible } as any);
|
||||
@@ -2402,7 +2527,7 @@ export class ChartManager {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).applyOptions({
|
||||
visible: this._chartOverlayVisibility.candle,
|
||||
visible: this._resolveCandleVisible(),
|
||||
});
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
@@ -2416,8 +2541,8 @@ export class ChartManager {
|
||||
|
||||
if (entry.type === 'BollingerBands') {
|
||||
detachBbBandFill(entry);
|
||||
if (this._isIndicatorEffectivelyVisible(config)) {
|
||||
attachBbBandFill(entry, config);
|
||||
if (this._shouldShowBbBandFillForConfig(config)) {
|
||||
attachBbBandFill(entry, config, true);
|
||||
entry.bbFillPrimitive?.requestRefresh();
|
||||
}
|
||||
}
|
||||
@@ -2551,15 +2676,13 @@ export class ChartManager {
|
||||
const plotDefs = config.plots ?? def?.plots ?? [];
|
||||
|
||||
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
||||
|
||||
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
|
||||
|
||||
entry.seriesList.forEach((series, i) => {
|
||||
let plot: PlotDef | undefined;
|
||||
const pid = entry.seriesMeta[i]?.plotId;
|
||||
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
|
||||
const visible = indicatorVisible
|
||||
&& (pid == null || config.plotVisibility?.[pid] !== false);
|
||||
const visible = this._resolveSeriesVisible(config, pid);
|
||||
if (!plot) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
series.applyOptions({ visible } as any);
|
||||
@@ -2645,8 +2768,8 @@ export class ChartManager {
|
||||
|
||||
if (entry.type === 'BollingerBands') {
|
||||
detachBbBandFill(entry);
|
||||
if (indicatorVisible) {
|
||||
attachBbBandFill(entry, config);
|
||||
if (this._shouldShowBbBandFillForConfig(config)) {
|
||||
attachBbBandFill(entry, config, true);
|
||||
entry.bbFillPrimitive?.requestRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { SMA_MA_COUNT, smaPlotId } from './smaConfig';
|
||||
|
||||
/** Custom / Custom1 — 동일 기능, 설정만 분리 (세션만, DB 미저장) */
|
||||
export type ChartCustomOverlaySlotId = 'custom' | 'custom1';
|
||||
|
||||
export const CHART_CUSTOM_OVERLAY_SLOTS: ChartCustomOverlaySlotId[] = ['custom', 'custom1'];
|
||||
|
||||
/** Custom 적용 시 표시할 항목 (세션만, DB 미저장) */
|
||||
export type ChartCustomOverlaySelection = {
|
||||
smaPlots: Record<string, boolean>;
|
||||
bollinger: {
|
||||
basis: boolean;
|
||||
upper: boolean;
|
||||
lower: boolean;
|
||||
background: boolean;
|
||||
};
|
||||
ichimoku: {
|
||||
conversion: boolean;
|
||||
base: boolean;
|
||||
lagging: boolean;
|
||||
spanA: boolean;
|
||||
spanB: boolean;
|
||||
bullishCloud: boolean;
|
||||
bearishCloud: boolean;
|
||||
};
|
||||
candle: boolean;
|
||||
};
|
||||
|
||||
export const BB_CUSTOM_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const;
|
||||
export const BB_CUSTOM_LABELS: Record<(typeof BB_CUSTOM_PLOT_IDS)[number], string> = {
|
||||
plot0: '중앙값',
|
||||
plot1: '어퍼',
|
||||
plot2: '로우어',
|
||||
};
|
||||
|
||||
export function createDefaultChartCustomOverlaySelection(): ChartCustomOverlaySelection {
|
||||
const smaPlots: Record<string, boolean> = {};
|
||||
for (let i = 0; i < SMA_MA_COUNT; i++) {
|
||||
smaPlots[smaPlotId(i)] = true;
|
||||
}
|
||||
return {
|
||||
smaPlots,
|
||||
bollinger: {
|
||||
basis: true,
|
||||
upper: true,
|
||||
lower: true,
|
||||
background: true,
|
||||
},
|
||||
ichimoku: {
|
||||
conversion: true,
|
||||
base: true,
|
||||
lagging: true,
|
||||
spanA: true,
|
||||
spanB: true,
|
||||
bullishCloud: true,
|
||||
bearishCloud: true,
|
||||
},
|
||||
candle: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneChartCustomOverlaySelection(
|
||||
sel: ChartCustomOverlaySelection,
|
||||
): ChartCustomOverlaySelection {
|
||||
return {
|
||||
smaPlots: { ...sel.smaPlots },
|
||||
bollinger: { ...sel.bollinger },
|
||||
ichimoku: { ...sel.ichimoku },
|
||||
candle: sel.candle,
|
||||
};
|
||||
}
|
||||
|
||||
export function smaMaLabel(plotIndex: number): string {
|
||||
return `MA${plotIndex + 1}`;
|
||||
}
|
||||
|
||||
export const ICHIMOKU_CUSTOM_ROWS: Array<{
|
||||
key: keyof ChartCustomOverlaySelection['ichimoku'];
|
||||
plotId?: string;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: 'conversion', plotId: 'plot0', label: '전환선' },
|
||||
{ key: 'base', plotId: 'plot1', label: '기준선' },
|
||||
{ key: 'lagging', plotId: 'plot2', label: '후행스팬' },
|
||||
{ key: 'spanA', plotId: 'plot3', label: '선행스팬1' },
|
||||
{ key: 'spanB', plotId: 'plot4', label: '선행스팬2' },
|
||||
{ key: 'bullishCloud', label: '상승 구름' },
|
||||
{ key: 'bearishCloud', label: '하락 구름' },
|
||||
];
|
||||
|
||||
/** 지표 설정 plotVisibility 와 무관 — custom 적용 시 이 맵만 사용 */
|
||||
export function isCustomPlotSelected(
|
||||
sel: ChartCustomOverlaySelection,
|
||||
type: string,
|
||||
plotId: string,
|
||||
): boolean {
|
||||
if (type === 'SMA') {
|
||||
return sel.smaPlots[plotId] === true;
|
||||
}
|
||||
if (type === 'BollingerBands') {
|
||||
if (plotId === 'plot0') return sel.bollinger.basis;
|
||||
if (plotId === 'plot1') return sel.bollinger.upper;
|
||||
if (plotId === 'plot2') return sel.bollinger.lower;
|
||||
return false;
|
||||
}
|
||||
if (type === 'IchimokuCloud') {
|
||||
const row = ICHIMOKU_CUSTOM_ROWS.find(r => r.plotId === plotId);
|
||||
if (!row) return false;
|
||||
return sel.ichimoku[row.key] === true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isCustomIchimokuCloudVisible(
|
||||
sel: ChartCustomOverlaySelection,
|
||||
which: 'bullish' | 'bearish',
|
||||
): boolean {
|
||||
return which === 'bullish'
|
||||
? sel.ichimoku.bullishCloud
|
||||
: sel.ichimoku.bearishCloud;
|
||||
}
|
||||
|
||||
export type CustomOverlaySectionId = 'sma' | 'bollinger' | 'ichimoku' | 'candle';
|
||||
|
||||
export function isCustomSectionAllSelected(
|
||||
sel: ChartCustomOverlaySelection,
|
||||
section: CustomOverlaySectionId,
|
||||
): boolean {
|
||||
switch (section) {
|
||||
case 'sma':
|
||||
return Array.from({ length: SMA_MA_COUNT }, (_, i) =>
|
||||
sel.smaPlots[smaPlotId(i)] !== false,
|
||||
).every(Boolean);
|
||||
case 'bollinger':
|
||||
return sel.bollinger.basis
|
||||
&& sel.bollinger.upper
|
||||
&& sel.bollinger.lower
|
||||
&& sel.bollinger.background;
|
||||
case 'ichimoku':
|
||||
return ICHIMOKU_CUSTOM_ROWS.every(r => sel.ichimoku[r.key]);
|
||||
case 'candle':
|
||||
return sel.candle;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCustomSectionAllSelected(
|
||||
sel: ChartCustomOverlaySelection,
|
||||
section: CustomOverlaySectionId,
|
||||
on: boolean,
|
||||
): void {
|
||||
switch (section) {
|
||||
case 'sma':
|
||||
for (let i = 0; i < SMA_MA_COUNT; i++) {
|
||||
sel.smaPlots[smaPlotId(i)] = on;
|
||||
}
|
||||
break;
|
||||
case 'bollinger':
|
||||
sel.bollinger.basis = on;
|
||||
sel.bollinger.upper = on;
|
||||
sel.bollinger.lower = on;
|
||||
sel.bollinger.background = on;
|
||||
break;
|
||||
case 'ichimoku':
|
||||
for (const row of ICHIMOKU_CUSTOM_ROWS) {
|
||||
sel.ichimoku[row.key] = on;
|
||||
}
|
||||
break;
|
||||
case 'candle':
|
||||
sel.candle = on;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export type ChartCustomOverlaySlotState = {
|
||||
active: boolean;
|
||||
selection: ChartCustomOverlaySelection;
|
||||
};
|
||||
|
||||
/** UI·Manager 공통: 한 번에 하나의 슬롯만 적용 (다른 슬롯 적용 시 기존 슬롯 자동 해제) */
|
||||
export function isCustomOverlaySlotActive(
|
||||
activeSlot: ChartCustomOverlaySlotId | null,
|
||||
slot: ChartCustomOverlaySlotId,
|
||||
): boolean {
|
||||
return activeSlot === slot;
|
||||
}
|
||||
|
||||
export function resolveActiveCustomOverlaySlot(
|
||||
slot: ChartCustomOverlaySlotId,
|
||||
turnOn: boolean,
|
||||
current: ChartCustomOverlaySlotId | null,
|
||||
): ChartCustomOverlaySlotId | null {
|
||||
if (turnOn) return slot;
|
||||
return current === slot ? null : current;
|
||||
}
|
||||
|
||||
type CustomOverlayManagerApi = {
|
||||
setChartCustomOverlaySelection(
|
||||
selection: ChartCustomOverlaySelection,
|
||||
slot: ChartCustomOverlaySlotId,
|
||||
): void;
|
||||
setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId): void;
|
||||
};
|
||||
|
||||
/** React → ChartManager: 두 슬롯 선택값 동기화 후 적용 중인 슬롯만 활성화 */
|
||||
export function syncChartManagerCustomOverlays(
|
||||
mgr: CustomOverlayManagerApi,
|
||||
custom: ChartCustomOverlaySlotState,
|
||||
custom1: ChartCustomOverlaySlotState,
|
||||
): void {
|
||||
mgr.setChartCustomOverlaySelection(custom.selection, 'custom');
|
||||
mgr.setChartCustomOverlaySelection(custom1.selection, 'custom1');
|
||||
mgr.setChartCustomOverlayActive(false, 'custom');
|
||||
mgr.setChartCustomOverlayActive(false, 'custom1');
|
||||
if (custom1.active) {
|
||||
mgr.setChartCustomOverlayActive(true, 'custom1');
|
||||
} else if (custom.active) {
|
||||
mgr.setChartCustomOverlayActive(true, 'custom');
|
||||
}
|
||||
}
|
||||
|
||||
/** 적용 슬롯 하나만 — Custom 적용 중 Custom1 적용 시 Custom 자동 미적용 */
|
||||
export function syncChartManagerCustomOverlaysByActiveSlot(
|
||||
mgr: CustomOverlayManagerApi,
|
||||
activeSlot: ChartCustomOverlaySlotId | null,
|
||||
customSelection: ChartCustomOverlaySelection,
|
||||
custom1Selection: ChartCustomOverlaySelection,
|
||||
): void {
|
||||
syncChartManagerCustomOverlays(
|
||||
mgr,
|
||||
{ active: activeSlot === 'custom', selection: customSelection },
|
||||
{ active: activeSlot === 'custom1', selection: custom1Selection },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user