실시간 차트 보조지표 탭 문제 수정
This commit is contained in:
+26
-7
@@ -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;
|
||||||
|
|||||||
+32
-2
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-12
@@ -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(
|
||||||
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
|
(s.indicators as IndicatorConfig[]).map(ind => {
|
||||||
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(ind);
|
if (ind.type === 'SMA') return normalizeSmaConfig(ind);
|
||||||
return ind;
|
if (ind.type === 'IchimokuCloud') return normalizeIchimokuConfig(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}
|
||||||
|
|||||||
@@ -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(
|
||||||
replaceIndicatorConfigsFromTypes(types, getParams, getVisualConfig, newIndId),
|
sortIndicatorsByTypeOrder(
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
// 누락 지표만 추가 (전체 제거·재추가로 화면이 비는 문제 방지)
|
// 지표 목록은 같지만 일부만 로드된 경우 — 누락분만 추가.
|
||||||
void repairMissingIndicators(mgr, indicators).then(() => {
|
// 차트에 이전 탭 id가 남아 있으면(설정 화면에서 탭 교체 등) 전체 재로드.
|
||||||
syncIndicatorTrackingRefs(indicators);
|
const loadedIds = mgr.getLoadedIndicatorIds();
|
||||||
prevBarsKey.current = barsKey(bars, market);
|
if (chartHasStaleIndicators(loadedIds, indicators)) {
|
||||||
});
|
reloadIndicatorsWithCover(mgr, indicators, () => {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
prevBarsKey.current = barsKey(bars, market);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void repairMissingIndicators(mgr, indicators).then(() => {
|
||||||
|
syncIndicatorTrackingRefs(indicators);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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,22 +1813,25 @@ 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;
|
||||||
try {
|
|
||||||
const el = series.getPane().getHTMLElement();
|
for (const series of entry.seriesList) {
|
||||||
if (!el) return null;
|
try {
|
||||||
const rect = el.getBoundingClientRect();
|
const el = series.getPane().getHTMLElement();
|
||||||
const containerRect = this.container.getBoundingClientRect();
|
if (!el) continue;
|
||||||
const height = Math.round(rect.height);
|
const rect = el.getBoundingClientRect();
|
||||||
if (height < 12) return null;
|
const height = Math.round(rect.height);
|
||||||
return {
|
if (height < MIN_H) continue;
|
||||||
topY: Math.round(rect.top - containerRect.top),
|
return {
|
||||||
height,
|
topY: Math.round(rect.top - containerRect.top),
|
||||||
};
|
height,
|
||||||
} catch {
|
};
|
||||||
return null;
|
} catch {
|
||||||
|
/* try next series */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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 (;;) {
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user