@qfo/qfchart 0.7.3 → 0.8.1
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 +368 -14
- package/dist/qfchart.min.browser.js +34 -16
- package/dist/qfchart.min.es.js +34 -16
- package/package.json +1 -1
- package/src/QFChart.ts +460 -311
- package/src/components/AbstractPlugin.ts +234 -104
- package/src/components/DrawingEditor.ts +297 -248
- package/src/components/DrawingRendererRegistry.ts +13 -0
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +72 -55
- package/src/components/SeriesBuilder.ts +110 -6
- package/src/components/TableCanvasRenderer.ts +467 -0
- package/src/components/TableOverlayRenderer.ts +38 -9
- package/src/components/TooltipFormatter.ts +97 -97
- package/src/components/renderers/BackgroundRenderer.ts +59 -47
- package/src/components/renderers/BoxRenderer.ts +113 -17
- package/src/components/renderers/FillRenderer.ts +118 -3
- package/src/components/renderers/LabelRenderer.ts +35 -9
- package/src/components/renderers/OHLCBarRenderer.ts +171 -161
- package/src/components/renderers/PolylineRenderer.ts +26 -19
- package/src/index.ts +17 -6
- package/src/plugins/ABCDPatternTool/ABCDPatternDrawingRenderer.ts +112 -0
- package/src/plugins/ABCDPatternTool/ABCDPatternTool.ts +136 -0
- package/src/plugins/ABCDPatternTool/index.ts +2 -0
- package/src/plugins/CypherPatternTool/CypherPatternDrawingRenderer.ts +80 -0
- package/src/plugins/CypherPatternTool/CypherPatternTool.ts +84 -0
- package/src/plugins/CypherPatternTool/index.ts +2 -0
- package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanDrawingRenderer.ts +163 -0
- package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanTool.ts +210 -0
- package/src/plugins/FibSpeedResistanceFanTool/index.ts +2 -0
- package/src/plugins/FibTrendExtensionTool/FibTrendExtensionDrawingRenderer.ts +141 -0
- package/src/plugins/FibTrendExtensionTool/FibTrendExtensionTool.ts +188 -0
- package/src/plugins/FibTrendExtensionTool/index.ts +2 -0
- package/src/plugins/FibonacciChannelTool/FibonacciChannelDrawingRenderer.ts +128 -0
- package/src/plugins/FibonacciChannelTool/FibonacciChannelTool.ts +231 -0
- package/src/plugins/FibonacciChannelTool/index.ts +2 -0
- package/src/plugins/FibonacciTool/FibonacciDrawingRenderer.ts +107 -0
- package/src/plugins/{FibonacciTool.ts → FibonacciTool/FibonacciTool.ts} +195 -192
- package/src/plugins/FibonacciTool/index.ts +2 -0
- package/src/plugins/HeadAndShouldersTool/HeadAndShouldersDrawingRenderer.ts +95 -0
- package/src/plugins/HeadAndShouldersTool/HeadAndShouldersTool.ts +97 -0
- package/src/plugins/HeadAndShouldersTool/index.ts +2 -0
- package/src/plugins/LineTool/LineDrawingRenderer.ts +49 -0
- package/src/plugins/{LineTool.ts → LineTool/LineTool.ts} +161 -190
- package/src/plugins/LineTool/index.ts +2 -0
- package/src/plugins/{MeasureTool.ts → MeasureTool/MeasureTool.ts} +324 -344
- package/src/plugins/MeasureTool/index.ts +1 -0
- package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternDrawingRenderer.ts +106 -0
- package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternTool.ts +98 -0
- package/src/plugins/ThreeDrivesPatternTool/index.ts +2 -0
- package/src/plugins/ToolGroup.ts +211 -0
- package/src/plugins/TrianglePatternTool/TrianglePatternDrawingRenderer.ts +107 -0
- package/src/plugins/TrianglePatternTool/TrianglePatternTool.ts +98 -0
- package/src/plugins/TrianglePatternTool/index.ts +2 -0
- package/src/plugins/XABCDPatternTool/XABCDPatternDrawingRenderer.ts +178 -0
- package/src/plugins/XABCDPatternTool/XABCDPatternTool.ts +213 -0
- package/src/plugins/XABCDPatternTool/index.ts +2 -0
- package/src/types.ts +39 -4
- package/src/utils/ColorUtils.ts +1 -1
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
import { QFChartOptions } from "../types";
|
|
2
|
-
|
|
3
|
-
export class TooltipFormatter {
|
|
4
|
-
public static format(params: any[], options: QFChartOptions): string {
|
|
5
|
-
if (!params || params.length === 0) return "";
|
|
6
|
-
|
|
7
|
-
const marketName = options.title || "
|
|
8
|
-
const upColor = options.upColor || "#00da3c";
|
|
9
|
-
const downColor = options.downColor || "#ec0000";
|
|
10
|
-
const fontFamily = options.fontFamily || "sans-serif";
|
|
11
|
-
|
|
12
|
-
// 1. Header: Date/Time (from the first param)
|
|
13
|
-
const date = params[0].axisValue;
|
|
14
|
-
let html = `<div style="font-weight: bold; margin-bottom: 5px; color: #cbd5e1; font-family: ${fontFamily};">${date}</div>`;
|
|
15
|
-
|
|
16
|
-
// 2. Separate Market Data (Candlestick) from Indicators
|
|
17
|
-
const marketSeries = params.find(
|
|
18
|
-
(p: any) => p.seriesType === "candlestick"
|
|
19
|
-
);
|
|
20
|
-
const indicatorParams = params.filter(
|
|
21
|
-
(p: any) => p.seriesType !== "candlestick"
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
// 3. Market Data Section
|
|
25
|
-
if (marketSeries) {
|
|
26
|
-
const [_, open, close, low, high] = marketSeries.value;
|
|
27
|
-
const color = close >= open ? upColor : downColor;
|
|
28
|
-
|
|
29
|
-
html += `
|
|
30
|
-
<div style="margin-bottom: 8px; font-family: ${fontFamily};">
|
|
31
|
-
<div style="display:flex; justify-content:space-between; color:${color}; font-weight:bold;">
|
|
32
|
-
<span>${marketName}</span>
|
|
33
|
-
</div>
|
|
34
|
-
<div style="display: grid; grid-template-columns: auto auto; gap: 2px 15px; font-size: 0.9em; color: #cbd5e1;">
|
|
35
|
-
<span>Open:</span> <span style="text-align: right; color: ${
|
|
36
|
-
close >= open ? upColor : downColor
|
|
37
|
-
}">${open}</span>
|
|
38
|
-
<span>High:</span> <span style="text-align: right; color: ${upColor}">${high}</span>
|
|
39
|
-
<span>Low:</span> <span style="text-align: right; color: ${downColor}">${low}</span>
|
|
40
|
-
<span>Close:</span> <span style="text-align: right; color: ${
|
|
41
|
-
close >= open ? upColor : downColor
|
|
42
|
-
}">${close}</span>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 4. Indicators Section
|
|
49
|
-
if (indicatorParams.length > 0) {
|
|
50
|
-
html += `<div style="border-top: 1px solid #334155; margin: 5px 0; padding-top: 5px;"></div>`;
|
|
51
|
-
|
|
52
|
-
// Group by Indicator ID (extracted from seriesName "ID::PlotName")
|
|
53
|
-
const indicators: { [key: string]: any[] } = {};
|
|
54
|
-
|
|
55
|
-
indicatorParams.forEach((p: any) => {
|
|
56
|
-
const parts = p.seriesName.split("::");
|
|
57
|
-
const indId = parts.length > 1 ? parts[0] : "Unknown";
|
|
58
|
-
const plotName = parts.length > 1 ? parts[1] : p.seriesName;
|
|
59
|
-
|
|
60
|
-
if (!indicators[indId]) indicators[indId] = [];
|
|
61
|
-
indicators[indId].push({ ...p, displayName: plotName });
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Render groups
|
|
65
|
-
Object.keys(indicators).forEach((indId) => {
|
|
66
|
-
html += `
|
|
67
|
-
<div style="margin-top: 8px; font-family: ${fontFamily};">
|
|
68
|
-
<div style="font-weight:bold; color: #fff; margin-bottom: 2px;">${indId}</div>
|
|
69
|
-
`;
|
|
70
|
-
|
|
71
|
-
indicators[indId].forEach((p) => {
|
|
72
|
-
let val = p.value;
|
|
73
|
-
if (Array.isArray(val)) {
|
|
74
|
-
val = val[1]; // Assuming [index, value]
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (val === null || val === undefined) return;
|
|
78
|
-
|
|
79
|
-
const valStr =
|
|
80
|
-
typeof val === "number"
|
|
81
|
-
? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
|
82
|
-
: val;
|
|
83
|
-
|
|
84
|
-
html += `
|
|
85
|
-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; padding-left: 8px;">
|
|
86
|
-
<div>${p.marker} <span style="color: #cbd5e1;">${p.displayName}</span></div>
|
|
87
|
-
<div style="font-size: 10px; color: #fff;padding-left:10px;">${valStr}</div>
|
|
88
|
-
</div>`;
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
html += `</div>`;
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return html;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
1
|
+
import { QFChartOptions } from "../types";
|
|
2
|
+
|
|
3
|
+
export class TooltipFormatter {
|
|
4
|
+
public static format(params: any[], options: QFChartOptions): string {
|
|
5
|
+
if (!params || params.length === 0) return "";
|
|
6
|
+
|
|
7
|
+
const marketName = options.title || "";
|
|
8
|
+
const upColor = options.upColor || "#00da3c";
|
|
9
|
+
const downColor = options.downColor || "#ec0000";
|
|
10
|
+
const fontFamily = options.fontFamily || "sans-serif";
|
|
11
|
+
|
|
12
|
+
// 1. Header: Date/Time (from the first param)
|
|
13
|
+
const date = params[0].axisValue;
|
|
14
|
+
let html = `<div style="font-weight: bold; margin-bottom: 5px; color: #cbd5e1; font-family: ${fontFamily};">${date}</div>`;
|
|
15
|
+
|
|
16
|
+
// 2. Separate Market Data (Candlestick) from Indicators
|
|
17
|
+
const marketSeries = params.find(
|
|
18
|
+
(p: any) => p.seriesType === "candlestick"
|
|
19
|
+
);
|
|
20
|
+
const indicatorParams = params.filter(
|
|
21
|
+
(p: any) => p.seriesType !== "candlestick"
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// 3. Market Data Section
|
|
25
|
+
if (marketSeries) {
|
|
26
|
+
const [_, open, close, low, high] = marketSeries.value;
|
|
27
|
+
const color = close >= open ? upColor : downColor;
|
|
28
|
+
|
|
29
|
+
html += `
|
|
30
|
+
<div style="margin-bottom: 8px; font-family: ${fontFamily};">
|
|
31
|
+
<div style="display:flex; justify-content:space-between; color:${color}; font-weight:bold;">
|
|
32
|
+
<span>${marketName}</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div style="display: grid; grid-template-columns: auto auto; gap: 2px 15px; font-size: 0.9em; color: #cbd5e1;">
|
|
35
|
+
<span>Open:</span> <span style="text-align: right; color: ${
|
|
36
|
+
close >= open ? upColor : downColor
|
|
37
|
+
}">${open}</span>
|
|
38
|
+
<span>High:</span> <span style="text-align: right; color: ${upColor}">${high}</span>
|
|
39
|
+
<span>Low:</span> <span style="text-align: right; color: ${downColor}">${low}</span>
|
|
40
|
+
<span>Close:</span> <span style="text-align: right; color: ${
|
|
41
|
+
close >= open ? upColor : downColor
|
|
42
|
+
}">${close}</span>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 4. Indicators Section
|
|
49
|
+
if (indicatorParams.length > 0) {
|
|
50
|
+
html += `<div style="border-top: 1px solid #334155; margin: 5px 0; padding-top: 5px;"></div>`;
|
|
51
|
+
|
|
52
|
+
// Group by Indicator ID (extracted from seriesName "ID::PlotName")
|
|
53
|
+
const indicators: { [key: string]: any[] } = {};
|
|
54
|
+
|
|
55
|
+
indicatorParams.forEach((p: any) => {
|
|
56
|
+
const parts = p.seriesName.split("::");
|
|
57
|
+
const indId = parts.length > 1 ? parts[0] : "Unknown";
|
|
58
|
+
const plotName = parts.length > 1 ? parts[1] : p.seriesName;
|
|
59
|
+
|
|
60
|
+
if (!indicators[indId]) indicators[indId] = [];
|
|
61
|
+
indicators[indId].push({ ...p, displayName: plotName });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Render groups
|
|
65
|
+
Object.keys(indicators).forEach((indId) => {
|
|
66
|
+
html += `
|
|
67
|
+
<div style="margin-top: 8px; font-family: ${fontFamily};">
|
|
68
|
+
<div style="font-weight:bold; color: #fff; margin-bottom: 2px;">${indId}</div>
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
indicators[indId].forEach((p) => {
|
|
72
|
+
let val = p.value;
|
|
73
|
+
if (Array.isArray(val)) {
|
|
74
|
+
val = val[1]; // Assuming [index, value]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (val === null || val === undefined) return;
|
|
78
|
+
|
|
79
|
+
const valStr =
|
|
80
|
+
typeof val === "number"
|
|
81
|
+
? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
|
82
|
+
: val;
|
|
83
|
+
|
|
84
|
+
html += `
|
|
85
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; padding-left: 8px;">
|
|
86
|
+
<div>${p.marker} <span style="color: #cbd5e1;">${p.displayName}</span></div>
|
|
87
|
+
<div style="font-size: 10px; color: #fff;padding-left:10px;">${valStr}</div>
|
|
88
|
+
</div>`;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
html += `</div>`;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return html;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,47 +1,59 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
|
+
|
|
4
|
+
export class BackgroundRenderer implements SeriesRenderer {
|
|
5
|
+
render(context: RenderContext): any {
|
|
6
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray } = context;
|
|
7
|
+
|
|
8
|
+
// Pre-parse colors to extract embedded alpha (e.g. #RRGGBBAA → rgb + opacity)
|
|
9
|
+
// This avoids the double-opacity problem where ECharts multiplies a hardcoded
|
|
10
|
+
// opacity with the alpha already embedded in the color string.
|
|
11
|
+
const parsedColors: { color: string; opacity: number }[] = [];
|
|
12
|
+
for (let i = 0; i < colorArray.length; i++) {
|
|
13
|
+
parsedColors[i] = colorArray[i] ? ColorUtils.parseColor(colorArray[i]) : { color: '', opacity: 0 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
name: seriesName,
|
|
18
|
+
type: 'custom',
|
|
19
|
+
xAxisIndex: xAxisIndex,
|
|
20
|
+
yAxisIndex: yAxisIndex,
|
|
21
|
+
z: -10,
|
|
22
|
+
renderItem: (params: any, api: any) => {
|
|
23
|
+
const xVal = api.value(0);
|
|
24
|
+
if (isNaN(xVal)) return;
|
|
25
|
+
|
|
26
|
+
const start = api.coord([xVal, 0.5]); // Use 0.5 as a fixed Y-value within [0,1] range
|
|
27
|
+
const size = api.size([1, 0]);
|
|
28
|
+
const width = size[0];
|
|
29
|
+
const sys = params.coordSys;
|
|
30
|
+
const x = start[0] - width / 2;
|
|
31
|
+
const barColor = colorArray[params.dataIndex];
|
|
32
|
+
const val = api.value(1);
|
|
33
|
+
|
|
34
|
+
if (!barColor || val === null || val === undefined || isNaN(val)) return;
|
|
35
|
+
|
|
36
|
+
const parsed = parsedColors[params.dataIndex];
|
|
37
|
+
if (!parsed || parsed.opacity <= 0) return; // Skip fully transparent
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
type: 'rect',
|
|
41
|
+
shape: {
|
|
42
|
+
x: x,
|
|
43
|
+
y: sys.y,
|
|
44
|
+
width: width,
|
|
45
|
+
height: sys.height,
|
|
46
|
+
},
|
|
47
|
+
style: {
|
|
48
|
+
fill: parsed.color,
|
|
49
|
+
opacity: parsed.opacity,
|
|
50
|
+
},
|
|
51
|
+
silent: true,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
// Normalize data values to 0.5 (middle of [0,1] range) to prevent Y-axis scaling issues
|
|
55
|
+
// The actual value is only used to check if the background should render (non-null/non-NaN)
|
|
56
|
+
data: dataArray.map((val, i) => [i, val !== null && val !== undefined && !isNaN(val) ? 0.5 : null]),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -19,6 +19,38 @@ function normalizeColor(color: string | undefined): string | undefined {
|
|
|
19
19
|
return color;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Parse a CSS color string into { r, g, b } (0-255 each).
|
|
24
|
+
* Supports #rgb, #rrggbb, #rrggbbaa, rgb(), rgba().
|
|
25
|
+
*/
|
|
26
|
+
function parseRGB(color: string | null | undefined): { r: number; g: number; b: number } | null {
|
|
27
|
+
if (!color || typeof color !== 'string') return null;
|
|
28
|
+
if (color.startsWith('#')) {
|
|
29
|
+
const hex = color.slice(1);
|
|
30
|
+
if (hex.length >= 6) {
|
|
31
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
32
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
33
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
34
|
+
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return { r, g, b };
|
|
35
|
+
}
|
|
36
|
+
if (hex.length === 3) {
|
|
37
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
38
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
39
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
40
|
+
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return { r, g, b };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
45
|
+
if (m) return { r: +m[1], g: +m[2], b: +m[3] };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Relative luminance (0 = black, 1 = white). */
|
|
50
|
+
function luminance(r: number, g: number, b: number): number {
|
|
51
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
/**
|
|
23
55
|
* Renderer for Pine Script box.* drawing objects.
|
|
24
56
|
* Each box is defined by two corners (left,top) → (right,bottom)
|
|
@@ -96,14 +128,21 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
// Background fill rect
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
131
|
+
// bgcolor = na means no fill (na resolves to NaN or undefined)
|
|
132
|
+
const rawBgColor = bx.bgcolor;
|
|
133
|
+
const isNaBgColor = rawBgColor === null || rawBgColor === undefined ||
|
|
134
|
+
(typeof rawBgColor === 'number' && isNaN(rawBgColor)) ||
|
|
135
|
+
rawBgColor === 'na' || rawBgColor === 'NaN' || rawBgColor === '';
|
|
136
|
+
const bgColor = isNaBgColor ? null : (normalizeColor(rawBgColor) || '#2962ff');
|
|
137
|
+
if (bgColor) {
|
|
138
|
+
children.push({
|
|
139
|
+
type: 'rect',
|
|
140
|
+
shape: { x, y, width: w, height: h },
|
|
141
|
+
style: { fill: bgColor, stroke: 'none' },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Explicit border rect (on top of fill)
|
|
107
146
|
// border_color = na means no border (na resolves to NaN or undefined)
|
|
108
147
|
const rawBorderColor = bx.border_color;
|
|
109
148
|
const isNaBorder = rawBorderColor === null || rawBorderColor === undefined ||
|
|
@@ -128,16 +167,38 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
128
167
|
if (bx.text) {
|
|
129
168
|
const textX = this.getTextX(x, w, bx.text_halign);
|
|
130
169
|
const textY = this.getTextY(y, h, bx.text_valign);
|
|
170
|
+
|
|
171
|
+
// Auto-contrast: TradingView renders box text as bold white on dark
|
|
172
|
+
// backgrounds. When text_color is the default black, compute luminance
|
|
173
|
+
// of bgcolor and use white text if the background is dark.
|
|
174
|
+
let textFill = normalizeColor(bx.text_color) || '#000000';
|
|
175
|
+
const isDefaultTextColor = !bx.text_color || bx.text_color === '#000000' ||
|
|
176
|
+
bx.text_color === 'black' || bx.text_color === 'color.black';
|
|
177
|
+
if (isDefaultTextColor && bgColor) {
|
|
178
|
+
const rgb = parseRGB(bgColor);
|
|
179
|
+
if (rgb && luminance(rgb.r, rgb.g, rgb.b) < 0.5) {
|
|
180
|
+
textFill = '#FFFFFF';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// TradingView renders box text bold by default (format_none → bold)
|
|
185
|
+
const isBold = !bx.text_formatting || bx.text_formatting === 'format_none' ||
|
|
186
|
+
bx.text_formatting === 'format_bold';
|
|
187
|
+
|
|
188
|
+
// Font size: for 'auto'/'size.auto', scale to fit within the box.
|
|
189
|
+
// For named sizes (tiny, small, etc.), use fixed values.
|
|
190
|
+
const fontSize = this.computeFontSize(bx.text_size, bx.text, Math.abs(w), Math.abs(h), isBold);
|
|
191
|
+
|
|
131
192
|
children.push({
|
|
132
193
|
type: 'text',
|
|
133
194
|
style: {
|
|
134
195
|
x: textX,
|
|
135
196
|
y: textY,
|
|
136
197
|
text: bx.text,
|
|
137
|
-
fill:
|
|
138
|
-
fontSize
|
|
198
|
+
fill: textFill,
|
|
199
|
+
fontSize,
|
|
139
200
|
fontFamily: bx.text_font_family === 'monospace' ? 'monospace' : 'sans-serif',
|
|
140
|
-
fontWeight:
|
|
201
|
+
fontWeight: isBold ? 'bold' : 'normal',
|
|
141
202
|
fontStyle: (bx.text_formatting === 'format_italic') ? 'italic' : 'normal',
|
|
142
203
|
textAlign: this.mapHAlign(bx.text_halign),
|
|
143
204
|
textVerticalAlign: this.mapVAlign(bx.text_valign),
|
|
@@ -170,12 +231,17 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
170
231
|
}
|
|
171
232
|
}
|
|
172
233
|
|
|
173
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Compute font size for box text.
|
|
236
|
+
* For 'auto'/'size.auto' (the default), dynamically scale text to fit within
|
|
237
|
+
* the box dimensions with a small gap — matching TradingView behavior.
|
|
238
|
+
* For explicit named sizes, return fixed pixel values.
|
|
239
|
+
*/
|
|
240
|
+
private computeFontSize(size: string | number, text: string, boxW: number, boxH: number, bold: boolean): number {
|
|
174
241
|
if (typeof size === 'number' && size > 0) return size;
|
|
242
|
+
|
|
243
|
+
// Fixed named sizes
|
|
175
244
|
switch (size) {
|
|
176
|
-
case 'auto':
|
|
177
|
-
case 'size.auto':
|
|
178
|
-
return 12;
|
|
179
245
|
case 'tiny':
|
|
180
246
|
case 'size.tiny':
|
|
181
247
|
return 8;
|
|
@@ -191,9 +257,39 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
191
257
|
case 'huge':
|
|
192
258
|
case 'size.huge':
|
|
193
259
|
return 36;
|
|
194
|
-
default:
|
|
195
|
-
return 12;
|
|
196
260
|
}
|
|
261
|
+
|
|
262
|
+
// 'auto' / 'size.auto' / default → scale to fit box
|
|
263
|
+
if (!text || boxW <= 0 || boxH <= 0) return 12;
|
|
264
|
+
|
|
265
|
+
const padding = 6; // px gap on each side
|
|
266
|
+
const availW = boxW - padding * 2;
|
|
267
|
+
const availH = boxH - padding * 2;
|
|
268
|
+
if (availW <= 0 || availH <= 0) return 6;
|
|
269
|
+
|
|
270
|
+
const lines = text.split('\n');
|
|
271
|
+
const numLines = lines.length;
|
|
272
|
+
|
|
273
|
+
// Find the longest line by character count
|
|
274
|
+
let maxChars = 1;
|
|
275
|
+
for (const line of lines) {
|
|
276
|
+
if (line.length > maxChars) maxChars = line.length;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Average character width ratio (font-size relative).
|
|
280
|
+
// Bold sans-serif is ~0.62; regular is ~0.55.
|
|
281
|
+
const charWidthRatio = bold ? 0.62 : 0.55;
|
|
282
|
+
|
|
283
|
+
// Max font size constrained by width: availW = maxChars * fontSize * ratio
|
|
284
|
+
const maxByWidth = availW / (maxChars * charWidthRatio);
|
|
285
|
+
|
|
286
|
+
// Max font size constrained by height: availH = numLines * fontSize * lineHeight
|
|
287
|
+
const lineHeight = 1.3;
|
|
288
|
+
const maxByHeight = availH / (numLines * lineHeight);
|
|
289
|
+
|
|
290
|
+
// Use the smaller of the two, clamped to a reasonable range
|
|
291
|
+
const computed = Math.min(maxByWidth, maxByHeight);
|
|
292
|
+
return Math.max(6, Math.min(computed, 48));
|
|
197
293
|
}
|
|
198
294
|
|
|
199
295
|
private mapHAlign(align: string): string {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
2
|
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for a single fill band within a batched render.
|
|
6
|
+
*/
|
|
7
|
+
export interface BatchedFillEntry {
|
|
8
|
+
plot1Data: (number | null)[];
|
|
9
|
+
plot2Data: (number | null)[];
|
|
10
|
+
barColors: { color: string; opacity: number }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export class FillRenderer implements SeriesRenderer {
|
|
5
14
|
render(context: RenderContext): any {
|
|
6
15
|
const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName, optionsArray } = context;
|
|
@@ -34,8 +43,25 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
34
43
|
);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
// --- Simple (
|
|
38
|
-
const { color:
|
|
46
|
+
// --- Simple fill (supports per-bar color when color is a series) ---
|
|
47
|
+
const { color: defaultFillColor, opacity: defaultFillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
|
|
48
|
+
|
|
49
|
+
// Check if we have per-bar color data in optionsArray
|
|
50
|
+
const hasPerBarColor = optionsArray?.some((o: any) => o && o.color !== undefined);
|
|
51
|
+
|
|
52
|
+
// Pre-parse per-bar colors for efficiency
|
|
53
|
+
let barColors: { color: string; opacity: number }[] | null = null;
|
|
54
|
+
if (hasPerBarColor) {
|
|
55
|
+
barColors = [];
|
|
56
|
+
for (let i = 0; i < totalDataLength; i++) {
|
|
57
|
+
const opts = optionsArray?.[i];
|
|
58
|
+
if (opts && opts.color !== undefined) {
|
|
59
|
+
barColors[i] = ColorUtils.parseColor(opts.color);
|
|
60
|
+
} else {
|
|
61
|
+
barColors[i] = { color: defaultFillColor, opacity: defaultFillOpacity };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
39
65
|
|
|
40
66
|
// Create fill data with previous values for smooth polygon rendering
|
|
41
67
|
const fillDataWithPrev: any[] = [];
|
|
@@ -54,6 +80,9 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
54
80
|
xAxisIndex: xAxisIndex,
|
|
55
81
|
yAxisIndex: yAxisIndex,
|
|
56
82
|
z: 1,
|
|
83
|
+
clip: true,
|
|
84
|
+
encode: { x: 0 },
|
|
85
|
+
animation: false,
|
|
57
86
|
renderItem: (params: any, api: any) => {
|
|
58
87
|
const index = params.dataIndex;
|
|
59
88
|
if (index === 0) return null;
|
|
@@ -70,6 +99,12 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
70
99
|
return null;
|
|
71
100
|
}
|
|
72
101
|
|
|
102
|
+
const fc = barColors ? barColors[index] : null;
|
|
103
|
+
|
|
104
|
+
// Skip fully transparent fills
|
|
105
|
+
const fillOpacity = fc ? fc.opacity : defaultFillOpacity;
|
|
106
|
+
if (fillOpacity < 0.01) return null;
|
|
107
|
+
|
|
73
108
|
const p1Prev = api.coord([index - 1, prevY1]);
|
|
74
109
|
const p1Curr = api.coord([index, y1]);
|
|
75
110
|
const p2Curr = api.coord([index, y2]);
|
|
@@ -81,13 +116,86 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
81
116
|
points: [p1Prev, p1Curr, p2Curr, p2Prev],
|
|
82
117
|
},
|
|
83
118
|
style: {
|
|
84
|
-
fill:
|
|
119
|
+
fill: fc ? fc.color : defaultFillColor,
|
|
85
120
|
opacity: fillOpacity,
|
|
86
121
|
},
|
|
87
122
|
silent: true,
|
|
88
123
|
};
|
|
89
124
|
},
|
|
90
125
|
data: fillDataWithPrev,
|
|
126
|
+
silent: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Batch-render multiple fill bands as a single ECharts custom series.
|
|
132
|
+
* Instead of N separate series (one per fill), this creates ONE series
|
|
133
|
+
* where each renderItem call draws all fill bands as a group of children.
|
|
134
|
+
*
|
|
135
|
+
* Performance: reduces series count from N to 1, eliminates per-series
|
|
136
|
+
* ECharts overhead, and enables viewport culling via clip + encode.
|
|
137
|
+
*/
|
|
138
|
+
renderBatched(
|
|
139
|
+
seriesName: string,
|
|
140
|
+
xAxisIndex: number,
|
|
141
|
+
yAxisIndex: number,
|
|
142
|
+
totalDataLength: number,
|
|
143
|
+
fills: BatchedFillEntry[]
|
|
144
|
+
): any {
|
|
145
|
+
// Simple index-only data for ECharts — encode: {x:0} enables dataZoom filtering
|
|
146
|
+
const data = Array.from({ length: totalDataLength }, (_, i) => [i]);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
name: seriesName,
|
|
150
|
+
type: 'custom',
|
|
151
|
+
xAxisIndex,
|
|
152
|
+
yAxisIndex,
|
|
153
|
+
z: 1,
|
|
154
|
+
clip: true,
|
|
155
|
+
encode: { x: 0 },
|
|
156
|
+
animation: false,
|
|
157
|
+
renderItem: (params: any, api: any) => {
|
|
158
|
+
const index = params.dataIndex;
|
|
159
|
+
if (index === 0) return null;
|
|
160
|
+
|
|
161
|
+
const children: any[] = [];
|
|
162
|
+
|
|
163
|
+
for (let f = 0; f < fills.length; f++) {
|
|
164
|
+
const fill = fills[f];
|
|
165
|
+
const y1 = fill.plot1Data[index];
|
|
166
|
+
const y2 = fill.plot2Data[index];
|
|
167
|
+
const prevY1 = fill.plot1Data[index - 1];
|
|
168
|
+
const prevY2 = fill.plot2Data[index - 1];
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
y1 == null || y2 == null || prevY1 == null || prevY2 == null ||
|
|
172
|
+
isNaN(y1 as number) || isNaN(y2 as number) ||
|
|
173
|
+
isNaN(prevY1 as number) || isNaN(prevY2 as number)
|
|
174
|
+
) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Skip fully transparent fills
|
|
179
|
+
const fc = fill.barColors[index];
|
|
180
|
+
if (!fc || fc.opacity < 0.01) continue;
|
|
181
|
+
|
|
182
|
+
const p1Prev = api.coord([index - 1, prevY1]);
|
|
183
|
+
const p1Curr = api.coord([index, y1]);
|
|
184
|
+
const p2Curr = api.coord([index, y2]);
|
|
185
|
+
const p2Prev = api.coord([index - 1, prevY2]);
|
|
186
|
+
|
|
187
|
+
children.push({
|
|
188
|
+
type: 'polygon',
|
|
189
|
+
shape: { points: [p1Prev, p1Curr, p2Curr, p2Prev] },
|
|
190
|
+
style: { fill: fc.color, opacity: fc.opacity },
|
|
191
|
+
silent: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return children.length > 0 ? { type: 'group', children, silent: true } : null;
|
|
196
|
+
},
|
|
197
|
+
data,
|
|
198
|
+
silent: true,
|
|
91
199
|
};
|
|
92
200
|
}
|
|
93
201
|
|
|
@@ -148,6 +256,9 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
148
256
|
xAxisIndex: xAxisIndex,
|
|
149
257
|
yAxisIndex: yAxisIndex,
|
|
150
258
|
z: 1,
|
|
259
|
+
clip: true,
|
|
260
|
+
encode: { x: 0 },
|
|
261
|
+
animation: false,
|
|
151
262
|
renderItem: (params: any, api: any) => {
|
|
152
263
|
const index = params.dataIndex;
|
|
153
264
|
if (index === 0) return null;
|
|
@@ -173,6 +284,9 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
173
284
|
const gc = gradientColors[index] || gradientColors[index - 1];
|
|
174
285
|
if (!gc) return null;
|
|
175
286
|
|
|
287
|
+
// Skip fully transparent gradient fills
|
|
288
|
+
if (gc.topOpacity < 0.01 && gc.bottomOpacity < 0.01) return null;
|
|
289
|
+
|
|
176
290
|
// Convert colors to rgba strings with their opacities
|
|
177
291
|
const topRgba = ColorUtils.toRgba(gc.topColor, gc.topOpacity);
|
|
178
292
|
const bottomRgba = ColorUtils.toRgba(gc.bottomColor, gc.bottomOpacity);
|
|
@@ -200,6 +314,7 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
200
314
|
};
|
|
201
315
|
},
|
|
202
316
|
data: fillDataWithPrev,
|
|
317
|
+
silent: true,
|
|
203
318
|
};
|
|
204
319
|
}
|
|
205
320
|
|