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

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
+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(캔들·거래량·보조지표) 구분선 */