@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.
@@ -0,0 +1,104 @@
1
+ import { Indicator as IndicatorInterface, IndicatorPlot, IndicatorPoint } from '../types';
2
+
3
+ export class Indicator implements IndicatorInterface {
4
+ public id: string;
5
+ public plots: { [name: string]: IndicatorPlot };
6
+ public paneIndex: number;
7
+ public height?: number;
8
+ public collapsed: boolean;
9
+ public titleColor?: string;
10
+ public controls?: { collapse?: boolean; maximize?: boolean };
11
+
12
+ constructor(
13
+ id: string,
14
+ plots: { [name: string]: IndicatorPlot },
15
+ paneIndex: number,
16
+ options: {
17
+ height?: number;
18
+ collapsed?: boolean;
19
+ titleColor?: string;
20
+ controls?: { collapse?: boolean; maximize?: boolean };
21
+ } = {}
22
+ ) {
23
+ this.id = id;
24
+ this.plots = plots;
25
+ this.paneIndex = paneIndex;
26
+ this.height = options.height;
27
+ this.collapsed = options.collapsed || false;
28
+ this.titleColor = options.titleColor;
29
+ this.controls = options.controls;
30
+ }
31
+
32
+ public toggleCollapse(): void {
33
+ this.collapsed = !this.collapsed;
34
+ }
35
+
36
+ public isVisible(): boolean {
37
+ return !this.collapsed;
38
+ }
39
+
40
+ /**
41
+ * Update indicator data incrementally by merging new points
42
+ *
43
+ * @param plots - New plots data to merge (same structure as constructor)
44
+ *
45
+ * @remarks
46
+ * This method merges new indicator data with existing data by timestamp.
47
+ * - New timestamps are added
48
+ * - Existing timestamps are updated with new values
49
+ * - All data is automatically sorted by time after merge
50
+ *
51
+ * **Important**: This method only updates the indicator's internal data structure.
52
+ * To see the changes reflected in the chart, you MUST call `chart.updateData()`
53
+ * after updating indicator data.
54
+ *
55
+ * **Usage Pattern**:
56
+ * ```typescript
57
+ * // 1. Update indicator data first
58
+ * indicator.updateData({
59
+ * macd: { data: [{ time: 1234567890, value: 150 }], options: { style: 'line', color: '#2962FF' } }
60
+ * });
61
+ *
62
+ * // 2. Then update chart data to trigger re-render
63
+ * chart.updateData([
64
+ * { time: 1234567890, open: 100, high: 105, low: 99, close: 103, volume: 1000 }
65
+ * ]);
66
+ * ```
67
+ *
68
+ * **Note**: If you update indicator data without corresponding market data changes,
69
+ * this typically indicates a recalculation scenario. In normal workflows, indicator
70
+ * values are derived from market data, so indicator updates should correspond to
71
+ * new or modified market bars.
72
+ */
73
+ public updateData(plots: { [name: string]: IndicatorPlot }): void {
74
+ Object.keys(plots).forEach((plotName) => {
75
+ if (!this.plots[plotName]) {
76
+ // New plot - add it
77
+ this.plots[plotName] = plots[plotName];
78
+ } else {
79
+ // Existing plot - merge data points
80
+ const existingPlot = this.plots[plotName];
81
+ const newPlot = plots[plotName];
82
+
83
+ // Update options if provided
84
+ if (newPlot.options) {
85
+ existingPlot.options = { ...existingPlot.options, ...newPlot.options };
86
+ }
87
+
88
+ // Merge data points by time
89
+ const existingTimeMap = new Map<number, IndicatorPoint>();
90
+ existingPlot.data.forEach((point) => {
91
+ existingTimeMap.set(point.time, point);
92
+ });
93
+
94
+ // Update or add new points
95
+ newPlot.data.forEach((point) => {
96
+ existingTimeMap.set(point.time, point);
97
+ });
98
+
99
+ // Rebuild data array sorted by time
100
+ existingPlot.data = Array.from(existingTimeMap.values()).sort((a, b) => a.time - b.time);
101
+ }
102
+ });
103
+ }
104
+ }
@@ -0,0 +1,459 @@
1
+ import { QFChartOptions, Indicator as IndicatorType } from '../types';
2
+
3
+ export interface PaneConfiguration {
4
+ index: number;
5
+ height: number;
6
+ top: number;
7
+ isCollapsed: boolean;
8
+ indicatorId?: string;
9
+ titleColor?: string;
10
+ controls?: {
11
+ collapse?: boolean;
12
+ maximize?: boolean;
13
+ };
14
+ }
15
+
16
+ export interface LayoutResult {
17
+ grid: any[];
18
+ xAxis: any[];
19
+ yAxis: any[];
20
+ dataZoom: any[];
21
+ paneLayout: PaneConfiguration[];
22
+ mainPaneHeight: number;
23
+ mainPaneTop: number;
24
+ pixelToPercent: number;
25
+ }
26
+
27
+ export class LayoutManager {
28
+ public static calculate(
29
+ containerHeight: number,
30
+ indicators: Map<string, IndicatorType>,
31
+ options: QFChartOptions,
32
+ isMainCollapsed: boolean = false,
33
+ maximizedPaneId: string | null = null
34
+ ): LayoutResult {
35
+ // Calculate pixelToPercent early for maximized logic
36
+ let pixelToPercent = 0;
37
+ if (containerHeight > 0) {
38
+ pixelToPercent = (1 / containerHeight) * 100;
39
+ }
40
+
41
+ // Identify unique separate panes (indices > 0) and sort them
42
+ const separatePaneIndices = Array.from(indicators.values())
43
+ .map((ind) => ind.paneIndex)
44
+ .filter((idx) => idx > 0)
45
+ .sort((a, b) => a - b)
46
+ .filter((value, index, self) => self.indexOf(value) === index); // Unique
47
+
48
+ const hasSeparatePane = separatePaneIndices.length > 0;
49
+
50
+ // DataZoom Configuration
51
+ const dzVisible = options.dataZoom?.visible ?? true;
52
+ const dzPosition = options.dataZoom?.position ?? 'top';
53
+ const dzHeight = options.dataZoom?.height ?? 6;
54
+ const dzStart = options.dataZoom?.start ?? 0;
55
+ const dzEnd = options.dataZoom?.end ?? 100;
56
+
57
+ // Layout Calculation
58
+ let mainPaneTop = 8;
59
+ let chartAreaBottom = 92; // Default if no dataZoom at bottom
60
+
61
+ // Maximized State Logic
62
+ let maximizeTargetIndex = -1; // -1 = none
63
+
64
+ if (maximizedPaneId) {
65
+ if (maximizedPaneId === 'main') {
66
+ maximizeTargetIndex = 0;
67
+ } else {
68
+ const ind = indicators.get(maximizedPaneId);
69
+ if (ind) {
70
+ maximizeTargetIndex = ind.paneIndex;
71
+ }
72
+ }
73
+ }
74
+
75
+ if (maximizeTargetIndex !== -1) {
76
+ // Special Layout for Maximize
77
+ // We must generate grid/axis definitions for ALL indices to maintain series mapping,
78
+ // but hide the non-maximized ones.
79
+
80
+ const grid: any[] = [];
81
+ const xAxis: any[] = [];
82
+ const yAxis: any[] = [];
83
+ const dataZoom: any[] = []; // Hide slider, keep inside?
84
+
85
+ // DataZoom: keep inside, maybe slider if main?
86
+ // Let's keep strict maximize: Full container.
87
+ // Use defaults for maximize if not available, or preserve logic?
88
+ // The calculateMaximized doesn't use LayoutManager.calculate directly but inline logic.
89
+ // It should probably respect the same zoom?
90
+ // But here we are inside LayoutManager.calculate.
91
+
92
+ const dzStart = options.dataZoom?.start ?? 50;
93
+ const dzEnd = options.dataZoom?.end ?? 100;
94
+
95
+ dataZoom.push({ type: 'inside', xAxisIndex: 'all', start: dzStart, end: dzEnd });
96
+
97
+ // Need to know total panes to iterate
98
+ const maxPaneIndex = hasSeparatePane ? Math.max(...separatePaneIndices) : 0;
99
+
100
+ const paneConfigs: PaneConfiguration[] = []; // For GraphicBuilder title placement
101
+
102
+ // Iterate 0 to maxPaneIndex
103
+ for (let i = 0; i <= maxPaneIndex; i++) {
104
+ const isTarget = i === maximizeTargetIndex;
105
+
106
+ // Grid
107
+ grid.push({
108
+ left: '10%',
109
+ right: '10%',
110
+ top: isTarget ? '5%' : '0%',
111
+ height: isTarget ? '90%' : '0%',
112
+ show: isTarget,
113
+ containLabel: false,
114
+ });
115
+
116
+ // X-Axis
117
+ xAxis.push({
118
+ type: 'category',
119
+ gridIndex: i,
120
+ data: [],
121
+ show: isTarget,
122
+ axisLabel: {
123
+ show: isTarget,
124
+ color: '#94a3b8',
125
+ fontFamily: options.fontFamily,
126
+ },
127
+ axisLine: { show: isTarget, lineStyle: { color: '#334155' } },
128
+ splitLine: {
129
+ show: isTarget,
130
+ lineStyle: { color: '#334155', opacity: 0.5 },
131
+ },
132
+ });
133
+
134
+ // Y-Axis
135
+ yAxis.push({
136
+ position: 'right',
137
+ gridIndex: i,
138
+ show: isTarget,
139
+ scale: true,
140
+ axisLabel: {
141
+ show: isTarget,
142
+ color: '#94a3b8',
143
+ fontFamily: options.fontFamily,
144
+ },
145
+ splitLine: {
146
+ show: isTarget,
147
+ lineStyle: { color: '#334155', opacity: 0.5 },
148
+ },
149
+ });
150
+
151
+ // Reconstruct Pane Config for GraphicBuilder
152
+ // We need to return `paneLayout` so GraphicBuilder can draw the Restore button
153
+ if (i > 0) {
154
+ // Find indicator for this pane
155
+ const ind = Array.from(indicators.values()).find((ind) => ind.paneIndex === i);
156
+ if (ind) {
157
+ paneConfigs.push({
158
+ index: i,
159
+ height: isTarget ? 90 : 0,
160
+ top: isTarget ? 5 : 0,
161
+ isCollapsed: false,
162
+ indicatorId: ind.id,
163
+ titleColor: ind.titleColor,
164
+ controls: ind.controls,
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ return {
171
+ grid,
172
+ xAxis,
173
+ yAxis,
174
+ dataZoom,
175
+ paneLayout: paneConfigs,
176
+ mainPaneHeight: maximizeTargetIndex === 0 ? 90 : 0,
177
+ mainPaneTop: maximizeTargetIndex === 0 ? 5 : 0,
178
+ pixelToPercent,
179
+ };
180
+ }
181
+
182
+ if (dzVisible) {
183
+ if (dzPosition === 'top') {
184
+ // DataZoom takes top 0% to dzHeight%
185
+ // Main chart starts below it with a small gap
186
+ mainPaneTop = dzHeight + 4; // dzHeight + 4% gap
187
+ chartAreaBottom = 95; // Use more space at bottom since slider is gone
188
+ } else {
189
+ // DataZoom takes bottom
190
+ // Chart ends at 100 - dzHeight - margin
191
+ chartAreaBottom = 100 - dzHeight - 2;
192
+ mainPaneTop = 8;
193
+ }
194
+ } else {
195
+ // No data zoom
196
+ mainPaneTop = 5;
197
+ chartAreaBottom = 95;
198
+ }
199
+
200
+ // We need to calculate height distribution dynamically to avoid overlap.
201
+ // Calculate gap in percent
202
+ let gapPercent = 5;
203
+ if (containerHeight > 0) {
204
+ gapPercent = (20 / containerHeight) * 100;
205
+ }
206
+
207
+ let mainHeightVal = 75; // Default if no separate pane
208
+
209
+ // Prepare separate panes configuration
210
+ let paneConfigs: PaneConfiguration[] = [];
211
+
212
+ if (hasSeparatePane) {
213
+ // Resolve heights for all separate panes
214
+ // 1. Identify panes and their requested heights
215
+ const panes = separatePaneIndices.map((idx) => {
216
+ const ind = Array.from(indicators.values()).find((i) => i.paneIndex === idx);
217
+ return {
218
+ index: idx,
219
+ requestedHeight: ind?.height,
220
+ isCollapsed: ind?.collapsed ?? false,
221
+ indicatorId: ind?.id,
222
+ titleColor: ind?.titleColor,
223
+ controls: ind?.controls,
224
+ };
225
+ });
226
+
227
+ // 2. Assign actual heights
228
+ // If collapsed, use small fixed height (e.g. 3%)
229
+ const resolvedPanes = panes.map((p) => ({
230
+ ...p,
231
+ height: p.isCollapsed ? 3 : p.requestedHeight !== undefined ? p.requestedHeight : 15,
232
+ }));
233
+
234
+ // 3. Calculate total space needed for indicators
235
+ const totalIndicatorHeight = resolvedPanes.reduce((sum, p) => sum + p.height, 0);
236
+ const totalGaps = resolvedPanes.length * gapPercent;
237
+ const totalBottomSpace = totalIndicatorHeight + totalGaps;
238
+
239
+ // 4. Calculate Main Chart Height
240
+ // Available space = chartAreaBottom - mainPaneTop;
241
+ const totalAvailable = chartAreaBottom - mainPaneTop;
242
+ mainHeightVal = totalAvailable - totalBottomSpace;
243
+
244
+ if (isMainCollapsed) {
245
+ mainHeightVal = 3;
246
+ } else {
247
+ // Safety check: ensure main chart has at least some space (e.g. 20%)
248
+ if (mainHeightVal < 20) {
249
+ mainHeightVal = Math.max(mainHeightVal, 10);
250
+ }
251
+ }
252
+
253
+ // 5. Calculate positions
254
+ let currentTop = mainPaneTop + mainHeightVal + gapPercent;
255
+
256
+ paneConfigs = resolvedPanes.map((p) => {
257
+ const config = {
258
+ index: p.index,
259
+ height: p.height,
260
+ top: currentTop,
261
+ isCollapsed: p.isCollapsed,
262
+ indicatorId: p.indicatorId,
263
+ titleColor: p.titleColor,
264
+ controls: p.controls,
265
+ };
266
+ currentTop += p.height + gapPercent;
267
+ return config;
268
+ });
269
+ } else {
270
+ mainHeightVal = chartAreaBottom - mainPaneTop;
271
+ if (isMainCollapsed) {
272
+ mainHeightVal = 3;
273
+ }
274
+ }
275
+
276
+ // --- Generate Grids ---
277
+ const grid: any[] = [];
278
+ // Main Grid (index 0)
279
+ grid.push({
280
+ left: '10%',
281
+ right: '10%',
282
+ top: mainPaneTop + '%',
283
+ height: mainHeightVal + '%',
284
+ containLabel: false, // We handle margins explicitly
285
+ });
286
+
287
+ // Separate Panes Grids
288
+ paneConfigs.forEach((pane) => {
289
+ grid.push({
290
+ left: '10%',
291
+ right: '10%',
292
+ top: pane.top + '%',
293
+ height: pane.height + '%',
294
+ containLabel: false,
295
+ });
296
+ });
297
+
298
+ // --- Generate X-Axes ---
299
+ const allXAxisIndices = [0, ...paneConfigs.map((_, i) => i + 1)];
300
+ const xAxis: any[] = [];
301
+
302
+ // Main X-Axis
303
+ const isMainBottom = paneConfigs.length === 0;
304
+ xAxis.push({
305
+ type: 'category',
306
+ data: [], // Will be filled by SeriesBuilder or QFChart
307
+ gridIndex: 0,
308
+ scale: true,
309
+ // boundaryGap will be set in QFChart.ts based on padding option
310
+ axisLine: {
311
+ onZero: false,
312
+ show: !isMainCollapsed,
313
+ lineStyle: { color: '#334155' },
314
+ },
315
+ splitLine: {
316
+ show: !isMainCollapsed,
317
+ lineStyle: { color: '#334155', opacity: 0.5 },
318
+ },
319
+ axisLabel: {
320
+ show: !isMainCollapsed,
321
+ color: '#94a3b8',
322
+ fontFamily: options.fontFamily || 'sans-serif',
323
+ },
324
+ axisTick: { show: !isMainCollapsed },
325
+ axisPointer: {
326
+ label: {
327
+ show: isMainBottom,
328
+ fontSize: 11,
329
+ backgroundColor: '#475569',
330
+ },
331
+ },
332
+ });
333
+
334
+ // Separate Panes X-Axes
335
+ paneConfigs.forEach((pane, i) => {
336
+ const isBottom = i === paneConfigs.length - 1;
337
+ xAxis.push({
338
+ type: 'category',
339
+ gridIndex: i + 1, // 0 is main
340
+ data: [], // Shared data
341
+ axisLabel: { show: false }, // Hide labels on indicator panes
342
+ axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
343
+ axisTick: { show: false },
344
+ splitLine: { show: false },
345
+ axisPointer: {
346
+ label: {
347
+ show: isBottom,
348
+ fontSize: 11,
349
+ backgroundColor: '#475569',
350
+ },
351
+ },
352
+ });
353
+ });
354
+
355
+ // --- Generate Y-Axes ---
356
+ const yAxis: any[] = [];
357
+ // Main Y-Axis
358
+ yAxis.push({
359
+ position: 'right',
360
+ scale: true,
361
+ gridIndex: 0,
362
+ splitLine: {
363
+ show: !isMainCollapsed,
364
+ lineStyle: { color: '#334155', opacity: 0.5 },
365
+ },
366
+ axisLine: { show: !isMainCollapsed, lineStyle: { color: '#334155' } },
367
+ axisLabel: {
368
+ show: !isMainCollapsed,
369
+ color: '#94a3b8',
370
+ fontFamily: options.fontFamily || 'sans-serif',
371
+ },
372
+ });
373
+
374
+ // Separate Panes Y-Axes
375
+ paneConfigs.forEach((pane, i) => {
376
+ yAxis.push({
377
+ position: 'right',
378
+ scale: true,
379
+ gridIndex: i + 1,
380
+ splitLine: {
381
+ show: !pane.isCollapsed,
382
+ lineStyle: { color: '#334155', opacity: 0.3 },
383
+ },
384
+ axisLabel: {
385
+ show: !pane.isCollapsed,
386
+ color: '#94a3b8',
387
+ fontFamily: options.fontFamily || 'sans-serif',
388
+ fontSize: 10,
389
+ },
390
+ axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
391
+ });
392
+ });
393
+
394
+ // --- Generate DataZoom ---
395
+ const dataZoom: any[] = [];
396
+ if (dzVisible) {
397
+ dataZoom.push({
398
+ type: 'inside',
399
+ xAxisIndex: allXAxisIndices,
400
+ start: dzStart,
401
+ end: dzEnd,
402
+ });
403
+
404
+ if (dzPosition === 'top') {
405
+ dataZoom.push({
406
+ type: 'slider',
407
+ xAxisIndex: allXAxisIndices,
408
+ top: '1%',
409
+ height: dzHeight + '%',
410
+ start: dzStart,
411
+ end: dzEnd,
412
+ borderColor: '#334155',
413
+ textStyle: { color: '#cbd5e1' },
414
+ brushSelect: false,
415
+ });
416
+ } else {
417
+ dataZoom.push({
418
+ type: 'slider',
419
+ xAxisIndex: allXAxisIndices,
420
+ bottom: '1%',
421
+ height: dzHeight + '%',
422
+ start: dzStart,
423
+ end: dzEnd,
424
+ borderColor: '#334155',
425
+ textStyle: { color: '#cbd5e1' },
426
+ brushSelect: false,
427
+ });
428
+ }
429
+ }
430
+
431
+ return {
432
+ grid,
433
+ xAxis,
434
+ yAxis,
435
+ dataZoom,
436
+ paneLayout: paneConfigs,
437
+ mainPaneHeight: mainHeightVal,
438
+ mainPaneTop,
439
+ pixelToPercent,
440
+ };
441
+ }
442
+
443
+ private static calculateMaximized(
444
+ containerHeight: number,
445
+ options: QFChartOptions,
446
+ targetPaneIndex: number // 0 for main, 1+ for indicators
447
+ ): LayoutResult {
448
+ return {
449
+ grid: [],
450
+ xAxis: [],
451
+ yAxis: [],
452
+ dataZoom: [],
453
+ paneLayout: [],
454
+ mainPaneHeight: 0,
455
+ mainPaneTop: 0,
456
+ pixelToPercent: 0,
457
+ } as any;
458
+ }
459
+ }