diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index fa186dd..a73c46e 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -1,10 +1,10 @@ /** * 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석 */ -import '../styles/strategyEvaluation.css'; import '../styles/backtestDashboard.css'; import '../styles/virtualTradingDashboard.css'; import '../styles/strategyEditorTheme.css'; +import '../styles/strategyEvaluation.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme, Timeframe } from '../types'; import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots'; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx index 567941c..86f4514 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationBarSelector.tsx @@ -69,18 +69,16 @@ function indexFromClientX( return findNearestBarIndex(bars, time); } -/** 캔들 pane 클릭 좌표 → 봉 index (전략선택봉 이동) */ +/** 차트 클릭 좌표 → 봉 index (전략선택봉 이동, 전략선택봉 영역 제외는 호출측) */ export function resolveBarIndexAtChartClick( manager: ChartManager, bars: OHLCVBar[], clientX: number, clientY: number, ): number { - const rect = manager.getContainer().getBoundingClientRect(); - const clickY = clientY - rect.top; - const main = manager.getPaneLayouts().find(l => l.paneIndex === 0); - if (!main || clickY < main.topY || clickY >= main.topY + main.height) return -1; - return indexFromClientX(manager, bars, clientX); + const idx = manager.resolveNearestBarIndexAtClientPoint(clientX, clientY); + if (idx < 0 || idx >= bars.length) return -1; + return idx; } const StrategyEvaluationBarSelector: React.FC = ({ diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index d781817..c6d273a 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -306,21 +306,53 @@ const StrategyEvaluationChart: React.FC = ({ const mgr = chartMgr; if (!mgr || bars.length === 0) return; - const onChartClick = (e: MouseEvent) => { - if (e.button !== 0) return; - const target = e.target as HTMLElement | null; - if (target?.closest('[data-no-chart-pan]')) return; + const SELECT_DRAG_PX = 8; + let pointerStart: { x: number; y: number; onSelector: boolean } | null = null; - const idx = resolveBarIndexAtChartClick(mgr, bars, e.clientX, e.clientY); - if (idx >= 0 && idx !== selectedBarIndex) { - onSelectedBarIndexChange(idx); - } + const isSelectorTarget = (target: EventTarget | null) => + target instanceof Element && Boolean(target.closest('[data-no-chart-pan]')); + + const applyBarSelect = (clientX: number, clientY: number) => { + const idx = resolveBarIndexAtChartClick(mgr, bars, clientX, clientY); + if (idx >= 0) onSelectedBarIndexChange(idx); }; + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 0) return; + pointerStart = { + x: e.clientX, + y: e.clientY, + onSelector: isSelectorTarget(e.target), + }; + }; + + const onPointerUp = (e: PointerEvent) => { + if (e.button !== 0 || !pointerStart) return; + const start = pointerStart; + pointerStart = null; + if (start.onSelector || isSelectorTarget(e.target)) return; + + const moved = Math.hypot(e.clientX - start.x, e.clientY - start.y); + if (moved > SELECT_DRAG_PX) return; + + applyBarSelect(e.clientX, e.clientY); + }; + + const unsubLwc = mgr.subscribeBarSelectClick(idx => { + if (idx >= 0 && idx < bars.length) onSelectedBarIndexChange(idx); + }); + const container = mgr.getContainer(); - container.addEventListener('click', onChartClick); - return () => container.removeEventListener('click', onChartClick); - }, [chartMgr, bars, selectedBarIndex, onSelectedBarIndexChange]); + container.addEventListener('pointerdown', onPointerDown, true); + container.addEventListener('pointerup', onPointerUp, true); + container.addEventListener('pointercancel', onPointerUp, true); + return () => { + unsubLwc(); + container.removeEventListener('pointerdown', onPointerDown, true); + container.removeEventListener('pointerup', onPointerUp, true); + container.removeEventListener('pointercancel', onPointerUp, true); + }; + }, [chartMgr, bars, onSelectedBarIndexChange]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionList.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionList.tsx new file mode 100644 index 0000000..74ee7ec --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionList.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import type { ConditionMetric } from '../../utils/virtualSignalMetrics'; +import { resolveHeatTier } from '../../utils/virtualSignalMetrics'; +import { coerceFiniteNumber } from '../../utils/safeFormat'; +import { formatVirtualConditionListLabel } from '../../utils/virtualStrategyConditions'; + +interface Props { + metrics: ConditionMetric[]; + /** 3열 레이아웃 가운데 pane — 외곽 박스 생략 */ + embedded?: boolean; +} + +const IconMismatch = () => ( + + + + +); + +const IconPending = () => ( + + + + + +); + +const IconMatch = () => ( + + + +); + +/** 전략평가 전용 — vtd-heat-* 와 분리된 고정 높이 지표 목록 */ +const StrategyEvaluationConditionList: React.FC = ({ metrics, embedded = false }) => { + if (metrics.length === 0) { + return

조건 없음

; + } + + return ( +
+
    + {metrics.map(({ row, status, matchRate }) => { + const tier = resolveHeatTier(matchRate, status); + const pct = Math.min(100, Math.max(0, coerceFiniteNumber(matchRate) ?? 0)); + const minFill = pct > 0 && tier === 'nomatch' ? 6 : pct > 0 ? 6 : 0; + const label = formatVirtualConditionListLabel(row); + return ( +
  • +
    + + {label} + + + {pct}% +
    + + {tier === 'match' ? : tier === 'pending' ? : } + +
  • + ); + })} +
+
+ ); +}; + +export default StrategyEvaluationConditionList; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationMatchVisual.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationMatchVisual.tsx new file mode 100644 index 0000000..7261453 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationMatchVisual.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from 'react'; +import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight'; +import { + computeMatchRate, + getTrafficLightState, + resolveSideHeadlineMatchRate, + resolveSideTrafficState, + type ConditionMetric, +} from '../../utils/virtualSignalMetrics'; +import VirtualSignalEqualizer from '../virtual/VirtualSignalEqualizer'; +import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight'; +import StrategyEvaluationConditionList from './StrategyEvaluationConditionList'; + +interface Props { + metrics: ConditionMetric[]; + overallMet?: boolean | null; +} + +/** 전략평가 — 이퀄라이저 | 지표 목록 | 신호등 (3열, 가상매매와 동일) */ +const StrategyEvaluationMatchVisual: React.FC = ({ metrics, overallMet }) => { + const matchRate = useMemo( + () => (overallMet != null + ? resolveSideHeadlineMatchRate(metrics, overallMet) + : computeMatchRate(metrics)), + [metrics, overallMet], + ); + const trafficState = useMemo( + () => (overallMet != null + ? resolveSideTrafficState(metrics, overallMet) + : getTrafficLightState(matchRate, metrics)), + [matchRate, metrics, overallMet], + ); + const { visualRef, lightRef } = useSignalVisualRailHeight( + [metrics.length, matchRate, trafficState], + '--seval-gauge-rail-height', + ); + + return ( +
+
SIGNAL INTELLIGENCE & MATCH RATES
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ ); +}; + +export default StrategyEvaluationMatchVisual; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index b61b0f3..8b0230a 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -4,7 +4,7 @@ import { buildConditionMetrics, type ConditionMetric, } from '../../utils/virtualSignalMetrics'; -import VirtualSignalMatchVisual from '../virtual/VirtualSignalMatchVisual'; +import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual'; import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals'; interface Props { @@ -52,12 +52,7 @@ const SideCard: React.FC = ({ side, metrics, overallMet, signalAc
- +
); @@ -92,18 +87,18 @@ const StrategyEvaluationSignalPanel: React.FC = ({ return (
- +
); }; diff --git a/frontend/src/components/virtual/VirtualConditionList.tsx b/frontend/src/components/virtual/VirtualConditionList.tsx index f48a398..183f7a0 100644 --- a/frontend/src/components/virtual/VirtualConditionList.tsx +++ b/frontend/src/components/virtual/VirtualConditionList.tsx @@ -5,6 +5,9 @@ import { coerceFiniteNumber } from '../../utils/safeFormat'; interface Props { metrics: ConditionMetric[]; + /** 전략평가 등 — 고정 높이 컴팩트 목록 */ + listClassName?: string; + scrollWrap?: boolean; } const IconMismatch = () => ( @@ -33,13 +36,16 @@ const IconMatch = () => ( ); -const VirtualConditionList: React.FC = ({ metrics }) => { +const VirtualConditionList: React.FC = ({ metrics, listClassName = '', scrollWrap = false }) => { if (metrics.length === 0) { return

조건 없음

; } - return ( -
    + const list = ( +
      {metrics.map(({ row, status, matchRate }) => { const tier = resolveHeatTier(matchRate, status); const pct = Math.min(100, Math.max(0, coerceFiniteNumber(matchRate) ?? 0)); @@ -68,6 +74,12 @@ const VirtualConditionList: React.FC = ({ metrics }) => { })}
    ); + + if (scrollWrap) { + return
    {list}
    ; + } + + return list; }; export default VirtualConditionList; diff --git a/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx index 3cf6a57..fae1345 100644 --- a/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx +++ b/frontend/src/components/virtual/VirtualSignalMatchVisual.tsx @@ -19,6 +19,8 @@ interface Props { backendMatchRate?: number | null; /** DSL 전체 Rule 충족 — 헤드라인 일치율·신호등 우선 */ overallMet?: boolean | null; + /** 전략평가 — 지표 목록 고정 높이·스크롤 */ + compactIndicatorList?: boolean; className?: string; panelClassName?: string; } @@ -28,6 +30,7 @@ const VirtualSignalMatchVisual: React.FC = ({ title, backendMatchRate, overallMet, + compactIndicatorList = false, className = '', panelClassName = '', }) => { @@ -60,7 +63,11 @@ const VirtualSignalMatchVisual: React.FC = ({
    - +
    diff --git a/frontend/src/hooks/useSignalVisualRailHeight.ts b/frontend/src/hooks/useSignalVisualRailHeight.ts index 835ddc7..265e8d0 100644 --- a/frontend/src/hooks/useSignalVisualRailHeight.ts +++ b/frontend/src/hooks/useSignalVisualRailHeight.ts @@ -2,9 +2,12 @@ import { useLayoutEffect, useRef } from 'react'; /** * 신호등+하단 %/문구(.vtd-sig-light-rail) 높이를 측정해 - * 이퀄라이저 pane 눈금·막대 높이(--vtd-sig-rail-height)와 동기화 + * 이퀄라이저 pane 눈금·막대 높이 CSS 변수와 동기화 */ -export function useSignalVisualRailHeight(deps: unknown[] = []) { +export function useSignalVisualRailHeight( + deps: unknown[] = [], + railHeightVar = '--vtd-sig-rail-height', +) { const visualRef = useRef(null); const lightRef = useRef(null); @@ -16,7 +19,7 @@ export function useSignalVisualRailHeight(deps: unknown[] = []) { const sync = () => { const h = Math.ceil(light.getBoundingClientRect().height); if (h > 0) { - visual.style.setProperty('--vtd-sig-rail-height', `${h}px`); + visual.style.setProperty(railHeightVar, `${h}px`); } }; diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 8d2c1b9..48115ce 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -441,11 +441,15 @@ } .seval-side-card--buy { - border-top-color: color-mix(in srgb, #3fb950 55%, var(--se-border)); + border-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 42%, var(--se-border, var(--btd-divider))); + border-top: 2px solid var(--gc-trade-buy, #ef5350); + background: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 7%, var(--vtd-sig-panel-bg, #0d111f)); } .seval-side-card--sell { - border-top-color: color-mix(in srgb, #f85149 55%, var(--se-border)); + border-color: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 42%, var(--se-border, var(--btd-divider))); + border-top: 2px solid var(--gc-trade-sell, #4dabf7); + background: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 7%, var(--vtd-sig-panel-bg, #0d111f)); } .seval-side-card-head { @@ -458,6 +462,14 @@ border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent); } +.seval-side-card--buy .seval-side-card-head { + border-bottom-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 28%, var(--se-border)); +} + +.seval-side-card--sell .seval-side-card-head { + border-bottom-color: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 28%, var(--se-border)); +} + .seval-side-card-head-main { display: flex; flex-direction: column; @@ -472,11 +484,11 @@ } .seval-side-card-badge--buy { - color: #3fb950; + color: var(--gc-trade-buy, #ef5350); } .seval-side-card-badge--sell { - color: #f85149; + color: var(--gc-trade-sell, #4dabf7); } .seval-side-overall { @@ -489,10 +501,16 @@ line-height: 1.3; } -.seval-side-overall--met { - color: #3fb950; - background: color-mix(in srgb, #3fb950 14%, transparent); - border: 1px solid color-mix(in srgb, #3fb950 35%, transparent); +.seval-side-card--buy .seval-side-overall--met { + color: var(--gc-trade-buy, #ef5350); + background: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--gc-trade-buy, #ef5350) 35%, transparent); +} + +.seval-side-card--sell .seval-side-overall--met { + color: var(--gc-trade-sell, #4dabf7); + background: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 35%, transparent); } .seval-side-overall--unmet { @@ -510,50 +528,406 @@ flex-direction: column; } -.seval-side-card-body .seval-side-sig-panel { +/* ── 전략평가 전용 매치 비주얼 (vtd-heat-* 미사용) ─────────────────────────── */ +.seval-match-visual { flex: 1; min-height: 0; - gap: 4px; + display: flex; + flex-direction: column; + gap: 6px; + justify-content: flex-start; } -.seval-side-card-body .seval-side-sig-panel .vtd-sig-panel-title { +.seval-match-visual-title { + flex-shrink: 0; font-size: 8px; - padding: 2px 0 0; + font-weight: 800; letter-spacing: 0.06em; + color: var(--vtd-sig-gold-text, var(--se-gold, #e6c200)); + text-align: center; + padding: 2px 0 0; } -.seval-side-card-body .vtd-sig-visual { - --vtd-sig-rail-height: 120px; - flex: 1; - min-height: 0; - padding: 6px 8px 8px; - gap: 8px 10px; +/* 이퀄라이저 | 지표 목록 | 신호등 — 3열 (가상매매 vtd-sig-visual 동일) */ +.seval-match-visual-row { + --seval-gauge-rail-height: 108px; + flex-shrink: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: stretch; + gap: 8px; + padding: 6px 8px; + border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 32%, var(--se-border)); + border-radius: 8px; + background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 72%, transparent); + overflow: hidden; + box-sizing: border-box; } -.seval-side-card-body .vtd-sig-visual-heat { +.seval-match-pane { + display: flex; + flex-direction: column; + min-width: 0; min-height: 0; + padding: 4px 5px; + border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 28%, var(--se-border)); + border-radius: 6px; + background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 55%, transparent); + box-sizing: border-box; +} + +.seval-match-pane-eq { + flex-shrink: 0; + justify-content: stretch; + height: var(--seval-gauge-rail-height); + min-height: var(--seval-gauge-rail-height); + max-height: var(--seval-gauge-rail-height); overflow: hidden; } -.seval-side-card-body .vtd-sig-visual-heat .vtd-heat-list { - max-height: min(140px, 100%); - overflow-y: auto; +.seval-match-pane-list { + justify-content: flex-start; + height: var(--seval-gauge-rail-height); + min-height: var(--seval-gauge-rail-height); + max-height: var(--seval-gauge-rail-height); + overflow: hidden; } -.seval-side-card-body .vtd-sig-visual-eq { - height: var(--vtd-sig-rail-height); - min-height: var(--vtd-sig-rail-height); - max-height: var(--vtd-sig-rail-height); +.seval-match-pane-light { + flex-shrink: 0; + align-items: center; + justify-content: flex-start; + height: var(--seval-gauge-rail-height); + min-height: var(--seval-gauge-rail-height); + overflow: hidden; } -.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-rate { +.seval-match-pane-eq .vtd-sig-eq, +.seval-match-visual-eq .vtd-sig-eq { + display: flex; + gap: 8px; + align-items: stretch; + flex: 1; + min-height: 0; + height: 100%; + width: auto; +} + +.seval-match-pane-eq .vtd-sig-eq-scale, +.seval-match-visual-eq .vtd-sig-eq-scale { + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 0 0 auto; + height: 100%; + font-size: 8px; + color: var(--se-text-muted); + padding: 2px 0; + line-height: 1; +} + +.seval-match-pane-eq .vtd-sig-eq-tick, +.seval-match-visual-eq .vtd-sig-eq-tick { + flex: 0 0 auto; + white-space: nowrap; +} + +.seval-match-pane-eq .vtd-sig-eq-bar-wrap, +.seval-match-visual-eq .vtd-sig-eq-bar-wrap { + position: relative; + flex: 0 0 auto; + width: 44px; + min-width: 44px; + max-width: 44px; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.seval-match-pane-eq .vtd-sig-eq-bar, +.seval-match-visual-eq .vtd-sig-eq-bar { + display: flex; + flex-direction: column-reverse; + gap: 2px; + flex: 1; + min-height: 0; + height: 100%; + padding: 4px; + border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border)); + border-radius: 6px; + background: var(--vtd-sig-eq-bg); + box-sizing: border-box; +} + +.seval-match-pane-eq .vtd-sig-eq-seg, +.seval-match-visual-eq .vtd-sig-eq-seg { + flex: 1; + min-height: 2px; + border-radius: 2px; + background: color-mix(in srgb, var(--se-text-muted) 15%, transparent); + transition: background 0.25s ease, box-shadow 0.25s ease; +} + +.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--red, +.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--red { + background: linear-gradient(90deg, #c62828, #ff8a80); + box-shadow: 0 0 6px color-mix(in srgb, #ef5350 50%, transparent); +} + +.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue, +.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue { + background: linear-gradient(90deg, #1e6fd9, #4fc3f7); + box-shadow: 0 0 6px color-mix(in srgb, #4fc3f7 50%, transparent); +} + +.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--gold, +.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--gold { + background: linear-gradient(90deg, #b8860b, #ffd54f); + box-shadow: 0 0 6px color-mix(in srgb, #ffd54f 45%, transparent); +} + +.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--peak, +.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--peak { + background: linear-gradient(90deg, #1565c0, #82b1ff); + box-shadow: 0 0 10px color-mix(in srgb, #82b1ff 70%, transparent); +} + +.seval-match-pane-eq .vtd-sig-eq-badge, +.seval-match-visual-eq .vtd-sig-eq-badge { + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.04em; + color: #82b1ff; + white-space: nowrap; + text-align: center; + line-height: 1.2; + text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent); + pointer-events: none; +} + +.seval-match-pane-light, +.seval-match-visual-light { + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: flex-start; + min-width: 0; +} + +.seval-match-light-rail { + display: flex; + flex-direction: column; + align-items: center; +} + +.seval-match-pane-light .vtd-sig-light--card-right, +.seval-match-visual-light .vtd-sig-light--card-right { + flex: 0 0 auto; + width: auto; + height: auto; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 0; +} + +.seval-match-pane-light .vtd-sig-light-rate, +.seval-match-visual-light .vtd-sig-light-rate { font-size: 1.15rem; + font-weight: 800; + font-variant-numeric: tabular-nums; } -.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-label { +.seval-match-pane-light .vtd-sig-light-label, +.seval-match-visual-light .vtd-sig-light-label { font-size: 0.58rem; max-width: 76px; line-height: 1.2; + text-align: center; +} + +/* 지표 목록 — 행당 22px, 세로 나열 · 3열 pane 안에서는 스크롤 */ +.seval-cond-list-wrap { + flex: 1 1 auto; + align-self: stretch; + width: 100%; + min-height: 0; + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 2px 3px; + border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 32%, var(--se-border)); + border-radius: 6px; + background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 72%, transparent); + -webkit-overflow-scrolling: touch; + box-sizing: border-box; +} + +.seval-cond-list-wrap--embedded { + flex: 1; + border: none; + border-radius: 0; + background: transparent; + padding: 0 1px; +} + +.seval-cond-list-wrap::-webkit-scrollbar { + width: 4px; +} + +.seval-cond-list-wrap::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--se-gold, #e6c200) 35%, var(--se-border)); + border-radius: 2px; +} + +.seval-cond-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.seval-cond-row { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 22px; + height: 22px; + min-height: 22px; + max-height: 22px; +} + +.seval-cond-row:last-child { + margin-bottom: 0; +} + +.seval-cond-track { + position: relative; + flex: 1; + min-width: 0; + height: 22px; + border-radius: 5px; + border: 1px solid color-mix(in srgb, var(--se-border) 70%, var(--se-text-muted)); + background: var(--vtd-heat-track-bg, color-mix(in srgb, #060a14 85%, #000)); + overflow: hidden; +} + +.seval-cond-label { + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + z-index: 2; + max-width: calc(100% - 38px); + font-size: 8px; + font-weight: 600; + line-height: 1; + color: var(--vtd-heat-label-color, #e6edf3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.seval-cond-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + min-width: 0; + border-radius: 4px 0 0 4px; + z-index: 0; +} + +.seval-cond-pct { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + z-index: 3; + font-size: 8px; + font-weight: 800; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.seval-cond-icon { + flex: 0 0 14px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.seval-cond-icon-svg { + width: 10px; + height: 10px; + display: block; +} + +.seval-cond-row--nomatch .seval-cond-track { + border-color: color-mix(in srgb, #ef5350 40%, var(--se-border)); +} + +.seval-cond-row--nomatch .seval-cond-fill { + background: linear-gradient(90deg, #b71c1c 0%, #ef5350 55%, #ff8a80 100%); +} + +.seval-cond-row--nomatch .seval-cond-pct { + color: #ef5350; +} + +.seval-cond-row--nomatch .seval-cond-icon { + color: #ef5350; +} + +.seval-cond-row--pending .seval-cond-track { + border-color: color-mix(in srgb, #ffb300 45%, var(--se-border)); +} + +.seval-cond-row--pending .seval-cond-fill { + background: linear-gradient(90deg, #e65100 0%, #ffb300 45%, #ffe082 100%); +} + +.seval-cond-row--pending .seval-cond-pct { + color: #ffb300; +} + +.seval-cond-row--pending .seval-cond-icon { + color: #ffb300; +} + +.seval-cond-row--match .seval-cond-track { + border-color: color-mix(in srgb, #3f7ef5 45%, var(--se-border)); +} + +.seval-cond-row--match .seval-cond-fill { + background: linear-gradient(90deg, #1565c0 0%, #3f7ef5 45%, #82b1ff 100%); +} + +.seval-cond-row--match .seval-cond-pct { + color: #82b1ff; +} + +.seval-cond-row--match .seval-cond-icon { + color: #3f7ef5; +} + +.seval-cond-empty { + margin: 0; + padding: 10px 4px; + font-size: 0.68rem; + text-align: center; + color: var(--se-text-muted); } .seval-side-card-body .vtd-heat-list-empty { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index d0697bf..5fe03c3 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -4653,6 +4653,67 @@ export class ChartManager { return t === null ? null : (t as number); } + /** + * 전략 평가 — 클라이언트 좌표 → rawBars 내 가장 가까운 봉 index. + * 캔들·보조지표 pane 클릭 허용, 가격축·시간축·플롯 밖 제외. + */ + resolveNearestBarIndexAtClientPoint(clientX: number, clientY: number): number { + const rect = this.container.getBoundingClientRect(); + const chartX = clientX - rect.left; + const chartY = clientY - rect.top; + + if ( + chartX < 0 || chartY < 0 + || chartX > this.container.clientWidth + || chartY > this.container.clientHeight + ) { + return -1; + } + if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return -1; + + const layouts = this.getPaneLayouts(); + const inPane = layouts.some( + l => chartY >= l.topY && chartY < l.topY + l.height && l.height >= 4, + ); + if (!inPane || this.rawBars.length === 0) return -1; + + const bar = this._barAtChartX(chartX); + if (!bar) return -1; + + const idx = this.rawBars.findIndex(b => (b.time as number) === (bar.time as number)); + return idx >= 0 ? idx : -1; + } + + /** LWC 클릭 — 전략선택봉 이동 (캔들 pane 클릭) */ + subscribeBarSelectClick(onSelect: (barIndex: number) => void): () => void { + const handler = (param: MouseEventParams