라벨 중복문제 수정

This commit is contained in:
Macbook
2026-05-26 22:45:54 +09:00
parent a4830e9bd8
commit 94e96b9a9b
9 changed files with 388 additions and 43 deletions
+29 -9
View File
@@ -9295,8 +9295,7 @@ html.theme-blue {
.rsp-trade-stack .rsp-trade-card-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overflow: hidden;
display: flex;
flex-direction: column;
container-type: size;
@@ -9332,14 +9331,25 @@ html.theme-blue {
color: var(--down);
}
.rsp-trade-card-body .top-panel {
flex: 1;
flex: 1 1 auto;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 0;
background: transparent;
padding: 6px 10px 6px;
}
.rsp-trade-card-body .top-panel-fields {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
background: transparent;
padding: 6px 10px 6px;
justify-content: space-evenly;
gap: clamp(4px, 1.2cqi, 10px);
overflow-y: auto;
overflow-x: hidden;
}
.rsp-trade-card-body .top-field {
margin-bottom: 0;
@@ -9382,7 +9392,9 @@ html.theme-blue {
}
.rsp-trade-card-body .top-actions {
flex-shrink: 0;
margin-top: auto;
margin-top: clamp(6px, 1.5cqi, 12px);
padding-top: clamp(4px, 1cqi, 8px);
border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
}
.rsp-trade-card-body .top-reset,
.rsp-trade-card-body .top-submit {
@@ -9390,7 +9402,15 @@ html.theme-blue {
font-size: 13px;
}
/* 가격·수량: 기본 세로 배치, 공간 부족 시 한 줄 */
.rsp-trade-card-body .top-price-qty-block {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: clamp(4px, 1cqi, 8px);
}
/* 가격·수량: 기본 세로 배치 */
.top-price-qty-block {
display: flex;
flex-direction: column;
@@ -247,6 +247,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return (
<div className={`top-panel top-panel--${side}`}>
<div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
)}
@@ -399,6 +400,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
<span> {feeRate}%</span>
</span>
</div>
</div>
<div className="top-actions">
<button type="button" className="top-reset" onClick={handleReset}>
+9 -1
View File
@@ -345,6 +345,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}, [market, barsMarket]);
const queueIndicatorRecovery = useCallback(() => {
if (reloadInFlightRef.current) return;
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
recoveryTimerRef.current = window.setTimeout(() => {
recoveryTimerRef.current = null;
@@ -889,7 +890,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
if (barsChanged || ctChanged || thChanged || indicatorsIncomplete) {
if (thChanged && !barsChanged && !indChanged && !ctChanged && !lsChanged && !indicatorsIncomplete) {
prevTheme.current = theme;
mgr.setTheme(theme);
return;
}
if (barsChanged || ctChanged || indicatorsIncomplete) {
// 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (lsChanged) {
@@ -1011,6 +1018,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const timers = [120, 400, 900].map(delay =>
window.setTimeout(() => {
if (reloadInFlightRef.current) return;
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
if (!canApplyChartBars(bars, barsMarket, market)) return;
@@ -122,21 +122,36 @@ const TrendSearchCardChart: React.FC<Props> = ({
}, [market, chartTf]);
useEffect(() => {
const wrap = canvasWrapRef.current;
if (!wrap) return;
const applyPaneHeights = () => {
const h = wrap.clientHeight;
if (h <= 0) return;
const mgr = managerRef.current;
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
};
const timers = [100, 300, 800, 1500].map(delay =>
setTimeout(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap) return;
const h = wrap.clientHeight;
if (!canvasWrapRef.current) return;
const h = canvasWrapRef.current.clientHeight;
if (h <= 0) return;
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
if (managerRef.current?.hasMainSeries()) managerRef.current.resetPaneHeights(h);
else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, delay),
);
return () => timers.forEach(clearTimeout);
const ro = new ResizeObserver(() => applyPaneHeights());
ro.observe(wrap);
return () => {
timers.forEach(clearTimeout);
ro.disconnect();
};
}, [market, chartTf, bars.length]);
useEffect(() => {
@@ -145,8 +160,11 @@ const TrendSearchCardChart: React.FC<Props> = ({
}, [market, chartTf, useUpbit]);
return (
<div className="vtd-card-chart-wrap">
<div ref={canvasWrapRef} className="vtd-card-chart slot-chart-wrap">
<div className="vtd-card-chart-panel vtd-card-chart-panel--fill">
<div
ref={canvasWrapRef}
className="vtd-card-chart-canvas slot-chart-wrap vtd-card-chart-canvas--fill"
>
{isLoading && <div className="vtd-card-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
@@ -71,8 +71,8 @@ const TrendSearchResultCard: React.FC<Props> = ({
className={[
'vtd-card',
'vtd-card--signal',
'vtd-card--detail',
isChart ? 'vtd-card--chart-mode' : '',
!isChart ? 'vtd-card--detail' : '',
isChart ? 'vtd-card--chart-mode tsd-result-card--chart' : '',
selected ? 'vtd-card--selected' : '',
flashCls,
].filter(Boolean).join(' ')}
@@ -4,7 +4,7 @@ import type { StrategyDto } from '../../utils/backendApi';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { buildConditionMetrics, resolveVirtualTradeTiming } from '../../utils/virtualSignalMetrics';
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
import { normalizeStartCandleType } from '../../utils/strategyStartNodes';
import type { Timeframe } from '../../types';
@@ -76,7 +76,6 @@ const VirtualTargetCard: React.FC<Props> = ({
const rows = snapshot?.rows ?? [];
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
const tradeTiming = useMemo(() => resolveVirtualTradeTiming(metrics), [metrics]);
const metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
@@ -102,8 +101,6 @@ const VirtualTargetCard: React.FC<Props> = ({
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
isChart ? 'vtd-card--chart-mode' : '',
selected ? 'vtd-card--selected' : '',
tradeTiming === 'buy' ? 'vtd-card--timing-buy' : '',
tradeTiming === 'sell' ? 'vtd-card--timing-sell' : '',
].filter(Boolean).join(' ')}
onClick={onSelect}
onKeyDown={e => {
@@ -780,6 +780,49 @@
.bps-page--tsd .vtd-grid--chart-mode {
grid-auto-rows: minmax(560px, auto);
align-items: stretch;
}
/* 추세검색 카드 — 차트 모드: 헤더·메타 제외 영역을 차트가 채움 */
.bps-page--tsd .tsd-result-card--chart.vtd-card--chart-mode {
display: flex;
flex-direction: column;
height: 100%;
min-height: 560px;
overflow: hidden;
align-self: stretch;
}
.bps-page--tsd .tsd-result-card--chart .vtd-card-head,
.bps-page--tsd .tsd-result-card--chart .vtd-card-meta {
flex-shrink: 0;
}
.bps-page--tsd .tsd-result-card--chart .vtd-card-chart-panel--fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.bps-page--tsd .tsd-result-card--chart .vtd-card-chart-canvas--fill {
flex: 1;
min-height: 0;
height: auto !important;
border: 1px solid var(--se-border);
border-radius: 10px;
overflow: hidden;
background: var(--se-center-bg);
}
.bps-page--tsd .tsd-result-card--chart .vtd-card-chart-canvas--fill > .tv-chart-wrap {
flex: 1;
min-height: 0;
width: 100%;
}
.bps-page--tsd .tsd-result-card--chart .vtd-card-chart-canvas--fill .chart-hover-toolbar {
display: none !important;
}
.tsd-card-high-badge {
+205 -19
View File
@@ -1020,33 +1020,33 @@
cursor: pointer;
}
.vtd-card[role='button']:focus {
outline: none;
}
.vtd-card[role='button']:focus-visible {
outline: 1px solid color-mix(in srgb, var(--se-gold) 55%, var(--se-border));
outline-offset: 0;
}
/* 선택 카드 — 기본 카드와 동일한 1px 테두리 (굵은 파란 outline 제거) */
.vtd-card--selected {
outline: 2px solid var(--accent, #3f7ef5);
outline-offset: 2px;
border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border));
outline: none;
box-shadow: none;
z-index: 1;
}
/* TradeAlertModal: 매수 #3f7ef5, 매도 #ef5350 */
.vtd-card--timing-buy {
border-color: #3f7ef5 !important;
box-shadow:
0 0 0 1px rgba(63, 126, 245, 0.45),
0 0 16px rgba(63, 126, 245, 0.15);
}
/* 매수·매도 타이밍 — 카드 테두리 강조 비활성 (신호등·패널에서만 표시) */
.vtd-card--timing-buy,
.vtd-card--timing-sell {
border-color: #ef5350 !important;
box-shadow:
0 0 0 1px rgba(239, 83, 80, 0.45),
0 0 16px rgba(239, 83, 80, 0.15);
}
.vtd-card--selected.vtd-card--timing-buy {
outline-color: #3f7ef5;
border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border));
box-shadow: none;
}
.vtd-card--selected.vtd-card--timing-buy,
.vtd-card--selected.vtd-card--timing-sell {
outline-color: #ef5350;
outline: none;
}
.vtd-card--live {
@@ -2402,3 +2402,189 @@
background: #787b86;
opacity: 0.5;
}
/* ── 우측 매매 패널: 매수·매도 카드 간격·타이포 ── */
.bps-page--vtd .ptd-split-panel--right .ptd-split-card-body {
padding: 8px 12px 10px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card > .top-panel {
gap: 0;
justify-content: flex-start;
padding: 6px 6px 0;
height: 100%;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 4px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-field,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance {
margin: 0;
flex-shrink: 0;
}
/* 기본: 공간 부족 시 하단 안내 텍스트 숨김 */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: none;
margin: 0;
padding: 0;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-label {
margin-bottom: 7px;
font-size: 12px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-input--full {
padding: 9px 11px;
font-size: 13px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-symbol-input {
padding: 9px 11px;
font-size: 13px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-order-kind {
gap: 6px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-kind-btn {
padding: 7px 5px;
font-size: 11px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-row--balance {
padding: 4px 0 2px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-row {
margin-top: 8px;
gap: 6px;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-pct-btn {
padding: 6px 4px;
font-size: 11px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-step {
width: 32px;
font-size: 15px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-balance {
font-size: 13px;
}
/* paperDashboard 2열 그리드보다 우선 — 항상 세로 스택 */
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 0 0 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 12px;
margin: 0;
overflow: visible;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-field,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block .top-pct-row--block {
margin: 0;
flex-shrink: 0;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-actions {
flex-shrink: 0;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
gap: 10px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-reset,
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-submit {
padding: 10px 14px;
font-size: 14px;
}
/* 카드 높이 여유 있을 때: 간격 확대 + 하단 안내 텍스트 표시 */
@container trade-card (min-height: 440px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: space-between;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 1 1 auto;
justify-content: space-evenly;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: flex;
font-size: 11px;
padding-top: 2px;
}
}
@container trade-card (min-height: 520px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
gap: 18px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
gap: 16px;
}
}
/* container query 미지원 fallback */
@supports not (container-type: size) {
@media (max-height: 820px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: none;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: flex-start;
gap: 12px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 0 0 auto;
justify-content: flex-start;
}
}
@media (min-height: 821px) {
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-meta {
display: flex;
font-size: 11px;
padding-top: 2px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-panel-fields {
justify-content: space-between;
gap: 14px;
}
.bps-page--vtd .ptd-split-panel--right .ptd-order-card .top-price-qty-block {
flex: 1 1 auto;
justify-content: space-evenly;
}
}
}
+71
View File
@@ -467,6 +467,7 @@ export class ChartManager {
// ─── Theme ──────────────────────────────────────────────────────────────
setTheme(theme: Theme): void {
this.currentTheme = theme;
const t = getTheme(theme);
this.chart.applyOptions({
layout: { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
@@ -475,6 +476,59 @@ export class ChartManager {
timeScale: { borderColor: t.borderColor },
rightPriceScale: { borderColor: t.borderColor },
});
this._applyMainSeriesTheme(t);
this._applyVolumeSeriesTheme(t);
}
private _applyMainSeriesTheme(t: ThemeTokens): void {
if (!this.mainSeries) return;
switch (this.currentChartType) {
case 'candlestick':
this.mainSeries.applyOptions({
upColor: t.upColor,
downColor: t.downColor,
borderUpColor: t.upColor,
borderDownColor: t.downColor,
wickUpColor: t.upColor,
wickDownColor: t.downColor,
});
break;
case 'bar':
this.mainSeries.applyOptions({ upColor: t.upColor, downColor: t.downColor });
break;
default:
break;
}
}
private _applyVolumeSeriesTheme(t: ThemeTokens): void {
if (!this.volumeSeries || this.rawBars.length === 0) return;
this.volumeSeries.setData(this.rawBars.map(b => ({
time: b.time as Time,
value: b.volume,
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
})));
}
private _discardIndicatorSeries(
seriesList: ISeriesApi<SeriesType>[],
options?: { cloudPlugin?: IchimokuCloudPlugin; fillPrimitive?: IndicatorFillPrimitive },
): void {
if (options?.cloudPlugin) {
for (const s of seriesList) {
try { s.detachPrimitive(options.cloudPlugin); } catch { /* ok */ }
}
}
if (options?.fillPrimitive && seriesList[0]) {
try { seriesList[0].detachPrimitive(options.fillPrimitive); } catch { /* ok */ }
}
for (const s of seriesList) {
try { this.chart.removeSeries(s); } catch { /* ok */ }
}
}
private _indicatorLoadStale(loadGen: number): boolean {
return loadGen !== this._dataGeneration;
}
// ─── Indicators ─────────────────────────────────────────────────────────
@@ -505,6 +559,10 @@ export class ChartManager {
// ─── 일목균형표 특별 처리 ────────────────────────────────────────────
if (config.type === 'IchimokuCloud') {
const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
this._discardIndicatorSeries(seriesList, { cloudPlugin });
return;
}
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
this.applyIndicatorStyle(config);
return;
@@ -593,6 +651,10 @@ export class ChartManager {
id: config.id, type: config.type, seriesList, seriesMeta,
paneIndex: pane, alertLines: [], hlineRefs, fillPrimitive, config,
};
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
this._discardIndicatorSeries(seriesList, { fillPrimitive });
return;
}
this.indicators.set(config.id, entry);
this.applyIndicatorStyle(config);
if (config.type === 'BollingerBands' && !indicatorHidden) {
@@ -613,6 +675,10 @@ export class ChartManager {
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
*/
async reloadIndicatorsOnly(inds: import('../types').IndicatorConfig[]): Promise<void> {
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
const loadGen = this._dataGeneration;
// ① 기존 보조지표 시리즈 모두 제거 (메인·볼륨 제외)
for (const entry of this.indicators.values()) {
if (entry.cloudPlugin) {
@@ -632,6 +698,7 @@ export class ChartManager {
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순)
this._indRunning = false;
for (const ind of sortIndicatorsForPaneLoad(inds)) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(ind);
}
@@ -1293,6 +1360,9 @@ export class ChartManager {
* 심볼/타임프레임 변경 시 사용. 실시간 수신 시에는 appendBar()를 사용할 것.
*/
async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
this.cancelPendingIndicatorUpdates();
this._dataGeneration += 1;
const loadGen = this._dataGeneration;
this.rawBars = newBars;
const configs = Array.from(this.indicators.values()).map(e => e.config);
@@ -1307,6 +1377,7 @@ export class ChartManager {
this._removeExtraSubPanes();
for (const cfg of configs) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(cfg);
}
}