@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.
- package/dist_bundle/bundle.js +476 -118
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.d.ts +36 -0
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.js +444 -118
- 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/dist_ts_web/elements/00group-chart/dees-chart-area/styles.js +11 -1
- package/dist_ts_web/elements/00group-chart/dees-chart-area/template.js +4 -2
- package/package.json +2 -2
- package/readme.md +10 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-chart/dees-chart-area/component.ts +438 -115
- package/ts_web/elements/00group-chart/dees-chart-area/realtime-updates.ts +110 -0
- package/ts_web/elements/00group-chart/dees-chart-area/styles.ts +10 -0
- package/ts_web/elements/00group-chart/dees-chart-area/template.ts +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
467
|
+
this.seriesApis.set(this.getSeriesKey(s, index), api);
|
|
252
468
|
});
|
|
253
469
|
this.computeStats(chartSeries);
|
|
470
|
+
this.pendingRealtimePrune = false;
|
|
254
471
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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')
|
|
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.
|
|
610
|
+
this.crosshairMoveHandler = (param: MouseEventParams) => {
|
|
333
611
|
if (!this.tooltipEl) return;
|
|
334
612
|
|
|
335
|
-
if (!param.point ||
|
|
336
|
-
this.
|
|
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
|
|
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
|
|
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
|
-
|
|
450
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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.
|
|
772
|
+
this.chart.timeScale().fitContent();
|
|
477
773
|
}
|
|
478
774
|
}
|
|
775
|
+
if (this.chart && autoScrollConfigurationChanged) {
|
|
776
|
+
this.syncAutoScroll();
|
|
777
|
+
}
|
|
479
778
|
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
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.
|
|
657
|
-
const
|
|
658
|
-
const api = this.seriesApis.get(
|
|
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(
|
|
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
|
}
|