@design.estate/dees-catalog 3.98.0 → 3.98.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_bundle/bundle.js +310 -115
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.d.ts +14 -0
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.js +271 -117
- package/dist_ts_web/elements/00group-chart/dees-chart-area/realtime-updates.d.ts +21 -0
- package/dist_ts_web/elements/00group-chart/dees-chart-area/realtime-updates.js +79 -0
- package/package.json +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-chart/dees-chart-area/component.ts +276 -116
- package/ts_web/elements/00group-chart/dees-chart-area/realtime-updates.ts +110 -0
|
@@ -10,6 +10,11 @@ import * as domtools from '@design.estate/dees-domtools';
|
|
|
10
10
|
import { demoFunc } from './demo.js';
|
|
11
11
|
import { chartAreaStyles } from './styles.js';
|
|
12
12
|
import { renderChartArea } from './template.js';
|
|
13
|
+
import {
|
|
14
|
+
haveSameSeriesLayout,
|
|
15
|
+
planRealtimeChartUpdates,
|
|
16
|
+
type IRealtimeChartPoint,
|
|
17
|
+
} from './realtime-updates.js';
|
|
13
18
|
import { getEchartsSeriesColors, hexToRgba } from '../dees-chart-echarts-theme.js';
|
|
14
19
|
|
|
15
20
|
import type { IChartApi, ISeriesApi, UTCTimestamp, MouseEventParams } from 'lightweight-charts';
|
|
@@ -101,22 +106,44 @@ export class DeesChartArea extends DeesElement {
|
|
|
101
106
|
private seriesApis: Map<string, ISeriesApi<any>> = new Map();
|
|
102
107
|
private priceLines: Map<string, any[]> = new Map();
|
|
103
108
|
private tooltipEl: HTMLDivElement | null = null;
|
|
109
|
+
private crosshairActive = false;
|
|
110
|
+
private pendingRealtimePrune = false;
|
|
111
|
+
private crosshairMoveHandler: ((param: MouseEventParams) => void) | null = null;
|
|
112
|
+
private chartContainer: HTMLDivElement | null = null;
|
|
113
|
+
private readonly chartMouseLeaveHandler = () => {
|
|
114
|
+
this.chart?.clearCrosshairPosition();
|
|
115
|
+
this.handleCrosshairCleared();
|
|
116
|
+
};
|
|
104
117
|
|
|
105
118
|
constructor() {
|
|
106
119
|
super();
|
|
107
120
|
domtools.elementBasic.setup();
|
|
108
121
|
this.registerGarbageFunction(async () => {
|
|
109
122
|
this.stopAutoScroll();
|
|
110
|
-
|
|
123
|
+
const chart = this.chart;
|
|
124
|
+
const crosshairMoveHandler = this.crosshairMoveHandler;
|
|
125
|
+
this.chart = null;
|
|
126
|
+
this.crosshairMoveHandler = null;
|
|
127
|
+
this.chartContainer?.removeEventListener('mouseleave', this.chartMouseLeaveHandler);
|
|
128
|
+
this.chartContainer = null;
|
|
129
|
+
this.tooltipEl = null;
|
|
130
|
+
this.crosshairActive = false;
|
|
131
|
+
this.pendingRealtimePrune = false;
|
|
132
|
+
if (crosshairMoveHandler && chart) {
|
|
111
133
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.priceLines.clear();
|
|
116
|
-
} catch (e) {
|
|
117
|
-
console.error('Error destroying chart:', e);
|
|
134
|
+
chart.unsubscribeCrosshairMove(crosshairMoveHandler);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Error unsubscribing chart crosshair:', error);
|
|
118
137
|
}
|
|
119
138
|
}
|
|
139
|
+
try {
|
|
140
|
+
chart?.remove();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('Error destroying chart:', error);
|
|
143
|
+
} finally {
|
|
144
|
+
this.seriesApis.clear();
|
|
145
|
+
this.priceLines.clear();
|
|
146
|
+
}
|
|
120
147
|
});
|
|
121
148
|
}
|
|
122
149
|
|
|
@@ -129,14 +156,48 @@ export class DeesChartArea extends DeesElement {
|
|
|
129
156
|
// --- Helpers ---
|
|
130
157
|
|
|
131
158
|
private convertDataToLC(data: Array<{ x: any; y: number }>): Array<{ time: UTCTimestamp; value: number }> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
const pointsByTime = new Map<number, { time: UTCTimestamp; value: number }>();
|
|
160
|
+
for (const point of data) {
|
|
161
|
+
const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
|
|
162
|
+
const time = Math.floor(ms / 1000);
|
|
163
|
+
if (!Number.isFinite(time) || !Number.isFinite(point.y)) continue;
|
|
164
|
+
// Lightweight Charts requires unique ordered timestamps. The latest
|
|
165
|
+
// sample wins when multiple updates land in the same second.
|
|
166
|
+
pointsByTime.set(time, { time: time as UTCTimestamp, value: point.y });
|
|
167
|
+
}
|
|
168
|
+
return [...pointsByTime.values()]
|
|
137
169
|
.sort((a, b) => (a.time as number) - (b.time as number));
|
|
138
170
|
}
|
|
139
171
|
|
|
172
|
+
private getSeriesName(series: ChartSeriesConfig[number], index: number): string {
|
|
173
|
+
return series.name || `series-${index}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private getSeriesKey(series: ChartSeriesConfig[number], index: number): string {
|
|
177
|
+
return `${index}:${this.getSeriesName(series, index)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private getCanonicalChartSeries(chartSeries: ChartSeriesConfig): ChartSeriesConfig {
|
|
181
|
+
const cutoffTime = Date.now() - this.rollingWindow;
|
|
182
|
+
return chartSeries.map((series) => ({
|
|
183
|
+
...series,
|
|
184
|
+
data: this.convertDataToLC(series.data.filter((point) => {
|
|
185
|
+
if (!this.realtimeMode || this.rollingWindow <= 0) return true;
|
|
186
|
+
const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
|
|
187
|
+
return ms > cutoffTime;
|
|
188
|
+
})).map((point) => ({
|
|
189
|
+
x: (point.time as number) * 1000,
|
|
190
|
+
y: point.value,
|
|
191
|
+
})),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private getRenderedSeriesData(api: ISeriesApi<any>): IRealtimeChartPoint[] {
|
|
196
|
+
return api.data()
|
|
197
|
+
.filter((point): point is { time: UTCTimestamp; value: number } => 'value' in point)
|
|
198
|
+
.map((point) => ({ time: point.time as number, value: point.value }));
|
|
199
|
+
}
|
|
200
|
+
|
|
140
201
|
private colorToRgba(color: string, alpha: number): string {
|
|
141
202
|
if (/^#[0-9a-fA-F]{6}$/.test(color)) {
|
|
142
203
|
return hexToRgba(color, alpha);
|
|
@@ -238,7 +299,7 @@ export class DeesChartArea extends DeesElement {
|
|
|
238
299
|
});
|
|
239
300
|
|
|
240
301
|
api.setData(this.convertDataToLC(s.data));
|
|
241
|
-
this.updatePriceLines(s
|
|
302
|
+
this.updatePriceLines(this.getSeriesKey(s, index), api, s.data, color);
|
|
242
303
|
|
|
243
304
|
if (this.yAxisScaling !== 'dynamic') {
|
|
244
305
|
api.applyOptions({
|
|
@@ -248,17 +309,77 @@ export class DeesChartArea extends DeesElement {
|
|
|
248
309
|
} as any);
|
|
249
310
|
}
|
|
250
311
|
|
|
251
|
-
this.seriesApis.set(s
|
|
312
|
+
this.seriesApis.set(this.getSeriesKey(s, index), api);
|
|
252
313
|
});
|
|
253
314
|
this.computeStats(chartSeries);
|
|
315
|
+
this.pendingRealtimePrune = false;
|
|
254
316
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
317
|
+
if (this.realtimeMode && this.rollingWindow > 0) {
|
|
318
|
+
void this.updateTimeWindow();
|
|
319
|
+
} else {
|
|
258
320
|
this.chart.timeScale().fitContent();
|
|
259
321
|
}
|
|
260
322
|
}
|
|
261
323
|
|
|
324
|
+
private updateSeriesPresentation(chartSeries: ChartSeriesConfig) {
|
|
325
|
+
const isDark = !this.goBright;
|
|
326
|
+
chartSeries.forEach((series, index) => {
|
|
327
|
+
const seriesKey = this.getSeriesKey(series, index);
|
|
328
|
+
const api = this.seriesApis.get(seriesKey);
|
|
329
|
+
if (!api) return;
|
|
330
|
+
const color = this.resolveSeriesColor(series, index, isDark);
|
|
331
|
+
api.applyOptions({
|
|
332
|
+
topColor: this.colorToRgba(color, isDark ? 0.4 : 0.5),
|
|
333
|
+
bottomColor: this.colorToRgba(color, 0),
|
|
334
|
+
lineColor: color,
|
|
335
|
+
});
|
|
336
|
+
this.updatePriceLines(seriesKey, api, series.data, color);
|
|
337
|
+
});
|
|
338
|
+
this.computeStats(chartSeries);
|
|
339
|
+
|
|
340
|
+
if (this.yAxisScaling === 'dynamic') {
|
|
341
|
+
const allValues = chartSeries.flatMap((series) => series.data.map((point) => point.y));
|
|
342
|
+
if (allValues.length > 0) {
|
|
343
|
+
const dynamicMax = Math.ceil(Math.max(...allValues) * 1.1);
|
|
344
|
+
for (const [, api] of this.seriesApis) {
|
|
345
|
+
api.applyOptions({
|
|
346
|
+
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: dynamicMax } }),
|
|
347
|
+
} as any);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private handleCrosshairCleared() {
|
|
354
|
+
this.crosshairActive = false;
|
|
355
|
+
if (this.tooltipEl) this.tooltipEl.style.display = 'none';
|
|
356
|
+
this.flushPendingRealtimePrune();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private clearCrosshairForDataReplacement() {
|
|
360
|
+
this.pendingRealtimePrune = false;
|
|
361
|
+
this.chart?.clearCrosshairPosition();
|
|
362
|
+
this.crosshairActive = false;
|
|
363
|
+
if (this.tooltipEl) this.tooltipEl.style.display = 'none';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private flushPendingRealtimePrune() {
|
|
367
|
+
if (
|
|
368
|
+
!this.pendingRealtimePrune
|
|
369
|
+
|| this.crosshairActive
|
|
370
|
+
|| !this.chart
|
|
371
|
+
|| !this.realtimeMode
|
|
372
|
+
|| this.rollingWindow <= 0
|
|
373
|
+
) return;
|
|
374
|
+
|
|
375
|
+
const canonicalSeries = this.getCanonicalChartSeries(this.internalChartData);
|
|
376
|
+
canonicalSeries.forEach((series, index) => {
|
|
377
|
+
const api = this.seriesApis.get(this.getSeriesKey(series, index));
|
|
378
|
+
if (api) api.setData(this.convertDataToLC(series.data));
|
|
379
|
+
});
|
|
380
|
+
this.pendingRealtimePrune = false;
|
|
381
|
+
}
|
|
382
|
+
|
|
262
383
|
private computeStats(chartSeries: ChartSeriesConfig) {
|
|
263
384
|
const isDark = !this.goBright;
|
|
264
385
|
this.seriesStats = chartSeries.map((s, index) => {
|
|
@@ -327,15 +448,18 @@ export class DeesChartArea extends DeesElement {
|
|
|
327
448
|
this.tooltipEl = document.createElement('div');
|
|
328
449
|
this.tooltipEl.className = 'lw-tooltip';
|
|
329
450
|
this.tooltipEl.style.display = 'none';
|
|
330
|
-
this.shadowRoot!.querySelector('.chartContainer')
|
|
451
|
+
this.chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement | null;
|
|
452
|
+
this.chartContainer?.appendChild(this.tooltipEl);
|
|
453
|
+
this.chartContainer?.addEventListener('mouseleave', this.chartMouseLeaveHandler);
|
|
331
454
|
|
|
332
|
-
this.
|
|
455
|
+
this.crosshairMoveHandler = (param: MouseEventParams) => {
|
|
333
456
|
if (!this.tooltipEl) return;
|
|
334
457
|
|
|
335
|
-
if (!param.point ||
|
|
336
|
-
this.
|
|
458
|
+
if (!param.point || param.time === undefined || param.point.x < 0 || param.point.y < 0) {
|
|
459
|
+
this.handleCrosshairCleared();
|
|
337
460
|
return;
|
|
338
461
|
}
|
|
462
|
+
this.crosshairActive = true;
|
|
339
463
|
|
|
340
464
|
const isDark = !this.goBright;
|
|
341
465
|
const bgColor = isDark ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)';
|
|
@@ -345,11 +469,13 @@ export class DeesChartArea extends DeesElement {
|
|
|
345
469
|
let html = '';
|
|
346
470
|
let idx = 0;
|
|
347
471
|
let hasData = false;
|
|
348
|
-
for (const
|
|
472
|
+
for (const api of this.seriesApis.values()) {
|
|
349
473
|
const data = param.seriesData.get(api);
|
|
350
474
|
if (data && 'value' in data && (data as any).value !== undefined) {
|
|
351
475
|
hasData = true;
|
|
352
|
-
const
|
|
476
|
+
const series = this.internalChartData[idx];
|
|
477
|
+
const name = series ? this.getSeriesName(series, idx) : `series-${idx}`;
|
|
478
|
+
const color = this.resolveSeriesColor(series, idx, isDark);
|
|
353
479
|
const formatted = this.yAxisFormatter((data as any).value);
|
|
354
480
|
html += `<div style="display:flex;align-items:center;gap:8px;margin:${idx > 0 ? '6px' : '0'} 0;">
|
|
355
481
|
<span style="display:inline-block;width:10px;height:10px;background:${color};border-radius:2px;"></span>
|
|
@@ -378,15 +504,19 @@ export class DeesChartArea extends DeesElement {
|
|
|
378
504
|
if (left + 200 > containerWidth) left = param.point.x - 216;
|
|
379
505
|
this.tooltipEl.style.left = `${left}px`;
|
|
380
506
|
this.tooltipEl.style.top = `${param.point.y - 16}px`;
|
|
381
|
-
}
|
|
507
|
+
};
|
|
508
|
+
this.chart.subscribeCrosshairMove(this.crosshairMoveHandler);
|
|
382
509
|
}
|
|
383
510
|
|
|
384
511
|
// --- Lifecycle ---
|
|
385
512
|
|
|
386
513
|
public async firstUpdated() {
|
|
387
514
|
await this.domtoolsPromise;
|
|
515
|
+
if (!this.isConnected) return;
|
|
388
516
|
this.lcBundle = await DeesServiceLibLoader.getInstance().loadLightweightCharts();
|
|
517
|
+
if (!this.isConnected) return;
|
|
389
518
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
519
|
+
if (!this.isConnected) return;
|
|
390
520
|
|
|
391
521
|
const chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement;
|
|
392
522
|
if (!chartContainer) return;
|
|
@@ -446,9 +576,11 @@ export class DeesChartArea extends DeesElement {
|
|
|
446
576
|
},
|
|
447
577
|
];
|
|
448
578
|
|
|
449
|
-
|
|
450
|
-
this.
|
|
579
|
+
const canonicalSeries = this.getCanonicalChartSeries(chartSeries);
|
|
580
|
+
this.internalChartData = canonicalSeries;
|
|
581
|
+
this.recreateSeries(canonicalSeries);
|
|
451
582
|
this.setupTooltip();
|
|
583
|
+
this.syncAutoScroll();
|
|
452
584
|
} catch (error) {
|
|
453
585
|
console.error('Failed to initialize chart:', error);
|
|
454
586
|
}
|
|
@@ -461,7 +593,8 @@ export class DeesChartArea extends DeesElement {
|
|
|
461
593
|
this.applyTheme();
|
|
462
594
|
}
|
|
463
595
|
|
|
464
|
-
|
|
596
|
+
const seriesChanged = changedProperties.has('series') && this.series.length > 0;
|
|
597
|
+
if (seriesChanged && this.chart) {
|
|
465
598
|
await this.updateSeries(this.series);
|
|
466
599
|
}
|
|
467
600
|
|
|
@@ -469,19 +602,22 @@ export class DeesChartArea extends DeesElement {
|
|
|
469
602
|
// yAxisFormatter is used by the tooltip; LC price scale uses default formatting
|
|
470
603
|
}
|
|
471
604
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
605
|
+
const realtimeConfigurationChanged = changedProperties.has('realtimeMode')
|
|
606
|
+
|| changedProperties.has('rollingWindow');
|
|
607
|
+
const autoScrollConfigurationChanged = realtimeConfigurationChanged
|
|
608
|
+
|| changedProperties.has('autoScrollInterval');
|
|
609
|
+
if (this.chart && realtimeConfigurationChanged) {
|
|
610
|
+
if (!seriesChanged && this.internalChartData.length > 0) {
|
|
611
|
+
await this.updateSeries(this.internalChartData);
|
|
612
|
+
}
|
|
613
|
+
if (this.realtimeMode && this.rollingWindow > 0) {
|
|
614
|
+
await this.updateTimeWindow();
|
|
475
615
|
} else {
|
|
476
|
-
this.
|
|
616
|
+
this.chart.timeScale().fitContent();
|
|
477
617
|
}
|
|
478
618
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
this.stopAutoScroll();
|
|
482
|
-
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
|
483
|
-
this.startAutoScroll();
|
|
484
|
-
}
|
|
619
|
+
if (this.chart && autoScrollConfigurationChanged) {
|
|
620
|
+
this.syncAutoScroll();
|
|
485
621
|
}
|
|
486
622
|
|
|
487
623
|
if (changedProperties.has('chartLines') && this.chart) {
|
|
@@ -507,80 +643,84 @@ export class DeesChartArea extends DeesElement {
|
|
|
507
643
|
if (!this.chart) return;
|
|
508
644
|
|
|
509
645
|
try {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
646
|
+
void animate;
|
|
647
|
+
const canonicalSeries = this.getCanonicalChartSeries(newSeries);
|
|
648
|
+
this.internalChartData = canonicalSeries;
|
|
649
|
+
const renderedKeys = [...this.seriesApis.keys()];
|
|
650
|
+
const canonicalKeys = canonicalSeries.map((series, index) => this.getSeriesKey(series, index));
|
|
651
|
+
|
|
652
|
+
if (!haveSameSeriesLayout(renderedKeys, canonicalKeys)) {
|
|
653
|
+
this.clearCrosshairForDataReplacement();
|
|
654
|
+
this.recreateSeries(canonicalSeries);
|
|
655
|
+
this.updateSeriesPresentation(canonicalSeries);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
515
658
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return ms > cutoffTime;
|
|
527
|
-
});
|
|
528
|
-
api.setData(this.convertDataToLC(filtered));
|
|
529
|
-
const color = this.resolveSeriesColor(s, index, isDark);
|
|
530
|
-
api.applyOptions({
|
|
531
|
-
topColor: this.colorToRgba(color, isDark ? 0.4 : 0.5),
|
|
532
|
-
bottomColor: this.colorToRgba(color, 0),
|
|
533
|
-
lineColor: color,
|
|
534
|
-
});
|
|
535
|
-
this.updatePriceLines(name, api, filtered, color);
|
|
536
|
-
});
|
|
537
|
-
this.computeStats(newSeries);
|
|
538
|
-
}
|
|
659
|
+
if (!this.realtimeMode || this.rollingWindow <= 0) {
|
|
660
|
+
this.clearCrosshairForDataReplacement();
|
|
661
|
+
canonicalSeries.forEach((series, index) => {
|
|
662
|
+
this.seriesApis.get(this.getSeriesKey(series, index))!
|
|
663
|
+
.setData(this.convertDataToLC(series.data));
|
|
664
|
+
});
|
|
665
|
+
this.pendingRealtimePrune = false;
|
|
666
|
+
this.updateSeriesPresentation(canonicalSeries);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
539
669
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
670
|
+
const plannedUpdates = canonicalSeries.map((series, index) => {
|
|
671
|
+
const api = this.seriesApis.get(this.getSeriesKey(series, index))!;
|
|
672
|
+
const canonicalData = this.convertDataToLC(series.data)
|
|
673
|
+
.map((point) => ({ time: point.time as number, value: point.value }));
|
|
674
|
+
return {
|
|
675
|
+
api,
|
|
676
|
+
canonicalData,
|
|
677
|
+
plan: planRealtimeChartUpdates(this.getRenderedSeriesData(api), canonicalData),
|
|
678
|
+
};
|
|
679
|
+
});
|
|
546
680
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
);
|
|
554
|
-
if (allValues.length > 0) {
|
|
555
|
-
const dynamicMax = Math.ceil(Math.max(...allValues) * 1.1);
|
|
556
|
-
for (const [, api] of this.seriesApis) {
|
|
557
|
-
api.applyOptions({
|
|
558
|
-
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: dynamicMax } }),
|
|
559
|
-
} as any);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
681
|
+
if (plannedUpdates.some(({ plan }) => plan.replacementRequired)) {
|
|
682
|
+
this.clearCrosshairForDataReplacement();
|
|
683
|
+
for (const { api, canonicalData } of plannedUpdates) {
|
|
684
|
+
api.setData(canonicalData.map((point) => ({
|
|
685
|
+
time: point.time as UTCTimestamp,
|
|
686
|
+
value: point.value,
|
|
687
|
+
})));
|
|
562
688
|
}
|
|
689
|
+
this.pendingRealtimePrune = false;
|
|
563
690
|
} else {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
691
|
+
for (const { api, plan } of plannedUpdates) {
|
|
692
|
+
for (const update of plan.updates) {
|
|
693
|
+
api.update({
|
|
694
|
+
time: update.point.time as UTCTimestamp,
|
|
695
|
+
value: update.point.value,
|
|
696
|
+
}, update.historicalUpdate);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const pruneRequired = plannedUpdates.some(({ plan }) => plan.pruneRequired);
|
|
701
|
+
const pruneRecommended = plannedUpdates.some(({ plan }) => plan.pruneRecommended);
|
|
702
|
+
if (pruneRequired) {
|
|
703
|
+
this.clearCrosshairForDataReplacement();
|
|
704
|
+
for (const { api, canonicalData } of plannedUpdates) {
|
|
705
|
+
api.setData(canonicalData.map((point) => ({
|
|
706
|
+
time: point.time as UTCTimestamp,
|
|
707
|
+
value: point.value,
|
|
708
|
+
})));
|
|
709
|
+
}
|
|
710
|
+
} else if (pruneRecommended && this.crosshairActive) {
|
|
711
|
+
this.pendingRealtimePrune = true;
|
|
712
|
+
} else if (pruneRecommended) {
|
|
713
|
+
for (const { api, canonicalData } of plannedUpdates) {
|
|
714
|
+
api.setData(canonicalData.map((point) => ({
|
|
715
|
+
time: point.time as UTCTimestamp,
|
|
716
|
+
value: point.value,
|
|
717
|
+
})));
|
|
718
|
+
}
|
|
719
|
+
this.pendingRealtimePrune = false;
|
|
582
720
|
}
|
|
583
721
|
}
|
|
722
|
+
|
|
723
|
+
this.updateSeriesPresentation(canonicalSeries);
|
|
584
724
|
} catch (error) {
|
|
585
725
|
console.error('Failed to update chart series:', error);
|
|
586
726
|
}
|
|
@@ -600,15 +740,28 @@ export class DeesChartArea extends DeesElement {
|
|
|
600
740
|
|
|
601
741
|
public async appendData(newData: { name?: string; data: Array<{ x: any; y: number }> }[]) {
|
|
602
742
|
if (!this.chart) return;
|
|
743
|
+
const nextSeries = this.internalChartData.map((series) => ({
|
|
744
|
+
...series,
|
|
745
|
+
data: [...series.data],
|
|
746
|
+
}));
|
|
747
|
+
const namedOccurrences = new Map<string, number>();
|
|
603
748
|
newData.forEach((s, index) => {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
749
|
+
let targetIndex = index;
|
|
750
|
+
if (s.name) {
|
|
751
|
+
const occurrence = namedOccurrences.get(s.name) ?? 0;
|
|
752
|
+
namedOccurrences.set(s.name, occurrence + 1);
|
|
753
|
+
const matchingIndices = this.internalChartData
|
|
754
|
+
.map((series, seriesIndex) => series.name === s.name ? seriesIndex : -1)
|
|
755
|
+
.filter((seriesIndex) => seriesIndex >= 0);
|
|
756
|
+
const matchingIndex = matchingIndices[occurrence];
|
|
757
|
+
if (matchingIndex === undefined) return;
|
|
758
|
+
targetIndex = matchingIndex;
|
|
610
759
|
}
|
|
760
|
+
const targetSeries = nextSeries[targetIndex];
|
|
761
|
+
if (!targetSeries || s.data.length === 0) return;
|
|
762
|
+
targetSeries.data.push(...s.data);
|
|
611
763
|
});
|
|
764
|
+
await this.updateSeries(nextSeries);
|
|
612
765
|
}
|
|
613
766
|
|
|
614
767
|
public async updateOptions(options: Record<string, any>) {
|
|
@@ -653,25 +806,32 @@ export class DeesChartArea extends DeesElement {
|
|
|
653
806
|
|
|
654
807
|
private refreshPriceLines() {
|
|
655
808
|
const isDark = !this.goBright;
|
|
656
|
-
this.
|
|
657
|
-
const
|
|
658
|
-
const api = this.seriesApis.get(
|
|
809
|
+
this.getCanonicalChartSeries(this.internalChartData).forEach((s, index) => {
|
|
810
|
+
const seriesKey = this.getSeriesKey(s, index);
|
|
811
|
+
const api = this.seriesApis.get(seriesKey);
|
|
659
812
|
if (!api) return;
|
|
660
|
-
this.updatePriceLines(
|
|
813
|
+
this.updatePriceLines(seriesKey, api, s.data, this.resolveSeriesColor(s, index, isDark));
|
|
661
814
|
});
|
|
662
815
|
}
|
|
663
816
|
|
|
664
817
|
private startAutoScroll() {
|
|
665
|
-
if (this.autoScrollTimer) return;
|
|
818
|
+
if (this.autoScrollTimer !== null) return;
|
|
666
819
|
this.autoScrollTimer = window.setInterval(() => {
|
|
667
820
|
this.updateTimeWindow();
|
|
668
821
|
}, this.autoScrollInterval);
|
|
669
822
|
}
|
|
670
823
|
|
|
671
824
|
private stopAutoScroll() {
|
|
672
|
-
if (this.autoScrollTimer) {
|
|
825
|
+
if (this.autoScrollTimer !== null) {
|
|
673
826
|
window.clearInterval(this.autoScrollTimer);
|
|
674
827
|
this.autoScrollTimer = null;
|
|
675
828
|
}
|
|
676
829
|
}
|
|
830
|
+
|
|
831
|
+
private syncAutoScroll() {
|
|
832
|
+
this.stopAutoScroll();
|
|
833
|
+
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
|
834
|
+
this.startAutoScroll();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
677
837
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export interface IRealtimeChartPoint {
|
|
2
|
+
time: number;
|
|
3
|
+
value: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface IRealtimeChartUpdate {
|
|
7
|
+
point: IRealtimeChartPoint;
|
|
8
|
+
historicalUpdate: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IRealtimeChartUpdatePlan {
|
|
12
|
+
replacementRequired: boolean;
|
|
13
|
+
updates: IRealtimeChartUpdate[];
|
|
14
|
+
pruneRecommended: boolean;
|
|
15
|
+
pruneRequired: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Lightweight Charts can update the newest point or append newer points with
|
|
20
|
+
* update(). Historical update() calls may only target timestamps that already
|
|
21
|
+
* exist. Everything else needs an exact setData() replacement.
|
|
22
|
+
*/
|
|
23
|
+
export const planRealtimeChartUpdates = (
|
|
24
|
+
renderedData: readonly IRealtimeChartPoint[],
|
|
25
|
+
canonicalData: readonly IRealtimeChartPoint[],
|
|
26
|
+
): IRealtimeChartUpdatePlan => {
|
|
27
|
+
if (canonicalData.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
replacementRequired: renderedData.length !== 0,
|
|
30
|
+
updates: [],
|
|
31
|
+
pruneRecommended: false,
|
|
32
|
+
pruneRequired: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (renderedData.length === 0) {
|
|
36
|
+
return {
|
|
37
|
+
replacementRequired: false,
|
|
38
|
+
updates: canonicalData.map((point) => ({ point, historicalUpdate: false })),
|
|
39
|
+
pruneRecommended: false,
|
|
40
|
+
pruneRequired: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const renderedByTime = new Map(renderedData.map((point) => [point.time, point]));
|
|
45
|
+
const canonicalByTime = new Map(canonicalData.map((point) => [point.time, point]));
|
|
46
|
+
const canonicalStart = canonicalData[0].time;
|
|
47
|
+
const renderedLastTime = renderedData[renderedData.length - 1].time;
|
|
48
|
+
|
|
49
|
+
// Points before canonicalStart are an expired rolling-window prefix and may
|
|
50
|
+
// stay hidden temporarily. Missing points anywhere else are structural
|
|
51
|
+
// deletions (including suffix deletion) and require an exact replacement.
|
|
52
|
+
const hasStructuralDeletion = renderedData.some(
|
|
53
|
+
(point) => point.time >= canonicalStart && !canonicalByTime.has(point.time),
|
|
54
|
+
);
|
|
55
|
+
if (hasStructuralDeletion) {
|
|
56
|
+
return {
|
|
57
|
+
replacementRequired: true,
|
|
58
|
+
updates: [],
|
|
59
|
+
pruneRecommended: false,
|
|
60
|
+
pruneRequired: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const updates: IRealtimeChartUpdate[] = [];
|
|
65
|
+
for (const canonicalPoint of canonicalData) {
|
|
66
|
+
const renderedPoint = renderedByTime.get(canonicalPoint.time);
|
|
67
|
+
if (!renderedPoint) {
|
|
68
|
+
if (canonicalPoint.time < renderedLastTime) {
|
|
69
|
+
// Historical update() cannot insert a missing timestamp.
|
|
70
|
+
return {
|
|
71
|
+
replacementRequired: true,
|
|
72
|
+
updates: [],
|
|
73
|
+
pruneRecommended: false,
|
|
74
|
+
pruneRequired: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
updates.push({ point: canonicalPoint, historicalUpdate: false });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (renderedPoint.value !== canonicalPoint.value) {
|
|
82
|
+
updates.push({
|
|
83
|
+
point: canonicalPoint,
|
|
84
|
+
historicalUpdate: canonicalPoint.time < renderedLastTime,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const firstCanonicalPointIndex = renderedData.findIndex((point) => point.time >= canonicalStart);
|
|
90
|
+
const retainedPrefixLength = firstCanonicalPointIndex === -1
|
|
91
|
+
? renderedData.length
|
|
92
|
+
: firstCanonicalPointIndex;
|
|
93
|
+
const projectedLength = renderedData.length
|
|
94
|
+
+ updates.filter((update) => !renderedByTime.has(update.point.time)).length;
|
|
95
|
+
// Retain at most one additional hidden rolling window before compacting.
|
|
96
|
+
const retentionLimit = Math.max(canonicalData.length * 2, 2);
|
|
97
|
+
const pruneRecommended = retainedPrefixLength > 0 && projectedLength > retentionLimit;
|
|
98
|
+
// Hover may defer a recommended compaction to preserve the active crosshair,
|
|
99
|
+
// but never beyond two additional hidden windows.
|
|
100
|
+
const hardRetentionLimit = Math.max(canonicalData.length * 3, 3);
|
|
101
|
+
const pruneRequired = retainedPrefixLength > 0 && projectedLength > hardRetentionLimit;
|
|
102
|
+
|
|
103
|
+
return { replacementRequired: false, updates, pruneRecommended, pruneRequired };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const haveSameSeriesLayout = (
|
|
107
|
+
renderedNames: readonly string[],
|
|
108
|
+
canonicalNames: readonly string[],
|
|
109
|
+
): boolean => renderedNames.length === canonicalNames.length
|
|
110
|
+
&& renderedNames.every((name, index) => name === canonicalNames[index]);
|