@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.
@@ -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
- if (this.chart) {
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
- this.chart.remove();
113
- this.chart = null;
114
- this.seriesApis.clear();
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
- return data
133
- .map(point => {
134
- const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
135
- return { time: Math.floor(ms / 1000) as UTCTimestamp, value: point.y };
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.name || `series-${index}`, api, s.data, color);
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.name || `series-${index}`, api);
312
+ this.seriesApis.set(this.getSeriesKey(s, index), api);
252
313
  });
253
314
  this.computeStats(chartSeries);
315
+ this.pendingRealtimePrune = false;
254
316
 
255
- // Without an explicit rolling window the visible range is never set elsewhere,
256
- // leaving lightweight-charts' default zoom (data squeezed to the right edge).
257
- if (this.rollingWindow <= 0) {
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')?.appendChild(this.tooltipEl);
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.chart.subscribeCrosshairMove((param: MouseEventParams) => {
455
+ this.crosshairMoveHandler = (param: MouseEventParams) => {
333
456
  if (!this.tooltipEl) return;
334
457
 
335
- if (!param.point || !param.time || param.point.x < 0 || param.point.y < 0) {
336
- this.tooltipEl.style.display = 'none';
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 [name, api] of this.seriesApis) {
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 color = this.resolveSeriesColor(this.chartSeries[idx], idx, isDark);
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
- this.internalChartData = chartSeries;
450
- this.recreateSeries(chartSeries);
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
- if (changedProperties.has('series') && this.chart && this.series.length > 0) {
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
- if (changedProperties.has('realtimeMode') && this.chart) {
473
- if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
474
- this.startAutoScroll();
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.stopAutoScroll();
616
+ this.chart.timeScale().fitContent();
477
617
  }
478
618
  }
479
-
480
- if (changedProperties.has('autoScrollInterval') && this.chart) {
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
- this.internalChartData = newSeries;
511
-
512
- if (this.rollingWindow > 0 && this.realtimeMode) {
513
- const now = Date.now();
514
- const cutoffTime = now - this.rollingWindow;
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
- if (newSeries.length !== this.seriesApis.size) {
517
- this.recreateSeries(newSeries);
518
- } else {
519
- const isDark = !this.goBright;
520
- newSeries.forEach((s, index) => {
521
- const name = s.name || `series-${index}`;
522
- const api = this.seriesApis.get(name);
523
- if (!api) return;
524
- const filtered = s.data.filter(point => {
525
- const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
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
- try {
541
- this.chart.timeScale().setVisibleRange({
542
- from: Math.floor(cutoffTime / 1000) as UTCTimestamp,
543
- to: Math.floor(now / 1000) as UTCTimestamp,
544
- });
545
- } catch (e) { /* range may be invalid */ }
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
- if (this.yAxisScaling === 'dynamic') {
548
- const allValues = newSeries.flatMap(s =>
549
- s.data.filter(p => {
550
- const ms = typeof p.x === 'number' ? p.x : new Date(p.x).getTime();
551
- return ms > cutoffTime;
552
- }).map(d => d.y)
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
- if (newSeries.length !== this.seriesApis.size) {
565
- this.recreateSeries(newSeries);
566
- } else {
567
- const isDark = !this.goBright;
568
- newSeries.forEach((s, index) => {
569
- const name = s.name || `series-${index}`;
570
- const api = this.seriesApis.get(name);
571
- if (!api) return;
572
- api.setData(this.convertDataToLC(s.data));
573
- const color = this.resolveSeriesColor(s, index, isDark);
574
- api.applyOptions({
575
- topColor: this.colorToRgba(color, isDark ? 0.4 : 0.5),
576
- bottomColor: this.colorToRgba(color, 0),
577
- lineColor: color,
578
- });
579
- this.updatePriceLines(name, api, s.data, color);
580
- });
581
- this.computeStats(newSeries);
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
- const name = s.name || `series-${index}`;
605
- const api = this.seriesApis.get(name);
606
- if (!api || s.data.length === 0) return;
607
- for (const point of s.data) {
608
- const lcPoints = this.convertDataToLC([point]);
609
- if (lcPoints.length > 0) api.update(lcPoints[0]);
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.chartSeries.forEach((s, index) => {
657
- const name = s.name || `series-${index}`;
658
- const api = this.seriesApis.get(name);
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(name, api, s.data, this.resolveSeriesColor(s, index, isDark));
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]);