custom 툴발 적용
This commit is contained in:
@@ -1103,6 +1103,34 @@ html.theme-blue {
|
|||||||
animation: flyout-in-right 0.1s ease;
|
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 {
|
@keyframes flyout-in-right {
|
||||||
from { opacity: 0; transform: translateX(calc(-100% + 6px)); }
|
from { opacity: 0; transform: translateX(calc(-100% + 6px)); }
|
||||||
to { opacity: 1; transform: translateX(-100%); }
|
to { opacity: 1; transform: translateX(-100%); }
|
||||||
@@ -2561,6 +2589,74 @@ html.theme-blue {
|
|||||||
border-color: rgba(122, 162, 247, 0.65);
|
border-color: rgba(122, 162, 247, 0.65);
|
||||||
background: rgba(122, 162, 247, 0.22);
|
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 드래그 핸들 ─────────────────────────────────────────── */
|
/* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */
|
||||||
.pane-legend-item {
|
.pane-legend-item {
|
||||||
|
|||||||
+102
-1
@@ -54,6 +54,15 @@ import {
|
|||||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||||
listChartOverlayToggleItems,
|
listChartOverlayToggleItems,
|
||||||
} from './utils/chartOverlayVisibility';
|
} 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 { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
|
||||||
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||||
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
|
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
|
||||||
@@ -1455,6 +1464,73 @@ function App() {
|
|||||||
[overlayVisibility],
|
[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) => {
|
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
||||||
setOverlayVisibility(prev => {
|
setOverlayVisibility(prev => {
|
||||||
const next = { ...prev, [key]: !prev[key] };
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
@@ -2451,7 +2527,9 @@ function App() {
|
|||||||
onCrosshair={setLegend}
|
onCrosshair={setLegend}
|
||||||
onTradeOrderRequest={applyTradeFill}
|
onTradeOrderRequest={applyTradeFill}
|
||||||
onDataLoaded={() => {
|
onDataLoaded={() => {
|
||||||
managerRef.current?.setChartOverlayVisibility(overlayVisibilityRef.current);
|
const mgr = managerRef.current;
|
||||||
|
mgr?.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
|
if (mgr) syncCustomOverlaysToManager(mgr);
|
||||||
syncBacktestMarkersRef.current();
|
syncBacktestMarkersRef.current();
|
||||||
}}
|
}}
|
||||||
onCandlesReady={handleCandlesReady}
|
onCandlesReady={handleCandlesReady}
|
||||||
@@ -2474,6 +2552,7 @@ function App() {
|
|||||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
|
syncCustomOverlaysToManager(mgr);
|
||||||
syncBacktestMarkersRef.current();
|
syncBacktestMarkersRef.current();
|
||||||
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||||
mgr.subscribeVisibleLogicalRange(r => {
|
mgr.subscribeVisibleLogicalRange(r => {
|
||||||
@@ -2520,8 +2599,30 @@ function App() {
|
|||||||
paneSeparatorOptions={chartPaneSeparator}
|
paneSeparatorOptions={chartPaneSeparator}
|
||||||
candleOverlayToggles={candleOverlayToggles}
|
candleOverlayToggles={candleOverlayToggles}
|
||||||
onToggleCandleOverlay={handleToggleCandleOverlay}
|
onToggleCandleOverlay={handleToggleCandleOverlay}
|
||||||
|
customOverlayActive={customOverlayActive}
|
||||||
|
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
|
||||||
|
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
|
||||||
|
custom1OverlayActive={custom1OverlayActive}
|
||||||
|
onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange}
|
||||||
|
onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')}
|
||||||
showHoverToolbar={chartHoverToolbarVisible}
|
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 && (
|
{isSingleLoadingMore && (
|
||||||
<div className="chart-history-loading">
|
<div className="chart-history-loading">
|
||||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
<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 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
|
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type { IndicatorConfig } from '../types';
|
import type { IndicatorConfig } from '../types';
|
||||||
import { ChartManager } from '../utils/ChartManager';
|
import { ChartManager } from '../utils/ChartManager';
|
||||||
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
import { getPaneHostId } from '../utils/indicatorPaneMerge';
|
||||||
@@ -35,6 +36,13 @@ export interface ChartRightToolbarProps {
|
|||||||
onToggleHidden?: (id: string) => void;
|
onToggleHidden?: (id: string) => void;
|
||||||
/** 지표명 툴바 — 설정 모달 */
|
/** 지표명 툴바 — 설정 모달 */
|
||||||
onSettings?: (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;
|
const BTN_SIZE = 32;
|
||||||
@@ -159,6 +167,24 @@ const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? (
|
|||||||
</svg>
|
</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 = () => (
|
const IconGear = () => (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
<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"/>
|
<circle cx="7" cy="7" r="2.3"/>
|
||||||
@@ -166,6 +192,62 @@ const IconGear = () => (
|
|||||||
</svg>
|
</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 {
|
interface MenuItemDef {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -266,6 +348,12 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
manager, indicators, chartHeight, paused = false,
|
manager, indicators, chartHeight, paused = false,
|
||||||
candleOverlayToggles,
|
candleOverlayToggles,
|
||||||
onToggleCandleOverlay,
|
onToggleCandleOverlay,
|
||||||
|
customOverlayActive = false,
|
||||||
|
onCustomOverlayActiveChange,
|
||||||
|
onOpenCustomOverlaySettings,
|
||||||
|
custom1OverlayActive = false,
|
||||||
|
onCustom1OverlayActiveChange,
|
||||||
|
onOpenCustom1OverlaySettings,
|
||||||
onSplit, onRemove, onDuplicate,
|
onSplit, onRemove, onDuplicate,
|
||||||
onExpand, onRestore, focusedId,
|
onExpand, onRestore, focusedId,
|
||||||
onDragStart, onToggleHidden, onSettings,
|
onDragStart, onToggleHidden, onSettings,
|
||||||
@@ -341,6 +429,10 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
|
|
||||||
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
||||||
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
|
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
|
const focusedLayoutId = focusedId
|
||||||
? resolveFocusedPaneLayoutId(focusedId, indicators)
|
? resolveFocusedPaneLayoutId(focusedId, indicators)
|
||||||
@@ -395,6 +487,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
{OVERLAY_ICONS[item.key]}
|
{OVERLAY_ICONS[item.key]}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -457,7 +576,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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, {
|
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
|
||||||
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
|
||||||
onToggleHidden, onSettings,
|
onToggleHidden, onSettings,
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ import {
|
|||||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||||
listChartOverlayToggleItems,
|
listChartOverlayToggleItems,
|
||||||
} from '../utils/chartOverlayVisibility';
|
} 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 { saveSlot } from '../utils/backendApi';
|
||||||
import type {
|
import type {
|
||||||
OHLCVBar, ChartType, Theme, Timeframe,
|
OHLCVBar, ChartType, Theme, Timeframe,
|
||||||
@@ -672,6 +681,73 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
[overlayVisibility],
|
[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) => {
|
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
||||||
setOverlayVisibility(prev => {
|
setOverlayVisibility(prev => {
|
||||||
const next = { ...prev, [key]: !prev[key] };
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
@@ -937,6 +1013,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
mgr.setVolumeVisible(chartVolumeVisible);
|
mgr.setVolumeVisible(chartVolumeVisible);
|
||||||
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
|
syncCustomOverlaysToManager(mgr);
|
||||||
// Time sync: 가시 시간 범위 변화 구독
|
// Time sync: 가시 시간 범위 변화 구독
|
||||||
mgr.subscribeVisibleTimeRange(r => {
|
mgr.subscribeVisibleTimeRange(r => {
|
||||||
if (!r || isSyncingTimeRef.current) return;
|
if (!r || isSyncingTimeRef.current) return;
|
||||||
@@ -962,6 +1039,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr) return;
|
if (!mgr) return;
|
||||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
|
syncCustomOverlaysToManager(mgr);
|
||||||
if (skipSyncApplyRef.current) {
|
if (skipSyncApplyRef.current) {
|
||||||
skipSyncApplyRef.current = false;
|
skipSyncApplyRef.current = false;
|
||||||
const fb = mgr.hasIchimoku() ? 28 : 0;
|
const fb = mgr.hasIchimoku() ? 28 : 0;
|
||||||
@@ -1016,8 +1094,31 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
|
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
|
||||||
candleOverlayToggles={compactMode ? undefined : candleOverlayToggles}
|
candleOverlayToggles={compactMode ? undefined : candleOverlayToggles}
|
||||||
onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay}
|
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 && (() => {
|
{ctxToolbar && (() => {
|
||||||
const cfg = ctxToolbar.entryId !== '__main__'
|
const cfg = ctxToolbar.entryId !== '__main__'
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ interface TradingChartProps {
|
|||||||
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
|
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
|
||||||
candleOverlayToggles?: CandleOverlayToggleItem[];
|
candleOverlayToggles?: CandleOverlayToggleItem[];
|
||||||
onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void;
|
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> = ({
|
const TradingChart: React.FC<TradingChartProps> = ({
|
||||||
@@ -209,6 +215,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
showCandlePaneControls = true,
|
showCandlePaneControls = true,
|
||||||
candleOverlayToggles,
|
candleOverlayToggles,
|
||||||
onToggleCandleOverlay,
|
onToggleCandleOverlay,
|
||||||
|
customOverlayActive,
|
||||||
|
onCustomOverlayActiveChange,
|
||||||
|
onOpenCustomOverlaySettings,
|
||||||
|
custom1OverlayActive,
|
||||||
|
onCustom1OverlayActiveChange,
|
||||||
|
onOpenCustom1OverlaySettings,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||||
@@ -1609,6 +1621,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
paused={paneLegendPaused}
|
paused={paneLegendPaused}
|
||||||
candleOverlayToggles={candleOverlayToggles}
|
candleOverlayToggles={candleOverlayToggles}
|
||||||
onToggleCandleOverlay={onToggleCandleOverlay}
|
onToggleCandleOverlay={onToggleCandleOverlay}
|
||||||
|
customOverlayActive={customOverlayActive}
|
||||||
|
onCustomOverlayActiveChange={onCustomOverlayActiveChange}
|
||||||
|
onOpenCustomOverlaySettings={onOpenCustomOverlaySettings}
|
||||||
|
custom1OverlayActive={custom1OverlayActive}
|
||||||
|
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
|
||||||
|
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
|
||||||
onSplit={onSplitIndicatorPane}
|
onSplit={onSplitIndicatorPane}
|
||||||
onRemove={onRemoveIndicator}
|
onRemove={onRemoveIndicator}
|
||||||
onDuplicate={onDuplicateIndicator}
|
onDuplicate={onDuplicateIndicator}
|
||||||
|
|||||||
@@ -64,7 +64,15 @@ import {
|
|||||||
type ChartOverlayVisibility,
|
type ChartOverlayVisibility,
|
||||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||||
chartOverlayKeyForType,
|
chartOverlayKeyForType,
|
||||||
|
isMovingAverageOverlayType,
|
||||||
} from './chartOverlayVisibility';
|
} from './chartOverlayVisibility';
|
||||||
|
import {
|
||||||
|
type ChartCustomOverlaySelection,
|
||||||
|
type ChartCustomOverlaySlotId,
|
||||||
|
createDefaultChartCustomOverlaySelection,
|
||||||
|
isCustomIchimokuCloudVisible,
|
||||||
|
isCustomPlotSelected,
|
||||||
|
} from './chartCustomOverlay';
|
||||||
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
||||||
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
||||||
import {
|
import {
|
||||||
@@ -171,8 +179,13 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void {
|
function attachBbBandFill(
|
||||||
if (!shouldShowBbBandFill(config)) {
|
entry: IndicatorEntry,
|
||||||
|
config: IndicatorConfig,
|
||||||
|
showFill?: boolean,
|
||||||
|
): void {
|
||||||
|
const show = showFill ?? shouldShowBbBandFill(config);
|
||||||
|
if (!show) {
|
||||||
detachBbBandFill(entry);
|
detachBbBandFill(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -240,6 +253,36 @@ export class ChartManager {
|
|||||||
private _chartOverlayVisibility: ChartOverlayVisibility = {
|
private _chartOverlayVisibility: ChartOverlayVisibility = {
|
||||||
...DEFAULT_CHART_OVERLAY_VISIBILITY,
|
...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;
|
private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -731,7 +774,10 @@ export class ChartManager {
|
|||||||
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
||||||
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
||||||
} else {
|
} 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 showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane);
|
||||||
const showSeriesTitle = pane < 2 && showPriceLabel;
|
const showSeriesTitle = pane < 2 && showPriceLabel;
|
||||||
series = this.chart.addSeries(LineSeries, {
|
series = this.chart.addSeries(LineSeries, {
|
||||||
@@ -804,7 +850,7 @@ export class ChartManager {
|
|||||||
this.indicators.set(config.id, entry);
|
this.indicators.set(config.id, entry);
|
||||||
this.applyIndicatorStyle(config);
|
this.applyIndicatorStyle(config);
|
||||||
if (config.type === 'BollingerBands' && !indicatorHidden) {
|
if (config.type === 'BollingerBands' && !indicatorHidden) {
|
||||||
attachBbBandFill(entry, config);
|
attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config));
|
||||||
entry.bbFillPrimitive?.requestRefresh();
|
entry.bbFillPrimitive?.requestRefresh();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1790,13 +1836,21 @@ export class ChartManager {
|
|||||||
|
|
||||||
// bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고
|
// bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고
|
||||||
// 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지.
|
// 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지.
|
||||||
const bullishVis = colors.bullishVisible !== false;
|
let bullishVis = colors.bullishVisible !== false;
|
||||||
const bearishVis = colors.bearishVisible !== 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 effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis;
|
||||||
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis;
|
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis;
|
||||||
|
bullishVis = effectiveBullish;
|
||||||
|
bearishVis = effectiveBearish;
|
||||||
|
}
|
||||||
|
|
||||||
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
||||||
plugin.setVisibility(effectiveBullish, effectiveBearish);
|
plugin.setVisibility(bullishVis, bearishVis);
|
||||||
|
|
||||||
if (!this._isIndicatorEffectivelyVisible(config)) {
|
if (!this._isIndicatorEffectivelyVisible(config)) {
|
||||||
plugin.setCloudData([]);
|
plugin.setCloudData([]);
|
||||||
@@ -2379,16 +2433,87 @@ export class ChartManager {
|
|||||||
return config.hidden !== true;
|
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 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */
|
/** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */
|
||||||
private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void {
|
private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void {
|
||||||
const config = entry.config;
|
const config = entry.config;
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
|
|
||||||
|
|
||||||
entry.seriesList.forEach((series, i) => {
|
entry.seriesList.forEach((series, i) => {
|
||||||
const plotId = entry.seriesMeta[i]?.plotId;
|
const plotId = entry.seriesMeta[i]?.plotId;
|
||||||
const visible = indicatorVisible
|
const visible = this._resolveSeriesVisible(config, plotId);
|
||||||
&& (plotId == null || config.plotVisibility?.[plotId] !== false);
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
series.applyOptions({ visible } as any);
|
series.applyOptions({ visible } as any);
|
||||||
@@ -2402,7 +2527,7 @@ export class ChartManager {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(this.mainSeries as ISeriesApi<any>).applyOptions({
|
(this.mainSeries as ISeriesApi<any>).applyOptions({
|
||||||
visible: this._chartOverlayVisibility.candle,
|
visible: this._resolveCandleVisible(),
|
||||||
});
|
});
|
||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
}
|
}
|
||||||
@@ -2416,8 +2541,8 @@ export class ChartManager {
|
|||||||
|
|
||||||
if (entry.type === 'BollingerBands') {
|
if (entry.type === 'BollingerBands') {
|
||||||
detachBbBandFill(entry);
|
detachBbBandFill(entry);
|
||||||
if (this._isIndicatorEffectivelyVisible(config)) {
|
if (this._shouldShowBbBandFillForConfig(config)) {
|
||||||
attachBbBandFill(entry, config);
|
attachBbBandFill(entry, config, true);
|
||||||
entry.bbFillPrimitive?.requestRefresh();
|
entry.bbFillPrimitive?.requestRefresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2551,15 +2676,13 @@ export class ChartManager {
|
|||||||
const plotDefs = config.plots ?? def?.plots ?? [];
|
const plotDefs = config.plots ?? def?.plots ?? [];
|
||||||
|
|
||||||
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
||||||
|
|
||||||
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
|
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
|
||||||
|
|
||||||
entry.seriesList.forEach((series, i) => {
|
entry.seriesList.forEach((series, i) => {
|
||||||
let plot: PlotDef | undefined;
|
let plot: PlotDef | undefined;
|
||||||
const pid = entry.seriesMeta[i]?.plotId;
|
const pid = entry.seriesMeta[i]?.plotId;
|
||||||
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
|
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
|
||||||
const visible = indicatorVisible
|
const visible = this._resolveSeriesVisible(config, pid);
|
||||||
&& (pid == null || config.plotVisibility?.[pid] !== false);
|
|
||||||
if (!plot) {
|
if (!plot) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
series.applyOptions({ visible } as any);
|
series.applyOptions({ visible } as any);
|
||||||
@@ -2645,8 +2768,8 @@ export class ChartManager {
|
|||||||
|
|
||||||
if (entry.type === 'BollingerBands') {
|
if (entry.type === 'BollingerBands') {
|
||||||
detachBbBandFill(entry);
|
detachBbBandFill(entry);
|
||||||
if (indicatorVisible) {
|
if (this._shouldShowBbBandFillForConfig(config)) {
|
||||||
attachBbBandFill(entry, config);
|
attachBbBandFill(entry, config, true);
|
||||||
entry.bbFillPrimitive?.requestRefresh();
|
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