실시간 차트 보조지표 탭 문제 수정

This commit is contained in:
Macbook
2026-05-28 01:26:53 +09:00
parent 4f6694b206
commit 98dfb3613c
19 changed files with 801 additions and 182 deletions
@@ -67,13 +67,20 @@ public class StrategyConditionTimeframeService {
GcStrategy strategy = opt.get(); GcStrategy strategy = opt.get();
ensureDslRepaired(strategy); ensureDslRepaired(strategy);
Set<String> out = new LinkedHashSet<>(); Set<String> buyOut = new LinkedHashSet<>();
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), out); Set<String> sellOut = new LinkedHashSet<>();
boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), out); boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut);
if (!out.isEmpty()) { // 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록
return out; if (buyScoped && !buyOut.isEmpty()) {
return buyOut;
} }
if (sellScoped && !sellOut.isEmpty()) {
return sellOut;
}
Set<String> out = new LinkedHashSet<>();
// 레거시: START(TIMEFRAME) 래핑 없는 DSL // 레거시: START(TIMEFRAME) 래핑 없는 DSL
if (!buyScoped) { if (!buyScoped) {
@@ -95,6 +102,18 @@ public class StrategyConditionTimeframeService {
boolean changed = false; boolean changed = false;
String name = strategy.getName(); String name = strategy.getName();
String buyJson = strategy.getBuyConditionJson();
String sellJson = strategy.getSellConditionJson();
if (buyJson != null && !buyJson.isBlank()
&& sellJson != null && !sellJson.isBlank()) {
String alignedSell = dslNormalizer.alignSellStartTimeframeToBuy(buyJson, sellJson);
if (!alignedSell.equals(sellJson)) {
strategy.setSellConditionJson(alignedSell);
sellJson = alignedSell;
changed = true;
}
}
if (strategy.getBuyConditionJson() != null && !strategy.getBuyConditionJson().isBlank()) { if (strategy.getBuyConditionJson() != null && !strategy.getBuyConditionJson().isBlank()) {
String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name); String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name);
if (!fixed.equals(strategy.getBuyConditionJson())) { if (!fixed.equals(strategy.getBuyConditionJson())) {
@@ -102,8 +121,8 @@ public class StrategyConditionTimeframeService {
changed = true; changed = true;
} }
} }
if (strategy.getSellConditionJson() != null && !strategy.getSellConditionJson().isBlank()) { if (sellJson != null && !sellJson.isBlank()) {
String fixed = dslNormalizer.normalizeJson(strategy.getSellConditionJson(), name); String fixed = dslNormalizer.normalizeJson(sellJson, name);
if (!fixed.equals(strategy.getSellConditionJson())) { if (!fixed.equals(strategy.getSellConditionJson())) {
strategy.setSellConditionJson(fixed); strategy.setSellConditionJson(fixed);
changed = true; changed = true;
@@ -83,6 +83,70 @@ public class StrategyDslTimeframeNormalizer {
} }
} }
/**
* 매도 DSL START TIMEFRAME 을 매수와 동일 분봉으로 맞춤.
* (편집기 저장 전 매도만 1m 이 남은 레거시 전략 보정)
*/
public String alignSellStartTimeframeToBuy(String buyJson, String sellJson) {
if (buyJson == null || buyJson.isBlank() || sellJson == null || sellJson.isBlank()) {
return sellJson;
}
try {
JsonNode buyRoot = objectMapper.readTree(buyJson);
JsonNode sellRoot = objectMapper.readTree(sellJson);
JsonNode buyTf = primaryStartTimeframeNode(buyRoot);
if (buyTf == null || !hasExplicitStartTimeframe(buyTf)) return sellJson;
ObjectNode sellCopy = (ObjectNode) sellRoot.deepCopy();
JsonNode sellTf = primaryStartTimeframeNode(sellCopy);
if (sellTf == null || !sellTf.isObject()) return sellJson;
copyIntoStartTimeframe(buyTf, (ObjectNode) sellTf);
return objectMapper.writeValueAsString(sellCopy);
} catch (Exception e) {
return sellJson;
}
}
private static boolean hasExplicitStartTimeframe(JsonNode buyTf) {
if (hasCandleTypesArray(buyTf)) return true;
String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m"));
return !"1m".equals(ct);
}
private static JsonNode primaryStartTimeframeNode(JsonNode root) {
if (root == null || root.isNull()) return null;
String type = root.path("type").asText("");
if ("TIMEFRAME".equals(type)) return root;
if ("AND".equals(type) || "OR".equals(type)) {
JsonNode children = root.path("children");
if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) {
return children.get(0);
}
}
return null;
}
private void copyIntoStartTimeframe(JsonNode from, ObjectNode to) {
if (hasCandleTypesArray(from)) {
ArrayNode arr = objectMapper.createArrayNode();
for (JsonNode t : from.path("candleTypes")) {
arr.add(LiveStrategyTimeframeService.normalize(t.asText("")));
}
to.set("candleTypes", arr);
to.put("candleType", LiveStrategyTimeframeService.normalize(arr.get(0).asText("1m")));
} else {
to.remove("candleTypes");
to.put("candleType", LiveStrategyTimeframeService.normalize(from.path("candleType").asText("1m")));
}
}
private static boolean allTimeframeChildren(JsonNode children) {
for (JsonNode c : children) {
if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false;
}
return true;
}
/** 전략 이름에서 평가 분봉 추론 (예: cci_3분봉 테스트 → 3m) */ /** 전략 이름에서 평가 분봉 추론 (예: cci_3분봉 테스트 → 3m) */
public static String inferFromStrategyName(String name) { public static String inferFromStrategyName(String name) {
if (name == null || name.isBlank()) return null; if (name == null || name.isBlank()) return null;
@@ -6,7 +6,6 @@ import com.goldenchart.repository.GcStrategyRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy; import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -26,9 +25,20 @@ class StrategyConditionTimeframeServiceTest {
@Spy @Spy
private ObjectMapper objectMapper = new ObjectMapper(); private ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks @Mock
private StrategyDslTimeframeNormalizer dslNormalizer;
@Mock
private LiveStrategyEvaluator liveStrategyEvaluator;
private StrategyConditionTimeframeService service; private StrategyConditionTimeframeService service;
@BeforeEach
void setUpService() {
service = new StrategyConditionTimeframeService(
strategyRepo, objectMapper, dslNormalizer, liveStrategyEvaluator);
}
@BeforeEach @BeforeEach
void stubStrategy() { void stubStrategy() {
GcStrategy strategy = new GcStrategy(); GcStrategy strategy = new GcStrategy();
@@ -45,6 +55,10 @@ class StrategyConditionTimeframeServiceTest {
"""); """);
strategy.setSellConditionJson(null); strategy.setSellConditionJson(null);
when(strategyRepo.findById(1L)).thenReturn(Optional.of(strategy)); when(strategyRepo.findById(1L)).thenReturn(Optional.of(strategy));
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(1));
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(0));
} }
@Test @Test
@@ -71,6 +85,10 @@ class StrategyConditionTimeframeServiceTest {
} }
"""); """);
when(strategyRepo.findById(4L)).thenReturn(Optional.of(onlyUpper)); when(strategyRepo.findById(4L)).thenReturn(Optional.of(onlyUpper));
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(1));
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(0));
Set<String> tfs = service.collectForStrategy(4L); Set<String> tfs = service.collectForStrategy(4L);
assertEquals(Set.of("3m", "5m"), tfs); assertEquals(Set.of("3m", "5m"), tfs);
@@ -95,6 +113,10 @@ class StrategyConditionTimeframeServiceTest {
} }
"""); """);
when(strategyRepo.findById(3L)).thenReturn(Optional.of(multi)); when(strategyRepo.findById(3L)).thenReturn(Optional.of(multi));
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(1));
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(0));
Set<String> tfs = service.collectForStrategy(3L); Set<String> tfs = service.collectForStrategy(3L);
assertEquals(Set.of("1m", "3m", "5m"), tfs); assertEquals(Set.of("1m", "3m", "5m"), tfs);
@@ -127,6 +149,10 @@ class StrategyConditionTimeframeServiceTest {
} }
"""); """);
when(strategyRepo.findById(5L)).thenReturn(Optional.of(mixed)); when(strategyRepo.findById(5L)).thenReturn(Optional.of(mixed));
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(1));
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(0));
Set<String> tfs = service.collectForStrategy(5L); Set<String> tfs = service.collectForStrategy(5L);
assertEquals(Set.of("3m", "5m"), tfs); assertEquals(Set.of("3m", "5m"), tfs);
@@ -147,6 +173,10 @@ class StrategyConditionTimeframeServiceTest {
} }
"""); """);
when(strategyRepo.findById(2L)).thenReturn(Optional.of(legacy)); when(strategyRepo.findById(2L)).thenReturn(Optional.of(legacy));
when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(1));
when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenAnswer(inv -> inv.getArgument(0));
assertEquals(Set.of("1m"), service.collectForStrategy(2L)); assertEquals(Set.of("1m"), service.collectForStrategy(2L));
} }
@@ -0,0 +1,34 @@
package com.goldenchart.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class StrategyDslTimeframeNormalizerAlignTest {
private final StrategyDslTimeframeNormalizer normalizer =
new StrategyDslTimeframeNormalizer(new ObjectMapper());
@Test
void alignSell_copiesBuyCandleTypesToSell() throws Exception {
String buy = """
{
"type": "TIMEFRAME",
"candleType": "5m",
"candleTypes": ["5m"],
"children": [{ "type": "CONDITION", "condition": { "indicatorType": "RSI" } }]
}
""";
String sell = """
{
"type": "TIMEFRAME",
"candleType": "1m",
"children": [{ "type": "CONDITION", "condition": { "indicatorType": "RSI" } }]
}
""";
String aligned = normalizer.alignSellStartTimeframeToBuy(buy, sell);
assertTrue(aligned.contains("\"5m\""));
assertFalse(aligned.contains("\"candleType\":\"1m\""));
}
}
+38
View File
@@ -1514,6 +1514,14 @@ html.theme-blue {
gap: 10px; gap: 10px;
transition: background 0.1s; transition: background 0.1s;
} }
.ind-panel-item--drag-over {
background: #2962ff14;
outline: 1px solid rgba(41, 98, 255, 0.35);
outline-offset: -1px;
}
.ind-panel-drag-handle {
margin: 0 0 0 -2px;
}
.ind-panel-item:hover { background: var(--bg3); } .ind-panel-item:hover { background: var(--bg3); }
.ind-panel-item.active { background: #2962ff0e; } .ind-panel-item.active { background: #2962ff0e; }
.ind-panel-item-info { .ind-panel-item-info {
@@ -9450,6 +9458,36 @@ html.theme-blue {
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
.stg-ind-card.ind-settings-row--drag-over,
.ibsm-card.ind-settings-row--drag-over {
border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.35);
}
.ind-settings-drag-handle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 2px 0 -4px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted, #888);
font-size: 14px;
line-height: 1;
cursor: grab;
touch-action: none;
}
.ind-settings-drag-handle:hover {
color: var(--text, #ccc);
background: rgba(255, 255, 255, 0.06);
}
.ind-settings-drag-handle:active {
cursor: grabbing;
}
.stg-ind-card--expanded { .stg-ind-card--expanded {
border-color: rgba(122, 162, 247, 0.35); border-color: rgba(122, 162, 247, 0.35);
} }
+37 -9
View File
@@ -56,6 +56,10 @@ import {
splitIndicatorPane, splitIndicatorPane,
replaceIndicatorConfigsFromTypes, replaceIndicatorConfigsFromTypes,
} from './utils/indicatorPaneMerge'; } from './utils/indicatorPaneMerge';
import {
getOrderedSettingsIndicatorTypes,
sortIndicatorsByTypeOrder,
} from './utils/indicatorListOrder';
import { useAppSettings } from './hooks/useAppSettings'; import { useAppSettings } from './hooks/useAppSettings';
import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
@@ -1158,11 +1162,12 @@ function App() {
if (slot0.mode) setMode(slot0.mode as ChartMode); if (slot0.mode) setMode(slot0.mode as ChartMode);
if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale)); if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale));
if (Array.isArray(slot0.indicators)) { if (Array.isArray(slot0.indicators)) {
setIndicators((slot0.indicators as IndicatorConfig[]).map(ind => { const loaded = (slot0.indicators as IndicatorConfig[]).map(ind => {
if (ind.type === 'SMA') return normalizeSmaConfig(ind); if (ind.type === 'SMA') return normalizeSmaConfig(ind);
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
return ind; return ind;
})); });
setIndicators(sortIndicatorsByTypeOrder(loaded));
} }
if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]); if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]);
if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked)); if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked));
@@ -1178,11 +1183,13 @@ function App() {
symbol: s.symbol as string | undefined, symbol: s.symbol as string | undefined,
timeframe: s.timeframe as Timeframe | undefined, timeframe: s.timeframe as Timeframe | undefined,
indicators: Array.isArray(s.indicators) indicators: Array.isArray(s.indicators)
? (s.indicators as IndicatorConfig[]).map(ind => { ? sortIndicatorsByTypeOrder(
(s.indicators as IndicatorConfig[]).map(ind => {
if (ind.type === 'SMA') return normalizeSmaConfig(ind); if (ind.type === 'SMA') return normalizeSmaConfig(ind);
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
return ind; return ind;
}) }),
)
: undefined, : undefined,
mainChartStyle: s.mainChartStyle as MainChartStyle | undefined, mainChartStyle: s.mainChartStyle as MainChartStyle | undefined,
}; };
@@ -1347,6 +1354,20 @@ function App() {
} }
}, [layoutDef.count, activeSlot]); }, [layoutDef.count, activeSlot]);
/** 설정 목록 순서 변경 → 차트 pane 순서 동기화 */
const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => {
const resort = (inds: IndicatorConfig[]) => sortIndicatorsByTypeOrder(inds, orderedTypes);
if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot];
if (!slotRef) return;
const next = resort(slotRef.getIndicators());
slotRef.setIndicators(next);
setActiveSlotIndicators(next.map(i => ({ ...i })));
} else {
setIndicators(prev => resort(prev));
}
}, [layoutDef.count, activeSlot]);
const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => { const handleAddIndicator = useCallback((type: string, fromConfig?: IndicatorConfig) => {
if (layoutDef.count > 1) { if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot]; const slotRef = slotRefs.current[activeSlot];
@@ -1380,22 +1401,27 @@ function App() {
} else if (type === 'IchimokuCloud') { } else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg); cfg = normalizeIchimokuConfig(cfg);
} }
return [...prev, cfg]; return sortIndicatorsByTypeOrder([...prev, cfg]);
}); });
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
/** 지표 추가 팝업 — 탭 전체 추가 (기존 보조지표 제거 후 탭 지표만 적용) */ /** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */
const handleAddIndicators = useCallback((types: string[]) => { const handleAddIndicators = useCallback((types: string[]) => {
if (!types.length) return; if (!types.length) return;
const orderedTypes = types.filter(t => getIndicatorDef(t));
if (orderedTypes.length === 0) return;
if (layoutDef.count > 1) { if (layoutDef.count > 1) {
const slotRef = slotRefs.current[activeSlot]; const slotRef = slotRefs.current[activeSlot];
if (slotRef) { if (slotRef) {
slotRef.addIndicators(types); slotRef.addIndicators(orderedTypes);
return; return;
} }
} }
setIndicators( setIndicators(
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), sortIndicatorsByTypeOrder(
replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId),
orderedTypes,
),
); );
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
@@ -1543,7 +1569,7 @@ function App() {
allConfigs: IndicatorConfig[]; allConfigs: IndicatorConfig[];
}) => { }) => {
const { chartIndicators, allConfigs } = result; const { chartIndicators, allConfigs } = result;
applyChartIndicators(chartIndicators); applyChartIndicators(sortIndicatorsByTypeOrder(chartIndicators));
allConfigs.forEach(ind => { allConfigs.forEach(ind => {
saveParams(ind.type, ind.params); saveParams(ind.type, ind.params);
saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground); saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground);
@@ -1784,6 +1810,7 @@ function App() {
chartIndicators={bulkSettingsIndicators} chartIndicators={bulkSettingsIndicators}
onAddIndicatorToChart={handleAddIndicator} onAddIndicatorToChart={handleAddIndicator}
onRemoveIndicatorFromChart={handleRemoveByType} onRemoveIndicatorFromChart={handleRemoveByType}
onIndicatorListOrderChange={handleIndicatorListOrderChange}
chartSeriesPriceLabels={chartSeriesPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels}
onChartSeriesPriceLabels={v => { onChartSeriesPriceLabels={v => {
saveAppDef({ chartSeriesPriceLabels: v }); saveAppDef({ chartSeriesPriceLabels: v });
@@ -1898,6 +1925,7 @@ function App() {
onScrollToNow={() => managerRef.current?.scrollToRealTime()} onScrollToNow={() => managerRef.current?.scrollToRealTime()}
onAddIndicator={handleAddIndicator} onAddIndicator={handleAddIndicator}
onAddIndicators={handleAddIndicators} onAddIndicators={handleAddIndicators}
onIndicatorOrderChange={handleIndicatorListOrderChange}
onRemoveByType={handleRemoveByType} onRemoveByType={handleRemoveByType}
onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)} onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)}
onOpenIndicatorSettings={handleOpenIndicatorSettings} onOpenIndicatorSettings={handleOpenIndicatorSettings}
+5 -1
View File
@@ -36,6 +36,7 @@ import {
splitIndicatorPane, splitIndicatorPane,
replaceIndicatorConfigsFromTypes, replaceIndicatorConfigsFromTypes,
} from '../utils/indicatorPaneMerge'; } from '../utils/indicatorPaneMerge';
import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder';
import { saveSlot } from '../utils/backendApi'; import { saveSlot } from '../utils/backendApi';
import type { import type {
OHLCVBar, ChartType, Theme, Timeframe, OHLCVBar, ChartType, Theme, Timeframe,
@@ -279,13 +280,16 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
} else if (type === 'IchimokuCloud') { } else if (type === 'IchimokuCloud') {
cfg = normalizeIchimokuConfig(cfg); cfg = normalizeIchimokuConfig(cfg);
} }
return [...prev, cfg]; return sortIndicatorsByTypeOrder([...prev, cfg]);
}); });
}, },
addIndicators: (types: string[]) => { addIndicators: (types: string[]) => {
if (!types.length) return; if (!types.length) return;
setIndicators( setIndicators(
sortIndicatorsByTypeOrder(
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
types,
),
); );
}, },
removeIndicatorByType: (type: string) => { removeIndicatorByType: (type: string) => {
@@ -17,6 +17,7 @@ import {
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch'; import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
import IndicatorSettingsListRow from './IndicatorSettingsListRow'; import IndicatorSettingsListRow from './IndicatorSettingsListRow';
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab'; import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
import { useIndicatorListOrder } from '../hooks/useIndicatorListOrder';
export interface IndicatorSaveUiState { export interface IndicatorSaveUiState {
save: () => Promise<void>; save: () => Promise<void>;
@@ -48,6 +49,8 @@ export interface IndicatorMainDefaultsPanelProps {
onSaveUiState?: (state: IndicatorSaveUiState | null) => void; onSaveUiState?: (state: IndicatorSaveUiState | null) => void;
/** DB 로드 완료·저장 후 재로드 트리거 */ /** DB 로드 완료·저장 후 재로드 트리거 */
settingsRevision?: number; settingsRevision?: number;
/** 목록 순서 변경 시 차트 보조지표 순서 동기화 */
onListOrderChange?: (orderedTypes: string[]) => void;
} }
const ALL_TYPES = getSettingsIndicatorTypes(); const ALL_TYPES = getSettingsIndicatorTypes();
@@ -77,8 +80,12 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
saveAllDefaults, saveAllDefaults,
onSaveUiState, onSaveUiState,
settingsRevision = 0, settingsRevision = 0,
onListOrderChange,
}) => { }) => {
const onChartTypes = useMemo(() => new Set(chartIndicators.map(i => i.type)), [chartIndicators]); const onChartTypes = useMemo(() => new Set(chartIndicators.map(i => i.type)), [chartIndicators]);
const { listOrder, moveType } = useIndicatorListOrder(onListOrderChange);
const [draggingType, setDraggingType] = useState<string | null>(null);
const [dragOverType, setDragOverType] = useState<string | null>(null);
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set()); const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set());
const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({}); const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({});
const [baselineFp, setBaselineFp] = useState(''); const [baselineFp, setBaselineFp] = useState('');
@@ -97,14 +104,23 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
reloadFromDb(); reloadFromDb();
}, [reloadFromDb, settingsRevision]); }, [reloadFromDb, settingsRevision]);
useEffect(() => {
const clearDrag = () => {
setDraggingType(null);
setDragOverType(null);
};
document.addEventListener('dragend', clearDrag);
return () => document.removeEventListener('dragend', clearDrag);
}, []);
const dirty = useMemo( const dirty = useMemo(
() => baselineFp !== '' && fingerprintMap(configs) !== baselineFp, () => baselineFp !== '' && fingerprintMap(configs) !== baselineFp,
[configs, baselineFp], [configs, baselineFp],
); );
const filtered = useMemo( const filtered = useMemo(
() => filterSettingsIndicatorTypes(ALL_TYPES, search), () => filterSettingsIndicatorTypes(listOrder, search),
[search], [listOrder, search],
); );
const { main: filteredMain, other: filteredOther } = useMemo( const { main: filteredMain, other: filteredOther } = useMemo(
() => partitionFilteredTypes(filtered), () => partitionFilteredTypes(filtered),
@@ -184,6 +200,8 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
return () => onSaveUiState(null); return () => onSaveUiState(null);
}, [onSaveUiState, handleSaveAll, dirty, saving, saveMessage]); }, [onSaveUiState, handleSaveAll, dirty, saving, saveMessage]);
const canReorder = !search.trim();
const renderRows = (types: string[]) => types.map(type => { const renderRows = (types: string[]) => types.map(type => {
const cfg = configs[type]; const cfg = configs[type];
if (!cfg) return null; if (!cfg) return null;
@@ -195,6 +213,29 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
enabled={onChartTypes.has(type)} enabled={onChartTypes.has(type)}
expanded={expandedTypes.has(type)} expanded={expandedTypes.has(type)}
variant="settings" variant="settings"
draggable={canReorder}
isDragOver={canReorder && dragOverType === type && draggingType !== type}
onDragHandleStart={e => {
setDraggingType(type);
e.dataTransfer.setData('text/indicator-type', type);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={e => {
if (!draggingType || draggingType === type) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverType(type);
}}
onDragLeave={() => {
if (dragOverType === type) setDragOverType(null);
}}
onDrop={e => {
e.preventDefault();
const from = e.dataTransfer.getData('text/indicator-type') || draggingType;
if (from && from !== type) moveType(from, type, 'before');
setDraggingType(null);
setDragOverType(null);
}}
onToggleExpand={() => toggleExpand(type)} onToggleExpand={() => toggleExpand(type)}
onToggleEnabled={on => handleToggleChart(type, on)} onToggleEnabled={on => handleToggleChart(type, on)}
onChange={updated => handleConfigChange(type, updated)} onChange={updated => handleConfigChange(type, updated)}
@@ -208,7 +249,8 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
<div className="stg-ind-intro"> <div className="stg-ind-intro">
<p> <p>
<strong>Main</strong> {MAIN_INDICATOR_TYPES.length} , . <strong>Main</strong> {MAIN_INDICATOR_TYPES.length} , .
· . · <strong> </strong> DB에 . · . <strong></strong> , pane .
· <strong> </strong> DB에 .
</p> </p>
<button type="button" className="stg-ind-reset-all" onClick={handleResetAll}> <button type="button" className="stg-ind-reset-all" onClick={handleResetAll}>
@@ -218,7 +260,7 @@ const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
<IndicatorSettingsListSearch <IndicatorSettingsListSearch
value={search} value={search}
onChange={setSearch} onChange={setSearch}
totalCount={ALL_TYPES.length} totalCount={listOrder.length}
filteredCount={filtered.length} filteredCount={filtered.length}
className="stg-ind-search-block" className="stg-ind-search-block"
/> />
@@ -15,6 +15,13 @@ export interface IndicatorSettingsListRowProps {
onToggleEnabled: (enabled: boolean) => void; onToggleEnabled: (enabled: boolean) => void;
onChange: (updated: IndicatorConfig) => void; onChange: (updated: IndicatorConfig) => void;
onRowDefaults?: () => void; onRowDefaults?: () => void;
/** 목록 순서 드래그 */
draggable?: boolean;
isDragOver?: boolean;
onDragHandleStart?: (e: React.DragEvent) => void;
onDragOver?: (e: React.DragEvent) => void;
onDragLeave?: () => void;
onDrop?: (e: React.DragEvent) => void;
} }
const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({ const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({
@@ -28,14 +35,20 @@ const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({
onToggleEnabled, onToggleEnabled,
onChange, onChange,
onRowDefaults, onRowDefaults,
draggable = false,
isDragOver = false,
onDragHandleStart,
onDragOver,
onDragLeave,
onDrop,
}) => { }) => {
const labels = getIndicatorListLabels(type); const labels = getIndicatorListLabels(type);
const def = getIndicatorDef(type); const def = getIndicatorDef(type);
const cardClass = const cardClass =
variant === 'bulk' variant === 'bulk'
? `ibsm-card ibsm-main-row${enabled ? ' ibsm-main-row--active' : ''}${expanded ? ' ibsm-card-expanded' : ''}` ? `ibsm-card ibsm-main-row${enabled ? ' ibsm-main-row--active' : ''}${expanded ? ' ibsm-card-expanded' : ''}${isDragOver ? ' ind-settings-row--drag-over' : ''}`
: `stg-ind-card${enabled ? ' stg-ind-card--on-chart' : ''}${expanded ? ' stg-ind-card--expanded' : ''}`; : `stg-ind-card${enabled ? ' stg-ind-card--on-chart' : ''}${expanded ? ' stg-ind-card--expanded' : ''}${isDragOver ? ' ind-settings-row--drag-over' : ''}`;
const headClass = variant === 'bulk' ? 'ibsm-card-main ibsm-main-row-head' : 'stg-ind-card-head'; const headClass = variant === 'bulk' ? 'ibsm-card-main ibsm-main-row-head' : 'stg-ind-card-head';
const infoClass = variant === 'bulk' ? 'ibsm-main-row-info' : 'stg-ind-card-titles'; const infoClass = variant === 'bulk' ? 'ibsm-main-row-info' : 'stg-ind-card-titles';
@@ -48,8 +61,26 @@ const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({
const expandWrapClass = variant === 'bulk' ? 'ibsm-card-expand' : 'stg-ind-card-body'; const expandWrapClass = variant === 'bulk' ? 'ibsm-card-expand' : 'stg-ind-card-body';
return ( return (
<div className={cardClass}> <div
className={cardClass}
onDragOver={draggable ? onDragOver : undefined}
onDragLeave={draggable ? onDragLeave : undefined}
onDrop={draggable ? onDrop : undefined}
>
<div className={headClass}> <div className={headClass}>
{draggable && (
<button
type="button"
className="ind-settings-drag-handle"
draggable
title="드래그하여 순서 변경"
aria-label="순서 변경"
onDragStart={onDragHandleStart}
onClick={e => e.stopPropagation()}
>
</button>
)}
<div className={infoClass}> <div className={infoClass}>
<span className={koClass}>{labels.ko}</span> <span className={koClass}>{labels.ko}</span>
<span className={enClass}>{labels.en}</span> <span className={enClass}>{labels.en}</span>
+67 -106
View File
@@ -33,42 +33,6 @@ interface PaneItem {
const MIN_SUB_PANE_HEIGHT = 12; const MIN_SUB_PANE_HEIGHT = 12;
interface DomPaneLayout {
topY: number;
height: number;
}
/**
* LWC 테이블 DOM 에서 보조지표 pane 행만 읽음 (pane index 무관).
* orphan 빈 pane 행이 있으면 제외하고 하단 expectCount 개만 사용.
*/
function getDomSubIndicatorLayouts(
containerEl: HTMLElement,
expectCount: number,
): DomPaneLayout[] {
const containerRect = containerEl.getBoundingClientRect();
const rows = Array.from(containerEl.querySelectorAll('tr'))
.map(tr => tr.getBoundingClientRect())
.filter(rect => rect.height > MIN_SUB_PANE_HEIGHT)
.sort((a, b) => a.top - b.top);
// pane 0=캔들(첫 행). 둘째 행이 낮으면 거래량 pane → 보조지표는 그 다음부터
let startIdx = 1;
if (rows.length > 1 && rows[1].height < Math.min(100, rows[0].height * 0.35)) {
startIdx = 2;
}
let subs = rows.slice(startIdx).map(rect => ({
topY: Math.round(rect.top - containerRect.top),
height: Math.round(rect.height),
}));
if (expectCount > 0 && subs.length > expectCount) {
subs = subs.slice(subs.length - expectCount);
}
return subs;
}
interface PaneCapture { interface PaneCapture {
dataUrl: string; dataUrl: string;
cssWidth: number; cssWidth: number;
@@ -247,88 +211,80 @@ function fillMissingLayoutsByPaneIndex(
} }
} }
/** DOM 행 중 paneIndex 레이아웃 topY 와 가장 가까운 행 선택 */ /**
function fillMissingLayoutsFromDom( * 실제 시리즈가 붙은 sub-pane 만으로 좌표 보강 (orphan 빈 pane 제외).
* paneIndex 미등록(-1)·탭 일괄 교체 직후에도 레이블 위치를 맞춘다.
*/
function fillMissingLayoutsFromActiveSubPanes(
items: PaneItem[], items: PaneItem[],
containerEl: HTMLElement, subLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
paneLayouts: Array<{ paneIndex: number; topY: number; height: number }>,
): void { ): void {
const missing = items.filter(i => i.layoutTopY == null && i.paneIndex >= 2); if (subLayouts.length === 0) return;
const missing = items.filter(i => i.layoutTopY == null);
if (missing.length === 0) return; if (missing.length === 0) return;
const domSubs = getDomSubIndicatorLayouts(containerEl, missing.length); const usedPane = new Set<number>();
if (domSubs.length === 0) return;
const usedDom = new Set<number>();
const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex); const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex);
for (const item of sorted) { for (const item of sorted) {
const ref = layoutForPaneIndex(item.paneIndex, paneLayouts); if (item.paneIndex < 2) continue;
let bestIdx = -1; const lay = subLayouts.find(l => l.paneIndex === item.paneIndex && !usedPane.has(l.paneIndex));
let bestDist = Infinity; if (!lay) continue;
for (let i = 0; i < domSubs.length; i++) { applyPaneLayoutToItem(item, lay);
if (usedDom.has(i)) continue; usedPane.add(lay.paneIndex);
const dist = ref
? Math.abs(domSubs[i].topY - ref.topY)
: domSubs[i].topY;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
if (bestIdx < 0) continue;
if (ref && bestDist > 96) continue;
usedDom.add(bestIdx);
applyPaneLayoutToItem(item, domSubs[bestIdx]);
} }
} }
function buildPaneItems( function buildPaneItems(
manager: ChartManager, manager: ChartManager,
containerEl: HTMLElement | null,
indicators: IndicatorConfig[], indicators: IndicatorConfig[],
optPaneMap?: Map<string, number>,
): PaneItem[] { ): PaneItem[] {
const paneMap = new Map(optPaneMap ?? []); const wantedIds = new Set(
for (const info of manager.getIndicatorPaneInfo()) { indicators
paneMap.set(info.id, info.paneIndex); .filter(ind => {
} const def = getIndicatorDef(ind.type);
return def && !def.overlay && !def.returnsMarkers;
})
.map(ind => ind.id),
);
// ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외
const items: PaneItem[] = []; const items: PaneItem[] = [];
for (const ind of indicators) { for (const info of manager.getIndicatorPaneInfo()) {
if (!wantedIds.has(info.id)) continue;
const ind = info.config;
const def = getIndicatorDef(ind.type); const def = getIndicatorDef(ind.type);
if (!def || def.overlay || def.returnsMarkers) continue; if (!def || def.overlay || def.returnsMarkers) continue;
const plotColors = (ind.plots ?? def.plots ?? []).map(p => (p.color ?? '#aaa').slice(0, 7));
let chartPane = paneMap.get(ind.id) ?? -1; let chartPane = info.paneIndex;
if (ind.mergedWith) { if (ind.mergedWith) {
const hostPane = paneMap.get(ind.mergedWith); const host = manager.getIndicatorPaneIndex(ind.mergedWith);
if (hostPane != null && hostPane >= 2) chartPane = hostPane; if (host >= 2) chartPane = host;
} }
items.push({ items.push({
id: ind.id, id: info.id,
paneIndex: chartPane, paneIndex: chartPane,
label: makeLabel(ind), label: makeLabel(ind),
plotColors, plotColors: info.plotColors.map(c => c.slice(0, 7)),
hidden: ind.hidden === true, hidden: ind.hidden === true,
}); });
} }
if (items.length === 0) return []; if (items.length === 0) return [];
items.sort((a, b) => a.paneIndex - b.paneIndex);
const subLayouts = manager.getIndicatorSubPaneLayouts();
for (const item of items) { for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id); const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) applyPaneLayoutToItem(item, lay, true); if (lay) applyPaneLayoutToItem(item, lay, true);
} }
const paneLayouts = manager.getPaneLayouts(); fillMissingLayoutsByPaneIndex(items, subLayouts);
fillMissingLayoutsByPaneIndex(items, paneLayouts); fillMissingLayoutsFromActiveSubPanes(items, subLayouts);
fillMissingLayoutsByPaneIndex(items, manager.getIndicatorSubPaneLayouts());
if (containerEl?.isConnected) {
fillMissingLayoutsFromDom(items, containerEl, paneLayouts);
}
items.sort((a, b) => { items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9; const ay = a.layoutTopY ?? 1e9;
@@ -353,6 +309,17 @@ function paneItemLayout(
return l ? { topY: l.topY, height: l.height } : null; return l ? { topY: l.topY, height: l.height } : null;
} }
/** 렌더 시점 pane 좌표 — resetPaneHeights 후 캐시 topY 가 어긋나지 않도록 시리즈 DOM 우선 */
function resolvePaneItemLayout(
manager: ChartManager,
item: PaneItem,
layouts: Array<{ paneIndex: number; topY: number; height: number }>,
): { topY: number; height: number } | null {
const live = manager.getIndicatorPaneScreenLayout(item.id);
if (live) return live;
return paneItemLayout(item, layouts);
}
// ── 아이콘 SVG ──────────────────────────────────────────────────────────────── // ── 아이콘 SVG ────────────────────────────────────────────────────────────────
/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */ /** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */
const IconExpand = () => ( const IconExpand = () => (
@@ -422,6 +389,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
}) => { }) => {
const [paneItems, setPaneItems] = useState<PaneItem[]>([]); const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]); const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
const [layoutEpoch, setLayoutEpoch] = useState(0);
const [values, setValues] = useState<Record<string, number[]>>({}); const [values, setValues] = useState<Record<string, number[]>>({});
const [dragState, setDragState] = useState<DragState | null>(null); const [dragState, setDragState] = useState<DragState | null>(null);
const retryRef = useRef<ReturnType<typeof setTimeout>[]>([]); const retryRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -451,11 +419,10 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
const syncPaneItems = useCallback(() => { const syncPaneItems = useCallback(() => {
const el = containerElRef.current; if (!containerElRef.current?.isConnected) return;
if (!el?.isConnected) return;
const map = manager.getIndicatorPaneMapping();
setPaneLayouts(manager.getPaneLayouts()); setPaneLayouts(manager.getPaneLayouts());
setPaneItems(buildPaneItems(manager, el, indicators, map)); setPaneItems(buildPaneItems(manager, indicators));
setLayoutEpoch(e => e + 1);
}, [indicators, manager]); }, [indicators, manager]);
const refreshLayouts = useCallback(() => { const refreshLayouts = useCallback(() => {
@@ -486,7 +453,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
setPaneItems([]); setPaneItems([]);
scheduleRetry(); scheduleRetry();
return () => retryRef.current.forEach(clearTimeout); return () => retryRef.current.forEach(clearTimeout);
}, [scheduleRetry]); }, [scheduleRetry, indicators]);
useEffect(() => { useEffect(() => {
const unsub = manager.subscribePaneLayout(() => scheduleRetry()); const unsub = manager.subscribePaneLayout(() => scheduleRetry());
@@ -505,13 +472,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
useEffect(() => { useEffect(() => {
const unsub = manager.subscribeCrosshair((p: MouseEventParams<Time>) => { const unsub = manager.subscribeCrosshair((p: MouseEventParams<Time>) => {
const map = manager.getIndicatorPaneMapping();
const layouts = manager.getPaneLayouts();
setPaneLayouts(layouts);
const el = containerElRef.current;
if (el?.isConnected) {
setPaneItems(buildPaneItems(manager, el, indicatorsRef.current, map));
}
if (!p.time) { setValues({}); return; } if (!p.time) { setValues({}); return; }
setValues(manager.getIndicatorValuesByIdFromParams(p)); setValues(manager.getIndicatorValuesByIdFromParams(p));
}); });
@@ -525,16 +485,16 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
if (!items.length) return null; if (!items.length) return null;
const rect = containerElRef.current.getBoundingClientRect(); const rect = containerElRef.current.getBoundingClientRect();
if (idx <= 0) { if (idx <= 0) {
const pl = paneItemLayout(items[0], layouts); const pl = resolvePaneItemLayout(manager, items[0], layouts);
return pl ? rect.top + pl.topY : null; return pl ? rect.top + pl.topY : null;
} }
if (idx >= items.length) { if (idx >= items.length) {
const pl = paneItemLayout(items[items.length - 1], layouts); const pl = resolvePaneItemLayout(manager, items[items.length - 1], layouts);
return pl ? rect.top + pl.topY + pl.height : null; return pl ? rect.top + pl.topY + pl.height : null;
} }
const pl = paneItemLayout(items[idx - 1], layouts); const pl = resolvePaneItemLayout(manager, items[idx - 1], layouts);
return pl ? rect.top + pl.topY + pl.height : null; return pl ? rect.top + pl.topY + pl.height : null;
}, []); }, [manager]);
const unbindPaneDragRef = useRef<(() => void) | null>(null); const unbindPaneDragRef = useRef<(() => void) | null>(null);
@@ -553,7 +513,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const fromIndex = paneItemsRef.current.findIndex(i => i.id === id); const fromIndex = paneItemsRef.current.findIndex(i => i.id === id);
if (!item || fromIndex === -1) return; if (!item || fromIndex === -1) return;
const layout = paneItemLayout(item, paneLayoutsRef.current); const layout = resolvePaneItemLayout(manager, item, paneLayoutsRef.current);
const paneCapture = layout const paneCapture = layout
? capturePaneRegion(containerElRef.current, layout.topY, layout.height) ? capturePaneRegion(containerElRef.current, layout.topY, layout.height)
: null; : null;
@@ -628,7 +588,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
for (const item of items) { for (const item of items) {
if (getPaneHostId(item.id, inds) === dragHost) continue; if (getPaneHostId(item.id, inds) === dragHost) continue;
const l = paneItemLayout(item, layouts); const l = resolvePaneItemLayout(manager, item, layouts);
if (!l || mouseY < l.topY || mouseY >= l.topY + l.height) continue; if (!l || mouseY < l.topY || mouseY >= l.topY + l.height) continue;
const rel = (mouseY - l.topY) / Math.max(l.height, 1); const rel = (mouseY - l.topY) / Math.max(l.height, 1);
if (rel > 0.28 && rel < 0.72) { if (rel > 0.28 && rel < 0.72) {
@@ -640,7 +600,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
if (dropMode === 'reorder') { if (dropMode === 'reorder') {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const l = paneItemLayout(items[i], layouts); const l = resolvePaneItemLayout(manager, items[i], layouts);
if (l && mouseY < l.topY + l.height / 2) { if (l && mouseY < l.topY + l.height / 2) {
drop = i; drop = i;
break; break;
@@ -674,6 +634,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
? getDropLineY(dragState!.dropBeforeIndex) ? getDropLineY(dragState!.dropBeforeIndex)
: null; : null;
const containerRect = containerEl.getBoundingClientRect(); const containerRect = containerEl.getBoundingClientRect();
void layoutEpoch;
const draggingHostId = isDragging const draggingHostId = isDragging
? getPaneHostId(dragState!.draggingId, indicators) ? getPaneHostId(dragState!.draggingId, indicators)
@@ -684,20 +645,20 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
: -1; : -1;
const draggingLayout = draggingItemIdx >= 0 const draggingLayout = draggingItemIdx >= 0
? paneItemLayout(paneItems[draggingItemIdx], paneLayouts) ? resolvePaneItemLayout(manager, paneItems[draggingItemIdx], paneLayouts)
: null; : null;
const mergeTargetLayout = isDragging && dragState?.mergeTargetId const mergeTargetLayout = isDragging && dragState?.mergeTargetId
? (() => { ? (() => {
const t = paneItems.find(i => i.id === dragState.mergeTargetId); const t = paneItems.find(i => i.id === dragState.mergeTargetId);
return t ? paneItemLayout(t, paneLayouts) : null; return t ? resolvePaneItemLayout(manager, t, paneLayouts) : null;
})() })()
: null; : null;
return ( return (
<> <>
{paneItems.map((item, itemIdx) => { {paneItems.map((item, itemIdx) => {
const pl = paneItemLayout(item, paneLayouts); const pl = resolvePaneItemLayout(manager, item, paneLayouts);
if (!pl) return null; if (!pl) return null;
const screenX = containerRect.left; const screenX = containerRect.left;
@@ -918,8 +879,8 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
<> <>
{/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */} {/* ① 전체 지표 영역 dim — 고스트·shift 가 더 선명하게 보이도록 */}
{paneItems.length > 0 && (() => { {paneItems.length > 0 && (() => {
const firstL = paneItemLayout(paneItems[0], paneLayouts); const firstL = resolvePaneItemLayout(manager, paneItems[0], paneLayouts);
const lastL = paneItemLayout(paneItems[paneItems.length - 1], paneLayouts); const lastL = resolvePaneItemLayout(manager, paneItems[paneItems.length - 1], paneLayouts);
if (!firstL || !lastL) return null; if (!firstL || !lastL) return null;
return ( return (
<div style={{ <div style={{
+4
View File
@@ -106,6 +106,8 @@ interface SettingsPageProps {
chartIndicators?: IndicatorConfig[]; chartIndicators?: IndicatorConfig[];
onAddIndicatorToChart?: (type: string, config?: IndicatorConfig) => void; onAddIndicatorToChart?: (type: string, config?: IndicatorConfig) => void;
onRemoveIndicatorFromChart?: (type: string) => void; onRemoveIndicatorFromChart?: (type: string) => void;
/** 보조지표 설정 목록 순서 변경 → 차트 pane 순서 */
onIndicatorListOrderChange?: (orderedTypes: string[]) => void;
chartSeriesPriceLabels?: boolean; chartSeriesPriceLabels?: boolean;
onChartSeriesPriceLabels?: (v: boolean) => void; onChartSeriesPriceLabels?: (v: boolean) => void;
chartVolumeVisible?: boolean; chartVolumeVisible?: boolean;
@@ -1516,6 +1518,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
chartIndicators = [], chartIndicators = [],
onAddIndicatorToChart, onAddIndicatorToChart,
onRemoveIndicatorFromChart, onRemoveIndicatorFromChart,
onIndicatorListOrderChange,
chartSeriesPriceLabels = true, chartSeriesPriceLabels = true,
onChartSeriesPriceLabels, onChartSeriesPriceLabels,
chartVolumeVisible = true, chartVolumeVisible = true,
@@ -1642,6 +1645,7 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
saveAllDefaults={saveAllIndicatorDefaults} saveAllDefaults={saveAllIndicatorDefaults}
onSaveUiState={setIndicatorSaveUi} onSaveUiState={setIndicatorSaveUi}
settingsRevision={indicatorSettingsRevision} settingsRevision={indicatorSettingsRevision}
onListOrderChange={onIndicatorListOrderChange}
/> />
); );
case 'backtest': return ( case 'backtest': return (
+109 -13
View File
@@ -13,6 +13,16 @@ import {
import { import {
confirmAndApplyIndicatorTab, confirmAndApplyIndicatorTab,
} from '../utils/indicatorTabApply'; } from '../utils/indicatorTabApply';
import {
getOrderedMainIndicatorTypes,
saveMainTabOrder,
} from '../utils/indicatorMainTabOrder';
import {
mergeIndicatorListOrder,
reorderIndicatorListType,
saveIndicatorListOrder,
} from '../utils/indicatorListOrder';
import { getSettingsIndicatorTypes } from '../utils/indicatorSettingsList';
import { import {
loadCustomTabs, loadCustomTabs,
createCustomTab, createCustomTab,
@@ -76,6 +86,8 @@ export interface ToolbarProps {
onScrollToNow?: () => void; onScrollToNow?: () => void;
onAddIndicator: (type: string) => void; onAddIndicator: (type: string) => void;
onAddIndicators?: (types: string[]) => void; onAddIndicators?: (types: string[]) => void;
/** 탭 목록 순서 변경 → 차트 pane 순서 동기화 */
onIndicatorOrderChange?: (orderedTypes: string[]) => void;
onRemoveByType: (type: string) => void; onRemoveByType: (type: string) => void;
onAutoFib: () => void; onAutoFib: () => void;
onClearFib: () => void; onClearFib: () => void;
@@ -307,17 +319,21 @@ interface IndDropdownProps {
activeIndicators: IndicatorConfig[]; activeIndicators: IndicatorConfig[];
onAdd: (type: string) => void; onAdd: (type: string) => void;
onAddMany?: (types: string[]) => void; onAddMany?: (types: string[]) => void;
onIndicatorOrderChange?: (orderedTypes: string[]) => void;
onRemove: (type: string) => void; onRemove: (type: string) => void;
onOpenSettings?: (type: string) => void; onOpenSettings?: (type: string) => void;
onClose: () => void; onClose: () => void;
} }
const IndDropdown: React.FC<IndDropdownProps> = ({ const IndDropdown: React.FC<IndDropdownProps> = ({
activeIndicators, onAdd, onAddMany, onRemove, onOpenSettings, onClose, activeIndicators, onAdd, onAddMany, onIndicatorOrderChange, onRemove, onOpenSettings, onClose,
}) => { }) => {
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');
const [category, setCategory] = React.useState<IndicatorTabKey>('All'); const [category, setCategory] = React.useState<IndicatorTabKey>('All');
const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs()); const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs());
const [mainOrderTick, setMainOrderTick] = React.useState(0);
const [draggingType, setDraggingType] = React.useState<string | null>(null);
const [dragOverType, setDragOverType] = React.useState<string | null>(null);
const [editorOpen, setEditorOpen] = React.useState(false); const [editorOpen, setEditorOpen] = React.useState(false);
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create'); const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -364,10 +380,52 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
setSearch(''); setSearch('');
}; };
const tabTypeOrder = React.useMemo((): string[] | null => {
void mainOrderTick;
if (search.trim()) return null;
if (category === 'Main') return getOrderedMainIndicatorTypes();
if (isCustomTabKey(category) && selectedCustomTab) {
return [...selectedCustomTab.indicatorTypes];
}
return null;
}, [category, selectedCustomTab, customTabs, search, mainOrderTick]);
const canReorderList = tabTypeOrder != null && tabTypeOrder.length > 1;
const syncChartToTabOrder = useCallback(() => {
if (tabTypeOrder?.length && onIndicatorOrderChange) {
onIndicatorOrderChange(tabTypeOrder);
}
}, [tabTypeOrder, onIndicatorOrderChange]);
const moveTypeInTabList = useCallback((fromType: string, toType: string) => {
if (!tabTypeOrder) return;
const next = reorderIndicatorListType(tabTypeOrder, fromType, toType, 'before');
if (category === 'Main') {
saveMainTabOrder(next);
setMainOrderTick(t => t + 1);
saveIndicatorListOrder(mergeIndicatorListOrder(next, getSettingsIndicatorTypes()));
onIndicatorOrderChange?.(next);
} else if (isCustomTabKey(category) && selectedCustomTab) {
updateCustomTab(selectedCustomTab.id, { indicatorTypes: next });
refreshCustomTabs();
onIndicatorOrderChange?.(next);
}
}, [tabTypeOrder, category, selectedCustomTab, refreshCustomTabs, onIndicatorOrderChange]);
useEffect(() => {
const clearDrag = () => {
setDraggingType(null);
setDragOverType(null);
};
document.addEventListener('dragend', clearDrag);
return () => document.removeEventListener('dragend', clearDrag);
}, []);
const addAllContext = React.useMemo(() => { const addAllContext = React.useMemo(() => {
if (category === 'All') return null; if (category === 'All') return null;
if (category === 'Main') { if (category === 'Main') {
return { label: '주요지표', types: [...MAIN_INDICATOR_TYPES] }; return { label: '주요지표', types: getOrderedMainIndicatorTypes() };
} }
if (isCustomTabKey(category) && selectedCustomTab) { if (isCustomTabKey(category) && selectedCustomTab) {
const types = selectedCustomTab.indicatorTypes.filter( const types = selectedCustomTab.indicatorTypes.filter(
@@ -430,25 +488,22 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
); );
}; };
if (category === 'Main') { if (category === 'Main' && tabTypeOrder) {
return MAIN_INDICATOR_TYPES return tabTypeOrder
.map(type => INDICATOR_REGISTRY.find(d => d.type === type)) .map(type => INDICATOR_REGISTRY.find(d => d.type === type))
.filter((d): d is IndicatorDef => d != null) .filter((d): d is IndicatorDef => d != null)
.filter(matchesQuery); .filter(matchesQuery);
} }
if (isCustomTabKey(category)) { if (isCustomTabKey(category) && tabTypeOrder) {
const tabId = parseCustomTabKey(category); return tabTypeOrder
const tab = tabId ? customTabs.find(t => t.id === tabId) : null;
if (!tab) return [];
return tab.indicatorTypes
.map(type => INDICATOR_REGISTRY.find(d => d.type === type)) .map(type => INDICATOR_REGISTRY.find(d => d.type === type))
.filter((d): d is IndicatorDef => d != null) .filter((d): d is IndicatorDef => d != null)
.filter(matchesQuery); .filter(matchesQuery);
} }
return INDICATOR_REGISTRY.filter(matchesQuery); return INDICATOR_REGISTRY.filter(matchesQuery);
}, [search, category, customTabs]); }, [search, category, customTabs, tabTypeOrder]);
const { const {
panelRef, panelRef,
@@ -625,8 +680,45 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
const mainLbl = (category === 'Main' || isCustomTabKey(category)) const mainLbl = (category === 'Main' || isCustomTabKey(category))
? MAIN_INDICATOR_LABELS[def.type] ? MAIN_INDICATOR_LABELS[def.type]
: undefined; : undefined;
const isDragOver = canReorderList && dragOverType === def.type && draggingType !== def.type;
return ( return (
<div key={def.type} className={`ind-panel-item${active ? ' active' : ''}`}> <div
key={def.type}
className={`ind-panel-item${active ? ' active' : ''}${isDragOver ? ' ind-panel-item--drag-over' : ''}`}
onDragOver={canReorderList ? e => {
if (!draggingType || draggingType === def.type) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverType(def.type);
} : undefined}
onDragLeave={canReorderList ? () => {
if (dragOverType === def.type) setDragOverType(null);
} : undefined}
onDrop={canReorderList ? e => {
e.preventDefault();
const from = e.dataTransfer.getData('text/indicator-type') || draggingType;
if (from && from !== def.type) moveTypeInTabList(from, def.type);
setDraggingType(null);
setDragOverType(null);
} : undefined}
>
{canReorderList && (
<button
type="button"
className="ind-settings-drag-handle ind-panel-drag-handle"
draggable
title="드래그하여 순서 변경"
aria-label="순서 변경"
onDragStart={e => {
setDraggingType(def.type);
e.dataTransfer.setData('text/indicator-type', def.type);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={e => e.stopPropagation()}
>
</button>
)}
<div className="ind-panel-item-info"> <div className="ind-panel-item-info">
<span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span> <span className="ind-panel-name">{mainLbl?.ko ?? def.description}</span>
<span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span> <span className="ind-panel-desc">{mainLbl?.en ?? def.name}</span>
@@ -640,7 +732,10 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
type="button" type="button"
className="ind-panel-add" className="ind-panel-add"
title="차트에 추가" title="차트에 추가"
onClick={() => onAdd(def.type)} onClick={() => {
onAdd(def.type);
syncChartToTabOrder();
}}
> >
<IcPlus /> <IcPlus />
</button> </button>
@@ -687,7 +782,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
symbol, timeframe, chartType, theme, mode, logScale, showGrid, symbol, timeframe, chartType, theme, mode, logScale, showGrid,
activeIndicators, activeIndicators,
onSymbol, onTimeframe, onChartType, onTheme, onMode, onSymbol, onTimeframe, onChartType, onTheme, onMode,
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onRemoveByType, onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
onAutoFib, onClearFib, onScreenshot, onAutoFib, onClearFib, onScreenshot,
onToggleStats, onToggleWatch, onToggleAlert, onToggleStats, onToggleWatch, onToggleAlert,
tradeNotifyUnread = 0, onOpenTradeNotifications, tradeNotifyUnread = 0, onOpenTradeNotifications,
@@ -865,6 +960,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
activeIndicators={activeIndicators} activeIndicators={activeIndicators}
onAdd={type => onAddIndicator(type)} onAdd={type => onAddIndicator(type)}
onAddMany={onAddIndicators} onAddMany={onAddIndicators}
onIndicatorOrderChange={onIndicatorOrderChange}
onRemove={type => onRemoveByType(type)} onRemove={type => onRemoveByType(type)}
onOpenSettings={onOpenIndicatorSettings} onOpenSettings={onOpenIndicatorSettings}
onClose={() => setShowIndMenu(false)} onClose={() => setShowIndMenu(false)}
+61 -12
View File
@@ -50,7 +50,7 @@ import ChartContextMenu from './ChartContextMenu';
import CandlePaneControls from './CandlePaneControls'; import CandlePaneControls from './CandlePaneControls';
import { getKoreanName } from '../utils/marketNameCache'; import { getKoreanName } from '../utils/marketNameCache';
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
import { classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync'; import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKey } from '../utils/indicatorChartSync';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import PaneSeparatorOverlay from './PaneSeparatorOverlay'; import PaneSeparatorOverlay from './PaneSeparatorOverlay';
@@ -429,6 +429,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
return; return;
} }
afterIndicatorPaneMutation(mgr); afterIndicatorPaneMutation(mgr);
// resetPaneHeights 직후 레이블 좌표 재동기화 (탭 교체 후 어긋남 방지)
mgr.notifyPaneLayoutChanged();
requestAnimationFrame(() => requestAnimationFrame(() => { requestAnimationFrame(() => requestAnimationFrame(() => {
if (reloadGen !== indicatorReloadGenRef.current) { if (reloadGen !== indicatorReloadGenRef.current) {
fadeOutIndicatorReloadCover(cover); fadeOutIndicatorReloadCover(cover);
@@ -437,6 +439,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
restoreLogicalRange(mgr, savedLR); restoreLogicalRange(mgr, savedLR);
fadeOutIndicatorReloadCover(cover); fadeOutIndicatorReloadCover(cover);
indicatorSyncInFlightRef.current = false; indicatorSyncInFlightRef.current = false;
mgr.notifyPaneLayoutChanged();
[50, 150, 350, 700, 1200].forEach(ms => {
setTimeout(() => {
if (reloadGen === indicatorReloadGenRef.current) {
mgr.notifyPaneLayoutChanged();
}
}, ms);
});
onComplete?.(); onComplete?.();
})); }));
}); });
@@ -475,7 +485,17 @@ const TradingChart: React.FC<TradingChartProps> = ({
const expected = countExpectedVisibleIndicators(inds); const expected = countExpectedVisibleIndicators(inds);
if (expected === 0) return; if (expected === 0) return;
if (m.getLoadedIndicatorCount() >= expected) return;
const loadedIds = m.getLoadedIndicatorIds();
if (chartHasStaleIndicators(loadedIds, inds)) {
reloadIndicatorsWithCover(m, inds, () => {
prevBarsKey.current = barsKey(barData, market);
syncIndicatorTrackingRefs(inds);
applyPaneLayout(m);
});
return;
}
if (loadedIds.size >= expected) return;
prevBarsKey.current = ''; prevBarsKey.current = '';
const ct = prevChartType.current; const ct = prevChartType.current;
@@ -484,7 +504,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (m.hasMainSeries()) { if (m.hasMainSeries()) {
void repairMissingIndicators(m, inds).then(() => { void repairMissingIndicators(m, inds).then(() => {
if (m.getLoadedIndicatorCount() >= expected) { if (
m.getLoadedIndicatorCount() >= expected
&& !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
) {
prevBarsKey.current = barsKey(barData, market); prevBarsKey.current = barsKey(barData, market);
syncIndicatorTrackingRefs(inds); syncIndicatorTrackingRefs(inds);
applyPaneLayout(m); applyPaneLayout(m);
@@ -496,7 +519,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
} }
reloadAllRef.current?.(m, barData, ct, th, ls, inds); reloadAllRef.current?.(m, barData, ct, th, ls, inds);
}, 120); }, 120);
}, [market, barsMarket, applyPaneLayout, repairMissingIndicators, syncIndicatorTrackingRefs]); }, [market, barsMarket, applyPaneLayout, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
/** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */ /** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */
const commitCandleLayout = useCallback((mgr: ChartManager) => { const commitCandleLayout = useCallback((mgr: ChartManager) => {
@@ -1037,12 +1060,21 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (barsChanged || ctChanged) { if (barsChanged || ctChanged) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators); void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (indicatorsIncomplete) { } else if (indicatorsIncomplete && !indChanged) {
// 누락 지표만 추가 (전체 제거·재추가로 화면이 비는 문제 방지) // 지표 목록은 같지만 일부만 로드된 경우 — 누락분만 추가.
// 차트에 이전 탭 id가 남아 있으면(설정 화면에서 탭 교체 등) 전체 재로드.
const loadedIds = mgr.getLoadedIndicatorIds();
if (chartHasStaleIndicators(loadedIds, indicators)) {
reloadIndicatorsWithCover(mgr, indicators, () => {
syncIndicatorTrackingRefs(indicators);
prevBarsKey.current = barsKey(bars, market);
});
} else {
void repairMissingIndicators(mgr, indicators).then(() => { void repairMissingIndicators(mgr, indicators).then(() => {
syncIndicatorTrackingRefs(indicators); syncIndicatorTrackingRefs(indicators);
prevBarsKey.current = barsKey(bars, market); prevBarsKey.current = barsKey(bars, market);
}); });
}
} else if (lsChanged) { } else if (lsChanged) {
prevLogScale.current = logScale; prevLogScale.current = logScale;
mgr.setLogScale(logScale); mgr.setLogScale(logScale);
@@ -1168,7 +1200,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
const expected = countExpectedVisibleIndicators(indicators); const expected = countExpectedVisibleIndicators(indicators);
if (expected === 0) return; if (expected === 0) return;
if (mgr.getLoadedIndicatorCount() >= expected) return; const loadedIds = mgr.getLoadedIndicatorIds();
if (
loadedIds.size >= expected
&& !chartHasStaleIndicators(loadedIds, indicators)
) return;
const timers = [120, 400, 900].map(delay => const timers = [120, 400, 900].map(delay =>
window.setTimeout(() => { window.setTimeout(() => {
@@ -1176,10 +1212,23 @@ const TradingChart: React.FC<TradingChartProps> = ({
const m = managerRef.current; const m = managerRef.current;
if (!m || !m.hasMainSeries()) return; if (!m || !m.hasMainSeries()) return;
if (!canApplyChartBars(bars, barsMarket, market)) return; if (!canApplyChartBars(bars, barsMarket, market)) return;
const exp = countExpectedVisibleIndicators(indicatorsRef.current); const inds = indicatorsRef.current;
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return; const exp = countExpectedVisibleIndicators(inds);
void repairMissingIndicators(m, indicatorsRef.current).then(() => { if (exp === 0) return;
if (m.getLoadedIndicatorCount() >= exp) return; const ids = m.getLoadedIndicatorIds();
if (chartHasStaleIndicators(ids, inds)) {
reloadIndicatorsWithCover(m, inds, () => {
syncIndicatorTrackingRefs(inds);
prevBarsKey.current = barsKey(bars, market);
});
return;
}
if (ids.size >= exp) return;
void repairMissingIndicators(m, inds).then(() => {
if (
m.getLoadedIndicatorCount() >= exp
&& !chartHasStaleIndicators(m.getLoadedIndicatorIds(), inds)
) return;
prevBarsKey.current = ''; prevBarsKey.current = '';
queueIndicatorRecovery(); queueIndicatorRecovery();
}); });
@@ -1187,7 +1236,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
); );
return () => { timers.forEach(clearTimeout); }; return () => { timers.forEach(clearTimeout); };
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators]); }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, chartVisible, queueIndicatorRecovery, repairMissingIndicators, reloadIndicatorsWithCover, syncIndicatorTrackingRefs]);
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경 // cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => { const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react';
import {
getOrderedSettingsIndicatorTypes,
loadIndicatorListOrder,
mergeIndicatorListOrder,
reorderIndicatorListType,
saveIndicatorListOrder,
} from '../utils/indicatorListOrder';
import { getSettingsIndicatorTypes } from '../utils/indicatorSettingsList';
/** 보조지표 설정 목록 순서 — 드래그 정렬 + localStorage */
export function useIndicatorListOrder(onOrderChange?: (order: string[]) => void) {
const [listOrder, setListOrder] = useState<string[]>(() => getOrderedSettingsIndicatorTypes());
useEffect(() => {
const canonical = getSettingsIndicatorTypes();
setListOrder(prev => mergeIndicatorListOrder(prev.length ? prev : loadIndicatorListOrder(), canonical));
}, []);
const persistOrder = useCallback((next: string[]) => {
const canonical = getSettingsIndicatorTypes();
const merged = mergeIndicatorListOrder(next, canonical);
setListOrder(merged);
saveIndicatorListOrder(merged);
onOrderChange?.(merged);
return merged;
}, [onOrderChange]);
const moveType = useCallback(
(fromType: string, toType: string, position: 'before' | 'after' = 'before') => {
setListOrder(prev => persistOrder(reorderIndicatorListType(prev, fromType, toType, position)));
},
[persistOrder],
);
return { listOrder, persistOrder, moveType, setListOrder };
}
+33 -6
View File
@@ -715,6 +715,7 @@ export class ChartManager {
await this.addIndicator(config, { skipLayout: true }); await this.addIndicator(config, { skipLayout: true });
} }
if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) { if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) {
this._removeOrphanSubPanes();
this.resetPaneHeights(this._lastLayoutAvailableHeight); this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout(); this._notifyPaneLayout();
} }
@@ -778,6 +779,8 @@ export class ChartManager {
if (this._indicatorLoadStale(loadGen)) return; if (this._indicatorLoadStale(loadGen)) return;
// 시리즈 제거 후 남은 빈 sub-pane 제거 (상단 orphan pane → 레이블·그래프 어긋남 방지)
this._removeOrphanSubPanes();
// 빈 pane stretch 정리 + 캔들 pane 비율 복구 // 빈 pane stretch 정리 + 캔들 pane 비율 복구
this.resetPaneHeights(); this.resetPaneHeights();
this._notifyPaneLayout(); this._notifyPaneLayout();
@@ -1723,6 +1726,11 @@ export class ChartManager {
} }
} }
/** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */
notifyPaneLayoutChanged(): void {
this._notifyPaneLayout();
}
/** /**
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환. * 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
* IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다. * IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
@@ -1805,23 +1813,26 @@ export class ChartManager {
getIndicatorPaneScreenLayout(id: string): { topY: number; height: number } | null { getIndicatorPaneScreenLayout(id: string): { topY: number; height: number } | null {
const entry = this.indicators.get(id); const entry = this.indicators.get(id);
if (!entry || entry.paneIndex == null || entry.paneIndex < 2) return null; if (!entry || entry.paneIndex == null || entry.paneIndex < 2) return null;
const series = entry.seriesList[0]; const containerRect = this.container.getBoundingClientRect();
if (!series) return null; const MIN_H = 4;
for (const series of entry.seriesList) {
try { try {
const el = series.getPane().getHTMLElement(); const el = series.getPane().getHTMLElement();
if (!el) return null; if (!el) continue;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
const height = Math.round(rect.height); const height = Math.round(rect.height);
if (height < 12) return null; if (height < MIN_H) continue;
return { return {
topY: Math.round(rect.top - containerRect.top), topY: Math.round(rect.top - containerRect.top),
height, height,
}; };
} catch { } catch {
return null; /* try next series */
} }
} }
return null;
}
/** /**
* 서브 pane(비오버레이) 인디케이터 정보 목록 반환. * 서브 pane(비오버레이) 인디케이터 정보 목록 반환.
@@ -2373,6 +2384,22 @@ export class ChartManager {
} }
} }
/** 활성 지표가 없는 sub-pane 제거 (중간·상단 orphan pane 포함) */
private _removeOrphanSubPanes(): void {
const active = this._activeIndicatorPaneIndices();
for (let i = this.chart.panes().length - 1; i >= 2; i--) {
const panes = this.chart.panes();
if (i >= panes.length) continue;
const pi = panes[i].paneIndex();
if (active.has(pi)) continue;
try {
this.chart.removePane(i);
} catch {
return;
}
}
}
/** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */ /** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
private _trimTrailingEmptySubPanes(): void { private _trimTrailingEmptySubPanes(): void {
for (;;) { for (;;) {
+15
View File
@@ -19,6 +19,21 @@ export function singleIndParamKey(i: IndicatorConfig): string {
return `${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`; return `${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`;
} }
/** 차트에 로드된 지표 id 중 현재 설정에 없는(이전 탭 잔존) 항목이 있는지 */
export function chartHasStaleIndicators(
loadedIds: Set<string>,
indicators: IndicatorConfig[],
): boolean {
if (loadedIds.size === 0) return false;
const expected = new Set(
indicators.filter(i => i.hidden !== true).map(i => i.id),
);
for (const id of loadedIds) {
if (!expected.has(id)) return true;
}
return false;
}
/** 이전·현재 지표 목록 차이를 분류해 최소 갱신 경로를 선택 */ /** 이전·현재 지표 목록 차이를 분류해 최소 갱신 경로를 선택 */
export function classifyIndicatorChartChange( export function classifyIndicatorChartChange(
prev: IndicatorConfig[], prev: IndicatorConfig[],
+109
View File
@@ -0,0 +1,109 @@
/**
* 보조지표 설정 목록 표시·차트 pane 순서 (localStorage)
*/
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
import type { IndicatorConfig } from '../types';
import { getPaneHostId } from './indicatorPaneMerge';
const STORAGE_KEY = 'gc_indicator_settings_list_order';
export function loadIndicatorListOrder(): string[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
} catch {
return null;
}
}
export function saveIndicatorListOrder(types: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
} catch {
/* quota */
}
}
/** 저장 순서 + registry 신규 type 병합 */
export function mergeIndicatorListOrder(saved: string[] | null, canonical: readonly string[]): string[] {
const canonSet = new Set(canonical);
const out: string[] = [];
const seen = new Set<string>();
if (saved) {
for (const t of saved) {
if (canonSet.has(t) && !seen.has(t)) {
seen.add(t);
out.push(t);
}
}
}
for (const t of canonical) {
if (!seen.has(t)) {
seen.add(t);
out.push(t);
}
}
return out;
}
export function getOrderedSettingsIndicatorTypes(): string[] {
const canonical = getSettingsIndicatorTypes();
return mergeIndicatorListOrder(loadIndicatorListOrder(), canonical);
}
export function compareIndicatorTypeOrder(
a: string,
b: string,
order: readonly string[],
): number {
const rank = new Map(order.map((t, i) => [t, i]));
const ra = rank.get(a) ?? 1_000_000;
const rb = rank.get(b) ?? 1_000_000;
if (ra !== rb) return ra - rb;
return a.localeCompare(b);
}
/** 차트 인스턴스 배열을 설정 목록 type 순서로 정렬 (병합 pane 은 호스트와 함께 이동) */
export function sortIndicatorsByTypeOrder(
inds: IndicatorConfig[],
order: readonly string[] = getOrderedSettingsIndicatorTypes(),
): IndicatorConfig[] {
if (inds.length <= 1) return inds;
const rankOfInd = (ind: IndicatorConfig): number => {
const hostId = getPaneHostId(ind.id, inds);
const host = inds.find(i => i.id === hostId) ?? ind;
const idx = order.indexOf(host.type);
const hostRank = idx >= 0 ? idx : 1_000_000;
if (ind.id === hostId) return hostRank;
const selfIdx = order.indexOf(ind.type);
return hostRank + (selfIdx >= 0 ? selfIdx * 0.0001 : 0.0005);
};
return [...inds].sort((a, b) => {
const d = rankOfInd(a) - rankOfInd(b);
if (d !== 0) return d;
return a.id.localeCompare(b.id);
});
}
export function reorderIndicatorListType(
order: string[],
draggedType: string,
targetType: string,
place: 'before' | 'after' = 'before',
): string[] {
if (draggedType === targetType) return order;
const without = order.filter(t => t !== draggedType);
let idx = without.indexOf(targetType);
if (idx < 0) return order;
if (place === 'after') idx += 1;
without.splice(idx, 0, draggedType);
return without;
}
@@ -0,0 +1,31 @@
/**
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서
*/
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
import { mergeIndicatorListOrder } from './indicatorListOrder';
const STORAGE_KEY = 'gc_indicator_main_tab_order';
export function loadMainTabOrder(): string[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
} catch {
return null;
}
}
export function saveMainTabOrder(types: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
} catch {
/* quota */
}
}
export function getOrderedMainIndicatorTypes(): string[] {
return mergeIndicatorListOrder(loadMainTabOrder(), MAIN_INDICATOR_TYPES);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { INDICATOR_REGISTRY } from './indicatorRegistry'; import { INDICATOR_REGISTRY } from './indicatorRegistry';
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab'; import { getOrderedMainIndicatorTypes } from './indicatorMainTabOrder';
import type { IndicatorCustomTab } from './indicatorCustomTabsStorage'; import type { IndicatorCustomTab } from './indicatorCustomTabsStorage';
export type IndicatorTabApplySource = export type IndicatorTabApplySource =
@@ -8,7 +8,7 @@ export type IndicatorTabApplySource =
export function resolveIndicatorTabApplyTypes(source: IndicatorTabApplySource): string[] { export function resolveIndicatorTabApplyTypes(source: IndicatorTabApplySource): string[] {
if (source.kind === 'main') { if (source.kind === 'main') {
return [...MAIN_INDICATOR_TYPES].filter( return getOrderedMainIndicatorTypes().filter(
type => INDICATOR_REGISTRY.some(d => d.type === type), type => INDICATOR_REGISTRY.some(d => d.type === type),
); );
} }