실시간 차트 스크롤시 날짜 동기화 문제

This commit is contained in:
Macbook
2026-05-29 21:30:34 +09:00
parent 6556741f83
commit ab5c11fa14
14 changed files with 263 additions and 73 deletions
+76 -31
View File
@@ -1,7 +1,10 @@
/**
* 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시.
*
* 드래그 패닝 중에는 라벨 좌표를 재계산하지 않고 translate3d 로 차트와 동일 픽셀만큼 이동한다.
* (setVisibleLogicalRange 직후 LWC 좌표 API가 한 프레임 늦게 갱신되는 문제 회피)
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import type { ChartManager } from '../utils/ChartManager';
import { subscribeChartTimeFormat } from '../utils/chartTimeFormat';
@@ -12,24 +15,81 @@ interface CandlePaneTimeAxisProps {
containerEl: HTMLElement;
}
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
const [state, setState] = useState<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>(null);
function applyOverlayLayout(
root: HTMLDivElement,
pool: HTMLSpanElement[],
state: NonNullable<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>,
): void {
root.style.display = 'flex';
root.style.top = `${state.topY}px`;
root.style.height = `${state.height ?? AXIS_HEIGHT}px`;
root.style.width = `${state.plotWidth}px`;
const sync = useCallback(() => {
if (!containerEl.isConnected) return;
setState(manager.getCandlePaneTimeAxisOverlay());
state.labels.forEach((label, i) => {
let span = pool[i];
if (!span) {
span = document.createElement('span');
span.className = 'candle-pane-time-axis__label';
root.appendChild(span);
pool[i] = span;
}
span.style.display = '';
span.style.left = `${label.x}px`;
span.textContent = label.text;
});
for (let i = state.labels.length; i < pool.length; i++) {
if (pool[i]) pool[i].style.display = 'none';
}
}
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
const axisRef = useRef<HTMLDivElement>(null);
const labelPoolRef = useRef<HTMLSpanElement[]>([]);
const renderOverlay = useCallback(() => {
const root = axisRef.current;
if (!root || !containerEl.isConnected) return;
const panActive = manager.isCandleAxisPanActive();
const panOffset = manager.getCandleAxisPanOffsetPx();
if (panActive) {
const state = manager.getCandlePaneTimeAxisOverlay();
if (!state || state.labels.length === 0) {
root.style.display = 'none';
return;
}
if (panOffset === 0) {
applyOverlayLayout(root, labelPoolRef.current, state);
}
root.style.transform = `translate3d(${panOffset}px, 0, 0)`;
return;
}
root.style.transform = '';
const state = manager.getCandlePaneTimeAxisOverlay();
if (!state || state.labels.length === 0) {
root.style.display = 'none';
for (const span of labelPoolRef.current) {
span.style.display = 'none';
}
return;
}
applyOverlayLayout(root, labelPoolRef.current, state);
}, [manager, containerEl]);
useEffect(() => {
sync();
const ro = new ResizeObserver(sync);
renderOverlay();
const ro = new ResizeObserver(renderOverlay);
ro.observe(containerEl);
const unsubLayout = manager.subscribePaneLayout(sync);
const unsubRange = manager.subscribeViewport(sync);
const unsubFormat = subscribeChartTimeFormat(sync);
const unsubLayout = manager.subscribePaneLayout(renderOverlay);
const unsubRange = manager.subscribeViewport(renderOverlay);
const unsubFormat = subscribeChartTimeFormat(renderOverlay);
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms));
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(renderOverlay, ms));
return () => {
ro.disconnect();
unsubLayout();
@@ -37,30 +97,15 @@ const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, contai
unsubFormat();
timers.forEach(clearTimeout);
};
}, [manager, containerEl, sync]);
if (!state || state.labels.length === 0) return null;
}, [manager, containerEl, renderOverlay]);
return (
<div
ref={axisRef}
className="candle-pane-time-axis"
style={{
top: state.topY,
height: state.height ?? AXIS_HEIGHT,
width: state.plotWidth,
}}
style={{ display: 'none' }}
aria-hidden
>
{state.labels.map((label, idx) => (
<span
key={`${label.x}-${idx}`}
className="candle-pane-time-axis__label"
style={{ left: label.x }}
>
{label.text}
</span>
))}
</div>
/>
);
};
+9 -5
View File
@@ -28,6 +28,8 @@ const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isU
export interface ChartLegendBarProps {
visibility: ChartLegendVisibility;
/** 크로스헤어 이동 시 OHLC·지표 수치 표시 */
crosshairInfoVisible?: boolean;
symbol: string;
timeframe: string;
useUpbit: boolean;
@@ -43,6 +45,7 @@ export interface ChartLegendBarProps {
export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
visibility: v,
crosshairInfoVisible = true,
symbol,
timeframe,
useUpbit,
@@ -54,11 +57,12 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
legend,
}) => {
const ohlcUp = ohlcBar ? ohlcBar.close >= ohlcBar.open : true;
const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close);
const showOhlc = crosshairInfoVisible && ohlcBar && (v.open || v.high || v.low || v.close);
const showLiveQuote = (v.close || v.change) && liveQuote.price != null;
const showCrosshairIndicators = crosshairInfoVisible && v.indicators && indicators.length > 0;
const showAny =
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|| showLiveQuote || showOhlc || (v.indicators && indicators.length > 0);
|| showLiveQuote || showOhlc || showCrosshairIndicators;
if (!showAny) return null;
@@ -96,7 +100,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
)}
</div>
</div>
{(showOhlc || (v.indicators && indicators.length > 0)) && (
{(showOhlc || showCrosshairIndicators) && (
<div className="tv-legend-bottom">
{showOhlc && (
<span className="tv-legend-ohlcv">
@@ -114,7 +118,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
)}
</span>
)}
{v.indicators && indicators.map(ind => {
{showCrosshairIndicators && indicators.map(ind => {
const def = getIndicatorDef(ind.type);
const vals = legend?.indicatorValues?.[ind.type];
const plots = ind.plots ?? def?.plots ?? [];
@@ -128,7 +132,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
return def ? (
<span key={ind.id} className="tv-legend-ind">
<span className="tv-legend-ind-name">{label}</span>
{v.indicatorValues && vals && vals.length > 0 && (
{crosshairInfoVisible && v.indicatorValues && vals && vals.length > 0 && (
<span className="tv-legend-ind-vals">
{vals.map((n, i) =>
Number.isFinite(n) ? (
+4
View File
@@ -186,6 +186,8 @@ export interface ChartSlotProps {
chartVisible?: boolean;
/** pane 구분선 */
chartPaneSeparator?: ChartPaneSeparatorOptions;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */
chartCrosshairInfoVisible?: boolean;
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
marketTickers?: Map<string, TickerData>;
}
@@ -212,6 +214,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartLiveReceiveHighlight = true,
chartVisible = true,
chartPaneSeparator,
chartCrosshairInfoVisible = true,
marketTickers,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
@@ -853,6 +856,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
showHoverToolbar={!compactMode}
volumeVisible={chartVolumeVisible}
paneSeparatorOptions={chartPaneSeparator}
crosshairInfoVisible={chartCrosshairInfoVisible}
onCrosshair={data => {
setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
+9 -2
View File
@@ -78,6 +78,8 @@ export interface PaneLegendProps {
focusedId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestore?: () => void;
/** 크로스헤어 이동 시 보조지표 수치 표시 */
crosshairInfoVisible?: boolean;
}
// ── 지그재그 배경색 — 홀짝 2단계로만 번갈아 적용 ───────────────────────────
@@ -328,6 +330,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
manager, indicators, containerEl, paused = false,
onHoverName, onLeaveName, onDoubleClick,
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
crosshairInfoVisible = true,
}) => {
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
@@ -426,12 +429,16 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
}, [containerEl, refreshLayouts, syncPaneItems]);
useEffect(() => {
if (!crosshairInfoVisible) {
setValues({});
return;
}
const unsub = manager.subscribeCrosshair((p: MouseEventParams<Time>) => {
if (!p.time) { setValues({}); return; }
setValues(manager.getIndicatorValuesByIdFromParams(p));
});
return unsub;
}, [manager]);
}, [manager, crosshairInfoVisible]);
// ── 드롭 라인 Y ────────────────────────────────────────────────────────────
const getDropLineY = useCallback((idx: number): number | null => {
@@ -697,7 +704,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
>
{item.label}
</span>
{itemVals.map((v, i) => isNaN(v) ? null : (
{crosshairInfoVisible && itemVals.map((v, i) => isNaN(v) ? null : (
<span key={i} className="pane-legend-val"
style={{ color: item.plotColors[i] ?? 'var(--text)' }}>
{formatVal(v)}
+28
View File
@@ -118,6 +118,8 @@ interface SettingsPageProps {
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartCrosshairInfoVisible?: boolean;
onChartCrosshairInfoVisible?: (v: boolean) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
paperTradingEnabled?: boolean;
@@ -672,6 +674,8 @@ interface ChartPanelProps {
onChartLiveReceiveHighlight?: (v: boolean) => void;
chartLegendOptions?: ChartLegendVisibility;
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartCrosshairInfoVisible?: boolean;
onChartCrosshairInfoVisible?: (v: boolean) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
chartTimeFormat?: string;
@@ -690,6 +694,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
chartCrosshairInfoVisible = true,
onChartCrosshairInfoVisible,
chartPaneSeparator,
onChartPaneSeparatorChange,
chartTimeFormat = 'MM-dd HH:mm',
@@ -881,6 +887,24 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
</SettingSection>
)}
{chartLegendOptions && onChartLegendOptionsChange && (
<SettingSection title="크로스헤어 정보">
<SettingRow
label="크로스헤어 정보 표시"
desc="마우스로 차트를 가리킬 때 표시되는 시가·고가·저가·종가와 보조지표 수치를 켜거나 끕니다. 종목·현재가·보조지표 이름은 유지됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartCrosshairInfoVisible}
onChange={e => onChartCrosshairInfoVisible?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
)}
{chartLegendOptions && onChartLegendOptionsChange && (
<SettingSection title="상단 정보 표시">
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
@@ -1571,6 +1595,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartLiveReceiveHighlight,
chartLegendOptions,
onChartLegendOptionsChange,
chartCrosshairInfoVisible = true,
onChartCrosshairInfoVisible,
chartPaneSeparator,
onChartPaneSeparatorChange,
tradeAlertSoundEnabled = true,
@@ -1667,6 +1693,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
chartLegendOptions={chartLegendOptions}
onChartLegendOptionsChange={onChartLegendOptionsChange}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
onChartCrosshairInfoVisible={onChartCrosshairInfoVisible}
chartPaneSeparator={chartPaneSeparator}
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
chartTimeFormat={chartTimeFormat}
+8 -1
View File
@@ -137,6 +137,8 @@ interface TradingChartProps {
paneLayoutClamp?: boolean;
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
showPaneLegend?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
crosshairInfoVisible?: boolean;
}
const TradingChart: React.FC<TradingChartProps> = ({
@@ -170,6 +172,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
paneSeparatorOptions,
paneLayoutClamp = false,
showPaneLegend = true,
crosshairInfoVisible = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -859,7 +862,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
const onPanPointerUp = () => {
if (!panStateRef.current.active) return;
if (panStateRef.current.moved) suppressClickRef.current = true;
if (panStateRef.current.moved) {
suppressClickRef.current = true;
}
managerRef.current?.endCandleAxisPan();
panStateRef.current.active = false;
container.style.cursor = canPanRef.current ? 'grab' : '';
};
@@ -1423,6 +1429,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
onDuplicate={onDuplicateIndicator}
focusedId={focusedIndicatorId}
onRestore={onRestoreIndicators}
crosshairInfoVisible={crosshairInfoVisible}
/>
)}