분석레포트 타임라인 표시

This commit is contained in:
Macbook
2026-06-10 16:53:03 +09:00
parent 55b1c75226
commit bf5554d375
12 changed files with 2032 additions and 28 deletions
@@ -1,40 +1,101 @@
/**
* 분석레포트 — 중앙 패널 (종목·실행 분석 상세)
* 분석레포트 — 중앙 패널 (분석요약 · 거래이력 탭)
*/
import React from 'react';
import React, { useCallback, useState } from 'react';
import { BacktestDashboard } from '../BacktestResultModal';
import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal';
import LiveTradeTimelinePanel from './LiveTradeTimelinePanel';
import type { LiveTimelineFilter } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
export type AnalysisCenterTab = 'summary' | 'history';
const CENTER_TAB_STORAGE_KEY = 'arp-center-tab';
function loadSavedCenterTab(): AnalysisCenterTab {
try {
const v = localStorage.getItem(CENTER_TAB_STORAGE_KEY);
if (v === 'summary' || v === 'history') return v;
} catch { /* ok */ }
return 'summary';
}
interface Props {
model: BacktestAnalysisReportModel | null;
timelineFilter?: LiveTimelineFilter;
timelineFilterLabel?: string;
chartTimeframe?: Timeframe;
}
export function AnalysisReportCenterPanel({ model }: Props) {
if (!model) {
return (
<div className="arp-center-shell arp-report-theme">
<div className="arp-report-empty">
<span className="arp-report-empty-icon" aria-hidden>📊</span>
<p> .</p>
</div>
</div>
);
}
export function AnalysisReportCenterPanel({
model,
timelineFilter,
timelineFilterLabel,
chartTimeframe = '3m',
}: Props) {
const [centerTab, setCenterTab] = useState<AnalysisCenterTab>(loadSavedCenterTab);
const handleTabChange = useCallback((tab: AnalysisCenterTab) => {
setCenterTab(tab);
try { localStorage.setItem(CENTER_TAB_STORAGE_KEY, tab); } catch { /* ok */ }
}, []);
return (
<div className="arp-center-shell arp-report-theme">
<div className="arp-center-inner">
<BacktestDashboard
analysis={model.analysis}
signals={model.signals}
strategyName={model.strategyName}
symbol={model.symbol}
timeframe={model.timeframe}
barCount={model.barCount}
createdAt={model.createdAt}
reportMode
hideSignals
/>
<div className="arp-center-tabs" role="tablist" aria-label="분석 결과">
<button
type="button"
role="tab"
aria-selected={centerTab === 'summary'}
className={`arp-center-tab${centerTab === 'summary' ? ' arp-center-tab--on' : ''}`}
onClick={() => handleTabChange('summary')}
>
</button>
<button
type="button"
role="tab"
aria-selected={centerTab === 'history'}
className={`arp-center-tab${centerTab === 'history' ? ' arp-center-tab--on' : ''}`}
onClick={() => handleTabChange('history')}
>
</button>
</div>
<div className="arp-center-tab-body">
{centerTab === 'summary' && (
model ? (
<div className="arp-center-inner">
<BacktestDashboard
analysis={model.analysis}
signals={model.signals}
strategyName={model.strategyName}
symbol={model.symbol}
timeframe={model.timeframe}
barCount={model.barCount}
createdAt={model.createdAt}
reportMode
hideSignals
/>
</div>
) : (
<div className="arp-report-empty">
<span className="arp-report-empty-icon" aria-hidden>📊</span>
<p> .</p>
</div>
)
)}
{centerTab === 'history' && (
<LiveTradeTimelinePanel
active
embedded
filter={timelineFilter}
filterLabel={timelineFilterLabel}
chartTimeframe={chartTimeframe}
/>
)}
</div>
</div>
);
@@ -2,7 +2,7 @@
* 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Theme } from '../../types';
import type { Theme, Timeframe } from '../../types';
import {
loadBacktestResults,
loadPaperTrades,
@@ -28,6 +28,7 @@ import {
buildLiveReportModel,
} from '../../utils/backtestReportModel';
import { enrichStrategyNameMap, strategyNamesFromList } from '../../utils/strategyNameResolver';
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
import '../../styles/backtestDashboard.css';
import '../../styles/analysisReportPage.css';
@@ -142,6 +143,42 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
return null;
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
const timelineFilter = useMemo(() => {
if (sourceTab === 'live' && selectedLive) {
return {
symbol: selectedLive.symbol,
strategyId: selectedLive.strategyId,
};
}
if (sourceTab === 'backtest' && selectedBacktest) {
return {
symbol: selectedBacktest.symbol,
strategyId: selectedBacktest.strategyId ?? null,
};
}
return undefined;
}, [sourceTab, selectedLive, selectedBacktest]);
const timelineFilterLabel = useMemo(() => {
if (sourceTab === 'live' && selectedLive) {
return `${selectedLive.symbol} · ${selectedLive.strategyLabel}`;
}
if (sourceTab === 'backtest' && selectedBacktest) {
return `${selectedBacktest.symbol} · ${selectedBacktest.strategyName}`;
}
return undefined;
}, [sourceTab, selectedLive, selectedBacktest]);
const timelineChartTimeframe = useMemo((): Timeframe => {
if (sourceTab === 'live' && selectedLive?.timeframe) {
return candleTypeToTimeframe(selectedLive.timeframe);
}
if (sourceTab === 'backtest' && selectedBacktest?.timeframe) {
return candleTypeToTimeframe(selectedBacktest.timeframe);
}
return '3m';
}, [sourceTab, selectedLive, selectedBacktest]);
const headerActions = (
<>
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
@@ -193,7 +230,14 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
}}
/>
)}
center={<AnalysisReportCenterPanel model={reportModel} />}
center={(
<AnalysisReportCenterPanel
model={reportModel}
timelineFilter={timelineFilter}
timelineFilterLabel={timelineFilterLabel}
chartTimeframe={timelineChartTimeframe}
/>
)}
right={<AnalysisReportSignalsPanel model={reportModel} />}
/>
@@ -0,0 +1,184 @@
/**
* 분석레포트 — 실시간 매매 거래 이력 타임라인 (패널 / 팝업 공용)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
loadPaperPendingOrders,
loadPaperTrades,
loadTradeSignals,
} from '../../utils/backendApi';
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
import {
buildLiveTradeTimeline,
type LiveTimelineEvent,
type LiveTimelineFilter,
} from '../../utils/liveTradeTimeline';
import {
LIVE_TIMELINE_VIEW_OPTIONS,
TimelineListView,
TimelineSplitView,
type LiveTimelineViewMode,
} from './LiveTradeTimelineViews';
import TimelineAxisView from './TimelineAxisView';
import type { Timeframe } from '../../types';
export const ARP_TIMELINE_VIEW_STORAGE_KEY = 'arp-timeline-view-mode';
const REFRESH_MS = 4000;
export function loadSavedTimelineViewMode(): LiveTimelineViewMode {
try {
const v = localStorage.getItem(ARP_TIMELINE_VIEW_STORAGE_KEY);
if (v === 'chart') return 'axis';
if (v === 'list' || v === 'split' || v === 'axis') return v;
} catch { /* ok */ }
return 'axis';
}
interface Props {
active?: boolean;
filter?: LiveTimelineFilter;
filterLabel?: string;
chartTimeframe?: Timeframe;
embedded?: boolean;
}
const LiveTradeTimelinePanel: React.FC<Props> = ({
active = true,
filter,
filterLabel,
chartTimeframe = '3m',
embedded = false,
}) => {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<LiveTimelineEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<LiveTimelineViewMode>(loadSavedTimelineViewMode);
const fetchTimeline = useCallback(async () => {
if (!active) return;
setError(null);
try {
const [signals, orders, trades] = await Promise.all([
loadTradeSignals(filter?.symbol),
loadPaperPendingOrders(),
loadPaperTrades(),
]);
setEvents(buildLiveTradeTimeline(signals, orders, trades, filter));
} catch (e) {
setError(e instanceof Error ? e.message : '거래 이력을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [active, filter]);
useEffect(() => {
if (!active) return;
setLoading(true);
void fetchTimeline();
const onPaper = () => { void fetchTimeline(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
const timer = window.setInterval(() => { void fetchTimeline(); }, REFRESH_MS);
return () => {
window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
window.clearInterval(timer);
};
}, [active, fetchTimeline]);
const handleViewChange = useCallback((mode: LiveTimelineViewMode) => {
setViewMode(mode);
try { localStorage.setItem(ARP_TIMELINE_VIEW_STORAGE_KEY, mode); } catch { /* ok */ }
}, []);
const subtitle = useMemo(() => {
if (filterLabel) return filterLabel;
if (filter?.symbol) return filter.symbol;
return '전체 종목';
}, [filter, filterLabel]);
const viewHint = `${chartTimeframe} 봉 시간 기준 · 매수/매도 거래 이력`;
const shellClass = [
'arp-timeline-panel',
embedded ? 'arp-timeline-panel--embedded' : '',
viewMode === 'split' ? 'arp-timeline-panel--split' : '',
viewMode === 'axis' ? 'arp-timeline-panel--axis' : '',
].filter(Boolean).join(' ');
return (
<div className={shellClass}>
<div className="arp-timeline-panel-toolbar">
<div className="arp-timeline-head">
<span className="arp-timeline-scope">{subtitle}</span>
<span className="arp-timeline-hint">{viewHint}</span>
</div>
<div className="arp-timeline-header-tools">
<div className="arp-timeline-view-switch" role="tablist" aria-label="타임라인 유형">
{LIVE_TIMELINE_VIEW_OPTIONS.map(opt => (
<button
key={opt.id}
type="button"
role="tab"
aria-selected={viewMode === opt.id}
className={`arp-timeline-view-btn${viewMode === opt.id ? ' arp-timeline-view-btn--on' : ''}`}
onClick={() => handleViewChange(opt.id)}
>
{opt.label}
</button>
))}
</div>
<button
type="button"
className="arp-timeline-refresh"
title="새로고침"
aria-label="새로고침"
onClick={() => void fetchTimeline()}
>
</button>
{!embedded && events.length > 0 && (
<span className="arp-timeline-badge">{events.length}</span>
)}
</div>
</div>
{viewMode === 'split' && (
<div className="arp-timeline-split-legend" aria-hidden>
<span className="arp-timeline-split-legend-item arp-timeline-split-legend-item--buy"> </span>
<span className="arp-timeline-split-legend-item arp-timeline-split-legend-item--sell"> </span>
</div>
)}
{viewMode === 'axis' && (
<div className="arp-timeline-axis-legend" aria-hidden>
<span className="arp-timeline-axis-legend-item arp-timeline-axis-legend-item--sell"> </span>
<span className="arp-timeline-axis-legend-item arp-timeline-axis-legend-item--buy"> </span>
</div>
)}
<div className="arp-timeline-panel-body">
{loading && events.length === 0 && (
<p className="arp-timeline-empty"> </p>
)}
{error && (
<p className="arp-timeline-error" role="alert">{error}</p>
)}
{!loading && !error && events.length === 0 && (
<p className="arp-timeline-empty"> .</p>
)}
{events.length > 0 && viewMode === 'list' && (
<TimelineListView events={events} timeframe={chartTimeframe} />
)}
{events.length > 0 && viewMode === 'split' && (
<TimelineSplitView events={events} timeframe={chartTimeframe} />
)}
{events.length > 0 && viewMode === 'axis' && (
<TimelineAxisView events={events} timeframe={chartTimeframe} />
)}
</div>
</div>
);
};
export default LiveTradeTimelinePanel;
@@ -0,0 +1,48 @@
/**
* 분석레포트 — 실시간 매매 거래 이력 타임라인 팝업
*/
import React from 'react';
import { AppPopup } from '../AppPopup';
import LiveTradeTimelinePanel from './LiveTradeTimelinePanel';
import type { LiveTimelineFilter } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
interface Props {
open: boolean;
onClose: () => void;
filter?: LiveTimelineFilter;
filterLabel?: string;
chartTimeframe?: Timeframe;
}
const LiveTradeTimelinePopup: React.FC<Props> = ({
open,
onClose,
filter,
filterLabel,
chartTimeframe = '3m',
}) => {
if (!open) return null;
return (
<AppPopup
onClose={onClose}
title="Trade History"
titleKo="실시간 거래 이력"
accentColor="#c9a227"
width={1120}
maxWidth="96vw"
shellClassName="arp-timeline-popup"
bodyClassName="app-popup-body arp-timeline-popup__body"
>
<LiveTradeTimelinePanel
active={open}
filter={filter}
filterLabel={filterLabel}
chartTimeframe={chartTimeframe}
/>
</AppPopup>
);
};
export default LiveTradeTimelinePopup;
@@ -0,0 +1,175 @@
/**
* 분석레포트 — 거래 이력 타임라인 뷰 (목록 / 좌우 분할)
*/
import React from 'react';
import {
LIVE_TIMELINE_KIND_LABEL,
LIVE_TIMELINE_SIDE_LABEL,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
import type { Timeframe } from '../../types';
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
export const LIVE_TIMELINE_VIEW_OPTIONS: { id: LiveTimelineViewMode; label: string }[] = [
{ id: 'list', label: '세로 목록' },
{ id: 'split', label: '좌우 분할' },
{ id: 'axis', label: '봉 시간축' },
];
function kindClass(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return 'signal';
if (kind === 'ORDER_PENDING') return 'pending';
return 'fill';
}
function sideClass(side: LiveTimelineEvent['side']): 'buy' | 'sell' {
return side === 'BUY' ? 'buy' : 'sell';
}
interface EventBodyProps {
ev: LiveTimelineEvent;
compact?: boolean;
}
export const TimelineEventBody: React.FC<EventBodyProps> = ({ ev, compact = false }) => {
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
return (
<>
<div className="arp-timeline-card-tags">
<span className={`arp-timeline-kind arp-timeline-kind--${kCls}`}>
{LIVE_TIMELINE_KIND_LABEL[ev.kind]}
</span>
<span className={`arp-timeline-side arp-timeline-side--${sCls}`}>
{LIVE_TIMELINE_SIDE_LABEL[ev.side]}
</span>
</div>
<div className={`arp-timeline-card-title${compact ? ' arp-timeline-card-title--compact' : ''}`}>
{koreanName || ev.symbol}
<span className="arp-timeline-symbol">{ev.symbol.replace(/^KRW-/, '')}</span>
</div>
<div className="arp-timeline-meta">
{ev.price > 0 && <span>{formatSignalPrice(ev.price)}</span>}
{ev.quantity != null && ev.quantity > 0 && (
<span>{ev.quantity.toLocaleString(undefined, { maximumFractionDigits: 8 })}</span>
)}
{ev.strategyName && <span>{ev.strategyName}</span>}
{ev.sourceLabel && <span>{ev.sourceLabel}</span>}
{ev.detail && <span className="arp-timeline-detail">{ev.detail}</span>}
</div>
</>
);
};
interface ListViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe }) => (
<div className="btd-timeline arp-timeline-scroll arp-timeline-scroll--list" aria-label="거래 이력 타임라인">
<div className="btd-timeline-line" aria-hidden />
{events.map(ev => {
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
const barTimeSec = eventToBarTimeSec(ev.ts, timeframe);
return (
<article
key={ev.id}
className={[
'btd-timeline-item',
'arp-timeline-item',
'arp-timeline-item--bar-anchor',
`arp-timeline-item--${kCls}`,
`arp-timeline-item--${sCls}`,
].join(' ')}
>
<TimelineBarTimeBox
barTimeSec={barTimeSec}
timeframe={timeframe}
side={sCls}
variant="compact"
className="arp-timeline-bar-time--list-node"
/>
<div className="btd-timeline-card">
<TimelineEventBody ev={ev} />
</div>
</article>
);
})}
</div>
);
interface SplitViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe }) => (
<div className="arp-timeline-split-scroll" aria-label="좌우 분할 거래 이력 타임라인">
<div className="arp-timeline-split-axis" aria-hidden />
{events.map(ev => {
const isBuy = ev.side === 'BUY';
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
const barTimeSec = eventToBarTimeSec(ev.ts, timeframe);
const cardCls = [
'arp-timeline-split-card',
`arp-timeline-split-card--${sCls}`,
`arp-timeline-split-card--${kCls}`,
isBuy ? 'arp-timeline-split-card--left' : 'arp-timeline-split-card--right',
].join(' ');
const card = (
<article className={cardCls}>
<TimelineEventBody ev={ev} compact />
</article>
);
return (
<div
key={ev.id}
className={`arp-timeline-split-row arp-timeline-split-row--${sCls}`}
>
<div className="arp-timeline-split-col arp-timeline-split-col--left">
{isBuy ? (
<div className="arp-timeline-split-stack arp-timeline-split-stack--buy">
{card}
<div
className="arp-timeline-split-connector arp-timeline-split-connector--buy"
aria-hidden
/>
</div>
) : null}
</div>
<div className="arp-timeline-split-col arp-timeline-split-col--center">
<TimelineBarTimeBox
barTimeSec={barTimeSec}
timeframe={timeframe}
side={sCls}
variant="compact"
className="arp-timeline-bar-time--split-node"
/>
</div>
<div className="arp-timeline-split-col arp-timeline-split-col--right">
{!isBuy ? (
<div className="arp-timeline-split-stack arp-timeline-split-stack--sell">
<div
className="arp-timeline-split-connector arp-timeline-split-connector--sell"
aria-hidden
/>
{card}
</div>
) : null}
</div>
</div>
);
})}
</div>
);
@@ -0,0 +1,66 @@
/**
* 분석레포트 — 봉 시간축 / 캔들 연동 공통 거래 카드
*/
import React from 'react';
import {
LIVE_TIMELINE_KIND_LABEL,
LIVE_TIMELINE_SIDE_LABEL,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
function kindClass(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return 'signal';
if (kind === 'ORDER_PENDING') return 'pending';
return 'fill';
}
function kindIcon(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return '⚡';
if (kind === 'ORDER_PENDING') return '◷';
return '✓';
}
interface Props {
ev: LiveTimelineEvent;
side: 'top' | 'bottom';
}
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
const kCls = kindClass(ev.kind);
const isSell = ev.side === 'SELL';
return (
<article
className={[
'arp-timeline-axis-card',
`arp-timeline-axis-card--${side}`,
`arp-timeline-axis-card--${kCls}`,
isSell ? 'arp-timeline-axis-card--sell' : 'arp-timeline-axis-card--buy',
].join(' ')}
>
<div className="arp-timeline-axis-card-icon" aria-hidden>
{kindIcon(ev.kind)}
</div>
<div className="arp-timeline-axis-card-body">
<header className="arp-timeline-axis-card-head">
<strong>{LIVE_TIMELINE_KIND_LABEL[ev.kind]}</strong>
<span>{LIVE_TIMELINE_SIDE_LABEL[ev.side]}</span>
</header>
<p className="arp-timeline-axis-card-title">
{koreanName || ev.symbol.replace(/^KRW-/, '')}
</p>
<div className="arp-timeline-axis-card-meta">
{ev.price > 0 && <span>{formatSignalPrice(ev.price)}</span>}
{ev.quantity != null && ev.quantity > 0 && (
<span>{ev.quantity.toLocaleString(undefined, { maximumFractionDigits: 6 })}</span>
)}
</div>
</div>
</article>
);
};
export default TimelineAxisEventCard;
@@ -0,0 +1,114 @@
/**
* 분석레포트 — 가로 봉 시간축 타임라인 (중앙 축 · 상단 매도 · 하단 매수)
*/
import React, { useMemo, useRef } from 'react';
import {
groupEventsByBarTime,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import TimelineBarTimeBox from './TimelineBarTimeBox';
import TimelineAxisEventCard from './TimelineAxisEventCard';
import { useHorizontalWheelScroll } from '../../hooks/useHorizontalWheelScroll';
import { useCenterAxisTimelineScroll } from '../../hooks/useCenterAxisTimelineScroll';
import type { Timeframe } from '../../types';
const SEGMENT_COLORS = ['c0', 'c1', 'c2', 'c3', 'c4'] as const;
interface Props {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
useHorizontalWheelScroll(scrollRef);
const slots = useMemo(
() => groupEventsByBarTime(events, timeframe),
[events, timeframe],
);
useCenterAxisTimelineScroll(scrollRef, trackRef, slots.length > 0, [slots.length, events.length]);
if (slots.length === 0) {
return <p className="arp-timeline-empty"> .</p>;
}
const trackMinWidth = Math.max(slots.length * 160, 320);
return (
<div
ref={scrollRef}
className="arp-timeline-axis-scroll arp-timeline-h-scroll"
aria-label="봉 시간축 거래 이력 타임라인"
>
<div className="arp-timeline-axis-scroll-inner">
<div
ref={trackRef}
className="arp-timeline-axis-track"
style={{ minWidth: trackMinWidth }}
>
<div className="arp-timeline-axis-zone arp-timeline-axis-zone--top">
<span className="arp-timeline-axis-zone-label"></span>
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`top-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.sells.map(ev => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<TimelineAxisEventCard ev={ev} side="top" />
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--down arp-timeline-axis-connector--sell"
aria-hidden
/>
</div>
))}
</div>
))}
</div>
</div>
<div className="arp-timeline-axis-rail" aria-hidden>
{slots.map((slot, i) => (
<div
key={slot.barTimeSec}
className={[
'arp-timeline-axis-segment',
`arp-timeline-axis-segment--${SEGMENT_COLORS[i % SEGMENT_COLORS.length]}`,
].join(' ')}
>
<TimelineBarTimeBox
barTimeSec={slot.barTimeSec}
timeframe={timeframe}
variant="segment"
showTimeframe={false}
/>
</div>
))}
</div>
<div className="arp-timeline-axis-zone arp-timeline-axis-zone--bottom">
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`bottom-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.buys.map(ev => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--up arp-timeline-axis-connector--buy"
aria-hidden
/>
<TimelineAxisEventCard ev={ev} side="bottom" />
</div>
))}
</div>
))}
</div>
<span className="arp-timeline-axis-zone-label"></span>
</div>
</div>
</div>
</div>
);
};
export default TimelineAxisView;
@@ -0,0 +1,49 @@
/**
* 타임라인 공통 — 봉 시작 시각 카드
*/
import React from 'react';
import { formatBarAxisLabel, snapMsToBarTimeSec } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
export type TimelineBarTimeVariant = 'default' | 'compact' | 'segment';
export function eventToBarTimeSec(tsMs: number, timeframe: string): number {
return snapMsToBarTimeSec(tsMs, timeframe);
}
interface Props {
barTimeSec: number;
timeframe: Timeframe | string;
side?: 'buy' | 'sell';
variant?: TimelineBarTimeVariant;
className?: string;
showTimeframe?: boolean;
}
const TimelineBarTimeBox: React.FC<Props> = ({
barTimeSec,
timeframe,
side,
variant = 'default',
className = '',
showTimeframe = variant !== 'segment',
}) => (
<div
className={[
'arp-timeline-bar-time',
variant !== 'default' ? `arp-timeline-bar-time--${variant}` : '',
side ? `arp-timeline-bar-time--${side}` : '',
className,
].filter(Boolean).join(' ')}
title={`${timeframe}`}
>
<span className="arp-timeline-bar-time-label">
{formatBarAxisLabel(barTimeSec, timeframe)}
</span>
{showTimeframe && (
<span className="arp-timeline-bar-time-tf">{timeframe}</span>
)}
</div>
);
export default TimelineBarTimeBox;