@qfo/qfchart 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/qfchart.min.browser.js +16 -16
- package/dist/qfchart.min.es.js +16 -16
- package/package.json +1 -1
- package/src/QFChart.ts +172 -10
- package/src/components/LayoutManager.ts +34 -2
- package/src/components/SeriesBuilder.ts +26 -4
- package/src/components/TableOverlayRenderer.ts +38 -15
- package/src/components/renderers/BoxRenderer.ts +21 -21
- package/src/components/renderers/DrawingLineRenderer.ts +12 -16
- package/src/components/renderers/FillRenderer.ts +138 -31
- package/src/components/renderers/HistogramRenderer.ts +67 -20
- package/src/components/renderers/LinefillRenderer.ts +4 -12
- package/src/components/renderers/PolylineRenderer.ts +6 -13
- package/src/utils/ColorUtils.ts +77 -32
package/src/QFChart.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as echarts from 'echarts';
|
|
2
2
|
import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
|
|
3
3
|
import { Indicator } from './components/Indicator';
|
|
4
|
-
import { LayoutManager } from './components/LayoutManager';
|
|
4
|
+
import { LayoutManager, LayoutResult, PaneBoundary } from './components/LayoutManager';
|
|
5
5
|
import { SeriesBuilder } from './components/SeriesBuilder';
|
|
6
6
|
import { GraphicBuilder } from './components/GraphicBuilder';
|
|
7
7
|
import { TooltipFormatter } from './components/TooltipFormatter';
|
|
@@ -90,6 +90,18 @@ export class QFChart implements ChartContext {
|
|
|
90
90
|
private overlayContainer: HTMLElement;
|
|
91
91
|
private _lastTables: any[] = [];
|
|
92
92
|
|
|
93
|
+
// Pane drag-resize state
|
|
94
|
+
private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
|
|
95
|
+
private _mainHeightOverride: number | null = null;
|
|
96
|
+
private _paneDragState: {
|
|
97
|
+
startY: number;
|
|
98
|
+
aboveId: string | 'main';
|
|
99
|
+
belowId: string;
|
|
100
|
+
startAboveHeight: number;
|
|
101
|
+
startBelowHeight: number;
|
|
102
|
+
} | null = null;
|
|
103
|
+
private _paneResizeRafId: number | null = null;
|
|
104
|
+
|
|
93
105
|
constructor(container: HTMLElement, options: QFChartOptions = {}) {
|
|
94
106
|
this.rootContainer = container;
|
|
95
107
|
this.options = {
|
|
@@ -206,17 +218,23 @@ export class QFChart implements ChartContext {
|
|
|
206
218
|
// @ts-ignore - ECharts event handler type mismatch
|
|
207
219
|
this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
|
|
208
220
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
209
|
-
this.chart.getZr().on('mousedown', (params: any) => this.events.emit('mouse:down', params));
|
|
221
|
+
this.chart.getZr().on('mousedown', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:down', params); });
|
|
210
222
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
211
|
-
this.chart.getZr().on('mousemove', (params: any) => this.events.emit('mouse:move', params));
|
|
223
|
+
this.chart.getZr().on('mousemove', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:move', params); });
|
|
212
224
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
213
225
|
this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
|
|
214
226
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
215
|
-
this.chart.getZr().on('click', (params: any) => this.events.emit('mouse:click', params));
|
|
227
|
+
this.chart.getZr().on('click', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:click', params); });
|
|
216
228
|
|
|
217
229
|
const zr = this.chart.getZr();
|
|
218
230
|
const originalSetCursorStyle = zr.setCursorStyle;
|
|
231
|
+
const self = this;
|
|
219
232
|
zr.setCursorStyle = function (cursorStyle: string) {
|
|
233
|
+
// During pane drag, force row-resize cursor
|
|
234
|
+
if (self._paneDragState) {
|
|
235
|
+
originalSetCursorStyle.call(this, 'row-resize');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
220
238
|
// Change 'grab' (default roam cursor) to 'crosshair' (more suitable for candlestick chart)
|
|
221
239
|
if (cursorStyle === 'grab') {
|
|
222
240
|
cursorStyle = 'crosshair';
|
|
@@ -228,6 +246,9 @@ export class QFChart implements ChartContext {
|
|
|
228
246
|
// Bind Drawing Events
|
|
229
247
|
this.bindDrawingEvents();
|
|
230
248
|
|
|
249
|
+
// Bind pane border drag-resize
|
|
250
|
+
this.bindPaneResizeEvents();
|
|
251
|
+
|
|
231
252
|
window.addEventListener('resize', this.resize.bind(this));
|
|
232
253
|
|
|
233
254
|
// Listen for fullscreen change to restore state if exited via ESC
|
|
@@ -252,6 +273,133 @@ export class QFChart implements ChartContext {
|
|
|
252
273
|
this.render();
|
|
253
274
|
};
|
|
254
275
|
|
|
276
|
+
// ── Pane border drag-resize ────────────────────────────────
|
|
277
|
+
private bindPaneResizeEvents(): void {
|
|
278
|
+
const MIN_MAIN = 10; // minimum main pane height %
|
|
279
|
+
const MIN_INDICATOR = 5; // minimum indicator pane height %
|
|
280
|
+
const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
|
|
281
|
+
|
|
282
|
+
const zr = this.chart.getZr();
|
|
283
|
+
|
|
284
|
+
/** Find a boundary near the mouse Y position (pixels). */
|
|
285
|
+
const findBoundary = (mouseY: number): PaneBoundary | null => {
|
|
286
|
+
if (!this._lastLayout || this._lastLayout.paneBoundaries.length === 0) return null;
|
|
287
|
+
if (this.maximizedPaneId) return null; // no resize when maximized
|
|
288
|
+
const containerH = this.chart.getHeight();
|
|
289
|
+
if (containerH <= 0) return null;
|
|
290
|
+
|
|
291
|
+
for (const b of this._lastLayout.paneBoundaries) {
|
|
292
|
+
const bY = (b.yPercent / 100) * containerH;
|
|
293
|
+
if (Math.abs(mouseY - bY) <= HIT_ZONE) {
|
|
294
|
+
// Don't allow resizing collapsed panes
|
|
295
|
+
if (b.aboveId === 'main' && this.isMainCollapsed) continue;
|
|
296
|
+
const belowInd = this.indicators.get(b.belowId);
|
|
297
|
+
if (belowInd?.collapsed) continue;
|
|
298
|
+
if (b.aboveId !== 'main') {
|
|
299
|
+
const aboveInd = this.indicators.get(b.aboveId);
|
|
300
|
+
if (aboveInd?.collapsed) continue;
|
|
301
|
+
}
|
|
302
|
+
return b;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** Get current height of a pane. */
|
|
309
|
+
const getPaneHeight = (id: string | 'main'): number => {
|
|
310
|
+
if (id === 'main') {
|
|
311
|
+
return this._lastLayout?.mainPaneHeight ?? 50;
|
|
312
|
+
}
|
|
313
|
+
const ind = this.indicators.get(id);
|
|
314
|
+
return ind?.height ?? 15;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// --- ZR event handlers ---
|
|
318
|
+
|
|
319
|
+
zr.on('mousemove', (e: any) => {
|
|
320
|
+
if (this._paneDragState) {
|
|
321
|
+
// Active drag: compute new heights
|
|
322
|
+
const deltaY = e.offsetY - this._paneDragState.startY;
|
|
323
|
+
const containerH = this.chart.getHeight();
|
|
324
|
+
if (containerH <= 0) return;
|
|
325
|
+
const deltaPct = (deltaY / containerH) * 100;
|
|
326
|
+
|
|
327
|
+
const minAbove = this._paneDragState.aboveId === 'main' ? MIN_MAIN : MIN_INDICATOR;
|
|
328
|
+
const minBelow = MIN_INDICATOR;
|
|
329
|
+
|
|
330
|
+
let newAbove = this._paneDragState.startAboveHeight + deltaPct;
|
|
331
|
+
let newBelow = this._paneDragState.startBelowHeight - deltaPct;
|
|
332
|
+
|
|
333
|
+
// Clamp
|
|
334
|
+
if (newAbove < minAbove) {
|
|
335
|
+
newAbove = minAbove;
|
|
336
|
+
newBelow = this._paneDragState.startAboveHeight + this._paneDragState.startBelowHeight - minAbove;
|
|
337
|
+
}
|
|
338
|
+
if (newBelow < minBelow) {
|
|
339
|
+
newBelow = minBelow;
|
|
340
|
+
newAbove = this._paneDragState.startAboveHeight + this._paneDragState.startBelowHeight - minBelow;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Apply heights
|
|
344
|
+
if (this._paneDragState.aboveId === 'main') {
|
|
345
|
+
this._mainHeightOverride = newAbove;
|
|
346
|
+
} else {
|
|
347
|
+
const aboveInd = this.indicators.get(this._paneDragState.aboveId);
|
|
348
|
+
if (aboveInd) aboveInd.height = newAbove;
|
|
349
|
+
}
|
|
350
|
+
const belowInd = this.indicators.get(this._paneDragState.belowId);
|
|
351
|
+
if (belowInd) belowInd.height = newBelow;
|
|
352
|
+
|
|
353
|
+
// Throttle re-render via rAF
|
|
354
|
+
if (!this._paneResizeRafId) {
|
|
355
|
+
this._paneResizeRafId = requestAnimationFrame(() => {
|
|
356
|
+
this._paneResizeRafId = null;
|
|
357
|
+
this.render();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Force row-resize cursor
|
|
362
|
+
zr.setCursorStyle('row-resize');
|
|
363
|
+
e.stop?.();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Not dragging: check hover over boundary
|
|
368
|
+
const boundary = findBoundary(e.offsetY);
|
|
369
|
+
if (boundary) {
|
|
370
|
+
zr.setCursorStyle('row-resize');
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
zr.on('mousedown', (e: any) => {
|
|
375
|
+
const boundary = findBoundary(e.offsetY);
|
|
376
|
+
if (!boundary) return;
|
|
377
|
+
|
|
378
|
+
// Start drag
|
|
379
|
+
this._paneDragState = {
|
|
380
|
+
startY: e.offsetY,
|
|
381
|
+
aboveId: boundary.aboveId,
|
|
382
|
+
belowId: boundary.belowId,
|
|
383
|
+
startAboveHeight: getPaneHeight(boundary.aboveId),
|
|
384
|
+
startBelowHeight: getPaneHeight(boundary.belowId),
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
zr.setCursorStyle('row-resize');
|
|
388
|
+
e.stop?.();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
zr.on('mouseup', () => {
|
|
392
|
+
if (this._paneDragState) {
|
|
393
|
+
this._paneDragState = null;
|
|
394
|
+
if (this._paneResizeRafId) {
|
|
395
|
+
cancelAnimationFrame(this._paneResizeRafId);
|
|
396
|
+
this._paneResizeRafId = null;
|
|
397
|
+
}
|
|
398
|
+
this.render();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
255
403
|
private bindDrawingEvents() {
|
|
256
404
|
let hideTimeout: any = null;
|
|
257
405
|
let lastHoveredGroup: any = null;
|
|
@@ -669,8 +817,10 @@ export class QFChart implements ChartContext {
|
|
|
669
817
|
this.options,
|
|
670
818
|
this.isMainCollapsed,
|
|
671
819
|
this.maximizedPaneId,
|
|
672
|
-
this.marketData
|
|
820
|
+
this.marketData,
|
|
821
|
+
this._mainHeightOverride ?? undefined,
|
|
673
822
|
);
|
|
823
|
+
this._lastLayout = layout;
|
|
674
824
|
|
|
675
825
|
// Pass full padded candlestick data for shape positioning
|
|
676
826
|
// But SeriesBuilder expects 'OHLCV[]', while paddedCandlestickData is array of arrays [open,close,low,high]
|
|
@@ -745,7 +895,11 @@ export class QFChart implements ChartContext {
|
|
|
745
895
|
plot.data?.forEach((entry: any) => {
|
|
746
896
|
const tables = Array.isArray(entry.value) ? entry.value : [entry.value];
|
|
747
897
|
tables.forEach((t: any) => {
|
|
748
|
-
if (t && !t._deleted)
|
|
898
|
+
if (t && !t._deleted) {
|
|
899
|
+
// Tag table with its indicator's pane for correct positioning
|
|
900
|
+
t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
|
|
901
|
+
allTables.push(t);
|
|
902
|
+
}
|
|
749
903
|
});
|
|
750
904
|
});
|
|
751
905
|
}
|
|
@@ -957,8 +1111,10 @@ export class QFChart implements ChartContext {
|
|
|
957
1111
|
}
|
|
958
1112
|
|
|
959
1113
|
private _renderTableOverlays(): void {
|
|
960
|
-
const
|
|
961
|
-
|
|
1114
|
+
const model = this.chart.getModel() as any;
|
|
1115
|
+
const getGridRect = (paneIndex: number) =>
|
|
1116
|
+
model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
|
|
1117
|
+
TableOverlayRenderer.render(this.overlayContainer, this._lastTables, getGridRect);
|
|
962
1118
|
}
|
|
963
1119
|
|
|
964
1120
|
public destroy(): void {
|
|
@@ -1040,8 +1196,10 @@ export class QFChart implements ChartContext {
|
|
|
1040
1196
|
this.options,
|
|
1041
1197
|
this.isMainCollapsed,
|
|
1042
1198
|
this.maximizedPaneId,
|
|
1043
|
-
this.marketData
|
|
1199
|
+
this.marketData,
|
|
1200
|
+
this._mainHeightOverride ?? undefined,
|
|
1044
1201
|
);
|
|
1202
|
+
this._lastLayout = layout;
|
|
1045
1203
|
|
|
1046
1204
|
// Convert user-provided dataZoom start/end to account for padding
|
|
1047
1205
|
// User's start/end refer to real data (0% = start of real data, 100% = end of real data)
|
|
@@ -1427,7 +1585,11 @@ export class QFChart implements ChartContext {
|
|
|
1427
1585
|
plot.data?.forEach((entry: any) => {
|
|
1428
1586
|
const tables = Array.isArray(entry.value) ? entry.value : [entry.value];
|
|
1429
1587
|
tables.forEach((t: any) => {
|
|
1430
|
-
if (t && !t._deleted)
|
|
1588
|
+
if (t && !t._deleted) {
|
|
1589
|
+
// Tag table with its indicator's pane for correct positioning
|
|
1590
|
+
t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
|
|
1591
|
+
allTables.push(t);
|
|
1592
|
+
}
|
|
1431
1593
|
});
|
|
1432
1594
|
});
|
|
1433
1595
|
}
|
|
@@ -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;
|
|
@@ -283,7 +291,10 @@ export class LayoutManager {
|
|
|
283
291
|
const totalAvailable = chartAreaBottom - mainPaneTop;
|
|
284
292
|
mainHeightVal = totalAvailable - totalBottomSpace;
|
|
285
293
|
|
|
286
|
-
|
|
294
|
+
// Apply user-dragged main height override
|
|
295
|
+
if (mainHeightOverride !== undefined && mainHeightOverride > 0 && !isMainCollapsed) {
|
|
296
|
+
mainHeightVal = mainHeightOverride;
|
|
297
|
+
} else if (isMainCollapsed) {
|
|
287
298
|
mainHeightVal = 3;
|
|
288
299
|
} else {
|
|
289
300
|
// Safety check: ensure main chart has at least some space (e.g. 20%)
|
|
@@ -315,6 +326,25 @@ export class LayoutManager {
|
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
|
|
329
|
+
// --- Build pane boundaries for drag-resize ---
|
|
330
|
+
const paneBoundaries: PaneBoundary[] = [];
|
|
331
|
+
if (paneConfigs.length > 0) {
|
|
332
|
+
// Boundary between main chart and first indicator
|
|
333
|
+
paneBoundaries.push({
|
|
334
|
+
yPercent: mainPaneTop + mainHeightVal + gapPercent / 2,
|
|
335
|
+
aboveId: 'main',
|
|
336
|
+
belowId: paneConfigs[0].indicatorId || '',
|
|
337
|
+
});
|
|
338
|
+
// Boundaries between consecutive indicators
|
|
339
|
+
for (let i = 0; i < paneConfigs.length - 1; i++) {
|
|
340
|
+
paneBoundaries.push({
|
|
341
|
+
yPercent: paneConfigs[i].top + paneConfigs[i].height + gapPercent / 2,
|
|
342
|
+
aboveId: paneConfigs[i].indicatorId || '',
|
|
343
|
+
belowId: paneConfigs[i + 1].indicatorId || '',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
318
348
|
// --- Generate Grids ---
|
|
319
349
|
const grid: any[] = [];
|
|
320
350
|
// Main Grid (index 0)
|
|
@@ -658,6 +688,7 @@ export class LayoutManager {
|
|
|
658
688
|
mainPaneHeight: mainHeightVal,
|
|
659
689
|
mainPaneTop,
|
|
660
690
|
pixelToPercent,
|
|
691
|
+
paneBoundaries,
|
|
661
692
|
overlayYAxisMap,
|
|
662
693
|
separatePaneYAxisOffset,
|
|
663
694
|
};
|
|
@@ -677,6 +708,7 @@ export class LayoutManager {
|
|
|
677
708
|
mainPaneHeight: 0,
|
|
678
709
|
mainPaneTop: 0,
|
|
679
710
|
pixelToPercent: 0,
|
|
711
|
+
paneBoundaries: [],
|
|
680
712
|
} as any;
|
|
681
713
|
}
|
|
682
714
|
}
|
|
@@ -133,7 +133,24 @@ export class SeriesBuilder {
|
|
|
133
133
|
// IMPORTANT: If indicator is overlay (paneIndex === 0), treat all plots as overlays
|
|
134
134
|
// This allows visual-only plots (background, barcolor) to have separate Y-axes while
|
|
135
135
|
// still being on the main chart pane
|
|
136
|
-
|
|
136
|
+
let plotOverlay = plot.options.overlay;
|
|
137
|
+
|
|
138
|
+
// Fill plots inherit overlay from their referenced plots.
|
|
139
|
+
// If both referenced plots are overlay, the fill should render on the
|
|
140
|
+
// overlay pane too — otherwise its price-scale data stretches the
|
|
141
|
+
// indicator sub-pane's y-axis to extreme ranges.
|
|
142
|
+
if (plot.options.style === 'fill' && plotOverlay === undefined) {
|
|
143
|
+
const p1Name = plot.options.plot1;
|
|
144
|
+
const p2Name = plot.options.plot2;
|
|
145
|
+
if (p1Name && p2Name) {
|
|
146
|
+
const p1 = indicator.plots[p1Name];
|
|
147
|
+
const p2 = indicator.plots[p2Name];
|
|
148
|
+
if (p1?.options?.overlay === true && p2?.options?.overlay === true) {
|
|
149
|
+
plotOverlay = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
const isPlotOverlay = indicator.paneIndex === 0 || plotOverlay === true;
|
|
138
155
|
|
|
139
156
|
if (isPlotOverlay) {
|
|
@@ -158,6 +175,7 @@ export class SeriesBuilder {
|
|
|
158
175
|
// Prepare data arrays
|
|
159
176
|
// For 'fill' style, we don't use plot.data directly in the same way, but we initialize generic arrays
|
|
160
177
|
const dataArray = new Array(totalDataLength).fill(null);
|
|
178
|
+
const rawDataArray = new Array(totalDataLength).fill(null); // Unmodified values for fill references
|
|
161
179
|
const colorArray = new Array(totalDataLength).fill(null);
|
|
162
180
|
const optionsArray = new Array(totalDataLength).fill(null); // Store per-point options
|
|
163
181
|
|
|
@@ -171,6 +189,10 @@ export class SeriesBuilder {
|
|
|
171
189
|
let value = point.value;
|
|
172
190
|
const pointColor = point.options?.color;
|
|
173
191
|
|
|
192
|
+
// Always store the raw value for fill plots to reference
|
|
193
|
+
// (fills need the actual data even when the line is invisible via color=na)
|
|
194
|
+
rawDataArray[offsetIndex] = value;
|
|
195
|
+
|
|
174
196
|
// TradingView compatibility: if color is 'na' (NaN, null, undefined, or "na"), break the line
|
|
175
197
|
// When the options object explicitly has a 'color' key set to undefined,
|
|
176
198
|
// this means PineTS evaluated the color expression to na (hidden segment).
|
|
@@ -193,9 +215,9 @@ export class SeriesBuilder {
|
|
|
193
215
|
}
|
|
194
216
|
});
|
|
195
217
|
|
|
196
|
-
// Store data array for fill plots to reference
|
|
197
|
-
//
|
|
198
|
-
plotDataArrays.set(`${id}::${plotName}`,
|
|
218
|
+
// Store raw data array (before na-color nullification) for fill plots to reference
|
|
219
|
+
// Fill plots need the actual numeric values even when the referenced plot is invisible (color=na)
|
|
220
|
+
plotDataArrays.set(`${id}::${plotName}`, rawDataArray);
|
|
199
221
|
|
|
200
222
|
if (plot.options?.style?.startsWith('style_')) {
|
|
201
223
|
plot.options.style = plot.options.style.replace('style_', '') as IndicatorStyle;
|
|
@@ -30,10 +30,13 @@ export class TableOverlayRenderer {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Clear all existing table overlays and render new ones.
|
|
33
|
-
* @param
|
|
34
|
-
* representing the actual plot area within the container.
|
|
33
|
+
* @param getGridRect Function that returns the ECharts grid rect for a given pane index.
|
|
35
34
|
*/
|
|
36
|
-
static render(
|
|
35
|
+
static render(
|
|
36
|
+
container: HTMLElement,
|
|
37
|
+
tables: any[],
|
|
38
|
+
getGridRect?: (paneIndex: number) => { x: number; y: number; width: number; height: number } | undefined,
|
|
39
|
+
): void {
|
|
37
40
|
TableOverlayRenderer.clearAll(container);
|
|
38
41
|
|
|
39
42
|
// Pine Script: only the last table at each position is displayed
|
|
@@ -45,6 +48,8 @@ export class TableOverlayRenderer {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
byPosition.forEach((tbl) => {
|
|
51
|
+
const paneIndex = tbl._paneIndex ?? 0;
|
|
52
|
+
const gridRect = getGridRect ? getGridRect(paneIndex) : undefined;
|
|
48
53
|
const el = TableOverlayRenderer.buildTable(tbl);
|
|
49
54
|
TableOverlayRenderer.positionTable(el, tbl.position, gridRect);
|
|
50
55
|
container.appendChild(el);
|
|
@@ -59,9 +64,18 @@ export class TableOverlayRenderer {
|
|
|
59
64
|
|
|
60
65
|
private static buildTable(tbl: any): HTMLElement {
|
|
61
66
|
const table = document.createElement('table');
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
const borderWidth = tbl.border_width ?? 0;
|
|
68
|
+
const frameWidth = tbl.frame_width ?? 0;
|
|
69
|
+
// Use collapse when no visible borders — prevents sub-pixel hairlines between cells.
|
|
70
|
+
// Use separate when visible borders are present — so each cell's border is drawn independently.
|
|
71
|
+
const hasVisibleBorders = (borderWidth > 0 && !!tbl.border_color) || (frameWidth > 0 && !!tbl.frame_color);
|
|
72
|
+
if (hasVisibleBorders) {
|
|
73
|
+
table.style.borderCollapse = 'separate';
|
|
74
|
+
table.style.borderSpacing = '0';
|
|
75
|
+
} else {
|
|
76
|
+
table.style.borderCollapse = 'collapse';
|
|
77
|
+
}
|
|
78
|
+
table.style.pointerEvents = 'none';
|
|
65
79
|
table.style.fontSize = '14px';
|
|
66
80
|
table.style.lineHeight = '1.4';
|
|
67
81
|
table.style.fontFamily = 'sans-serif';
|
|
@@ -75,11 +89,13 @@ export class TableOverlayRenderer {
|
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// Frame (outer border)
|
|
78
|
-
|
|
92
|
+
// Pine Script default frame_color is "no color" (transparent), so only
|
|
93
|
+
// draw frame when an explicit color is provided.
|
|
94
|
+
if (frameWidth > 0 && tbl.frame_color) {
|
|
79
95
|
const { color: fc } = TableOverlayRenderer.safeParseColor(tbl.frame_color);
|
|
80
|
-
table.style.border = `${
|
|
81
|
-
} else
|
|
82
|
-
table.style.border =
|
|
96
|
+
table.style.border = `${frameWidth}px solid ${fc}`;
|
|
97
|
+
} else {
|
|
98
|
+
table.style.border = 'none';
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
// Build merge lookup: for each cell, determine colspan/rowspan
|
|
@@ -103,6 +119,14 @@ export class TableOverlayRenderer {
|
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
121
|
|
|
122
|
+
// Cell border settings
|
|
123
|
+
// Pine Script default border_color is "no color" (transparent), so only
|
|
124
|
+
// draw cell borders when an explicit color is provided.
|
|
125
|
+
const hasCellBorders = borderWidth > 0 && !!tbl.border_color;
|
|
126
|
+
const borderColorStr = hasCellBorders
|
|
127
|
+
? TableOverlayRenderer.safeParseColor(tbl.border_color).color
|
|
128
|
+
: '';
|
|
129
|
+
|
|
106
130
|
// Build rows
|
|
107
131
|
const rows = tbl.rows || 0;
|
|
108
132
|
const cols = tbl.columns || 0;
|
|
@@ -126,11 +150,10 @@ export class TableOverlayRenderer {
|
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
// Cell borders
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
td.style.border = `${tbl.border_width}px solid ${bc}`;
|
|
153
|
+
if (hasCellBorders) {
|
|
154
|
+
td.style.border = `${borderWidth}px solid ${borderColorStr}`;
|
|
155
|
+
} else {
|
|
156
|
+
td.style.border = 'none';
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
// Get cell data
|
|
@@ -50,20 +50,13 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
50
50
|
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// Compute y-range for axis scaling
|
|
54
|
-
let yMin = Infinity, yMax = -Infinity;
|
|
55
|
-
for (const bx of boxObjects) {
|
|
56
|
-
if (bx.top < yMin) yMin = bx.top;
|
|
57
|
-
if (bx.top > yMax) yMax = bx.top;
|
|
58
|
-
if (bx.bottom < yMin) yMin = bx.bottom;
|
|
59
|
-
if (bx.bottom > yMax) yMax = bx.bottom;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
53
|
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
63
54
|
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
64
55
|
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
65
56
|
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
66
|
-
//
|
|
57
|
+
// Note: We do NOT encode y-dimensions — drawing objects should not influence the
|
|
58
|
+
// y-axis auto-scaling. Otherwise boxes drawn at the chart's end would prevent
|
|
59
|
+
// the y-axis from adapting when scrolling to earlier (lower-priced) history.
|
|
67
60
|
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
68
61
|
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
69
62
|
|
|
@@ -87,16 +80,16 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
87
80
|
let w = pBottomRight[0] - pTopLeft[0];
|
|
88
81
|
let h = pBottomRight[1] - pTopLeft[1];
|
|
89
82
|
|
|
90
|
-
// Handle extend (
|
|
83
|
+
// Handle extend (none/n | left/l | right/r | both/b)
|
|
91
84
|
const extend = bx.extend || 'none';
|
|
92
|
-
if (extend !== 'none') {
|
|
85
|
+
if (extend !== 'none' && extend !== 'n') {
|
|
93
86
|
const cs = params.coordSys;
|
|
94
|
-
if (extend === 'left' || extend === 'both') {
|
|
87
|
+
if (extend === 'left' || extend === 'l' || extend === 'both' || extend === 'b') {
|
|
95
88
|
x = cs.x;
|
|
96
|
-
w = (extend === 'both') ? cs.width : (pBottomRight[0] - cs.x);
|
|
89
|
+
w = (extend === 'both' || extend === 'b') ? cs.width : (pBottomRight[0] - cs.x);
|
|
97
90
|
}
|
|
98
|
-
if (extend === 'right' || extend === 'both') {
|
|
99
|
-
if (extend === 'right') {
|
|
91
|
+
if (extend === 'right' || extend === 'r' || extend === 'both' || extend === 'b') {
|
|
92
|
+
if (extend === 'right' || extend === 'r') {
|
|
100
93
|
w = cs.x + cs.width - pTopLeft[0];
|
|
101
94
|
}
|
|
102
95
|
}
|
|
@@ -107,13 +100,18 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
107
100
|
children.push({
|
|
108
101
|
type: 'rect',
|
|
109
102
|
shape: { x, y, width: w, height: h },
|
|
110
|
-
style: { fill: bgColor },
|
|
103
|
+
style: { fill: bgColor, stroke: 'none' },
|
|
111
104
|
});
|
|
112
105
|
|
|
113
106
|
// Border rect (on top of fill)
|
|
114
|
-
|
|
107
|
+
// border_color = na means no border (na resolves to NaN or undefined)
|
|
108
|
+
const rawBorderColor = bx.border_color;
|
|
109
|
+
const isNaBorder = rawBorderColor === null || rawBorderColor === undefined ||
|
|
110
|
+
(typeof rawBorderColor === 'number' && isNaN(rawBorderColor)) ||
|
|
111
|
+
rawBorderColor === 'na' || rawBorderColor === 'NaN';
|
|
112
|
+
const borderColor = isNaBorder ? null : (normalizeColor(rawBorderColor) || '#2962ff');
|
|
115
113
|
const borderWidth = bx.border_width ?? 1;
|
|
116
|
-
if (borderWidth > 0) {
|
|
114
|
+
if (borderWidth > 0 && borderColor) {
|
|
117
115
|
children.push({
|
|
118
116
|
type: 'rect',
|
|
119
117
|
shape: { x, y, width: w, height: h },
|
|
@@ -150,9 +148,11 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
150
148
|
|
|
151
149
|
return { type: 'group', children };
|
|
152
150
|
},
|
|
153
|
-
data: [[0, lastBarIndex
|
|
151
|
+
data: [[0, lastBarIndex]],
|
|
154
152
|
clip: true,
|
|
155
|
-
encode: { x: [0, 1]
|
|
153
|
+
encode: { x: [0, 1] },
|
|
154
|
+
// Prevent ECharts visual system from overriding element colors with palette
|
|
155
|
+
itemStyle: { color: 'transparent', borderColor: 'transparent' },
|
|
156
156
|
z: 14,
|
|
157
157
|
silent: true,
|
|
158
158
|
emphasis: { disabled: true },
|
|
@@ -35,20 +35,13 @@ export class DrawingLineRenderer implements SeriesRenderer {
|
|
|
35
35
|
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Compute y-range for axis scaling
|
|
39
|
-
let yMin = Infinity, yMax = -Infinity;
|
|
40
|
-
for (const ln of lineObjects) {
|
|
41
|
-
if (ln.y1 < yMin) yMin = ln.y1;
|
|
42
|
-
if (ln.y1 > yMax) yMax = ln.y1;
|
|
43
|
-
if (ln.y2 < yMin) yMin = ln.y2;
|
|
44
|
-
if (ln.y2 > yMax) yMax = ln.y2;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
38
|
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
48
39
|
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
49
40
|
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
50
41
|
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
51
|
-
//
|
|
42
|
+
// Note: We do NOT encode y-dimensions — drawing objects should not influence the
|
|
43
|
+
// y-axis auto-scaling. Otherwise lines drawn at the chart's end would prevent
|
|
44
|
+
// the y-axis from adapting when scrolling to earlier (lower-priced) history.
|
|
52
45
|
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
53
46
|
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
54
47
|
|
|
@@ -67,9 +60,9 @@ export class DrawingLineRenderer implements SeriesRenderer {
|
|
|
67
60
|
let p1 = api.coord([ln.x1 + xOff, ln.y1]);
|
|
68
61
|
let p2 = api.coord([ln.x2 + xOff, ln.y2]);
|
|
69
62
|
|
|
70
|
-
// Handle extend (none | left | right | both)
|
|
63
|
+
// Handle extend (none/n | left/l | right/r | both/b)
|
|
71
64
|
const extend = ln.extend || 'none';
|
|
72
|
-
if (extend !== 'none') {
|
|
65
|
+
if (extend !== 'none' && extend !== 'n') {
|
|
73
66
|
const cs = params.coordSys;
|
|
74
67
|
[p1, p2] = this.extendLine(p1, p2, extend, cs.x, cs.x + cs.width, cs.y, cs.y + cs.height);
|
|
75
68
|
}
|
|
@@ -81,6 +74,7 @@ export class DrawingLineRenderer implements SeriesRenderer {
|
|
|
81
74
|
type: 'line',
|
|
82
75
|
shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },
|
|
83
76
|
style: {
|
|
77
|
+
fill: 'none',
|
|
84
78
|
stroke: color,
|
|
85
79
|
lineWidth,
|
|
86
80
|
lineDash: this.getDashPattern(ln.style),
|
|
@@ -100,9 +94,11 @@ export class DrawingLineRenderer implements SeriesRenderer {
|
|
|
100
94
|
|
|
101
95
|
return { type: 'group', children };
|
|
102
96
|
},
|
|
103
|
-
data: [[0, lastBarIndex
|
|
97
|
+
data: [[0, lastBarIndex]],
|
|
104
98
|
clip: true,
|
|
105
|
-
encode: { x: [0, 1]
|
|
99
|
+
encode: { x: [0, 1] },
|
|
100
|
+
// Prevent ECharts visual system from overriding element colors with palette
|
|
101
|
+
itemStyle: { color: 'transparent', borderColor: 'transparent' },
|
|
106
102
|
z: 15,
|
|
107
103
|
silent: true,
|
|
108
104
|
emphasis: { disabled: true },
|
|
@@ -151,10 +147,10 @@ export class DrawingLineRenderer implements SeriesRenderer {
|
|
|
151
147
|
let newP1 = p1;
|
|
152
148
|
let newP2 = p2;
|
|
153
149
|
|
|
154
|
-
if (extend === 'right' || extend === 'both') {
|
|
150
|
+
if (extend === 'right' || extend === 'r' || extend === 'both' || extend === 'b') {
|
|
155
151
|
newP2 = extendPoint(p1, [dx, dy]);
|
|
156
152
|
}
|
|
157
|
-
if (extend === 'left' || extend === 'both') {
|
|
153
|
+
if (extend === 'left' || extend === 'l' || extend === 'both' || extend === 'b') {
|
|
158
154
|
newP1 = extendPoint(p2, [-dx, -dy]);
|
|
159
155
|
}
|
|
160
156
|
|