@qfo/qfchart 0.7.2 → 0.8.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.
@@ -14,6 +14,12 @@ export interface PaneConfiguration {
14
14
  };
15
15
  }
16
16
 
17
+ export interface PaneBoundary {
18
+ yPercent: number; // Y position in %, center of the gap between panes
19
+ aboveId: string | 'main'; // pane above (main chart or indicator id)
20
+ belowId: string; // indicator id below
21
+ }
22
+
17
23
  export interface LayoutResult {
18
24
  grid: any[];
19
25
  xAxis: any[];
@@ -23,6 +29,7 @@ export interface LayoutResult {
23
29
  mainPaneHeight: number;
24
30
  mainPaneTop: number;
25
31
  pixelToPercent: number;
32
+ paneBoundaries: PaneBoundary[];
26
33
  }
27
34
 
28
35
  export class LayoutManager {
@@ -32,7 +39,8 @@ export class LayoutManager {
32
39
  options: QFChartOptions,
33
40
  isMainCollapsed: boolean = false,
34
41
  maximizedPaneId: string | null = null,
35
- marketData?: import('../types').OHLCV[]
42
+ marketData?: import('../types').OHLCV[],
43
+ mainHeightOverride?: number
36
44
  ): LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number } {
37
45
  // Calculate pixelToPercent early for maximized logic
38
46
  let pixelToPercent = 0;
@@ -43,6 +51,17 @@ export class LayoutManager {
43
51
  // Get Y-axis padding percentage (default 5%)
44
52
  const yAxisPaddingPercent = options.yAxisPadding !== undefined ? options.yAxisPadding : 5;
45
53
 
54
+ // Grid styling options
55
+ const gridShow = options.grid?.show === true; // default false
56
+ const gridLineColor = options.grid?.lineColor ?? '#334155';
57
+ const gridLineOpacity = options.grid?.lineOpacity ?? 0.5;
58
+ const gridBorderColor = options.grid?.borderColor ?? '#334155';
59
+ const gridBorderShow = options.grid?.borderShow === true; // default false
60
+
61
+ // Layout margin options
62
+ const layoutLeft = options.layout?.left ?? '10%';
63
+ const layoutRight = options.layout?.right ?? '10%';
64
+
46
65
  // Identify unique separate panes (indices > 0) and sort them
47
66
  const separatePaneIndices = Array.from(indicators.values())
48
67
  .map((ind) => ind.paneIndex)
@@ -114,8 +133,8 @@ export class LayoutManager {
114
133
 
115
134
  // Grid
116
135
  grid.push({
117
- left: '10%',
118
- right: '10%',
136
+ left: layoutLeft,
137
+ right: layoutRight,
119
138
  top: isTarget ? '5%' : '0%',
120
139
  height: isTarget ? '90%' : '0%',
121
140
  show: isTarget,
@@ -133,10 +152,10 @@ export class LayoutManager {
133
152
  color: '#94a3b8',
134
153
  fontFamily: options.fontFamily,
135
154
  },
136
- axisLine: { show: isTarget, lineStyle: { color: '#334155' } },
155
+ axisLine: { show: isTarget && gridBorderShow, lineStyle: { color: gridBorderColor } },
137
156
  splitLine: {
138
- show: isTarget,
139
- lineStyle: { color: '#334155', opacity: 0.5 },
157
+ show: isTarget && gridShow,
158
+ lineStyle: { color: gridLineColor, opacity: gridLineOpacity },
140
159
  },
141
160
  });
142
161
 
@@ -183,8 +202,8 @@ export class LayoutManager {
183
202
  },
184
203
  },
185
204
  splitLine: {
186
- show: isTarget,
187
- lineStyle: { color: '#334155', opacity: 0.5 },
205
+ show: isTarget && gridShow,
206
+ lineStyle: { color: gridLineColor, opacity: gridLineOpacity },
188
207
  },
189
208
  });
190
209
 
@@ -283,7 +302,10 @@ export class LayoutManager {
283
302
  const totalAvailable = chartAreaBottom - mainPaneTop;
284
303
  mainHeightVal = totalAvailable - totalBottomSpace;
285
304
 
286
- if (isMainCollapsed) {
305
+ // Apply user-dragged main height override
306
+ if (mainHeightOverride !== undefined && mainHeightOverride > 0 && !isMainCollapsed) {
307
+ mainHeightVal = mainHeightOverride;
308
+ } else if (isMainCollapsed) {
287
309
  mainHeightVal = 3;
288
310
  } else {
289
311
  // Safety check: ensure main chart has at least some space (e.g. 20%)
@@ -315,12 +337,31 @@ export class LayoutManager {
315
337
  }
316
338
  }
317
339
 
340
+ // --- Build pane boundaries for drag-resize ---
341
+ const paneBoundaries: PaneBoundary[] = [];
342
+ if (paneConfigs.length > 0) {
343
+ // Boundary between main chart and first indicator
344
+ paneBoundaries.push({
345
+ yPercent: mainPaneTop + mainHeightVal + gapPercent / 2,
346
+ aboveId: 'main',
347
+ belowId: paneConfigs[0].indicatorId || '',
348
+ });
349
+ // Boundaries between consecutive indicators
350
+ for (let i = 0; i < paneConfigs.length - 1; i++) {
351
+ paneBoundaries.push({
352
+ yPercent: paneConfigs[i].top + paneConfigs[i].height + gapPercent / 2,
353
+ aboveId: paneConfigs[i].indicatorId || '',
354
+ belowId: paneConfigs[i + 1].indicatorId || '',
355
+ });
356
+ }
357
+ }
358
+
318
359
  // --- Generate Grids ---
319
360
  const grid: any[] = [];
320
361
  // Main Grid (index 0)
321
362
  grid.push({
322
- left: '10%',
323
- right: '10%',
363
+ left: layoutLeft,
364
+ right: layoutRight,
324
365
  top: mainPaneTop + '%',
325
366
  height: mainHeightVal + '%',
326
367
  containLabel: false, // We handle margins explicitly
@@ -329,8 +370,8 @@ export class LayoutManager {
329
370
  // Separate Panes Grids
330
371
  paneConfigs.forEach((pane) => {
331
372
  grid.push({
332
- left: '10%',
333
- right: '10%',
373
+ left: layoutLeft,
374
+ right: layoutRight,
334
375
  top: pane.top + '%',
335
376
  height: pane.height + '%',
336
377
  containLabel: false,
@@ -351,12 +392,12 @@ export class LayoutManager {
351
392
  // boundaryGap will be set in QFChart.ts based on padding option
352
393
  axisLine: {
353
394
  onZero: false,
354
- show: !isMainCollapsed,
355
- lineStyle: { color: '#334155' },
395
+ show: !isMainCollapsed && gridBorderShow,
396
+ lineStyle: { color: gridBorderColor },
356
397
  },
357
398
  splitLine: {
358
- show: !isMainCollapsed,
359
- lineStyle: { color: '#334155', opacity: 0.5 },
399
+ show: !isMainCollapsed && gridShow,
400
+ lineStyle: { color: gridLineColor, opacity: gridLineOpacity },
360
401
  },
361
402
  axisLabel: {
362
403
  show: !isMainCollapsed,
@@ -390,7 +431,7 @@ export class LayoutManager {
390
431
  gridIndex: i + 1, // 0 is main
391
432
  data: [], // Shared data
392
433
  axisLabel: { show: false }, // Hide labels on indicator panes
393
- axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
434
+ axisLine: { show: !pane.isCollapsed && gridBorderShow, lineStyle: { color: gridBorderColor } },
394
435
  axisTick: { show: false },
395
436
  splitLine: { show: false },
396
437
  axisPointer: {
@@ -430,10 +471,10 @@ export class LayoutManager {
430
471
  max: mainYAxisMax,
431
472
  gridIndex: 0,
432
473
  splitLine: {
433
- show: !isMainCollapsed,
434
- lineStyle: { color: '#334155', opacity: 0.5 },
474
+ show: !isMainCollapsed && gridShow,
475
+ lineStyle: { color: gridLineColor, opacity: gridLineOpacity },
435
476
  },
436
- axisLine: { show: !isMainCollapsed, lineStyle: { color: '#334155' } },
477
+ axisLine: { show: !isMainCollapsed && gridBorderShow, lineStyle: { color: gridBorderColor } },
437
478
  axisLabel: {
438
479
  show: !isMainCollapsed,
439
480
  color: '#94a3b8',
@@ -583,8 +624,8 @@ export class LayoutManager {
583
624
  max: AxisUtils.createMaxFunction(yAxisPaddingPercent),
584
625
  gridIndex: i + 1,
585
626
  splitLine: {
586
- show: !pane.isCollapsed,
587
- lineStyle: { color: '#334155', opacity: 0.3 },
627
+ show: !pane.isCollapsed && gridShow,
628
+ lineStyle: { color: gridLineColor, opacity: gridLineOpacity * 0.6 },
588
629
  },
589
630
  axisLabel: {
590
631
  show: !pane.isCollapsed,
@@ -601,7 +642,7 @@ export class LayoutManager {
601
642
  return AxisUtils.formatValue(value, decimals);
602
643
  },
603
644
  },
604
- axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
645
+ axisLine: { show: !pane.isCollapsed && gridBorderShow, lineStyle: { color: gridBorderColor } },
605
646
  });
606
647
  });
607
648
 
@@ -658,6 +699,7 @@ export class LayoutManager {
658
699
  mainPaneHeight: mainHeightVal,
659
700
  mainPaneTop,
660
701
  pixelToPercent,
702
+ paneBoundaries,
661
703
  overlayYAxisMap,
662
704
  separatePaneYAxisOffset,
663
705
  };
@@ -677,6 +719,7 @@ export class LayoutManager {
677
719
  mainPaneHeight: 0,
678
720
  mainPaneTop: 0,
679
721
  pixelToPercent: 0,
722
+ paneBoundaries: [],
680
723
  } as any;
681
724
  }
682
725
  }
@@ -2,6 +2,8 @@ import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot, Indic
2
2
  import { PaneConfiguration } from './LayoutManager';
3
3
  import { SeriesRendererFactory } from './SeriesRendererFactory';
4
4
  import { AxisUtils } from '../utils/AxisUtils';
5
+ import { FillRenderer, BatchedFillEntry } from './renderers/FillRenderer';
6
+ import { ColorUtils } from '../utils/ColorUtils';
5
7
 
6
8
  export class SeriesBuilder {
7
9
  private static readonly DEFAULT_COLOR = '#2962ff';
@@ -121,8 +123,17 @@ export class SeriesBuilder {
121
123
  return 0;
122
124
  });
123
125
 
126
+ // Collect non-gradient fill plots for batching (performance: N series → 1 series)
127
+ // Keyed by "xAxisIndex:yAxisIndex" to batch fills on the same axis pair
128
+ const pendingFills = new Map<string, { entries: BatchedFillEntry[]; xAxisIndex: number; yAxisIndex: number }>();
129
+
124
130
  sortedPlots.forEach((plotName) => {
125
131
  const plot = indicator.plots[plotName];
132
+
133
+ // display.none: don't render visually, but still populate data arrays
134
+ // so that fill() plots referencing this plot can find the data.
135
+ const isDisplayNone = plot.options.display === 'none';
136
+
126
137
  const seriesName = `${id}::${plotName}`;
127
138
 
128
139
  // Find axis index for THIS SPECIFIC PLOT
@@ -133,7 +144,24 @@ export class SeriesBuilder {
133
144
  // IMPORTANT: If indicator is overlay (paneIndex === 0), treat all plots as overlays
134
145
  // This allows visual-only plots (background, barcolor) to have separate Y-axes while
135
146
  // still being on the main chart pane
136
- const plotOverlay = plot.options.overlay;
147
+ let plotOverlay = plot.options.overlay;
148
+
149
+ // Fill plots inherit overlay from their referenced plots.
150
+ // If both referenced plots are overlay, the fill should render on the
151
+ // overlay pane too — otherwise its price-scale data stretches the
152
+ // indicator sub-pane's y-axis to extreme ranges.
153
+ if (plot.options.style === 'fill' && plotOverlay === undefined) {
154
+ const p1Name = plot.options.plot1;
155
+ const p2Name = plot.options.plot2;
156
+ if (p1Name && p2Name) {
157
+ const p1 = indicator.plots[p1Name];
158
+ const p2 = indicator.plots[p2Name];
159
+ if (p1?.options?.overlay === true && p2?.options?.overlay === true) {
160
+ plotOverlay = true;
161
+ }
162
+ }
163
+ }
164
+
137
165
  const isPlotOverlay = indicator.paneIndex === 0 || plotOverlay === true;
138
166
 
139
167
  if (isPlotOverlay) {
@@ -202,6 +230,9 @@ export class SeriesBuilder {
202
230
  // Fill plots need the actual numeric values even when the referenced plot is invisible (color=na)
203
231
  plotDataArrays.set(`${id}::${plotName}`, rawDataArray);
204
232
 
233
+ // display.none plots: data is now stored for fill references, skip rendering
234
+ if (isDisplayNone) return;
235
+
205
236
  if (plot.options?.style?.startsWith('style_')) {
206
237
  plot.options.style = plot.options.style.replace('style_', '') as IndicatorStyle;
207
238
  }
@@ -237,6 +268,64 @@ export class SeriesBuilder {
237
268
  return;
238
269
  }
239
270
 
271
+ // Batch non-gradient fill plots for performance
272
+ // Instead of creating N separate ECharts custom series (one per fill),
273
+ // collect them and render as a single batched series per axis pair.
274
+ if (plot.options.style === 'fill' && plot.options.gradient !== true) {
275
+ const plot1Key = plot.options.plot1 ? `${id}::${plot.options.plot1}` : null;
276
+ const plot2Key = plot.options.plot2 ? `${id}::${plot.options.plot2}` : null;
277
+
278
+ if (plot1Key && plot2Key) {
279
+ const plot1Data = plotDataArrays.get(plot1Key);
280
+ const plot2Data = plotDataArrays.get(plot2Key);
281
+
282
+ if (plot1Data && plot2Data) {
283
+ // Parse per-bar colors
284
+ const { color: defaultColor, opacity: defaultOpacity } =
285
+ ColorUtils.parseColor(plot.options.color || 'rgba(128, 128, 128, 0.2)');
286
+ const hasPerBarColor = optionsArray.some((o: any) => o && o.color !== undefined);
287
+
288
+ const fillBarColors: { color: string; opacity: number }[] = [];
289
+ for (let i = 0; i < totalDataLength; i++) {
290
+ const opts = optionsArray[i];
291
+ if (hasPerBarColor && opts && opts.color !== undefined) {
292
+ fillBarColors[i] = ColorUtils.parseColor(opts.color);
293
+ } else {
294
+ fillBarColors[i] = { color: defaultColor, opacity: defaultOpacity };
295
+ }
296
+ }
297
+
298
+ const axisKey = `${xAxisIndex}:${yAxisIndex}`;
299
+ if (!pendingFills.has(axisKey)) {
300
+ pendingFills.set(axisKey, { entries: [], xAxisIndex, yAxisIndex });
301
+ }
302
+ pendingFills.get(axisKey)!.entries.push({
303
+ plot1Data,
304
+ plot2Data,
305
+ barColors: fillBarColors,
306
+ });
307
+ return; // Defer series creation to batch step below
308
+ }
309
+ }
310
+ }
311
+
312
+ // Skip fully transparent plots — they exist only as data sources for fills.
313
+ // Their data is already stored in plotDataArrays for fill references.
314
+ if (plot.options.color && typeof plot.options.color === 'string') {
315
+ const parsed = ColorUtils.parseColor(plot.options.color);
316
+ if (parsed.opacity < 0.01) {
317
+ // Check that ALL per-bar colors are also transparent (or absent)
318
+ const hasVisibleBarColor = colorArray.some((c: any) => {
319
+ if (c == null) return false;
320
+ const pc = ColorUtils.parseColor(c);
321
+ return pc.opacity >= 0.01;
322
+ });
323
+ if (!hasVisibleBarColor) {
324
+ return; // Skip rendering — data already in plotDataArrays for fills
325
+ }
326
+ }
327
+ }
328
+
240
329
  // Use Factory to get appropriate renderer
241
330
  const renderer = SeriesRendererFactory.get(plot.options.style);
242
331
  const seriesConfig = renderer.render({
@@ -258,6 +347,38 @@ export class SeriesBuilder {
258
347
  series.push(seriesConfig);
259
348
  }
260
349
  });
350
+
351
+ // Batch pending fills: merge multiple fill series into single batched series per axis pair
352
+ if (pendingFills.size > 0) {
353
+ const fillRenderer = new FillRenderer();
354
+ pendingFills.forEach(({ entries, xAxisIndex, yAxisIndex }, axisKey) => {
355
+ if (entries.length >= 2) {
356
+ // Batch multiple fills into a single ECharts custom series
357
+ const batchedConfig = fillRenderer.renderBatched(
358
+ `${id}::fills_batch_${axisKey}`,
359
+ xAxisIndex,
360
+ yAxisIndex,
361
+ totalDataLength,
362
+ entries
363
+ );
364
+ if (batchedConfig) {
365
+ series.push(batchedConfig);
366
+ }
367
+ } else if (entries.length === 1) {
368
+ // Single fill — still use batched renderer for consistency (clip + encode)
369
+ const batchedConfig = fillRenderer.renderBatched(
370
+ `${id}::fills_batch_${axisKey}`,
371
+ xAxisIndex,
372
+ yAxisIndex,
373
+ totalDataLength,
374
+ entries
375
+ );
376
+ if (batchedConfig) {
377
+ series.push(batchedConfig);
378
+ }
379
+ }
380
+ });
381
+ }
261
382
  });
262
383
 
263
384
  return { series, barColors };