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

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
+1
View File
@@ -988,6 +988,7 @@ html.theme-blue {
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
overflow: hidden;
will-change: transform;
}
.candle-pane-time-axis__label {
position: absolute;
+7
View File
@@ -638,6 +638,7 @@ function App() {
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
const chartLegendOptions = appDefaults.chartLegendOptions;
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
const chartPaneSeparator = appDefaults.chartPaneSeparator;
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
@@ -1878,6 +1879,8 @@ function App() {
chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record<string, boolean>,
});
}}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: v })}
chartPaneSeparator={chartPaneSeparator}
onChartPaneSeparatorChange={opts => {
saveAppDef({ chartPaneSeparator: opts });
@@ -2201,6 +2204,7 @@ function App() {
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
chartPaneSeparator={chartPaneSeparator}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
@@ -2241,6 +2245,7 @@ function App() {
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartVolumeVisible={chartVolumeVisible}
chartPaneSeparator={chartPaneSeparator}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
@@ -2257,6 +2262,7 @@ function App() {
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined }}>
<ChartLegendBar
visibility={chartLegendOptions}
crosshairInfoVisible={chartCrosshairInfoVisible}
symbol={symbol}
timeframe={timeframe}
useUpbit={useUpbit}
@@ -2291,6 +2297,7 @@ function App() {
drawings={drawings} logScale={logScale}
drawingsLocked={drawingsLocked}
drawingsVisible={drawingsVisible}
crosshairInfoVisible={chartCrosshairInfoVisible}
onCrosshair={setLegend}
onTradeOrderRequest={applyTradeFill}
onDataLoaded={() => syncBacktestMarkersRef.current()}
+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}
/>
)}
+1
View File
@@ -145,6 +145,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
chartVolumeVisible: s.chartVolumeVisible ?? true,
chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true,
chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true,
chartLegendOptions: resolveChartLegendOptions(
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
),
+107 -34
View File
@@ -418,6 +418,7 @@ export class ChartManager {
} else {
ts.fitContent();
}
this._notifyViewport();
return;
}
@@ -430,6 +431,7 @@ export class ChartManager {
from: bars[startIdx].time as Time,
to: to as Time,
});
this._notifyViewport();
}
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
@@ -1929,11 +1931,83 @@ export class ChartManager {
}
}
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
private readonly _viewportListeners = new Set<() => void>();
private _viewportLwcHooked = false;
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
/** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */
private _candleAxisPanActive = false;
private _candleAxisPanOffsetPx = 0;
private _notifyViewport(): void {
for (const cb of this._viewportListeners) {
try { cb(); } catch { /* ok */ }
}
}
private _hookViewportLwc(): void {
if (this._viewportLwcHooked) return;
this._viewportLwcHooked = true;
this.chart.timeScale().subscribeVisibleTimeRangeChange(this._lwcViewportHandler);
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this._lwcViewportHandler);
}
private _unhookViewportLwc(): void {
if (!this._viewportLwcHooked) return;
this._viewportLwcHooked = false;
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
}
/** 논리 범위 변경 + 오버레이(시간축·드로잉) 동기화 */
private _setVisibleLogicalRange(range: { from: number; to: number }): void {
try {
this.chart.timeScale().setVisibleLogicalRange(range);
this._notifyViewport();
} catch { /* 데이터 로드 전 */ }
}
/** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */
notifyPaneLayoutChanged(): void {
this._notifyPaneLayout();
}
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */
notifyViewportChanged(): void {
this._notifyViewport();
}
/** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */
endCandleAxisPan(): void {
if (!this._candleAxisPanActive) return;
this._candleAxisPanActive = false;
this._candleAxisPanOffsetPx = 0;
this._notifyViewport();
}
isCandleAxisPanActive(): boolean {
return this._candleAxisPanActive;
}
getCandleAxisPanOffsetPx(): number {
return this._candleAxisPanOffsetPx;
}
private _ensureCandleAxisPan(): void {
if (this._candleAxisPanActive) return;
this._candleAxisPanActive = true;
this._candleAxisPanOffsetPx = 0;
this._notifyViewport();
}
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
const span = lr.to - lr.from;
if (!Number.isFinite(span) || span <= 0) return 0;
return ((logical - lr.from) / span) * plotWidth;
}
/**
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
* IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
@@ -2340,7 +2414,7 @@ export class ChartManager {
const span = lr.to - lr.from;
const center = (lr.from + lr.to) / 2;
const newSpan = Math.max(span * 0.8, 5); // 최소 5바
ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
}
/** 시간축 축소 (visible logical range 20% 확대) */
@@ -2351,7 +2425,7 @@ export class ChartManager {
const span = lr.to - lr.from;
const center = (lr.from + lr.to) / 2;
const newSpan = span * 1.25;
ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
}
/** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */
@@ -2360,7 +2434,7 @@ export class ChartManager {
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const shift = (lr.to - lr.from) * 0.2;
ts.setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
this._setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
}
/** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */
@@ -2369,7 +2443,7 @@ export class ChartManager {
const lr = ts.getVisibleLogicalRange();
if (!lr) return;
const shift = (lr.to - lr.from) * 0.2;
ts.setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
this._setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
}
/** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */
@@ -2678,25 +2752,23 @@ export class ChartManager {
const lr = ts.getVisibleLogicalRange();
if (!lr) return null;
const plotWidth = this._candlePlotWidth();
const from = Math.max(0, Math.ceil(lr.from));
const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
if (to <= from) return null;
const plotWidth = Math.max(40, ts.width());
const lrSpan = lr.to - lr.from;
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
const span = to - from;
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
const step = Math.max(1, Math.ceil(span / targetCount));
const step = Math.max(1, Math.ceil((lr.to - Math.max(0, lr.from)) / targetCount));
const labels: Array<{ x: number; text: string }> = [];
const fmt = this.chartTimeFormat;
const tz = this.displayTimezone;
const startLogical = Math.max(0, Math.ceil(lr.from));
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
for (let i = from; i <= to; i += step) {
const bar = this.rawBars[i];
for (let logical = startLogical; logical <= endLogical; logical += step) {
const bar = this.rawBars[logical];
if (!bar?.time) continue;
const coord = ts.timeToCoordinate(bar.time as Time);
if (coord == null) continue;
const x = Number(coord);
const x = this._logicalToAxisX(logical, lr, plotWidth);
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
labels.push({
x,
@@ -2791,15 +2863,16 @@ export class ChartManager {
options?: { allowVerticalPan?: boolean },
): void {
if (deltaX !== 0) {
this._ensureCandleAxisPan();
this._candleAxisPanOffsetPx += deltaX;
const ts = this.chart.timeScale();
const lr = ts.getVisibleLogicalRange();
if (lr) {
const w = Math.max(1, this.container.clientWidth);
const w = Math.max(1, ts.width());
const span = lr.to - lr.from;
const dLog = (deltaX / w) * span;
try {
ts.setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
} catch { /* 데이터 로드 전 */ }
this._setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
}
}
@@ -2901,6 +2974,7 @@ export class ChartManager {
if (this.rawBars.length === 0) return;
try {
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
this._notifyViewport();
} catch { /* 데이터 로딩 전 호출 무시 */ }
}
@@ -2910,9 +2984,7 @@ export class ChartManager {
*/
applyVisibleLogicalRange(from: number, to: number): void {
if (this.rawBars.length === 0) return;
try {
this.chart.timeScale().setVisibleLogicalRange({ from, to });
} catch { /* 데이터 로딩 전 호출 무시 */ }
this._setVisibleLogicalRange({ from, to });
}
/** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */
@@ -2949,7 +3021,10 @@ export class ChartManager {
const t0 = this.xToTime(Math.min(x0, x1));
const t1 = this.xToTime(Math.max(x0, x1));
if (t0 === null || t1 === null || t0 >= t1) return;
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
try {
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
this._notifyViewport();
} catch { /* ok */ }
}
/**
@@ -3089,12 +3164,10 @@ export class ChartManager {
// 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동
if (logicalRange) {
try {
this.chart.timeScale().setVisibleLogicalRange({
from: logicalRange.from + newBars.length,
to: logicalRange.to + newBars.length,
});
} catch { /* 무시 */ }
this._setVisibleLogicalRange({
from: logicalRange.from + newBars.length,
to: logicalRange.to + newBars.length,
});
}
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
@@ -3309,13 +3382,13 @@ export class ChartManager {
return p;
}
/** Subscribe to visible range changes (time scale + price scale). Returns unsubscribe fn. */
/** 가시 시간 범위 변경 구독 (LWC 네이티브 스크롤 + 커스텀 패닝). unsubscribe fn 반환 */
subscribeViewport(cb: () => void): () => void {
this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
this._viewportListeners.add(cb);
this._hookViewportLwc();
return () => {
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
this._viewportListeners.delete(cb);
if (this._viewportListeners.size === 0) this._unhookViewportLwc();
};
}
+2
View File
@@ -392,6 +392,8 @@ export interface AppSettingsDto {
chartVolumeVisible?: boolean;
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
chartLiveReceiveHighlight?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
chartCrosshairInfoVisible?: boolean;
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
chartLegendOptions?: Record<string, boolean> | null;
/** 차트 pane(캔들·거래량·보조지표) 구분선 */