실시간 차트 갱신시 화면떨림 문제 해결

This commit is contained in:
Macbook
2026-05-27 14:54:50 +09:00
parent f7aac535d8
commit 7a0af36b9b
10 changed files with 517 additions and 130 deletions
+88 -26
View File
@@ -532,7 +532,10 @@ export class ChartManager {
}
// ─── Indicators ─────────────────────────────────────────────────────────
async addIndicator(config: IndicatorConfig): Promise<void> {
async addIndicator(
config: IndicatorConfig,
options?: { skipLayout?: boolean },
): Promise<void> {
config = enrichIndicatorConfig(config);
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
@@ -664,12 +667,50 @@ export class ChartManager {
} catch (e) {
console.error(`[Indicator] ${config.type} error:`, e);
} finally {
if (this._activeIndicatorPaneIndices().size > 0) {
if (
!options?.skipLayout
&& this._activeIndicatorPaneIndices().size > 0
) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
}
}
}
/** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */
async addIndicatorsBatch(configs: IndicatorConfig[]): Promise<void> {
for (const config of configs) {
await this.addIndicator(config, { skipLayout: true });
}
if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
}
private _detachIndicatorEntry(id: string): boolean {
const entry = this.indicators.get(id);
if (!entry) return false;
if (entry.seriesList.length > 0) {
for (const pl of entry.hlineRefs) {
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
}
if (entry.fillPrimitive) {
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
}
if (entry.cloudPlugin) {
for (const s of entry.seriesList) {
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
}
}
detachBbBandFill(entry);
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
this._reapplyAllPatternMarkers();
this.indicators.delete(id);
return true;
}
/**
* 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
@@ -708,31 +749,32 @@ export class ChartManager {
}
removeIndicator(id: string): void {
const entry = this.indicators.get(id);
if (!entry) return;
// hlines price line 및 fill primitive 정리
if (entry.seriesList.length > 0) {
for (const pl of entry.hlineRefs) {
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
}
if (entry.fillPrimitive) {
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
}
this.removeIndicators([id]);
}
/** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */
removeIndicators(ids: string[]): void {
let any = false;
for (const id of ids) {
if (this._detachIndicatorEntry(id)) any = true;
}
if (entry.cloudPlugin) {
for (const s of entry.seriesList) {
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
}
}
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
this._reapplyAllPatternMarkers();
this.indicators.delete(id);
this._removeExtraSubPanes();
if (!any) return;
this._trimTrailingEmptySubPanes();
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
if (configs.length === 0) return;
let any = false;
for (const c of configs) {
if (this._detachIndicatorEntry(c.id)) any = true;
}
if (any) this._trimTrailingEmptySubPanes();
await this.addIndicatorsBatch(configs);
}
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
private _disposeMainMarkersPlugin(): void {
if (!this.mainMarkersPlugin) return;
@@ -2254,7 +2296,7 @@ export class ChartManager {
return indices;
}
/** pane 2+ 빈 sub-pane 제거 — 제거·재로드 후 고아 pane 이 높이를 나눠 가져가는 것 방지 */
/** pane 2+ 빈 sub-pane 제거 — reloadIndicatorsOnly 전용 (모든 지표 시리즈 제거 후) */
private _removeExtraSubPanes(): void {
for (;;) {
const panes = this.chart.panes();
@@ -2268,6 +2310,21 @@ export class ChartManager {
}
}
/** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
private _trimTrailingEmptySubPanes(): void {
for (;;) {
const panes = this.chart.panes();
if (panes.length <= 2) return;
const last = panes.length - 1;
if (this._activeIndicatorPaneIndices().has(last)) return;
try {
this.chart.removePane(last);
} catch {
return;
}
}
}
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
getPaneIndexAtChartY(chartY: number): number {
const layouts = this.getPaneLayouts();
@@ -2578,12 +2635,17 @@ export class ChartManager {
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
getLoadedIndicatorCount(): number {
let n = 0;
return this.getLoadedIndicatorIds().size;
}
/** 차트에 실제로 그려진 보조지표 id (부분 로드·증분 복구용) */
getLoadedIndicatorIds(): Set<string> {
const ids = new Set<string>();
for (const entry of this.indicators.values()) {
if (entry.config?.hidden === true) continue;
if (entry.cloudPlugin || entry.seriesList.length > 0) n += 1;
if (entry.cloudPlugin || entry.seriesList.length > 0) ids.add(entry.id);
}
return n;
return ids;
}
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
+2 -2
View File
@@ -29,8 +29,8 @@ export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
{ plotIndex: 3, plotId: 'plot3', paramKey: 'laggingSpan2Periods' },
{ plotIndex: 4, plotId: 'plot4', paramKey: 'displacement' },
{ plotIndex: 3, plotId: 'plot3' },
{ plotIndex: 4, plotId: 'plot4', paramKey: 'laggingSpan2Periods' },
];
export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
+75
View File
@@ -0,0 +1,75 @@
import type { IndicatorConfig } from '../types';
export type IndicatorChartSyncMode =
| 'none'
| 'remove-only'
| 'add-only'
| 'params-changed'
| 'reorder-only'
| 'full';
export interface IndicatorChartChange {
mode: IndicatorChartSyncMode;
removedIds: string[];
added: IndicatorConfig[];
paramsChangedIds: string[];
}
export function singleIndParamKey(i: IndicatorConfig): string {
return `${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`;
}
/** 이전·현재 지표 목록 차이를 분류해 최소 갱신 경로를 선택 */
export function classifyIndicatorChartChange(
prev: IndicatorConfig[],
curr: IndicatorConfig[],
): IndicatorChartChange {
const prevMap = new Map(prev.map(i => [i.id, i]));
const currMap = new Map(curr.map(i => [i.id, i]));
const removedIds = prev.filter(i => !currMap.has(i.id)).map(i => i.id);
const added = curr.filter(i => !prevMap.has(i.id));
const paramsChangedIds: string[] = [];
for (const c of curr) {
const p = prevMap.get(c.id);
if (!p) continue;
if (singleIndParamKey(p) !== singleIndParamKey(c)) {
paramsChangedIds.push(c.id);
}
}
const empty: IndicatorChartChange = {
mode: 'none',
removedIds,
added,
paramsChangedIds,
};
if (removedIds.length === 0 && added.length === 0 && paramsChangedIds.length === 0) {
const sameSet = prev.length === curr.length
&& prev.every(p => currMap.has(p.id));
if (sameSet && prev.map(i => i.id).join(',') !== curr.map(i => i.id).join(',')) {
return { ...empty, mode: 'reorder-only' };
}
return empty;
}
if (removedIds.length > 0 && added.length === 0 && paramsChangedIds.length === 0) {
return { ...empty, mode: 'remove-only' };
}
if (added.length > 0 && removedIds.length === 0 && paramsChangedIds.length === 0) {
return { ...empty, mode: 'add-only' };
}
if (
paramsChangedIds.length > 0
&& removedIds.length === 0
&& added.length === 0
) {
return { ...empty, mode: 'params-changed' };
}
return { ...empty, mode: 'full' };
}
+37
View File
@@ -14,6 +14,7 @@ import {
import { normalizeIchimokuConfig } from './ichimokuConfig';
import type { IchimokuCloudColors } from './ichimokuConfig';
import { isMainIndicatorType } from './indicatorMainTab';
import { indicatorDefaultsFingerprint } from './indicatorDefaultsFingerprint';
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
@@ -93,6 +94,42 @@ function finalizeMainIndicatorConfig(cfg: IndicatorConfig, isNew: boolean): Indi
return out;
}
/** DB에 저장된 전역 기본값 → 차트 인스턴스에 반영 (id·hidden·pane 병합 상태 유지) */
export function mergeGlobalDefaultsOntoChartIndicators(
activeIndicators: IndicatorConfig[],
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
getVisualConfig: (
type: string,
defaultPlots?: PlotDef[],
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
): IndicatorConfig[] {
return activeIndicators.map(ind => {
const cfg = buildMainIndicatorConfig(ind.type, [ind], getParams, getVisualConfig);
if (!cfg) return enrichIndicatorConfig(ind);
return finalizeMainIndicatorConfig(
{
...cfg,
id: ind.id,
hidden: ind.hidden,
mergedWith: ind.mergedWith,
},
false,
);
});
}
/** 전역 기본값 병합 후 실제 렌더 설정이 바뀌었는지 */
export function chartIndicatorsNeedDefaultsRefresh(
before: IndicatorConfig[],
after: IndicatorConfig[],
): boolean {
if (before.length !== after.length) return true;
return before.some((ind, i) =>
indicatorDefaultsFingerprint(ind) !== indicatorDefaultsFingerprint(after[i]),
);
}
/** Main 탭 설정 → 현재 차트에 올라간 동일 type 인스턴스에 반영 (id·hidden 유지) */
export function mergeMainDraftOntoChart(
activeIndicators: IndicatorConfig[],
+19
View File
@@ -202,3 +202,22 @@ export function buildIndicatorConfigsFromTypes(
}
return out;
}
/** id 제거 — 병합 호스트 제거 시 해당 pane 멤버도 함께 제거 */
export function removeIndicatorFromList(
prev: IndicatorConfig[],
id: string,
): IndicatorConfig[] {
return prev.filter(i => i.id !== id && i.mergedWith !== id);
}
/** type 제거 — 해당 type 인스턴스 및 병합 멤버 함께 제거 */
export function removeIndicatorTypeFromList(
prev: IndicatorConfig[],
type: string,
): IndicatorConfig[] {
const removedIds = new Set(prev.filter(i => i.type === type).map(i => i.id));
return prev.filter(
i => i.type !== type && (!i.mergedWith || !removedIds.has(i.mergedWith)),
);
}
@@ -46,11 +46,12 @@ export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
IchimokuCloud: {
plot0: ['conversionPeriods'],
plot1: ['basePeriods'],
/** 행스팬1전환·기준 평균선의 미래 이동(shift) 기간 */
/** 행스팬 — chikou 이동(displacement) */
plot2: ['displacement'],
plot3: ['laggingSpan2Periods'],
/** 후행스팬 — chikou 이동(선행스팬1과 동일 displacement) */
plot4: ['displacement'],
/** 선행스팬1 — 전환·기준선에서 산출, 별도 기간 없음 */
plot3: [],
/** 선행스팬2 — Donchian 기간 */
plot4: ['laggingSpan2Periods'],
},
};