실시간 차트 스크롤시 날짜 동기화 문제
This commit is contained in:
@@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user