diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index de64e40..de232c5 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -67,13 +67,20 @@ public class StrategyConditionTimeframeService { GcStrategy strategy = opt.get(); ensureDslRepaired(strategy); - Set out = new LinkedHashSet<>(); - boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), out); - boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), out); + Set buyOut = new LinkedHashSet<>(); + Set sellOut = new LinkedHashSet<>(); + boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut); + boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut); - if (!out.isEmpty()) { - return out; + // 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록 + if (buyScoped && !buyOut.isEmpty()) { + return buyOut; } + if (sellScoped && !sellOut.isEmpty()) { + return sellOut; + } + + Set out = new LinkedHashSet<>(); // 레거시: START(TIMEFRAME) 래핑 없는 DSL if (!buyScoped) { @@ -95,6 +102,18 @@ public class StrategyConditionTimeframeService { boolean changed = false; 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()) { String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name); if (!fixed.equals(strategy.getBuyConditionJson())) { @@ -102,8 +121,8 @@ public class StrategyConditionTimeframeService { changed = true; } } - if (strategy.getSellConditionJson() != null && !strategy.getSellConditionJson().isBlank()) { - String fixed = dslNormalizer.normalizeJson(strategy.getSellConditionJson(), name); + if (sellJson != null && !sellJson.isBlank()) { + String fixed = dslNormalizer.normalizeJson(sellJson, name); if (!fixed.equals(strategy.getSellConditionJson())) { strategy.setSellConditionJson(fixed); changed = true; diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 8ec988e..6e34946 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -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) */ public static String inferFromStrategyName(String name) { if (name == null || name.isBlank()) return null; diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index b0dbe6c..e529de0 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -6,7 +6,6 @@ import com.goldenchart.repository.GcStrategyRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -26,9 +25,20 @@ class StrategyConditionTimeframeServiceTest { @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @InjectMocks + @Mock + private StrategyDslTimeframeNormalizer dslNormalizer; + + @Mock + private LiveStrategyEvaluator liveStrategyEvaluator; + private StrategyConditionTimeframeService service; + @BeforeEach + void setUpService() { + service = new StrategyConditionTimeframeService( + strategyRepo, objectMapper, dslNormalizer, liveStrategyEvaluator); + } + @BeforeEach void stubStrategy() { GcStrategy strategy = new GcStrategy(); @@ -45,6 +55,10 @@ class StrategyConditionTimeframeServiceTest { """); strategy.setSellConditionJson(null); 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 @@ -71,6 +85,10 @@ class StrategyConditionTimeframeServiceTest { } """); 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 tfs = service.collectForStrategy(4L); assertEquals(Set.of("3m", "5m"), tfs); @@ -95,6 +113,10 @@ class StrategyConditionTimeframeServiceTest { } """); 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 tfs = service.collectForStrategy(3L); assertEquals(Set.of("1m", "3m", "5m"), tfs); @@ -127,6 +149,10 @@ class StrategyConditionTimeframeServiceTest { } """); 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 tfs = service.collectForStrategy(5L); assertEquals(Set.of("3m", "5m"), tfs); @@ -147,6 +173,10 @@ class StrategyConditionTimeframeServiceTest { } """); 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)); } diff --git a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerAlignTest.java b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerAlignTest.java new file mode 100644 index 0000000..66a85a5 --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerAlignTest.java @@ -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\"")); + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index d01eecd..29c9aca 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1514,6 +1514,14 @@ html.theme-blue { gap: 10px; 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.active { background: #2962ff0e; } .ind-panel-item-info { @@ -9450,6 +9458,36 @@ html.theme-blue { border-radius: 8px; 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 { border-color: rgba(122, 162, 247, 0.35); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ddc9968..8acb8cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,10 @@ import { splitIndicatorPane, replaceIndicatorConfigsFromTypes, } from './utils/indicatorPaneMerge'; +import { + getOrderedSettingsIndicatorTypes, + sortIndicatorsByTypeOrder, +} from './utils/indicatorListOrder'; import { useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; @@ -1158,11 +1162,12 @@ function App() { if (slot0.mode) setMode(slot0.mode as ChartMode); if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale)); 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 === 'IchimokuCloud') return normalizeIchimokuConfig(ind); return ind; - })); + }); + setIndicators(sortIndicatorsByTypeOrder(loaded)); } if (Array.isArray(slot0.drawings)) setDrawings(slot0.drawings as Drawing[]); if (slot0.drawingsLocked != null) setDrawingsLocked(Boolean(slot0.drawingsLocked)); @@ -1178,11 +1183,13 @@ function App() { symbol: s.symbol as string | undefined, timeframe: s.timeframe as Timeframe | undefined, indicators: Array.isArray(s.indicators) - ? (s.indicators as IndicatorConfig[]).map(ind => { - if (ind.type === 'SMA') return normalizeSmaConfig(ind); - if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); - return ind; - }) + ? sortIndicatorsByTypeOrder( + (s.indicators as IndicatorConfig[]).map(ind => { + if (ind.type === 'SMA') return normalizeSmaConfig(ind); + if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind); + return ind; + }), + ) : undefined, mainChartStyle: s.mainChartStyle as MainChartStyle | undefined, }; @@ -1347,6 +1354,20 @@ function App() { } }, [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) => { if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; @@ -1380,22 +1401,27 @@ function App() { } else if (type === 'IchimokuCloud') { cfg = normalizeIchimokuConfig(cfg); } - return [...prev, cfg]; + return sortIndicatorsByTypeOrder([...prev, cfg]); }); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); - /** 지표 추가 팝업 — 탭 전체 추가 (기존 보조지표 제거 후 탭 지표만 적용) */ + /** 지표 추가 팝업 — 탭 전체 추가 (탭 목록 순서 유지) */ const handleAddIndicators = useCallback((types: string[]) => { if (!types.length) return; + const orderedTypes = types.filter(t => getIndicatorDef(t)); + if (orderedTypes.length === 0) return; if (layoutDef.count > 1) { const slotRef = slotRefs.current[activeSlot]; if (slotRef) { - slotRef.addIndicators(types); + slotRef.addIndicators(orderedTypes); return; } } setIndicators( - replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), + sortIndicatorsByTypeOrder( + replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId), + orderedTypes, + ), ); }, [layoutDef.count, activeSlot, getParams, getVisualConfig]); @@ -1543,7 +1569,7 @@ function App() { allConfigs: IndicatorConfig[]; }) => { const { chartIndicators, allConfigs } = result; - applyChartIndicators(chartIndicators); + applyChartIndicators(sortIndicatorsByTypeOrder(chartIndicators)); allConfigs.forEach(ind => { saveParams(ind.type, ind.params); saveVisual(ind.type, ind.plots, ind.hlines, ind.cloudColors, ind.bandBackground); @@ -1784,6 +1810,7 @@ function App() { chartIndicators={bulkSettingsIndicators} onAddIndicatorToChart={handleAddIndicator} onRemoveIndicatorFromChart={handleRemoveByType} + onIndicatorListOrderChange={handleIndicatorListOrderChange} chartSeriesPriceLabels={chartSeriesPriceLabels} onChartSeriesPriceLabels={v => { saveAppDef({ chartSeriesPriceLabels: v }); @@ -1898,6 +1925,7 @@ function App() { onScrollToNow={() => managerRef.current?.scrollToRealTime()} onAddIndicator={handleAddIndicator} onAddIndicators={handleAddIndicators} + onIndicatorOrderChange={handleIndicatorListOrderChange} onRemoveByType={handleRemoveByType} onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)} onOpenIndicatorSettings={handleOpenIndicatorSettings} diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index af18156..c0924a3 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -36,6 +36,7 @@ import { splitIndicatorPane, replaceIndicatorConfigsFromTypes, } from '../utils/indicatorPaneMerge'; +import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder'; import { saveSlot } from '../utils/backendApi'; import type { OHLCVBar, ChartType, Theme, Timeframe, @@ -279,13 +280,16 @@ const ChartSlot = forwardRef(function ChartSlot } else if (type === 'IchimokuCloud') { cfg = normalizeIchimokuConfig(cfg); } - return [...prev, cfg]; + return sortIndicatorsByTypeOrder([...prev, cfg]); }); }, addIndicators: (types: string[]) => { if (!types.length) return; setIndicators( - replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), + sortIndicatorsByTypeOrder( + replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId), + types, + ), ); }, removeIndicatorByType: (type: string) => { diff --git a/frontend/src/components/IndicatorMainDefaultsPanel.tsx b/frontend/src/components/IndicatorMainDefaultsPanel.tsx index 30ff395..383a1fc 100644 --- a/frontend/src/components/IndicatorMainDefaultsPanel.tsx +++ b/frontend/src/components/IndicatorMainDefaultsPanel.tsx @@ -17,6 +17,7 @@ import { import IndicatorSettingsListSearch from './IndicatorSettingsListSearch'; import IndicatorSettingsListRow from './IndicatorSettingsListRow'; import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab'; +import { useIndicatorListOrder } from '../hooks/useIndicatorListOrder'; export interface IndicatorSaveUiState { save: () => Promise; @@ -48,6 +49,8 @@ export interface IndicatorMainDefaultsPanelProps { onSaveUiState?: (state: IndicatorSaveUiState | null) => void; /** DB 로드 완료·저장 후 재로드 트리거 */ settingsRevision?: number; + /** 목록 순서 변경 시 차트 보조지표 순서 동기화 */ + onListOrderChange?: (orderedTypes: string[]) => void; } const ALL_TYPES = getSettingsIndicatorTypes(); @@ -77,8 +80,12 @@ const IndicatorMainDefaultsPanel: React.FC = ({ saveAllDefaults, onSaveUiState, settingsRevision = 0, + onListOrderChange, }) => { const onChartTypes = useMemo(() => new Set(chartIndicators.map(i => i.type)), [chartIndicators]); + const { listOrder, moveType } = useIndicatorListOrder(onListOrderChange); + const [draggingType, setDraggingType] = useState(null); + const [dragOverType, setDragOverType] = useState(null); const [expandedTypes, setExpandedTypes] = useState>(() => new Set()); const [configs, setConfigs] = useState>({}); const [baselineFp, setBaselineFp] = useState(''); @@ -97,14 +104,23 @@ const IndicatorMainDefaultsPanel: React.FC = ({ reloadFromDb(); }, [reloadFromDb, settingsRevision]); + useEffect(() => { + const clearDrag = () => { + setDraggingType(null); + setDragOverType(null); + }; + document.addEventListener('dragend', clearDrag); + return () => document.removeEventListener('dragend', clearDrag); + }, []); + const dirty = useMemo( () => baselineFp !== '' && fingerprintMap(configs) !== baselineFp, [configs, baselineFp], ); const filtered = useMemo( - () => filterSettingsIndicatorTypes(ALL_TYPES, search), - [search], + () => filterSettingsIndicatorTypes(listOrder, search), + [listOrder, search], ); const { main: filteredMain, other: filteredOther } = useMemo( () => partitionFilteredTypes(filtered), @@ -184,6 +200,8 @@ const IndicatorMainDefaultsPanel: React.FC = ({ return () => onSaveUiState(null); }, [onSaveUiState, handleSaveAll, dirty, saving, saveMessage]); + const canReorder = !search.trim(); + const renderRows = (types: string[]) => types.map(type => { const cfg = configs[type]; if (!cfg) return null; @@ -195,6 +213,29 @@ const IndicatorMainDefaultsPanel: React.FC = ({ enabled={onChartTypes.has(type)} expanded={expandedTypes.has(type)} 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)} onToggleEnabled={on => handleToggleChart(type, on)} onChange={updated => handleConfigChange(type, updated)} @@ -208,7 +249,8 @@ const IndicatorMainDefaultsPanel: React.FC = ({

지표 추가 팝업 Main 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었고, 그 아래에 등록된 모든 보조지표가 있습니다. - 우측 스위치로 차트에 바로 추가·제거할 수 있습니다. 파라미터·색상을 변경한 뒤 상단 저장 버튼을 눌러 DB에 반영하세요. + 우측 스위치로 차트에 바로 추가·제거할 수 있습니다. 핸들로 목록 순서를 바꿀 수 있으며, 차트에 표시되는 보조지표 pane 순서도 이 목록을 따릅니다. + 파라미터·색상을 변경한 뒤 상단 저장 버튼을 눌러 DB에 반영하세요.

+ )}
{labels.ko} {labels.en} diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index be58813..43f4e1b 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -33,42 +33,6 @@ interface PaneItem { 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 { dataUrl: string; cssWidth: number; @@ -247,88 +211,80 @@ function fillMissingLayoutsByPaneIndex( } } -/** DOM 행 중 paneIndex 레이아웃 topY 와 가장 가까운 행 선택 */ -function fillMissingLayoutsFromDom( +/** + * 실제 시리즈가 붙은 sub-pane 만으로 좌표 보강 (orphan 빈 pane 제외). + * paneIndex 미등록(-1)·탭 일괄 교체 직후에도 레이블 위치를 맞춘다. + */ +function fillMissingLayoutsFromActiveSubPanes( items: PaneItem[], - containerEl: HTMLElement, - paneLayouts: Array<{ paneIndex: number; topY: number; height: number }>, + subLayouts: Array<{ paneIndex: number; topY: number; height: number }>, ): 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; - const domSubs = getDomSubIndicatorLayouts(containerEl, missing.length); - if (domSubs.length === 0) return; - - const usedDom = new Set(); + const usedPane = new Set(); const sorted = [...missing].sort((a, b) => a.paneIndex - b.paneIndex); for (const item of sorted) { - const ref = layoutForPaneIndex(item.paneIndex, paneLayouts); - let bestIdx = -1; - let bestDist = Infinity; - for (let i = 0; i < domSubs.length; i++) { - if (usedDom.has(i)) continue; - 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]); + if (item.paneIndex < 2) continue; + const lay = subLayouts.find(l => l.paneIndex === item.paneIndex && !usedPane.has(l.paneIndex)); + if (!lay) continue; + applyPaneLayoutToItem(item, lay); + usedPane.add(lay.paneIndex); } } function buildPaneItems( manager: ChartManager, - containerEl: HTMLElement | null, indicators: IndicatorConfig[], - optPaneMap?: Map, ): PaneItem[] { - const paneMap = new Map(optPaneMap ?? []); - for (const info of manager.getIndicatorPaneInfo()) { - paneMap.set(info.id, info.paneIndex); - } + const wantedIds = new Set( + indicators + .filter(ind => { + const def = getIndicatorDef(ind.type); + return def && !def.overlay && !def.returnsMarkers; + }) + .map(ind => ind.id), + ); + // ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외 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); 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) { - const hostPane = paneMap.get(ind.mergedWith); - if (hostPane != null && hostPane >= 2) chartPane = hostPane; + const host = manager.getIndicatorPaneIndex(ind.mergedWith); + if (host >= 2) chartPane = host; } items.push({ - id: ind.id, + id: info.id, paneIndex: chartPane, label: makeLabel(ind), - plotColors, + plotColors: info.plotColors.map(c => c.slice(0, 7)), hidden: ind.hidden === true, }); } if (items.length === 0) return []; + items.sort((a, b) => a.paneIndex - b.paneIndex); + + const subLayouts = manager.getIndicatorSubPaneLayouts(); + for (const item of items) { const lay = manager.getIndicatorPaneScreenLayout(item.id); if (lay) applyPaneLayoutToItem(item, lay, true); } - const paneLayouts = manager.getPaneLayouts(); - fillMissingLayoutsByPaneIndex(items, paneLayouts); - fillMissingLayoutsByPaneIndex(items, manager.getIndicatorSubPaneLayouts()); - - if (containerEl?.isConnected) { - fillMissingLayoutsFromDom(items, containerEl, paneLayouts); - } + fillMissingLayoutsByPaneIndex(items, subLayouts); + fillMissingLayoutsFromActiveSubPanes(items, subLayouts); items.sort((a, b) => { const ay = a.layoutTopY ?? 1e9; @@ -353,6 +309,17 @@ function paneItemLayout( 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 ──────────────────────────────────────────────────────────────── /** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */ const IconExpand = () => ( @@ -422,6 +389,7 @@ const PaneLegend: React.FC = ({ }) => { const [paneItems, setPaneItems] = useState([]); const [paneLayouts, setPaneLayouts] = useState>([]); + const [layoutEpoch, setLayoutEpoch] = useState(0); const [values, setValues] = useState>({}); const [dragState, setDragState] = useState(null); const retryRef = useRef[]>([]); @@ -451,11 +419,10 @@ const PaneLegend: React.FC = ({ useEffect(() => { indicatorsRef.current = indicators; }, [indicators]); const syncPaneItems = useCallback(() => { - const el = containerElRef.current; - if (!el?.isConnected) return; - const map = manager.getIndicatorPaneMapping(); + if (!containerElRef.current?.isConnected) return; setPaneLayouts(manager.getPaneLayouts()); - setPaneItems(buildPaneItems(manager, el, indicators, map)); + setPaneItems(buildPaneItems(manager, indicators)); + setLayoutEpoch(e => e + 1); }, [indicators, manager]); const refreshLayouts = useCallback(() => { @@ -486,7 +453,7 @@ const PaneLegend: React.FC = ({ setPaneItems([]); scheduleRetry(); return () => retryRef.current.forEach(clearTimeout); - }, [scheduleRetry]); + }, [scheduleRetry, indicators]); useEffect(() => { const unsub = manager.subscribePaneLayout(() => scheduleRetry()); @@ -505,13 +472,6 @@ const PaneLegend: React.FC = ({ useEffect(() => { const unsub = manager.subscribeCrosshair((p: MouseEventParams