@design.estate/dees-catalog 3.98.0 → 3.99.0

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';
@@ -28,6 +33,11 @@ export type TChartPriceLine = 'avg' | 'max';
28
33
 
29
34
  export const CHART_LEGEND_STATS: TChartLegendStat[] = ['latest', 'min', 'max', 'avg'];
30
35
 
36
+ export interface IChartSelectedRange {
37
+ from: number;
38
+ to: number;
39
+ }
40
+
31
41
  declare global {
32
42
  interface HTMLElementTagNameMap {
33
43
  'dees-chart-area': DeesChartArea;
@@ -95,28 +105,116 @@ export class DeesChartArea extends DeesElement {
95
105
  @property({ type: Array })
96
106
  accessor chartLines: TChartPriceLine[] = ['avg', 'max'];
97
107
 
108
+
109
+ @property({ type: Boolean })
110
+ accessor rangeSelectionEnabled = false;
111
+
112
+ @property({ attribute: false })
113
+ accessor selectedRange: IChartSelectedRange | null = null;
98
114
  private internalChartData: ChartSeriesConfig = [];
99
115
  private autoScrollTimer: number | null = null;
100
116
  private lcBundle: ILightweightChartsBundle | null = null;
101
117
  private seriesApis: Map<string, ISeriesApi<any>> = new Map();
102
118
  private priceLines: Map<string, any[]> = new Map();
103
119
  private tooltipEl: HTMLDivElement | null = null;
120
+ private crosshairActive = false;
121
+ private pendingRealtimePrune = false;
122
+ private crosshairMoveHandler: ((param: MouseEventParams) => void) | null = null;
123
+ private chartContainer: HTMLDivElement | null = null;
124
+ private rangeOverlay: HTMLDivElement | null = null;
125
+ private rangeResizeObserver: ResizeObserver | null = null;
126
+ private rangeDrag: { pointerId: number; startX: number; startMs: number } | null = null;
127
+ private readonly rangePointerDownHandler = (event: PointerEvent): void => {
128
+ if (!this.rangeSelectionEnabled || event.button !== 0 || !this.chartContainer) return;
129
+ const x = this.getRangePointerX(event);
130
+ const timeMs = this.coordinateToEpochMs(x);
131
+ if (timeMs === null) return;
132
+ event.preventDefault();
133
+ this.rangeDrag = { pointerId: event.pointerId, startX: x, startMs: timeMs };
134
+ try { this.chartContainer.setPointerCapture(event.pointerId); } catch { /* synthetic event */ }
135
+ window.addEventListener('keydown', this.rangeKeydownHandler);
136
+ this.paintRangeOverlay(x, x);
137
+ };
138
+ private readonly rangePointerMoveHandler = (event: PointerEvent): void => {
139
+ if (!this.rangeDrag || event.pointerId !== this.rangeDrag.pointerId) return;
140
+ event.preventDefault();
141
+ this.paintRangeOverlay(this.rangeDrag.startX, this.getRangePointerX(event));
142
+ };
143
+ private readonly rangePointerUpHandler = (event: PointerEvent): void => {
144
+ const drag = this.rangeDrag;
145
+ if (!drag || event.pointerId !== drag.pointerId) return;
146
+ const endX = this.getRangePointerX(event);
147
+ const endMs = this.coordinateToEpochMs(endX);
148
+ this.finishRangeDrag(event.pointerId);
149
+ if (endMs === null || Math.abs(endX - drag.startX) < 4) {
150
+ this.syncRangeOverlay();
151
+ return;
152
+ }
153
+ const from = Math.min(drag.startMs, endMs);
154
+ const to = Math.max(drag.startMs, endMs);
155
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from >= to) {
156
+ this.syncRangeOverlay();
157
+ return;
158
+ }
159
+ this.dispatchEvent(new CustomEvent<IChartSelectedRange>('range-change', {
160
+ detail: { from, to },
161
+ bubbles: true,
162
+ composed: true,
163
+ }));
164
+ this.syncRangeOverlay();
165
+ };
166
+ private readonly rangePointerCancelHandler = (event: PointerEvent): void => {
167
+ if (!this.rangeDrag || event.pointerId !== this.rangeDrag.pointerId) return;
168
+ this.finishRangeDrag(event.pointerId);
169
+ this.syncRangeOverlay();
170
+ };
171
+ private readonly rangeKeydownHandler = (event: KeyboardEvent): void => {
172
+ if (event.key !== 'Escape' || !this.rangeDrag) return;
173
+ const pointerId = this.rangeDrag.pointerId;
174
+ this.finishRangeDrag(pointerId);
175
+ this.syncRangeOverlay();
176
+ };
177
+ private readonly rangeVisibleTimeChangeHandler = (): void => {
178
+ this.syncRangeOverlay();
179
+ };
180
+ private readonly chartMouseLeaveHandler = () => {
181
+ this.chart?.clearCrosshairPosition();
182
+ this.handleCrosshairCleared();
183
+ };
104
184
 
105
185
  constructor() {
106
186
  super();
107
187
  domtools.elementBasic.setup();
108
188
  this.registerGarbageFunction(async () => {
109
189
  this.stopAutoScroll();
110
- if (this.chart) {
190
+ const chart = this.chart;
191
+ const crosshairMoveHandler = this.crosshairMoveHandler;
192
+ this.crosshairMoveHandler = null;
193
+ this.chartContainer?.removeEventListener('mouseleave', this.chartMouseLeaveHandler);
194
+ this.teardownRangeSelection();
195
+ this.chart = null;
196
+ this.rangeResizeObserver?.disconnect();
197
+ this.rangeResizeObserver = null;
198
+ this.chartContainer = null;
199
+ this.tooltipEl = null;
200
+ this.rangeOverlay = null;
201
+ this.crosshairActive = false;
202
+ this.pendingRealtimePrune = false;
203
+ if (crosshairMoveHandler && chart) {
111
204
  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);
205
+ chart.unsubscribeCrosshairMove(crosshairMoveHandler);
206
+ } catch (error) {
207
+ console.error('Error unsubscribing chart crosshair:', error);
118
208
  }
119
209
  }
210
+ try {
211
+ chart?.remove();
212
+ } catch (error) {
213
+ console.error('Error destroying chart:', error);
214
+ } finally {
215
+ this.seriesApis.clear();
216
+ this.priceLines.clear();
217
+ }
120
218
  });
121
219
  }
122
220
 
@@ -128,15 +226,133 @@ export class DeesChartArea extends DeesElement {
128
226
 
129
227
  // --- Helpers ---
130
228
 
229
+ private getRangePointerX(event: PointerEvent): number {
230
+ const rect = this.chartContainer!.getBoundingClientRect();
231
+ return Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
232
+ }
233
+
234
+ private coordinateToEpochMs(coordinateArg: number): number | null {
235
+ const time = this.chart?.timeScale().coordinateToTime(coordinateArg);
236
+ if (typeof time !== 'number' || !Number.isFinite(time)) return null;
237
+ const epochMs = Math.round(time * 1000);
238
+ return Number.isSafeInteger(epochMs) && epochMs >= 0 ? epochMs : null;
239
+ }
240
+
241
+ private paintRangeOverlay(firstXArg: number, secondXArg: number): void {
242
+ if (!this.rangeOverlay) return;
243
+ const left = Math.min(firstXArg, secondXArg);
244
+ const width = Math.abs(secondXArg - firstXArg);
245
+ this.rangeOverlay.style.display = 'block';
246
+ this.rangeOverlay.style.left = `${left}px`;
247
+ this.rangeOverlay.style.width = `${Math.max(width, 1)}px`;
248
+ }
249
+
250
+ private finishRangeDrag(pointerIdArg: number): void {
251
+ this.rangeDrag = null;
252
+ window.removeEventListener('keydown', this.rangeKeydownHandler);
253
+ try { this.chartContainer?.releasePointerCapture(pointerIdArg); } catch { /* already released */ }
254
+ }
255
+
256
+ private setupRangeSelection(): void {
257
+ if (!this.chartContainer) return;
258
+ this.rangeOverlay = this.chartContainer.querySelector('.rangeSelectionOverlay');
259
+ this.chart?.timeScale().subscribeVisibleTimeRangeChange(this.rangeVisibleTimeChangeHandler);
260
+ this.chartContainer.addEventListener('pointerdown', this.rangePointerDownHandler);
261
+ this.chartContainer.addEventListener('pointermove', this.rangePointerMoveHandler);
262
+ this.chartContainer.addEventListener('pointerup', this.rangePointerUpHandler);
263
+ this.chartContainer.addEventListener('pointercancel', this.rangePointerCancelHandler);
264
+ this.chartContainer.addEventListener('lostpointercapture', this.rangePointerCancelHandler);
265
+ this.rangeResizeObserver = new ResizeObserver(() => this.syncRangeOverlay());
266
+ this.rangeResizeObserver.observe(this.chartContainer);
267
+ this.syncRangeOverlay();
268
+ }
269
+
270
+ private teardownRangeSelection(): void {
271
+ if (!this.chartContainer) return;
272
+ this.chart?.timeScale().unsubscribeVisibleTimeRangeChange(this.rangeVisibleTimeChangeHandler);
273
+ this.chartContainer.removeEventListener('pointerdown', this.rangePointerDownHandler);
274
+ this.chartContainer.removeEventListener('pointermove', this.rangePointerMoveHandler);
275
+ this.chartContainer.removeEventListener('pointerup', this.rangePointerUpHandler);
276
+ this.chartContainer.removeEventListener('pointercancel', this.rangePointerCancelHandler);
277
+ this.chartContainer.removeEventListener('lostpointercapture', this.rangePointerCancelHandler);
278
+ window.removeEventListener('keydown', this.rangeKeydownHandler);
279
+ if (this.rangeDrag) {
280
+ try { this.chartContainer.releasePointerCapture(this.rangeDrag.pointerId); } catch { /* no capture */ }
281
+ }
282
+ this.rangeDrag = null;
283
+ }
284
+
285
+ private syncRangeOverlay(): void {
286
+ if (!this.rangeOverlay || !this.chart || !this.rangeSelectionEnabled || !this.selectedRange) {
287
+ if (this.rangeOverlay) this.rangeOverlay.style.display = 'none';
288
+ return;
289
+ }
290
+ const { from, to } = this.selectedRange;
291
+ if (
292
+ !Number.isSafeInteger(from)
293
+ || !Number.isSafeInteger(to)
294
+ || from < 0
295
+ || to <= from
296
+ ) {
297
+ this.rangeOverlay.style.display = 'none';
298
+ return;
299
+ }
300
+ const fromCoordinate = this.chart.timeScale().timeToCoordinate(
301
+ Math.floor(from / 1000) as UTCTimestamp,
302
+ );
303
+ const toCoordinate = this.chart.timeScale().timeToCoordinate(
304
+ Math.floor(to / 1000) as UTCTimestamp,
305
+ );
306
+ if (fromCoordinate === null || toCoordinate === null) {
307
+ this.rangeOverlay.style.display = 'none';
308
+ return;
309
+ }
310
+ this.paintRangeOverlay(fromCoordinate, toCoordinate);
311
+ }
312
+
131
313
  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
- })
314
+ const pointsByTime = new Map<number, { time: UTCTimestamp; value: number }>();
315
+ for (const point of data) {
316
+ const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
317
+ const time = Math.floor(ms / 1000);
318
+ if (!Number.isFinite(time) || !Number.isFinite(point.y)) continue;
319
+ // Lightweight Charts requires unique ordered timestamps. The latest
320
+ // sample wins when multiple updates land in the same second.
321
+ pointsByTime.set(time, { time: time as UTCTimestamp, value: point.y });
322
+ }
323
+ return [...pointsByTime.values()]
137
324
  .sort((a, b) => (a.time as number) - (b.time as number));
138
325
  }
139
326
 
327
+ private getSeriesName(series: ChartSeriesConfig[number], index: number): string {
328
+ return series.name || `series-${index}`;
329
+ }
330
+
331
+ private getSeriesKey(series: ChartSeriesConfig[number], index: number): string {
332
+ return `${index}:${this.getSeriesName(series, index)}`;
333
+ }
334
+
335
+ private getCanonicalChartSeries(chartSeries: ChartSeriesConfig): ChartSeriesConfig {
336
+ const cutoffTime = Date.now() - this.rollingWindow;
337
+ return chartSeries.map((series) => ({
338
+ ...series,
339
+ data: this.convertDataToLC(series.data.filter((point) => {
340
+ if (!this.realtimeMode || this.rollingWindow <= 0) return true;
341
+ const ms = typeof point.x === 'number' ? point.x : new Date(point.x).getTime();
342
+ return ms > cutoffTime;
343
+ })).map((point) => ({
344
+ x: (point.time as number) * 1000,
345
+ y: point.value,
346
+ })),
347
+ }));
348
+ }
349
+
350
+ private getRenderedSeriesData(api: ISeriesApi<any>): IRealtimeChartPoint[] {
351
+ return api.data()
352
+ .filter((point): point is { time: UTCTimestamp; value: number } => 'value' in point)
353
+ .map((point) => ({ time: point.time as number, value: point.value }));
354
+ }
355
+
140
356
  private colorToRgba(color: string, alpha: number): string {
141
357
  if (/^#[0-9a-fA-F]{6}$/.test(color)) {
142
358
  return hexToRgba(color, alpha);
@@ -238,7 +454,7 @@ export class DeesChartArea extends DeesElement {
238
454
  });
239
455
 
240
456
  api.setData(this.convertDataToLC(s.data));
241
- this.updatePriceLines(s.name || `series-${index}`, api, s.data, color);
457
+ this.updatePriceLines(this.getSeriesKey(s, index), api, s.data, color);
242
458
 
243
459
  if (this.yAxisScaling !== 'dynamic') {
244
460
  api.applyOptions({
@@ -248,17 +464,77 @@ export class DeesChartArea extends DeesElement {
248
464
  } as any);
249
465
  }
250
466
 
251
- this.seriesApis.set(s.name || `series-${index}`, api);
467
+ this.seriesApis.set(this.getSeriesKey(s, index), api);
252
468
  });
253
469
  this.computeStats(chartSeries);
470
+ this.pendingRealtimePrune = false;
254
471
 
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) {
472
+ if (this.realtimeMode && this.rollingWindow > 0) {
473
+ void this.updateTimeWindow();
474
+ } else {
258
475
  this.chart.timeScale().fitContent();
259
476
  }
260
477
  }
261
478
 
479
+ private updateSeriesPresentation(chartSeries: ChartSeriesConfig) {
480
+ const isDark = !this.goBright;
481
+ chartSeries.forEach((series, index) => {
482
+ const seriesKey = this.getSeriesKey(series, index);
483
+ const api = this.seriesApis.get(seriesKey);
484
+ if (!api) return;
485
+ const color = this.resolveSeriesColor(series, index, isDark);
486
+ api.applyOptions({
487
+ topColor: this.colorToRgba(color, isDark ? 0.4 : 0.5),
488
+ bottomColor: this.colorToRgba(color, 0),
489
+ lineColor: color,
490
+ });
491
+ this.updatePriceLines(seriesKey, api, series.data, color);
492
+ });
493
+ this.computeStats(chartSeries);
494
+
495
+ if (this.yAxisScaling === 'dynamic') {
496
+ const allValues = chartSeries.flatMap((series) => series.data.map((point) => point.y));
497
+ if (allValues.length > 0) {
498
+ const dynamicMax = Math.ceil(Math.max(...allValues) * 1.1);
499
+ for (const [, api] of this.seriesApis) {
500
+ api.applyOptions({
501
+ autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: dynamicMax } }),
502
+ } as any);
503
+ }
504
+ }
505
+ }
506
+ }
507
+
508
+ private handleCrosshairCleared() {
509
+ this.crosshairActive = false;
510
+ if (this.tooltipEl) this.tooltipEl.style.display = 'none';
511
+ this.flushPendingRealtimePrune();
512
+ }
513
+
514
+ private clearCrosshairForDataReplacement() {
515
+ this.pendingRealtimePrune = false;
516
+ this.chart?.clearCrosshairPosition();
517
+ this.crosshairActive = false;
518
+ if (this.tooltipEl) this.tooltipEl.style.display = 'none';
519
+ }
520
+
521
+ private flushPendingRealtimePrune() {
522
+ if (
523
+ !this.pendingRealtimePrune
524
+ || this.crosshairActive
525
+ || !this.chart
526
+ || !this.realtimeMode
527
+ || this.rollingWindow <= 0
528
+ ) return;
529
+
530
+ const canonicalSeries = this.getCanonicalChartSeries(this.internalChartData);
531
+ canonicalSeries.forEach((series, index) => {
532
+ const api = this.seriesApis.get(this.getSeriesKey(series, index));
533
+ if (api) api.setData(this.convertDataToLC(series.data));
534
+ });
535
+ this.pendingRealtimePrune = false;
536
+ }
537
+
262
538
  private computeStats(chartSeries: ChartSeriesConfig) {
263
539
  const isDark = !this.goBright;
264
540
  this.seriesStats = chartSeries.map((s, index) => {
@@ -327,15 +603,18 @@ export class DeesChartArea extends DeesElement {
327
603
  this.tooltipEl = document.createElement('div');
328
604
  this.tooltipEl.className = 'lw-tooltip';
329
605
  this.tooltipEl.style.display = 'none';
330
- this.shadowRoot!.querySelector('.chartContainer')?.appendChild(this.tooltipEl);
606
+ this.chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement | null;
607
+ this.chartContainer?.appendChild(this.tooltipEl);
608
+ this.chartContainer?.addEventListener('mouseleave', this.chartMouseLeaveHandler);
331
609
 
332
- this.chart.subscribeCrosshairMove((param: MouseEventParams) => {
610
+ this.crosshairMoveHandler = (param: MouseEventParams) => {
333
611
  if (!this.tooltipEl) return;
334
612
 
335
- if (!param.point || !param.time || param.point.x < 0 || param.point.y < 0) {
336
- this.tooltipEl.style.display = 'none';
613
+ if (!param.point || param.time === undefined || param.point.x < 0 || param.point.y < 0) {
614
+ this.handleCrosshairCleared();
337
615
  return;
338
616
  }
617
+ this.crosshairActive = true;
339
618
 
340
619
  const isDark = !this.goBright;
341
620
  const bgColor = isDark ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)';
@@ -345,11 +624,13 @@ export class DeesChartArea extends DeesElement {
345
624
  let html = '';
346
625
  let idx = 0;
347
626
  let hasData = false;
348
- for (const [name, api] of this.seriesApis) {
627
+ for (const api of this.seriesApis.values()) {
349
628
  const data = param.seriesData.get(api);
350
629
  if (data && 'value' in data && (data as any).value !== undefined) {
351
630
  hasData = true;
352
- const color = this.resolveSeriesColor(this.chartSeries[idx], idx, isDark);
631
+ const series = this.internalChartData[idx];
632
+ const name = series ? this.getSeriesName(series, idx) : `series-${idx}`;
633
+ const color = this.resolveSeriesColor(series, idx, isDark);
353
634
  const formatted = this.yAxisFormatter((data as any).value);
354
635
  html += `<div style="display:flex;align-items:center;gap:8px;margin:${idx > 0 ? '6px' : '0'} 0;">
355
636
  <span style="display:inline-block;width:10px;height:10px;background:${color};border-radius:2px;"></span>
@@ -378,15 +659,19 @@ export class DeesChartArea extends DeesElement {
378
659
  if (left + 200 > containerWidth) left = param.point.x - 216;
379
660
  this.tooltipEl.style.left = `${left}px`;
380
661
  this.tooltipEl.style.top = `${param.point.y - 16}px`;
381
- });
662
+ };
663
+ this.chart.subscribeCrosshairMove(this.crosshairMoveHandler);
382
664
  }
383
665
 
384
666
  // --- Lifecycle ---
385
667
 
386
668
  public async firstUpdated() {
387
669
  await this.domtoolsPromise;
670
+ if (!this.isConnected) return;
388
671
  this.lcBundle = await DeesServiceLibLoader.getInstance().loadLightweightCharts();
672
+ if (!this.isConnected) return;
389
673
  await new Promise(resolve => requestAnimationFrame(resolve));
674
+ if (!this.isConnected) return;
390
675
 
391
676
  const chartContainer = this.shadowRoot!.querySelector('.chartContainer') as HTMLDivElement;
392
677
  if (!chartContainer) return;
@@ -446,9 +731,12 @@ export class DeesChartArea extends DeesElement {
446
731
  },
447
732
  ];
448
733
 
449
- this.internalChartData = chartSeries;
450
- this.recreateSeries(chartSeries);
734
+ const canonicalSeries = this.getCanonicalChartSeries(chartSeries);
735
+ this.internalChartData = canonicalSeries;
736
+ this.recreateSeries(canonicalSeries);
451
737
  this.setupTooltip();
738
+ this.setupRangeSelection();
739
+ this.syncAutoScroll();
452
740
  } catch (error) {
453
741
  console.error('Failed to initialize chart:', error);
454
742
  }
@@ -461,7 +749,8 @@ export class DeesChartArea extends DeesElement {
461
749
  this.applyTheme();
462
750
  }
463
751
 
464
- if (changedProperties.has('series') && this.chart && this.series.length > 0) {
752
+ const seriesChanged = changedProperties.has('series') && this.series.length > 0;
753
+ if (seriesChanged && this.chart) {
465
754
  await this.updateSeries(this.series);
466
755
  }
467
756
 
@@ -469,19 +758,29 @@ export class DeesChartArea extends DeesElement {
469
758
  // yAxisFormatter is used by the tooltip; LC price scale uses default formatting
470
759
  }
471
760
 
472
- if (changedProperties.has('realtimeMode') && this.chart) {
473
- if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
474
- this.startAutoScroll();
761
+ const realtimeConfigurationChanged = changedProperties.has('realtimeMode')
762
+ || changedProperties.has('rollingWindow');
763
+ const autoScrollConfigurationChanged = realtimeConfigurationChanged
764
+ || changedProperties.has('autoScrollInterval');
765
+ if (this.chart && realtimeConfigurationChanged) {
766
+ if (!seriesChanged && this.internalChartData.length > 0) {
767
+ await this.updateSeries(this.internalChartData);
768
+ }
769
+ if (this.realtimeMode && this.rollingWindow > 0) {
770
+ await this.updateTimeWindow();
475
771
  } else {
476
- this.stopAutoScroll();
772
+ this.chart.timeScale().fitContent();
477
773
  }
478
774
  }
775
+ if (this.chart && autoScrollConfigurationChanged) {
776
+ this.syncAutoScroll();
777
+ }
479
778
 
480
- if (changedProperties.has('autoScrollInterval') && this.chart) {
481
- this.stopAutoScroll();
482
- if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
483
- this.startAutoScroll();
484
- }
779
+ if (
780
+ changedProperties.has('selectedRange')
781
+ || changedProperties.has('rangeSelectionEnabled')
782
+ ) {
783
+ this.syncRangeOverlay();
485
784
  }
486
785
 
487
786
  if (changedProperties.has('chartLines') && this.chart) {
@@ -507,80 +806,84 @@ export class DeesChartArea extends DeesElement {
507
806
  if (!this.chart) return;
508
807
 
509
808
  try {
510
- this.internalChartData = newSeries;
511
-
512
- if (this.rollingWindow > 0 && this.realtimeMode) {
513
- const now = Date.now();
514
- const cutoffTime = now - this.rollingWindow;
809
+ void animate;
810
+ const canonicalSeries = this.getCanonicalChartSeries(newSeries);
811
+ this.internalChartData = canonicalSeries;
812
+ const renderedKeys = [...this.seriesApis.keys()];
813
+ const canonicalKeys = canonicalSeries.map((series, index) => this.getSeriesKey(series, index));
814
+
815
+ if (!haveSameSeriesLayout(renderedKeys, canonicalKeys)) {
816
+ this.clearCrosshairForDataReplacement();
817
+ this.recreateSeries(canonicalSeries);
818
+ this.updateSeriesPresentation(canonicalSeries);
819
+ return;
820
+ }
515
821
 
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
- }
822
+ if (!this.realtimeMode || this.rollingWindow <= 0) {
823
+ this.clearCrosshairForDataReplacement();
824
+ canonicalSeries.forEach((series, index) => {
825
+ this.seriesApis.get(this.getSeriesKey(series, index))!
826
+ .setData(this.convertDataToLC(series.data));
827
+ });
828
+ this.pendingRealtimePrune = false;
829
+ this.updateSeriesPresentation(canonicalSeries);
830
+ return;
831
+ }
539
832
 
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 */ }
833
+ const plannedUpdates = canonicalSeries.map((series, index) => {
834
+ const api = this.seriesApis.get(this.getSeriesKey(series, index))!;
835
+ const canonicalData = this.convertDataToLC(series.data)
836
+ .map((point) => ({ time: point.time as number, value: point.value }));
837
+ return {
838
+ api,
839
+ canonicalData,
840
+ plan: planRealtimeChartUpdates(this.getRenderedSeriesData(api), canonicalData),
841
+ };
842
+ });
546
843
 
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
- }
844
+ if (plannedUpdates.some(({ plan }) => plan.replacementRequired)) {
845
+ this.clearCrosshairForDataReplacement();
846
+ for (const { api, canonicalData } of plannedUpdates) {
847
+ api.setData(canonicalData.map((point) => ({
848
+ time: point.time as UTCTimestamp,
849
+ value: point.value,
850
+ })));
562
851
  }
852
+ this.pendingRealtimePrune = false;
563
853
  } 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);
854
+ for (const { api, plan } of plannedUpdates) {
855
+ for (const update of plan.updates) {
856
+ api.update({
857
+ time: update.point.time as UTCTimestamp,
858
+ value: update.point.value,
859
+ }, update.historicalUpdate);
860
+ }
861
+ }
862
+
863
+ const pruneRequired = plannedUpdates.some(({ plan }) => plan.pruneRequired);
864
+ const pruneRecommended = plannedUpdates.some(({ plan }) => plan.pruneRecommended);
865
+ if (pruneRequired) {
866
+ this.clearCrosshairForDataReplacement();
867
+ for (const { api, canonicalData } of plannedUpdates) {
868
+ api.setData(canonicalData.map((point) => ({
869
+ time: point.time as UTCTimestamp,
870
+ value: point.value,
871
+ })));
872
+ }
873
+ } else if (pruneRecommended && this.crosshairActive) {
874
+ this.pendingRealtimePrune = true;
875
+ } else if (pruneRecommended) {
876
+ for (const { api, canonicalData } of plannedUpdates) {
877
+ api.setData(canonicalData.map((point) => ({
878
+ time: point.time as UTCTimestamp,
879
+ value: point.value,
880
+ })));
881
+ }
882
+ this.pendingRealtimePrune = false;
582
883
  }
583
884
  }
885
+
886
+ this.updateSeriesPresentation(canonicalSeries);
584
887
  } catch (error) {
585
888
  console.error('Failed to update chart series:', error);
586
889
  }
@@ -600,15 +903,28 @@ export class DeesChartArea extends DeesElement {
600
903
 
601
904
  public async appendData(newData: { name?: string; data: Array<{ x: any; y: number }> }[]) {
602
905
  if (!this.chart) return;
906
+ const nextSeries = this.internalChartData.map((series) => ({
907
+ ...series,
908
+ data: [...series.data],
909
+ }));
910
+ const namedOccurrences = new Map<string, number>();
603
911
  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]);
912
+ let targetIndex = index;
913
+ if (s.name) {
914
+ const occurrence = namedOccurrences.get(s.name) ?? 0;
915
+ namedOccurrences.set(s.name, occurrence + 1);
916
+ const matchingIndices = this.internalChartData
917
+ .map((series, seriesIndex) => series.name === s.name ? seriesIndex : -1)
918
+ .filter((seriesIndex) => seriesIndex >= 0);
919
+ const matchingIndex = matchingIndices[occurrence];
920
+ if (matchingIndex === undefined) return;
921
+ targetIndex = matchingIndex;
610
922
  }
923
+ const targetSeries = nextSeries[targetIndex];
924
+ if (!targetSeries || s.data.length === 0) return;
925
+ targetSeries.data.push(...s.data);
611
926
  });
927
+ await this.updateSeries(nextSeries);
612
928
  }
613
929
 
614
930
  public async updateOptions(options: Record<string, any>) {
@@ -653,25 +969,32 @@ export class DeesChartArea extends DeesElement {
653
969
 
654
970
  private refreshPriceLines() {
655
971
  const isDark = !this.goBright;
656
- this.chartSeries.forEach((s, index) => {
657
- const name = s.name || `series-${index}`;
658
- const api = this.seriesApis.get(name);
972
+ this.getCanonicalChartSeries(this.internalChartData).forEach((s, index) => {
973
+ const seriesKey = this.getSeriesKey(s, index);
974
+ const api = this.seriesApis.get(seriesKey);
659
975
  if (!api) return;
660
- this.updatePriceLines(name, api, s.data, this.resolveSeriesColor(s, index, isDark));
976
+ this.updatePriceLines(seriesKey, api, s.data, this.resolveSeriesColor(s, index, isDark));
661
977
  });
662
978
  }
663
979
 
664
980
  private startAutoScroll() {
665
- if (this.autoScrollTimer) return;
981
+ if (this.autoScrollTimer !== null) return;
666
982
  this.autoScrollTimer = window.setInterval(() => {
667
983
  this.updateTimeWindow();
668
984
  }, this.autoScrollInterval);
669
985
  }
670
986
 
671
987
  private stopAutoScroll() {
672
- if (this.autoScrollTimer) {
988
+ if (this.autoScrollTimer !== null) {
673
989
  window.clearInterval(this.autoScrollTimer);
674
990
  this.autoScrollTimer = null;
675
991
  }
676
992
  }
993
+
994
+ private syncAutoScroll() {
995
+ this.stopAutoScroll();
996
+ if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
997
+ this.startAutoScroll();
998
+ }
999
+ }
677
1000
  }