@qfo/qfchart 0.8.1 → 0.8.4

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.
Files changed (39) hide show
  1. package/dist/index.d.ts +216 -1
  2. package/dist/qfchart.min.browser.js +20 -19
  3. package/dist/qfchart.min.es.js +18 -17
  4. package/package.json +1 -1
  5. package/src/QFChart.ts +146 -11
  6. package/src/components/LayoutManager.ts +76 -28
  7. package/src/components/PluginManager.ts +229 -229
  8. package/src/components/SeriesBuilder.ts +21 -14
  9. package/src/components/renderers/LabelRenderer.ts +6 -3
  10. package/src/components/renderers/ScatterRenderer.ts +92 -54
  11. package/src/components/renderers/ShapeRenderer.ts +12 -0
  12. package/src/index.ts +8 -0
  13. package/src/plugins/CrossLineTool/CrossLineDrawingRenderer.ts +49 -0
  14. package/src/plugins/CrossLineTool/CrossLineTool.ts +52 -0
  15. package/src/plugins/CrossLineTool/index.ts +2 -0
  16. package/src/plugins/ExtendedLineTool/ExtendedLineDrawingRenderer.ts +73 -0
  17. package/src/plugins/ExtendedLineTool/ExtendedLineTool.ts +173 -0
  18. package/src/plugins/ExtendedLineTool/index.ts +2 -0
  19. package/src/plugins/HorizontalLineTool/HorizontalLineDrawingRenderer.ts +54 -0
  20. package/src/plugins/HorizontalLineTool/HorizontalLineTool.ts +52 -0
  21. package/src/plugins/HorizontalLineTool/index.ts +2 -0
  22. package/src/plugins/HorizontalRayTool/HorizontalRayDrawingRenderer.ts +34 -0
  23. package/src/plugins/HorizontalRayTool/HorizontalRayTool.ts +52 -0
  24. package/src/plugins/HorizontalRayTool/index.ts +2 -0
  25. package/src/plugins/InfoLineTool/InfoLineDrawingRenderer.ts +72 -0
  26. package/src/plugins/InfoLineTool/InfoLineTool.ts +130 -0
  27. package/src/plugins/InfoLineTool/index.ts +2 -0
  28. package/src/plugins/LineTool/LineDrawingRenderer.ts +2 -2
  29. package/src/plugins/LineTool/LineTool.ts +5 -5
  30. package/src/plugins/RayTool/RayDrawingRenderer.ts +69 -0
  31. package/src/plugins/RayTool/RayTool.ts +162 -0
  32. package/src/plugins/RayTool/index.ts +2 -0
  33. package/src/plugins/TrendAngleTool/TrendAngleDrawingRenderer.ts +87 -0
  34. package/src/plugins/TrendAngleTool/TrendAngleTool.ts +176 -0
  35. package/src/plugins/TrendAngleTool/index.ts +2 -0
  36. package/src/plugins/VerticalLineTool/VerticalLineDrawingRenderer.ts +35 -0
  37. package/src/plugins/VerticalLineTool/VerticalLineTool.ts +52 -0
  38. package/src/plugins/VerticalLineTool/index.ts +2 -0
  39. package/src/types.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qfo/qfchart",
3
- "version": "0.8.1",
3
+ "version": "0.8.4",
4
4
  "description": "Professional financial charting library built on Apache ECharts with candlestick charts, technical indicators, and interactive drawing tools",
5
5
  "keywords": [
6
6
  "chart",
package/src/QFChart.ts CHANGED
@@ -984,6 +984,9 @@ export class QFChart implements ChartContext {
984
984
  return candle;
985
985
  });
986
986
 
987
+ // Build drawing range hints for Y-axis scaling
988
+ const updateDrawingRangeHints = this._buildDrawingRangeHints(layout, paddingPoints);
989
+
987
990
  // Update only the data arrays in the option, not the full config
988
991
  const updateOption: any = {
989
992
  xAxis: currentOption.xAxis.map((axis: any, index: number) => ({
@@ -995,6 +998,7 @@ export class QFChart implements ChartContext {
995
998
  markLine: candlestickSeries.markLine, // Ensure markLine is updated
996
999
  },
997
1000
  ...indicatorSeries,
1001
+ ...updateDrawingRangeHints,
998
1002
  ],
999
1003
  };
1000
1004
 
@@ -1232,6 +1236,132 @@ export class QFChart implements ChartContext {
1232
1236
  this._renderTableOverlays();
1233
1237
  }
1234
1238
 
1239
+ /**
1240
+ * Build invisible "scatter" series that carry the min/max Y values of Pine
1241
+ * Script drawing objects (lines, boxes, labels, polylines). ECharts includes
1242
+ * these points in its automatic Y-axis range calculation so drawings below
1243
+ * or above the candlestick range are no longer clipped.
1244
+ *
1245
+ * Returns one hidden series per pane that has drawing objects with Y-values
1246
+ * outside the default data range.
1247
+ */
1248
+ private _buildDrawingRangeHints(layout: any, paddingPoints: number): any[] {
1249
+ const hintSeries: any[] = [];
1250
+
1251
+ // Collect Y-value bounds per pane from all indicator drawing objects
1252
+ const boundsPerPane = new Map<number, { yMin: number; yMax: number }>();
1253
+
1254
+ for (const indicator of this.indicators) {
1255
+ if (!indicator.plots) continue;
1256
+ const paneIndex = indicator.paneIndex ?? 0;
1257
+ if (!boundsPerPane.has(paneIndex)) {
1258
+ boundsPerPane.set(paneIndex, { yMin: Infinity, yMax: -Infinity });
1259
+ }
1260
+ const bounds = boundsPerPane.get(paneIndex)!;
1261
+
1262
+ for (const [plotName, plot] of Object.entries(indicator.plots as Record<string, any>)) {
1263
+ if (!plot || !plot.options) continue;
1264
+ const style = plot.options?.style;
1265
+
1266
+ // Lines: y1, y2
1267
+ if (style === 'drawing_line' && plot.data) {
1268
+ for (const entry of plot.data) {
1269
+ const items = entry?.value ? (Array.isArray(entry.value) ? entry.value : [entry.value]) : [];
1270
+ for (const ln of items) {
1271
+ if (!ln || ln._deleted) continue;
1272
+ if (typeof ln.y1 === 'number' && isFinite(ln.y1)) {
1273
+ bounds.yMin = Math.min(bounds.yMin, ln.y1);
1274
+ bounds.yMax = Math.max(bounds.yMax, ln.y1);
1275
+ }
1276
+ if (typeof ln.y2 === 'number' && isFinite(ln.y2)) {
1277
+ bounds.yMin = Math.min(bounds.yMin, ln.y2);
1278
+ bounds.yMax = Math.max(bounds.yMax, ln.y2);
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ // Boxes: top, bottom
1285
+ if (style === 'drawing_box' && plot.data) {
1286
+ for (const entry of plot.data) {
1287
+ const items = entry?.value ? (Array.isArray(entry.value) ? entry.value : [entry.value]) : [];
1288
+ for (const bx of items) {
1289
+ if (!bx || bx._deleted) continue;
1290
+ if (typeof bx.top === 'number' && isFinite(bx.top)) {
1291
+ bounds.yMin = Math.min(bounds.yMin, bx.top);
1292
+ bounds.yMax = Math.max(bounds.yMax, bx.top);
1293
+ }
1294
+ if (typeof bx.bottom === 'number' && isFinite(bx.bottom)) {
1295
+ bounds.yMin = Math.min(bounds.yMin, bx.bottom);
1296
+ bounds.yMax = Math.max(bounds.yMax, bx.bottom);
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ // Labels: y
1303
+ if (style === 'label' && plot.data) {
1304
+ for (const entry of plot.data) {
1305
+ const items = entry?.value ? (Array.isArray(entry.value) ? entry.value : [entry.value]) : [];
1306
+ for (const lbl of items) {
1307
+ if (!lbl || lbl._deleted) continue;
1308
+ if (typeof lbl.y === 'number' && isFinite(lbl.y)) {
1309
+ bounds.yMin = Math.min(bounds.yMin, lbl.y);
1310
+ bounds.yMax = Math.max(bounds.yMax, lbl.y);
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ // Polylines: points[].price
1317
+ if (style === 'drawing_polyline' && plot.data) {
1318
+ for (const entry of plot.data) {
1319
+ const items = entry?.value ? (Array.isArray(entry.value) ? entry.value : [entry.value]) : [];
1320
+ for (const pl of items) {
1321
+ if (!pl || pl._deleted || !pl._points) continue;
1322
+ for (const pt of pl._points) {
1323
+ if (typeof pt?.price === 'number' && isFinite(pt.price)) {
1324
+ bounds.yMin = Math.min(bounds.yMin, pt.price);
1325
+ bounds.yMax = Math.max(bounds.yMax, pt.price);
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+ }
1332
+ }
1333
+
1334
+ // Create a hidden scatter series per pane with min/max Y values
1335
+ const midIndex = paddingPoints + Math.floor((this.marketData?.length || 0) / 2);
1336
+ boundsPerPane.forEach((bounds, paneIndex) => {
1337
+ if (!isFinite(bounds.yMin) || !isFinite(bounds.yMax)) return;
1338
+
1339
+ // Determine Y-axis index for this pane
1340
+ const yAxisIndex = paneIndex === 0
1341
+ ? 0
1342
+ : (layout.separatePaneYAxisOffset || 1) + (paneIndex - 1);
1343
+
1344
+ hintSeries.push({
1345
+ name: `_drawingRange_pane${paneIndex}`,
1346
+ type: 'scatter',
1347
+ xAxisIndex: paneIndex,
1348
+ yAxisIndex,
1349
+ symbol: 'none',
1350
+ symbolSize: 0,
1351
+ silent: true,
1352
+ animation: false,
1353
+ // Two invisible points at min and max Y — ECharts includes them in axis scaling
1354
+ data: [
1355
+ [midIndex, bounds.yMin],
1356
+ [midIndex, bounds.yMax],
1357
+ ],
1358
+ tooltip: { show: false },
1359
+ });
1360
+ });
1361
+
1362
+ return hintSeries;
1363
+ }
1364
+
1235
1365
  /**
1236
1366
  * Build table canvas graphic elements from the current _lastTables.
1237
1367
  * Must be called AFTER setOption so grid rects are available from ECharts.
@@ -1397,22 +1527,22 @@ export class QFChart implements ChartContext {
1397
1527
  });
1398
1528
  drawingsByPane.forEach((paneDrawings) => {
1399
1529
  drawingSeriesUpdates.push({
1400
- data: paneDrawings.map((d) => [
1401
- d.points[0].timeIndex + this.dataIndexOffset,
1402
- d.points[0].value,
1403
- d.points[1].timeIndex + this.dataIndexOffset,
1404
- d.points[1].value,
1405
- ]),
1530
+ data: paneDrawings.map((d) => {
1531
+ const row: number[] = [];
1532
+ d.points.forEach((p) => {
1533
+ row.push(p.timeIndex + this.dataIndexOffset, p.value);
1534
+ });
1535
+ return row;
1536
+ }),
1406
1537
  });
1407
1538
  });
1408
1539
 
1409
1540
  // 6. Merge update — preserves drag/interaction state
1410
1541
  const updateOption: any = {
1411
1542
  xAxis: currentOption.xAxis.map(() => ({ data: categoryData })),
1412
- dataZoom: [
1413
- { start: newStart, end: newEnd },
1414
- { start: newStart, end: newEnd },
1415
- ],
1543
+ dataZoom: (currentOption.dataZoom || []).map(() => ({
1544
+ start: newStart, end: newEnd,
1545
+ })),
1416
1546
  series: [
1417
1547
  { data: coloredCandlestickData, markLine: candlestickSeries.markLine },
1418
1548
  ...indicatorSeries.map((s) => {
@@ -1602,6 +1732,10 @@ export class QFChart implements ChartContext {
1602
1732
  layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
1603
1733
  );
1604
1734
 
1735
+ // Create hidden range-hint series so Pine Script drawing objects
1736
+ // (lines, boxes, labels, polylines) contribute to Y-axis auto-scaling.
1737
+ const drawingRangeHints = this._buildDrawingRangeHints(layout, paddingPoints);
1738
+
1605
1739
  // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1606
1740
  candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1607
1741
  if (barColors[i]) {
@@ -1668,6 +1802,7 @@ export class QFChart implements ChartContext {
1668
1802
  pixelPoints,
1669
1803
  isSelected: drawing.id === this.selectedDrawingId,
1670
1804
  api,
1805
+ coordSys: params.coordSys,
1671
1806
  });
1672
1807
  },
1673
1808
  data: drawings.map((d) => {
@@ -1769,7 +1904,7 @@ export class QFChart implements ChartContext {
1769
1904
  xAxis: layout.xAxis,
1770
1905
  yAxis: layout.yAxis,
1771
1906
  dataZoom: layout.dataZoom,
1772
- series: [candlestickSeries, ...indicatorSeries, ...drawingSeriesList],
1907
+ series: [candlestickSeries, ...indicatorSeries, ...drawingRangeHints, ...drawingSeriesList],
1773
1908
  };
1774
1909
 
1775
1910
  this.chart.setOption(option, true); // true = not merge, replace.
@@ -268,6 +268,18 @@ export class LayoutManager {
268
268
 
269
269
  let mainHeightVal = 75; // Default if no separate pane
270
270
 
271
+ // Parse layout.mainPaneHeight option (e.g. '40%' or 40)
272
+ let configuredMainHeight: number | undefined;
273
+ if (options.layout?.mainPaneHeight !== undefined) {
274
+ const raw = options.layout.mainPaneHeight;
275
+ if (typeof raw === 'string') {
276
+ const parsed = parseFloat(raw);
277
+ if (!isNaN(parsed)) configuredMainHeight = parsed;
278
+ } else if (typeof raw === 'number') {
279
+ configuredMainHeight = raw as unknown as number;
280
+ }
281
+ }
282
+
271
283
  // Prepare separate panes configuration
272
284
  let paneConfigs: PaneConfiguration[] = [];
273
285
 
@@ -286,33 +298,54 @@ export class LayoutManager {
286
298
  };
287
299
  });
288
300
 
289
- // 2. Assign actual heights
290
- // If collapsed, use small fixed height (e.g. 3%)
291
- const resolvedPanes = panes.map((p) => ({
301
+ // 2. Assign raw heights (collapsed = 3%, otherwise use requested or default 15)
302
+ const rawPanes = panes.map((p) => ({
292
303
  ...p,
293
- height: p.isCollapsed ? 3 : p.requestedHeight !== undefined ? p.requestedHeight : 15,
304
+ rawHeight: p.isCollapsed ? 3 : p.requestedHeight !== undefined ? p.requestedHeight : 15,
294
305
  }));
295
306
 
296
- // 3. Calculate total space needed for indicators
297
- const totalIndicatorHeight = resolvedPanes.reduce((sum, p) => sum + p.height, 0);
298
- const totalGaps = resolvedPanes.length * gapPercent;
299
- const totalBottomSpace = totalIndicatorHeight + totalGaps;
300
-
301
- // 4. Calculate Main Chart Height
302
- // Available space = chartAreaBottom - mainPaneTop;
303
307
  const totalAvailable = chartAreaBottom - mainPaneTop;
304
- mainHeightVal = totalAvailable - totalBottomSpace;
308
+ const totalGaps = rawPanes.length * gapPercent;
305
309
 
306
- // Apply user-dragged main height override
310
+ // 4. Determine main chart height
307
311
  if (mainHeightOverride !== undefined && mainHeightOverride > 0 && !isMainCollapsed) {
312
+ // Drag-resize takes absolute priority
308
313
  mainHeightVal = mainHeightOverride;
309
314
  } else if (isMainCollapsed) {
310
315
  mainHeightVal = 3;
316
+ } else if (configuredMainHeight !== undefined && configuredMainHeight > 0) {
317
+ // User set mainPaneHeight — indicators fill remaining space proportionally
318
+ mainHeightVal = configuredMainHeight;
311
319
  } else {
312
- // Safety check: ensure main chart has at least some space (e.g. 20%)
313
- if (mainHeightVal < 20) {
314
- mainHeightVal = Math.max(mainHeightVal, 10);
315
- }
320
+ // Auto: subtract indicator heights from available space
321
+ const totalIndicatorHeight = rawPanes.reduce((sum, p) => sum + p.rawHeight, 0);
322
+ mainHeightVal = totalAvailable - totalIndicatorHeight - totalGaps;
323
+ if (mainHeightVal < 20) mainHeightVal = Math.max(mainHeightVal, 10);
324
+ }
325
+
326
+ // 3. Resolve indicator heights
327
+ // When mainPaneHeight is configured (or drag override active), distribute remaining space
328
+ // proportionally among non-collapsed panes using their rawHeight as weights.
329
+ const isMainHeightFixed = (mainHeightOverride !== undefined && mainHeightOverride > 0 && !isMainCollapsed)
330
+ || (configuredMainHeight !== undefined && configuredMainHeight > 0 && !isMainCollapsed);
331
+
332
+ type ResolvedPane = (typeof rawPanes)[number] & { height: number };
333
+ let resolvedPanes: ResolvedPane[];
334
+ if (isMainHeightFixed) {
335
+ const remainingForIndicators = totalAvailable - mainHeightVal - totalGaps;
336
+ const totalWeights = rawPanes
337
+ .filter((p) => !p.isCollapsed)
338
+ .reduce((sum, p) => sum + p.rawHeight, 0);
339
+ resolvedPanes = rawPanes.map((p) => ({
340
+ ...p,
341
+ height: p.isCollapsed
342
+ ? 3
343
+ : totalWeights > 0
344
+ ? Math.max(5, (p.rawHeight / totalWeights) * remainingForIndicators)
345
+ : remainingForIndicators / rawPanes.filter((x) => !x.isCollapsed).length,
346
+ }));
347
+ } else {
348
+ resolvedPanes = rawPanes.map((p) => ({ ...p, height: p.rawHeight }));
316
349
  }
317
350
 
318
351
  // 5. Calculate positions
@@ -332,6 +365,7 @@ export class LayoutManager {
332
365
  return config;
333
366
  });
334
367
  } else {
368
+ // No secondary panes — mainPaneHeight is ignored, fill all available space
335
369
  mainHeightVal = chartAreaBottom - mainPaneTop;
336
370
  if (isMainCollapsed) {
337
371
  mainHeightVal = 3;
@@ -384,7 +418,10 @@ export class LayoutManager {
384
418
  const xAxis: any[] = [];
385
419
 
386
420
  // Main X-Axis
421
+ // Hide date labels on the main chart when indicator panes exist below —
422
+ // the bottom-most pane's x-axis will show them instead.
387
423
  const isMainBottom = paneConfigs.length === 0;
424
+ const showMainXLabels = !isMainCollapsed && isMainBottom;
388
425
  xAxis.push({
389
426
  type: 'category',
390
427
  data: [], // Will be filled by SeriesBuilder or QFChart
@@ -401,7 +438,7 @@ export class LayoutManager {
401
438
  lineStyle: { color: gridLineColor, opacity: gridLineOpacity },
402
439
  },
403
440
  axisLabel: {
404
- show: !isMainCollapsed,
441
+ show: showMainXLabels,
405
442
  color: '#94a3b8',
406
443
  fontFamily: options.fontFamily || 'sans-serif',
407
444
  formatter: (value: number) => {
@@ -413,7 +450,7 @@ export class LayoutManager {
413
450
  return AxisUtils.formatValue(value, decimals);
414
451
  },
415
452
  },
416
- axisTick: { show: !isMainCollapsed },
453
+ axisTick: { show: showMainXLabels },
417
454
  axisPointer: {
418
455
  label: {
419
456
  show: isMainBottom,
@@ -424,15 +461,21 @@ export class LayoutManager {
424
461
  });
425
462
 
426
463
  // Separate Panes X-Axes
464
+ // Show date labels only on the bottom-most pane
427
465
  paneConfigs.forEach((pane, i) => {
428
466
  const isBottom = i === paneConfigs.length - 1;
467
+ const showLabels = isBottom && !pane.isCollapsed;
429
468
  xAxis.push({
430
469
  type: 'category',
431
470
  gridIndex: i + 1, // 0 is main
432
471
  data: [], // Shared data
433
- axisLabel: { show: false }, // Hide labels on indicator panes
472
+ axisLabel: {
473
+ show: showLabels,
474
+ color: '#94a3b8',
475
+ fontFamily: options.fontFamily || 'sans-serif',
476
+ },
434
477
  axisLine: { show: !pane.isCollapsed && gridBorderShow, lineStyle: { color: gridBorderColor } },
435
- axisTick: { show: false },
478
+ axisTick: { show: showLabels },
436
479
  splitLine: { show: false },
437
480
  axisPointer: {
438
481
  label: {
@@ -515,16 +558,21 @@ export class LayoutManager {
515
558
  const plotKey = `${id}::${plotName}`;
516
559
 
517
560
  // Skip visual-only plot types that should never affect Y-axis scaling
518
- // EXCEPTION: shapes with abovebar/belowbar must stay on main Y-axis
519
- const visualOnlyStyles = ['background', 'barcolor', 'char'];
561
+ // EXCEPTION: shapes/chars with price-relative locations must stay on main Y-axis
562
+ const visualOnlyStyles = ['background', 'barcolor'];
520
563
 
521
- // Check if this is a shape with price-relative positioning
564
+ // Check if this is a shape/char with price-relative positioning
565
+ // Includes abovebar/belowbar (relative to candle) and absolute (exact Y value)
522
566
  const isShapeWithPriceLocation =
523
- plot.options.style === 'shape' &&
567
+ (plot.options.style === 'shape' || plot.options.style === 'char') &&
524
568
  (plot.options.location === 'abovebar' ||
525
569
  plot.options.location === 'AboveBar' ||
570
+ plot.options.location === 'ab' ||
526
571
  plot.options.location === 'belowbar' ||
527
- plot.options.location === 'BelowBar');
572
+ plot.options.location === 'BelowBar' ||
573
+ plot.options.location === 'bl' ||
574
+ plot.options.location === 'absolute' ||
575
+ plot.options.location === 'Absolute');
528
576
 
529
577
  if (visualOnlyStyles.includes(plot.options.style)) {
530
578
  // Assign these to a separate Y-axis so they don't affect price scale
@@ -535,8 +583,8 @@ export class LayoutManager {
535
583
  return; // Skip further processing for this plot
536
584
  }
537
585
 
538
- // If it's a shape but NOT with price-relative positioning, treat as visual-only
539
- if (plot.options.style === 'shape' && !isShapeWithPriceLocation) {
586
+ // If it's a shape/char but NOT with price-relative positioning, treat as visual-only
587
+ if ((plot.options.style === 'shape' || plot.options.style === 'char') && !isShapeWithPriceLocation) {
540
588
  if (!overlayYAxisMap.has(plotKey)) {
541
589
  overlayYAxisMap.set(plotKey, nextYAxisIndex);
542
590
  nextYAxisIndex++;