전략평가 화면 수정

This commit is contained in:
Macbook
2026-06-12 18:13:25 +09:00
parent b560b9de5c
commit a3f0584e72
13 changed files with 742 additions and 106 deletions
@@ -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<Props> = ({
@@ -306,21 +306,53 @@ const StrategyEvaluationChart: React.FC<Props> = ({
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) => {
@@ -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 = () => (
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
const IconPending = () => (
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 9v4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<path
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
</svg>
);
const IconMatch = () => (
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
/** 전략평가 전용 — vtd-heat-* 와 분리된 고정 높이 지표 목록 */
const StrategyEvaluationConditionList: React.FC<Props> = ({ metrics, embedded = false }) => {
if (metrics.length === 0) {
return <p className="seval-cond-empty"> </p>;
}
return (
<div className={['seval-cond-list-wrap', embedded ? 'seval-cond-list-wrap--embedded' : ''].filter(Boolean).join(' ')}>
<ul className="seval-cond-list" aria-label="전략 지표 매치율">
{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 (
<li key={row.id} className={`seval-cond-row seval-cond-row--${tier}`}>
<div
className="seval-cond-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={`${label} 매치율 ${pct}%`}
>
<span className="seval-cond-label" title={label}>
{label}
</span>
<span
className="seval-cond-fill"
style={{ width: `${Math.max(pct, minFill)}%` }}
/>
<span className="seval-cond-pct">{pct}%</span>
</div>
<span className="seval-cond-icon" aria-hidden>
{tier === 'match' ? <IconMatch /> : tier === 'pending' ? <IconPending /> : <IconMismatch />}
</span>
</li>
);
})}
</ul>
</div>
);
};
export default StrategyEvaluationConditionList;
@@ -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<Props> = ({ 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 (
<div className="seval-match-visual">
<div className="seval-match-visual-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="seval-match-visual-row" ref={visualRef}>
<div className="seval-match-pane seval-match-pane-eq">
<VirtualSignalEqualizer matchRate={matchRate} />
</div>
<div className="seval-match-pane seval-match-pane-list">
<StrategyEvaluationConditionList metrics={metrics} embedded />
</div>
<div className="seval-match-pane seval-match-pane-light">
<div ref={lightRef} className="seval-match-light-rail">
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
</div>
</div>
</div>
);
};
export default StrategyEvaluationMatchVisual;
@@ -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<SideCardProps> = ({ side, metrics, overallMet, signalAc
</div>
</header>
<div className="seval-side-card-body">
<VirtualSignalMatchVisual
metrics={metrics}
title="SIGNAL INTELLIGENCE & MATCH RATES"
overallMet={overallMet}
panelClassName="seval-side-sig-panel"
/>
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
</div>
</section>
);
@@ -92,18 +87,18 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
return (
<div className={['seval-signal-split', className].filter(Boolean).join(' ')}>
<SideCard
side="buy"
metrics={buyMetrics}
overallMet={snapshot?.overallEntryMet}
signalActive={barSignalHighlight?.buy ?? false}
/>
<SideCard
side="sell"
metrics={sellMetrics}
overallMet={snapshot?.overallExitMet}
signalActive={barSignalHighlight?.sell ?? false}
/>
<SideCard
side="buy"
metrics={buyMetrics}
overallMet={snapshot?.overallEntryMet}
signalActive={barSignalHighlight?.buy ?? false}
/>
</div>
);
};