@qfo/qfchart 0.5.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/src/QFChart.ts ADDED
@@ -0,0 +1,1198 @@
1
+ import * as echarts from 'echarts';
2
+ import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
3
+ import { Indicator } from './components/Indicator';
4
+ import { LayoutManager } from './components/LayoutManager';
5
+ import { SeriesBuilder } from './components/SeriesBuilder';
6
+ import { GraphicBuilder } from './components/GraphicBuilder';
7
+ import { TooltipFormatter } from './components/TooltipFormatter';
8
+ import { PluginManager } from './components/PluginManager';
9
+ import { DrawingEditor } from './components/DrawingEditor';
10
+ import { EventBus } from './utils/EventBus';
11
+
12
+ export class QFChart implements ChartContext {
13
+ private chart: echarts.ECharts;
14
+ private options: QFChartOptions;
15
+ private marketData: OHLCV[] = [];
16
+ private indicators: Map<string, Indicator> = new Map();
17
+ private timeToIndex: Map<number, number> = new Map();
18
+ private pluginManager: PluginManager;
19
+ private drawingEditor: DrawingEditor;
20
+ public events: EventBus = new EventBus();
21
+ private isMainCollapsed: boolean = false;
22
+ private maximizedPaneId: string | null = null;
23
+
24
+ private selectedDrawingId: string | null = null; // Track selected drawing
25
+
26
+ // Drawing System
27
+ private drawings: import('./types').DrawingElement[] = [];
28
+
29
+ public coordinateConversion = {
30
+ pixelToData: (point: { x: number; y: number }) => {
31
+ // Find which grid/pane the point is in
32
+ // We iterate through all panes (series indices usually match pane indices for base series)
33
+ // Actually, we need to know how many panes there are.
34
+ // We can use the layout logic or just check grid indices.
35
+ // ECharts instance has getOption().
36
+ const option = this.chart.getOption() as any;
37
+ if (!option || !option.grid) return null;
38
+
39
+ const gridCount = option.grid.length;
40
+ for (let i = 0; i < gridCount; i++) {
41
+ if (this.chart.containPixel({ gridIndex: i }, [point.x, point.y])) {
42
+ // Found the pane
43
+ const p = this.chart.convertFromPixel({ seriesIndex: i }, [point.x, point.y]);
44
+ // Note: convertFromPixel might need seriesIndex or gridIndex depending on setup.
45
+ // Using gridIndex in convertFromPixel is supported in newer ECharts but sometimes tricky.
46
+ // Since we have one base series per pane (candlestick at 0, indicators at 1+),
47
+ // assuming seriesIndex = gridIndex usually works if they are mapped 1:1.
48
+ // Wait, candlestick is series 0. Indicators are subsequent series.
49
+ // Series index != grid index necessarily.
50
+ // BUT we can use { gridIndex: i } for convertFromPixel too!
51
+ const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
52
+
53
+ if (pGrid) {
54
+ // Store padded coordinates directly (don't subtract offset)
55
+ // This ensures all coordinates are positive and within the valid padded range
56
+ return { timeIndex: Math.round(pGrid[0]), value: pGrid[1], paneIndex: i };
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ },
62
+ dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
63
+ const paneIdx = point.paneIndex || 0;
64
+ // Coordinates are already in padded space, so use directly
65
+ const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
66
+ if (p) {
67
+ return { x: p[0], y: p[1] };
68
+ }
69
+ return null;
70
+ },
71
+ };
72
+
73
+ // Default colors and constants
74
+ private readonly upColor: string = '#00da3c';
75
+ private readonly downColor: string = '#ec0000';
76
+ private readonly defaultPadding = 0.0;
77
+ private padding: number;
78
+ private dataIndexOffset: number = 0; // Offset for phantom padding data
79
+
80
+ // DOM Elements for Layout
81
+ private rootContainer: HTMLElement;
82
+ private layoutContainer: HTMLElement;
83
+ private toolbarContainer: HTMLElement; // New Toolbar
84
+ private leftSidebar: HTMLElement;
85
+ private rightSidebar: HTMLElement;
86
+ private chartContainer: HTMLElement;
87
+
88
+ constructor(container: HTMLElement, options: QFChartOptions = {}) {
89
+ this.rootContainer = container;
90
+ this.options = {
91
+ title: 'Market',
92
+ height: '600px',
93
+ backgroundColor: '#1e293b',
94
+ upColor: '#00da3c',
95
+ downColor: '#ec0000',
96
+ fontColor: '#cbd5e1',
97
+ fontFamily: 'sans-serif',
98
+ padding: 0.01,
99
+ dataZoom: {
100
+ visible: true,
101
+ position: 'top',
102
+ height: 6,
103
+ },
104
+ layout: {
105
+ mainPaneHeight: '50%',
106
+ gap: 13,
107
+ },
108
+ watermark: true,
109
+ ...options,
110
+ };
111
+
112
+ if (this.options.upColor) this.upColor = this.options.upColor;
113
+ if (this.options.downColor) this.downColor = this.options.downColor;
114
+ this.padding = this.options.padding !== undefined ? this.options.padding : this.defaultPadding;
115
+
116
+ if (this.options.height) {
117
+ if (typeof this.options.height === 'number') {
118
+ this.rootContainer.style.height = `${this.options.height}px`;
119
+ } else {
120
+ this.rootContainer.style.height = this.options.height;
121
+ }
122
+ }
123
+
124
+ // Initialize DOM Layout
125
+ this.rootContainer.innerHTML = '';
126
+
127
+ // Layout Container (Flex Row)
128
+ this.layoutContainer = document.createElement('div');
129
+ this.layoutContainer.style.display = 'flex';
130
+ this.layoutContainer.style.width = '100%';
131
+ this.layoutContainer.style.height = '100%';
132
+ this.layoutContainer.style.overflow = 'hidden';
133
+ this.rootContainer.appendChild(this.layoutContainer);
134
+
135
+ // Left Sidebar
136
+ this.leftSidebar = document.createElement('div');
137
+ this.leftSidebar.style.display = 'none';
138
+ this.leftSidebar.style.width = '250px'; // Default width
139
+ this.leftSidebar.style.flexShrink = '0';
140
+ this.leftSidebar.style.overflowY = 'auto';
141
+ this.leftSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
142
+ this.leftSidebar.style.borderRight = '1px solid #334155';
143
+ this.leftSidebar.style.padding = '10px';
144
+ this.leftSidebar.style.boxSizing = 'border-box';
145
+ this.leftSidebar.style.color = '#cbd5e1';
146
+ this.leftSidebar.style.fontSize = '12px';
147
+ this.leftSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
148
+ this.layoutContainer.appendChild(this.leftSidebar);
149
+
150
+ // Toolbar Container
151
+ this.toolbarContainer = document.createElement('div');
152
+ this.layoutContainer.appendChild(this.toolbarContainer);
153
+
154
+ // Chart Container
155
+ this.chartContainer = document.createElement('div');
156
+ this.chartContainer.style.flexGrow = '1';
157
+ this.chartContainer.style.height = '100%';
158
+ this.chartContainer.style.overflow = 'hidden';
159
+ this.layoutContainer.appendChild(this.chartContainer);
160
+
161
+ // Right Sidebar
162
+ this.rightSidebar = document.createElement('div');
163
+ this.rightSidebar.style.display = 'none';
164
+ this.rightSidebar.style.width = '250px';
165
+ this.rightSidebar.style.flexShrink = '0';
166
+ this.rightSidebar.style.overflowY = 'auto';
167
+ this.rightSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
168
+ this.rightSidebar.style.borderLeft = '1px solid #334155';
169
+ this.rightSidebar.style.padding = '10px';
170
+ this.rightSidebar.style.boxSizing = 'border-box';
171
+ this.rightSidebar.style.color = '#cbd5e1';
172
+ this.rightSidebar.style.fontSize = '12px';
173
+ this.rightSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
174
+ this.layoutContainer.appendChild(this.rightSidebar);
175
+
176
+ this.chart = echarts.init(this.chartContainer);
177
+ this.pluginManager = new PluginManager(this, this.toolbarContainer);
178
+ this.drawingEditor = new DrawingEditor(this);
179
+
180
+ // Bind global chart/ZRender events to the EventBus
181
+ this.chart.on('dataZoom', (params: any) => this.events.emit('chart:dataZoom', params));
182
+ // @ts-ignore - ECharts event handler type mismatch
183
+ this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
184
+ // @ts-ignore - ECharts ZRender event handler type mismatch
185
+ this.chart.getZr().on('mousedown', (params: any) => this.events.emit('mouse:down', params));
186
+ // @ts-ignore - ECharts ZRender event handler type mismatch
187
+ this.chart.getZr().on('mousemove', (params: any) => this.events.emit('mouse:move', params));
188
+ // @ts-ignore - ECharts ZRender event handler type mismatch
189
+ this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
190
+ // @ts-ignore - ECharts ZRender event handler type mismatch
191
+ this.chart.getZr().on('click', (params: any) => this.events.emit('mouse:click', params));
192
+
193
+ const zr = this.chart.getZr();
194
+ const originalSetCursorStyle = zr.setCursorStyle;
195
+ zr.setCursorStyle = function (cursorStyle: string) {
196
+ // Change 'grab' (default roam cursor) to 'crosshair' (more suitable for candlestick chart)
197
+ if (cursorStyle === 'grab') {
198
+ cursorStyle = 'crosshair';
199
+ }
200
+ // Call the original method with your modified style
201
+ originalSetCursorStyle.call(this, cursorStyle);
202
+ };
203
+
204
+ // Bind Drawing Events
205
+ this.bindDrawingEvents();
206
+
207
+ window.addEventListener('resize', this.resize.bind(this));
208
+
209
+ // Listen for fullscreen change to restore state if exited via ESC
210
+ document.addEventListener('fullscreenchange', this.onFullscreenChange);
211
+
212
+ // Keyboard listener for deletion
213
+ document.addEventListener('keydown', this.onKeyDown);
214
+ }
215
+
216
+ private onKeyDown = (e: KeyboardEvent) => {
217
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedDrawingId) {
218
+ this.removeDrawing(this.selectedDrawingId);
219
+ this.selectedDrawingId = null;
220
+ this.render();
221
+ // Optional: emit deleted event here or in removeDrawing?
222
+ // Since removeDrawing is generic, maybe better here if we want 'deleted by user' nuance.
223
+ // But removeDrawing is called from other places too.
224
+ }
225
+ };
226
+
227
+ private onFullscreenChange = () => {
228
+ this.render();
229
+ };
230
+
231
+ private bindDrawingEvents() {
232
+ let hideTimeout: any = null;
233
+ let lastHoveredGroup: any = null;
234
+
235
+ // Helper to get drawing info
236
+ const getDrawingInfo = (params: any) => {
237
+ if (!params || params.componentType !== 'series' || !params.seriesName?.startsWith('drawings')) {
238
+ return null;
239
+ }
240
+
241
+ // Find the drawing
242
+ const paneIndex = params.seriesIndex; // We can't rely on seriesIndex to find pane index easily as it shifts.
243
+ // But we named the series "drawings-pane-{index}".
244
+ const match = params.seriesName.match(/drawings-pane-(\d+)/);
245
+ if (!match) return null;
246
+
247
+ const paneIdx = parseInt(match[1]);
248
+
249
+ // We stored drawings for this pane in render(), but here we access the flat list?
250
+ // Wait, params.dataIndex is the index in the filtered array passed to that series.
251
+ // We need to re-find the drawing or store metadata.
252
+ // In render(), we map `drawingsByPane`.
253
+
254
+ // Efficient way: Re-filter to get the specific drawing.
255
+ // Assuming the order in render() is preserved.
256
+ const paneDrawings = this.drawings.filter((d) => (d.paneIndex || 0) === paneIdx);
257
+ const drawing = paneDrawings[params.dataIndex];
258
+
259
+ if (!drawing) return null;
260
+
261
+ // Check target for specific part (line or point)
262
+ // ECharts event params.event.target is the graphic element
263
+ const targetName = params.event?.target?.name; // 'line', 'point-start', 'point-end'
264
+
265
+ return { drawing, targetName, paneIdx };
266
+ };
267
+
268
+ this.chart.on('mouseover', (params: any) => {
269
+ const info = getDrawingInfo(params);
270
+ if (!info) return;
271
+
272
+ // Handle visibility of points
273
+ const group = params.event?.target?.parent;
274
+ if (group) {
275
+ // If the drawing is selected, we don't want hover to mess with opacity
276
+ // However, the user might be hovering a DIFFERENT drawing.
277
+ // Let's check the drawing ID from 'info'.
278
+ const isSelected = info.drawing.id === this.selectedDrawingId;
279
+
280
+ if (hideTimeout) {
281
+ clearTimeout(hideTimeout);
282
+ hideTimeout = null;
283
+ }
284
+
285
+ // Show points if not selected (if selected, they are already visible)
286
+ if (!isSelected) {
287
+ group.children().forEach((child: any) => {
288
+ if (child.name && child.name.startsWith('point')) {
289
+ child.attr('style', { opacity: 1 });
290
+ }
291
+ });
292
+ }
293
+
294
+ // Handle switching groups
295
+ if (lastHoveredGroup && lastHoveredGroup !== group) {
296
+ // Check if last group belongs to the selected drawing?
297
+ // We don't have easy access to the drawing ID of 'lastHoveredGroup' unless we stored it.
298
+ // But we can just iterate and hide points.
299
+ // Wait, if lastHoveredGroup IS the selected drawing, we should NOT hide points.
300
+ // We need to know if lastHoveredGroup corresponds to selected drawing.
301
+ // Storing 'lastHoveredDrawingId' would be better.
302
+ // Simple fix: We rely on the render() logic which sets opacity: 1 for selected.
303
+ // If we manually set opacity: 0 via ZRender attr, it might override the initial render state?
304
+ // Yes, ZRender elements are persistent until re-render.
305
+ // So we must be careful not to hide points of the selected drawing.
306
+ // But we don't know the ID of lastHoveredGroup here easily.
307
+ // Let's modify the hide logic to be safer.
308
+ }
309
+ lastHoveredGroup = group;
310
+ }
311
+
312
+ if (info.targetName === 'line') {
313
+ this.events.emit('drawing:hover', {
314
+ id: info.drawing.id,
315
+ type: info.drawing.type,
316
+ });
317
+ // Set cursor
318
+ this.chart.getZr().setCursorStyle('move');
319
+ } else if (info.targetName?.startsWith('point')) {
320
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
321
+ this.events.emit('drawing:point:hover', {
322
+ id: info.drawing.id,
323
+ pointIndex: pointIdx,
324
+ });
325
+ this.chart.getZr().setCursorStyle('pointer');
326
+ }
327
+ });
328
+
329
+ this.chart.on('mouseout', (params: any) => {
330
+ const info = getDrawingInfo(params);
331
+ if (!info) return;
332
+
333
+ // Hide points (with slight delay or check)
334
+ const group = params.event?.target?.parent;
335
+
336
+ // If selected, do not hide points
337
+ if (info.drawing.id === this.selectedDrawingId) {
338
+ // Keep points visible
339
+ return;
340
+ }
341
+
342
+ // Delay hide to allow moving between siblings
343
+ hideTimeout = setTimeout(() => {
344
+ if (group) {
345
+ // Check selection again inside timeout just in case
346
+ if (this.selectedDrawingId === info.drawing.id) return;
347
+
348
+ group.children().forEach((child: any) => {
349
+ if (child.name && child.name.startsWith('point')) {
350
+ child.attr('style', { opacity: 0 });
351
+ }
352
+ });
353
+ }
354
+ if (lastHoveredGroup === group) {
355
+ lastHoveredGroup = null;
356
+ }
357
+ }, 50);
358
+
359
+ if (info.targetName === 'line') {
360
+ this.events.emit('drawing:mouseout', { id: info.drawing.id });
361
+ } else if (info.targetName?.startsWith('point')) {
362
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
363
+ this.events.emit('drawing:point:mouseout', {
364
+ id: info.drawing.id,
365
+ pointIndex: pointIdx,
366
+ });
367
+ }
368
+ this.chart.getZr().setCursorStyle('default');
369
+ });
370
+
371
+ this.chart.on('mousedown', (params: any) => {
372
+ const info = getDrawingInfo(params);
373
+ if (!info) return;
374
+
375
+ const event = params.event?.event || params.event;
376
+ const x = event?.offsetX;
377
+ const y = event?.offsetY;
378
+
379
+ if (info.targetName === 'line') {
380
+ this.events.emit('drawing:mousedown', {
381
+ id: info.drawing.id,
382
+ x,
383
+ y,
384
+ });
385
+ } else if (info.targetName?.startsWith('point')) {
386
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
387
+ this.events.emit('drawing:point:mousedown', {
388
+ id: info.drawing.id,
389
+ pointIndex: pointIdx,
390
+ x,
391
+ y,
392
+ });
393
+ }
394
+ });
395
+
396
+ this.chart.on('click', (params: any) => {
397
+ const info = getDrawingInfo(params);
398
+ if (!info) return;
399
+
400
+ // Select Drawing logic
401
+ if (this.selectedDrawingId !== info.drawing.id) {
402
+ this.selectedDrawingId = info.drawing.id;
403
+ this.events.emit('drawing:selected', { id: info.drawing.id });
404
+ this.render(); // Re-render to update opacity permanent state
405
+ }
406
+
407
+ if (info.targetName === 'line') {
408
+ this.events.emit('drawing:click', { id: info.drawing.id });
409
+ } else if (info.targetName?.startsWith('point')) {
410
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
411
+ this.events.emit('drawing:point:click', {
412
+ id: info.drawing.id,
413
+ pointIndex: pointIdx,
414
+ });
415
+ }
416
+ });
417
+
418
+ // Background click to deselect
419
+ this.chart.getZr().on('click', (params: any) => {
420
+ // If target is undefined or not part of a drawing series we know...
421
+ if (!params.target) {
422
+ if (this.selectedDrawingId) {
423
+ this.events.emit('drawing:deselected', { id: this.selectedDrawingId });
424
+ this.selectedDrawingId = null;
425
+ this.render();
426
+ }
427
+ }
428
+ });
429
+ }
430
+
431
+ // --- Plugin System Integration ---
432
+
433
+ public getChart(): echarts.ECharts {
434
+ return this.chart;
435
+ }
436
+
437
+ public getMarketData(): OHLCV[] {
438
+ return this.marketData;
439
+ }
440
+
441
+ public getTimeToIndex(): Map<number, number> {
442
+ return this.timeToIndex;
443
+ }
444
+
445
+ public getOptions(): QFChartOptions {
446
+ return this.options;
447
+ }
448
+
449
+ public disableTools(): void {
450
+ this.pluginManager.deactivatePlugin();
451
+ }
452
+
453
+ public registerPlugin(plugin: Plugin): void {
454
+ this.pluginManager.register(plugin);
455
+ }
456
+
457
+ // --- Drawing System ---
458
+
459
+ public addDrawing(drawing: import('./types').DrawingElement): void {
460
+ this.drawings.push(drawing);
461
+ this.render(); // Re-render to show new drawing
462
+ }
463
+
464
+ public removeDrawing(id: string): void {
465
+ const index = this.drawings.findIndex((d) => d.id === id);
466
+ if (index !== -1) {
467
+ const drawing = this.drawings[index];
468
+ this.drawings.splice(index, 1);
469
+ this.events.emit('drawing:deleted', { id: drawing.id });
470
+ this.render();
471
+ }
472
+ }
473
+
474
+ public getDrawing(id: string): import('./types').DrawingElement | undefined {
475
+ return this.drawings.find((d) => d.id === id);
476
+ }
477
+
478
+ public updateDrawing(drawing: import('./types').DrawingElement): void {
479
+ const index = this.drawings.findIndex((d) => d.id === drawing.id);
480
+ if (index !== -1) {
481
+ this.drawings[index] = drawing;
482
+ this.render();
483
+ }
484
+ }
485
+
486
+ // --- Interaction Locking ---
487
+
488
+ private isLocked: boolean = false;
489
+ private lockedState: any = null;
490
+
491
+ public lockChart(): void {
492
+ if (this.isLocked) return;
493
+ this.isLocked = true;
494
+
495
+ const option = this.chart.getOption() as any;
496
+
497
+ // Store current state to restore later if needed (though setOption merge handles most)
498
+ // Actually, simply disabling interactions is enough.
499
+
500
+ // We update the option to disable dataZoom and tooltip
501
+ this.chart.setOption({
502
+ dataZoom: option.dataZoom.map((dz: any) => ({ ...dz, disabled: true })),
503
+ tooltip: { show: false }, // Hide tooltip during drag
504
+ // We can also disable series interaction if needed, but custom series is handled by us.
505
+ });
506
+ }
507
+
508
+ public unlockChart(): void {
509
+ if (!this.isLocked) return;
510
+ this.isLocked = false;
511
+
512
+ const option = this.chart.getOption() as any;
513
+
514
+ // Restore interactions
515
+ // We assume dataZoom was enabled before. If not, we might re-enable it wrongly.
516
+ // Ideally we should restore from 'options' or check the previous state.
517
+ // Since 'render' rebuilds everything from 'this.options', we can just call render?
518
+ // But render is expensive.
519
+ // Better: Re-enable based on this.options.
520
+
521
+ // Re-enable dataZoom
522
+ const dzConfig = this.options.dataZoom || {};
523
+ const dzVisible = dzConfig.visible ?? true;
524
+
525
+ // We can map over current option.dataZoom and set disabled: false
526
+ if (option.dataZoom) {
527
+ this.chart.setOption({
528
+ dataZoom: option.dataZoom.map((dz: any) => ({
529
+ ...dz,
530
+ disabled: false,
531
+ })),
532
+ tooltip: { show: true },
533
+ });
534
+ }
535
+ }
536
+
537
+ // --------------------------------
538
+
539
+ public setZoom(start: number, end: number): void {
540
+ this.chart.dispatchAction({
541
+ type: 'dataZoom',
542
+ start,
543
+ end,
544
+ });
545
+ }
546
+
547
+ public setMarketData(data: OHLCV[]): void {
548
+ this.marketData = data;
549
+ this.rebuildTimeIndex();
550
+ this.render();
551
+ }
552
+
553
+ /**
554
+ * Update market data incrementally without full re-render
555
+ * Merges new/updated OHLCV data with existing data by timestamp
556
+ *
557
+ * @param data - Array of OHLCV data to merge
558
+ *
559
+ * @remarks
560
+ * **Performance Optimization**: This method only triggers a chart update if the data array contains
561
+ * new or modified bars. If an empty array is passed, no update occurs (expected behavior).
562
+ *
563
+ * **Usage Pattern for Updating Indicators**:
564
+ * When updating both market data and indicators, follow this order:
565
+ *
566
+ * 1. Update indicator data first using `indicator.updateData(plots)`
567
+ * 2. Then call `chart.updateData(newBars)` with the new/modified market data
568
+ *
569
+ * The chart update will trigger a re-render that includes the updated indicator data.
570
+ *
571
+ * **Important**: If you update indicator data without updating market data (e.g., recalculation
572
+ * with same bars), you must still call `chart.updateData([...])` with at least one bar
573
+ * to trigger the re-render. Calling with an empty array will NOT trigger an update.
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * // Step 1: Update indicator data
578
+ * macdIndicator.updateData({
579
+ * macd: { data: [{ time: 1234567890, value: 150 }], options: { style: 'line', color: '#2962FF' } }
580
+ * });
581
+ *
582
+ * // Step 2: Update market data (triggers re-render with new indicator data)
583
+ * chart.updateData([
584
+ * { time: 1234567890, open: 100, high: 105, low: 99, close: 103, volume: 1000 }
585
+ * ]);
586
+ * ```
587
+ *
588
+ * @example
589
+ * ```typescript
590
+ * // If only updating existing bar (e.g., real-time tick updates):
591
+ * const lastBar = { ...existingBar, close: newPrice, high: Math.max(existingBar.high, newPrice) };
592
+ * chart.updateData([lastBar]); // Updates by timestamp
593
+ * ```
594
+ */
595
+ public updateData(data: OHLCV[]): void {
596
+ if (data.length === 0) return;
597
+
598
+ // Build a map of existing data by time for O(1) lookups
599
+ const existingTimeMap = new Map<number, OHLCV>();
600
+ this.marketData.forEach((bar) => {
601
+ existingTimeMap.set(bar.time, bar);
602
+ });
603
+
604
+ // Track if we added new data or only updated existing
605
+ let hasNewData = false;
606
+
607
+ // Merge new data
608
+ data.forEach((bar) => {
609
+ if (!existingTimeMap.has(bar.time)) {
610
+ hasNewData = true;
611
+ }
612
+ existingTimeMap.set(bar.time, bar);
613
+ });
614
+
615
+ // Rebuild marketData array sorted by time
616
+ this.marketData = Array.from(existingTimeMap.values()).sort((a, b) => a.time - b.time);
617
+
618
+ // Update timeToIndex map
619
+ this.rebuildTimeIndex();
620
+
621
+ // Use pre-calculated padding points from rebuildTimeIndex
622
+ const paddingPoints = this.dataIndexOffset;
623
+
624
+ // Build candlestick data with padding
625
+ const candlestickData = this.marketData.map((d) => [d.open, d.close, d.low, d.high]);
626
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
627
+ const paddedCandlestickData = [...Array(paddingPoints).fill(emptyCandle), ...candlestickData, ...Array(paddingPoints).fill(emptyCandle)];
628
+
629
+ // Build category data with padding
630
+ const categoryData = [
631
+ ...Array(paddingPoints).fill(''),
632
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
633
+ ...Array(paddingPoints).fill(''),
634
+ ];
635
+
636
+ // Build indicator series data
637
+ const currentOption = this.chart.getOption() as any;
638
+ const layout = LayoutManager.calculate(this.chart.getHeight(), this.indicators, this.options, this.isMainCollapsed, this.maximizedPaneId);
639
+ const indicatorSeries = SeriesBuilder.buildIndicatorSeries(
640
+ this.indicators,
641
+ this.timeToIndex,
642
+ layout.paneLayout,
643
+ categoryData.length,
644
+ paddingPoints
645
+ );
646
+
647
+ // Update only the data arrays in the option, not the full config
648
+ const updateOption: any = {
649
+ xAxis: currentOption.xAxis.map((axis: any, index: number) => ({
650
+ data: categoryData,
651
+ })),
652
+ series: [
653
+ {
654
+ data: paddedCandlestickData,
655
+ },
656
+ ...indicatorSeries.map((s) => ({
657
+ data: s.data,
658
+ })),
659
+ ],
660
+ };
661
+
662
+ // Merge the update (don't replace entire config)
663
+ this.chart.setOption(updateOption, { notMerge: false });
664
+ }
665
+
666
+ public addIndicator(
667
+ id: string,
668
+ plots: { [name: string]: IndicatorPlot },
669
+ options: {
670
+ isOverlay?: boolean;
671
+ height?: number;
672
+ titleColor?: string;
673
+ controls?: { collapse?: boolean; maximize?: boolean };
674
+ } = { isOverlay: false }
675
+ ): Indicator {
676
+ const isOverlay = options.isOverlay ?? false;
677
+ let paneIndex = 0;
678
+ if (!isOverlay) {
679
+ // Find the next available pane index
680
+ // Start from 1, as 0 is the main chart
681
+ let maxPaneIndex = 0;
682
+ this.indicators.forEach((ind) => {
683
+ if (ind.paneIndex > maxPaneIndex) {
684
+ maxPaneIndex = ind.paneIndex;
685
+ }
686
+ });
687
+ paneIndex = maxPaneIndex + 1;
688
+ }
689
+
690
+ // Create Indicator object
691
+ const indicator = new Indicator(id, plots, paneIndex, {
692
+ height: options.height,
693
+ collapsed: false,
694
+ titleColor: options.titleColor,
695
+ controls: options.controls,
696
+ });
697
+
698
+ this.indicators.set(id, indicator);
699
+ this.render();
700
+ return indicator;
701
+ }
702
+
703
+ // Deprecated: keeping for compatibility if needed, but redirects to addIndicator logic
704
+ public setIndicator(id: string, plot: IndicatorPlot, isOverlay: boolean = false): void {
705
+ // Wrap single plot into the new structure
706
+ this.addIndicator(id, { [id]: plot }, { isOverlay });
707
+ }
708
+
709
+ public removeIndicator(id: string): void {
710
+ this.indicators.delete(id);
711
+ this.render();
712
+ }
713
+
714
+ public toggleIndicator(id: string, action: 'collapse' | 'maximize' | 'fullscreen' = 'collapse'): void {
715
+ if (action === 'fullscreen') {
716
+ if (document.fullscreenElement) {
717
+ document.exitFullscreen();
718
+ } else {
719
+ this.rootContainer.requestFullscreen();
720
+ }
721
+ return;
722
+ }
723
+
724
+ if (action === 'maximize') {
725
+ if (this.maximizedPaneId === id) {
726
+ // Restore
727
+ this.maximizedPaneId = null;
728
+ } else {
729
+ // Maximize
730
+ this.maximizedPaneId = id;
731
+ }
732
+ this.render();
733
+ return;
734
+ }
735
+
736
+ if (id === 'main') {
737
+ this.isMainCollapsed = !this.isMainCollapsed;
738
+ this.render();
739
+ return;
740
+ }
741
+ const indicator = this.indicators.get(id);
742
+ if (indicator) {
743
+ indicator.toggleCollapse();
744
+ this.render();
745
+ }
746
+ }
747
+
748
+ public resize(): void {
749
+ this.chart.resize();
750
+ }
751
+
752
+ public destroy(): void {
753
+ window.removeEventListener('resize', this.resize.bind(this));
754
+ document.removeEventListener('fullscreenchange', this.onFullscreenChange);
755
+ document.removeEventListener('keydown', this.onKeyDown);
756
+ this.pluginManager.deactivatePlugin(); // Cleanup active tool
757
+ this.pluginManager.destroy(); // Cleanup tooltips
758
+ this.chart.dispose();
759
+ }
760
+
761
+ private rebuildTimeIndex(): void {
762
+ this.timeToIndex.clear();
763
+ this.marketData.forEach((k, index) => {
764
+ this.timeToIndex.set(k.time, index);
765
+ });
766
+
767
+ // Update dataIndexOffset whenever data changes
768
+ const dataLength = this.marketData.length;
769
+ const paddingPoints = Math.ceil(dataLength * this.padding);
770
+ this.dataIndexOffset = paddingPoints;
771
+ }
772
+
773
+ private render(): void {
774
+ if (this.marketData.length === 0) return;
775
+
776
+ // Capture current zoom state before rebuilding options
777
+ let currentZoomState: { start: number; end: number } | null = null;
778
+ try {
779
+ const currentOption = this.chart.getOption() as any;
780
+ if (currentOption && currentOption.dataZoom && currentOption.dataZoom.length > 0) {
781
+ // Find the slider or inside zoom component that controls the x-axis
782
+ const zoomComponent = currentOption.dataZoom.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
783
+ if (zoomComponent) {
784
+ currentZoomState = {
785
+ start: zoomComponent.start,
786
+ end: zoomComponent.end,
787
+ };
788
+ }
789
+ }
790
+ } catch (e) {
791
+ // Chart might not be initialized yet
792
+ }
793
+
794
+ // --- Sidebar Layout Management ---
795
+ const tooltipPos = this.options.databox?.position; // undefined if not present
796
+ const prevLeftDisplay = this.leftSidebar.style.display;
797
+ const prevRightDisplay = this.rightSidebar.style.display;
798
+
799
+ // If tooltipPos is undefined, we hide both sidebars and don't use them for tooltips.
800
+ // We only show sidebars if position is explicitly 'left' or 'right'.
801
+
802
+ const newLeftDisplay = tooltipPos === 'left' ? 'block' : 'none';
803
+ const newRightDisplay = tooltipPos === 'right' ? 'block' : 'none';
804
+
805
+ // Only resize if visibility changed to avoid unnecessary reflows/resizes
806
+ if (prevLeftDisplay !== newLeftDisplay || prevRightDisplay !== newRightDisplay) {
807
+ this.leftSidebar.style.display = newLeftDisplay;
808
+ this.rightSidebar.style.display = newRightDisplay;
809
+ this.chart.resize();
810
+ }
811
+ // ---------------------------------
812
+
813
+ // Use pre-calculated padding points from rebuildTimeIndex
814
+ const paddingPoints = this.dataIndexOffset;
815
+
816
+ // Create extended category data with empty labels for padding
817
+ const categoryData = [
818
+ ...Array(paddingPoints).fill(''), // Left padding
819
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
820
+ ...Array(paddingPoints).fill(''), // Right padding
821
+ ];
822
+
823
+ // 1. Calculate Layout
824
+ const layout = LayoutManager.calculate(this.chart.getHeight(), this.indicators, this.options, this.isMainCollapsed, this.maximizedPaneId);
825
+
826
+ // Apply preserved zoom state if available
827
+ if (currentZoomState && layout.dataZoom) {
828
+ layout.dataZoom.forEach((dz) => {
829
+ dz.start = currentZoomState!.start;
830
+ dz.end = currentZoomState!.end;
831
+ });
832
+ }
833
+
834
+ // Patch X-Axis with extended data
835
+ layout.xAxis.forEach((axis) => {
836
+ axis.data = categoryData;
837
+ axis.boundaryGap = false; // No additional gap needed, we have phantom data
838
+ });
839
+
840
+ // 2. Build Series with phantom data padding
841
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
842
+ // Extend candlestick data with empty objects (not null) to avoid rendering errors
843
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
844
+ candlestickSeries.data = [...Array(paddingPoints).fill(emptyCandle), ...candlestickSeries.data, ...Array(paddingPoints).fill(emptyCandle)];
845
+
846
+ const indicatorSeries = SeriesBuilder.buildIndicatorSeries(
847
+ this.indicators,
848
+ this.timeToIndex,
849
+ layout.paneLayout,
850
+ categoryData.length,
851
+ paddingPoints
852
+ );
853
+
854
+ // 3. Build Graphics
855
+ const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
856
+
857
+ // 4. Build Drawings Series (One Custom Series per Pane used)
858
+ const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
859
+ this.drawings.forEach((d) => {
860
+ const paneIdx = d.paneIndex || 0;
861
+ if (!drawingsByPane.has(paneIdx)) {
862
+ drawingsByPane.set(paneIdx, []);
863
+ }
864
+ drawingsByPane.get(paneIdx)!.push(d);
865
+ });
866
+
867
+ const drawingSeriesList: any[] = [];
868
+ drawingsByPane.forEach((drawings, paneIndex) => {
869
+ drawingSeriesList.push({
870
+ type: 'custom',
871
+ name: `drawings-pane-${paneIndex}`,
872
+ xAxisIndex: paneIndex,
873
+ yAxisIndex: paneIndex,
874
+ clip: true,
875
+ renderItem: (params: any, api: any) => {
876
+ const drawing = drawings[params.dataIndex];
877
+ if (!drawing) return;
878
+
879
+ const start = drawing.points[0];
880
+ const end = drawing.points[1];
881
+
882
+ if (!start || !end) return;
883
+
884
+ // Coordinates are already in padded space, use directly
885
+ const p1 = api.coord([start.timeIndex, start.value]);
886
+ const p2 = api.coord([end.timeIndex, end.value]);
887
+
888
+ const isSelected = drawing.id === this.selectedDrawingId;
889
+
890
+ if (drawing.type === 'line') {
891
+ return {
892
+ type: 'group',
893
+ children: [
894
+ {
895
+ type: 'line',
896
+ name: 'line',
897
+ shape: {
898
+ x1: p1[0],
899
+ y1: p1[1],
900
+ x2: p2[0],
901
+ y2: p2[1],
902
+ },
903
+ style: {
904
+ stroke: drawing.style?.color || '#3b82f6',
905
+ lineWidth: drawing.style?.lineWidth || 2,
906
+ },
907
+ },
908
+ {
909
+ type: 'circle',
910
+ name: 'point-start',
911
+ shape: { cx: p1[0], cy: p1[1], r: 4 },
912
+ style: {
913
+ fill: '#fff',
914
+ stroke: drawing.style?.color || '#3b82f6',
915
+ lineWidth: 1,
916
+ opacity: isSelected ? 1 : 0, // Show if selected
917
+ },
918
+ },
919
+ {
920
+ type: 'circle',
921
+ name: 'point-end',
922
+ shape: { cx: p2[0], cy: p2[1], r: 4 },
923
+ style: {
924
+ fill: '#fff',
925
+ stroke: drawing.style?.color || '#3b82f6',
926
+ lineWidth: 1,
927
+ opacity: isSelected ? 1 : 0, // Show if selected
928
+ },
929
+ },
930
+ ],
931
+ };
932
+ } else if (drawing.type === 'fibonacci') {
933
+ const x1 = p1[0];
934
+ const y1 = p1[1];
935
+ const x2 = p2[0];
936
+ const y2 = p2[1];
937
+
938
+ const startX = Math.min(x1, x2);
939
+ const endX = Math.max(x1, x2);
940
+ const width = endX - startX;
941
+ const diffY = y2 - y1;
942
+
943
+ const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
944
+ const colors = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
945
+
946
+ const children: any[] = [];
947
+
948
+ // 1. Diagonal Line
949
+ children.push({
950
+ type: 'line',
951
+ name: 'line', // Use 'line' name to enable dragging logic in DrawingEditor
952
+ shape: { x1, y1, x2, y2 },
953
+ style: {
954
+ stroke: '#999',
955
+ lineWidth: 1,
956
+ lineDash: [4, 4],
957
+ },
958
+ });
959
+
960
+ // 2. Control Points (invisible by default)
961
+ children.push({
962
+ type: 'circle',
963
+ name: 'point-start',
964
+ shape: { cx: x1, cy: y1, r: 4 },
965
+ style: {
966
+ fill: '#fff',
967
+ stroke: drawing.style?.color || '#3b82f6',
968
+ lineWidth: 1,
969
+ opacity: isSelected ? 1 : 0,
970
+ },
971
+ z: 100, // Ensure on top
972
+ });
973
+ children.push({
974
+ type: 'circle',
975
+ name: 'point-end',
976
+ shape: { cx: x2, cy: y2, r: 4 },
977
+ style: {
978
+ fill: '#fff',
979
+ stroke: drawing.style?.color || '#3b82f6',
980
+ lineWidth: 1,
981
+ opacity: isSelected ? 1 : 0,
982
+ },
983
+ z: 100,
984
+ });
985
+
986
+ // 3. Levels and Backgrounds
987
+ levels.forEach((level, index) => {
988
+ const levelY = y2 - diffY * level;
989
+ const color = colors[index % colors.length];
990
+
991
+ // Horizontal Line
992
+ children.push({
993
+ type: 'line',
994
+ name: 'fib-line', // distinct name, maybe we don't want to drag by clicking these lines? or yes? 'line' triggers drag. 'fib-line' won't unless we update logic.
995
+ // The user asked for "fib levels between start and end".
996
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
997
+ style: { stroke: color, lineWidth: 1 },
998
+ silent: true, // Make internal lines silent so clicks pass to background/diagonal?
999
+ });
1000
+
1001
+ const startVal = drawing.points[0].value;
1002
+ const endVal = drawing.points[1].value;
1003
+ const valDiff = endVal - startVal;
1004
+ const price = endVal - valDiff * level;
1005
+
1006
+ children.push({
1007
+ type: 'text',
1008
+ style: {
1009
+ text: `${level} (${price.toFixed(2)})`,
1010
+ x: startX + 5,
1011
+ y: levelY - 10,
1012
+ fill: color,
1013
+ fontSize: 10,
1014
+ },
1015
+ silent: true,
1016
+ });
1017
+
1018
+ // Background
1019
+ if (index < levels.length - 1) {
1020
+ const nextLevel = levels[index + 1];
1021
+ const nextY = y2 - diffY * nextLevel;
1022
+ const rectH = Math.abs(nextY - levelY);
1023
+ const rectY = Math.min(levelY, nextY);
1024
+
1025
+ children.push({
1026
+ type: 'rect',
1027
+ shape: { x: startX, y: rectY, width, height: rectH },
1028
+ style: {
1029
+ fill: colors[(index + 1) % colors.length],
1030
+ opacity: 0.1,
1031
+ },
1032
+ silent: true, // Let clicks pass through?
1033
+ });
1034
+ }
1035
+ });
1036
+
1037
+ const backgrounds: any[] = [];
1038
+ const linesAndText: any[] = [];
1039
+
1040
+ levels.forEach((level, index) => {
1041
+ const levelY = y2 - diffY * level;
1042
+ const color = colors[index % colors.length];
1043
+
1044
+ linesAndText.push({
1045
+ type: 'line',
1046
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1047
+ style: { stroke: color, lineWidth: 1 },
1048
+ silent: true,
1049
+ });
1050
+
1051
+ const startVal = drawing.points[0].value;
1052
+ const endVal = drawing.points[1].value;
1053
+ const valDiff = endVal - startVal;
1054
+ const price = endVal - valDiff * level;
1055
+
1056
+ linesAndText.push({
1057
+ type: 'text',
1058
+ style: {
1059
+ text: `${level} (${price.toFixed(2)})`,
1060
+ x: startX + 5,
1061
+ y: levelY - 10,
1062
+ fill: color,
1063
+ fontSize: 10,
1064
+ },
1065
+ silent: true,
1066
+ });
1067
+
1068
+ if (index < levels.length - 1) {
1069
+ const nextLevel = levels[index + 1];
1070
+ const nextY = y2 - diffY * nextLevel;
1071
+ const rectH = Math.abs(nextY - levelY);
1072
+ const rectY = Math.min(levelY, nextY);
1073
+
1074
+ backgrounds.push({
1075
+ type: 'rect',
1076
+ name: 'line', // Enable dragging by clicking background!
1077
+ shape: { x: startX, y: rectY, width, height: rectH },
1078
+ style: {
1079
+ fill: colors[(index + 1) % colors.length],
1080
+ opacity: 0.1,
1081
+ },
1082
+ });
1083
+ }
1084
+ });
1085
+
1086
+ return {
1087
+ type: 'group',
1088
+ children: [
1089
+ ...backgrounds,
1090
+ ...linesAndText,
1091
+ {
1092
+ type: 'line',
1093
+ name: 'line',
1094
+ shape: { x1, y1, x2, y2 },
1095
+ style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
1096
+ },
1097
+ {
1098
+ type: 'circle',
1099
+ name: 'point-start',
1100
+ shape: { cx: x1, cy: y1, r: 4 },
1101
+ style: {
1102
+ fill: '#fff',
1103
+ stroke: drawing.style?.color || '#3b82f6',
1104
+ lineWidth: 1,
1105
+ opacity: isSelected ? 1 : 0,
1106
+ },
1107
+ z: 100,
1108
+ },
1109
+ {
1110
+ type: 'circle',
1111
+ name: 'point-end',
1112
+ shape: { cx: x2, cy: y2, r: 4 },
1113
+ style: {
1114
+ fill: '#fff',
1115
+ stroke: drawing.style?.color || '#3b82f6',
1116
+ lineWidth: 1,
1117
+ opacity: isSelected ? 1 : 0,
1118
+ },
1119
+ z: 100,
1120
+ },
1121
+ ],
1122
+ };
1123
+ }
1124
+ },
1125
+ data: drawings.map((d) => [d.points[0].timeIndex, d.points[0].value, d.points[1].timeIndex, d.points[1].value]),
1126
+ z: 100,
1127
+ silent: false,
1128
+ });
1129
+ });
1130
+
1131
+ // 5. Tooltip Formatter
1132
+ const tooltipFormatter = (params: any[]) => {
1133
+ const html = TooltipFormatter.format(params, this.options);
1134
+ const mode = this.options.databox?.position; // undefined if not present
1135
+
1136
+ if (mode === 'left') {
1137
+ this.leftSidebar.innerHTML = html;
1138
+ return ''; // Hide tooltip box
1139
+ }
1140
+ if (mode === 'right') {
1141
+ this.rightSidebar.innerHTML = html;
1142
+ return ''; // Hide tooltip box
1143
+ }
1144
+
1145
+ if (!this.options.databox) {
1146
+ return ''; // No tooltip content
1147
+ }
1148
+
1149
+ // Default to floating if databox exists but position is 'floating' (or unspecified but object exists)
1150
+ return `<div style="min-width: 200px;">${html}</div>`;
1151
+ };
1152
+
1153
+ const option: any = {
1154
+ backgroundColor: this.options.backgroundColor,
1155
+ animation: false,
1156
+ legend: {
1157
+ show: false, // Hide default legend as we use tooltip
1158
+ },
1159
+ tooltip: {
1160
+ show: true,
1161
+ showContent: !!this.options.databox, // Show content only if databox is present
1162
+ trigger: 'axis',
1163
+ axisPointer: { type: 'cross', label: { backgroundColor: '#475569' } },
1164
+ backgroundColor: 'rgba(30, 41, 59, 0.9)',
1165
+ borderWidth: 1,
1166
+ borderColor: '#334155',
1167
+ padding: 10,
1168
+ textStyle: {
1169
+ color: '#fff',
1170
+ fontFamily: this.options.fontFamily || 'sans-serif',
1171
+ },
1172
+ formatter: tooltipFormatter,
1173
+ extraCssText: tooltipPos !== 'floating' && tooltipPos !== undefined ? 'display: none !important;' : undefined,
1174
+ position: (pos: any, params: any, el: any, elRect: any, size: any) => {
1175
+ const mode = this.options.databox?.position;
1176
+ if (mode === 'floating') {
1177
+ const obj = { top: 10 };
1178
+ obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)] as keyof typeof obj] = 30;
1179
+ return obj;
1180
+ }
1181
+ return null;
1182
+ },
1183
+ },
1184
+ axisPointer: {
1185
+ link: { xAxisIndex: 'all' },
1186
+ label: { backgroundColor: '#475569' },
1187
+ },
1188
+ graphic: graphic,
1189
+ grid: layout.grid,
1190
+ xAxis: layout.xAxis,
1191
+ yAxis: layout.yAxis,
1192
+ dataZoom: layout.dataZoom,
1193
+ series: [candlestickSeries, ...indicatorSeries, ...drawingSeriesList],
1194
+ };
1195
+
1196
+ this.chart.setOption(option, true); // true = not merge, replace.
1197
+ }
1198
+ }