@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.
- package/dist/index.d.ts +54 -2
- package/dist/qfchart.min.browser.js +16 -14
- package/dist/qfchart.min.es.js +16 -14
- package/package.json +1 -1
- package/src/QFChart.ts +533 -58
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +67 -24
- package/src/components/SeriesBuilder.ts +122 -1
- package/src/components/TableCanvasRenderer.ts +467 -0
- package/src/components/TableOverlayRenderer.ts +76 -24
- package/src/components/TooltipFormatter.ts +97 -97
- package/src/components/renderers/BackgroundRenderer.ts +59 -47
- package/src/components/renderers/BoxRenderer.ts +133 -37
- package/src/components/renderers/DrawingLineRenderer.ts +12 -16
- package/src/components/renderers/FillRenderer.ts +118 -3
- package/src/components/renderers/HistogramRenderer.ts +67 -20
- package/src/components/renderers/LabelRenderer.ts +35 -9
- package/src/components/renderers/LinefillRenderer.ts +4 -12
- package/src/components/renderers/OHLCBarRenderer.ts +171 -161
- package/src/components/renderers/PolylineRenderer.ts +32 -32
- package/src/types.ts +11 -2
- 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 || "Market";
|
|
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 || "Market";
|
|
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)
|
|
@@ -50,20 +82,13 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
50
82
|
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
51
83
|
}
|
|
52
84
|
|
|
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
85
|
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
63
86
|
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
64
87
|
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
65
88
|
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
66
|
-
//
|
|
89
|
+
// Note: We do NOT encode y-dimensions — drawing objects should not influence the
|
|
90
|
+
// y-axis auto-scaling. Otherwise boxes drawn at the chart's end would prevent
|
|
91
|
+
// the y-axis from adapting when scrolling to earlier (lower-priced) history.
|
|
67
92
|
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
68
93
|
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
69
94
|
|
|
@@ -87,33 +112,45 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
87
112
|
let w = pBottomRight[0] - pTopLeft[0];
|
|
88
113
|
let h = pBottomRight[1] - pTopLeft[1];
|
|
89
114
|
|
|
90
|
-
// Handle extend (
|
|
115
|
+
// Handle extend (none/n | left/l | right/r | both/b)
|
|
91
116
|
const extend = bx.extend || 'none';
|
|
92
|
-
if (extend !== 'none') {
|
|
117
|
+
if (extend !== 'none' && extend !== 'n') {
|
|
93
118
|
const cs = params.coordSys;
|
|
94
|
-
if (extend === 'left' || extend === 'both') {
|
|
119
|
+
if (extend === 'left' || extend === 'l' || extend === 'both' || extend === 'b') {
|
|
95
120
|
x = cs.x;
|
|
96
|
-
w = (extend === 'both') ? cs.width : (pBottomRight[0] - cs.x);
|
|
121
|
+
w = (extend === 'both' || extend === 'b') ? cs.width : (pBottomRight[0] - cs.x);
|
|
97
122
|
}
|
|
98
|
-
if (extend === 'right' || extend === 'both') {
|
|
99
|
-
if (extend === 'right') {
|
|
123
|
+
if (extend === 'right' || extend === 'r' || extend === 'both' || extend === 'b') {
|
|
124
|
+
if (extend === 'right' || extend === 'r') {
|
|
100
125
|
w = cs.x + cs.width - pTopLeft[0];
|
|
101
126
|
}
|
|
102
127
|
}
|
|
103
128
|
}
|
|
104
129
|
|
|
105
130
|
// Background fill rect
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)
|
|
146
|
+
// border_color = na means no border (na resolves to NaN or undefined)
|
|
147
|
+
const rawBorderColor = bx.border_color;
|
|
148
|
+
const isNaBorder = rawBorderColor === null || rawBorderColor === undefined ||
|
|
149
|
+
(typeof rawBorderColor === 'number' && isNaN(rawBorderColor)) ||
|
|
150
|
+
rawBorderColor === 'na' || rawBorderColor === 'NaN';
|
|
151
|
+
const borderColor = isNaBorder ? null : (normalizeColor(rawBorderColor) || '#2962ff');
|
|
115
152
|
const borderWidth = bx.border_width ?? 1;
|
|
116
|
-
if (borderWidth > 0) {
|
|
153
|
+
if (borderWidth > 0 && borderColor) {
|
|
117
154
|
children.push({
|
|
118
155
|
type: 'rect',
|
|
119
156
|
shape: { x, y, width: w, height: h },
|
|
@@ -130,16 +167,38 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
130
167
|
if (bx.text) {
|
|
131
168
|
const textX = this.getTextX(x, w, bx.text_halign);
|
|
132
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
|
+
|
|
133
192
|
children.push({
|
|
134
193
|
type: 'text',
|
|
135
194
|
style: {
|
|
136
195
|
x: textX,
|
|
137
196
|
y: textY,
|
|
138
197
|
text: bx.text,
|
|
139
|
-
fill:
|
|
140
|
-
fontSize
|
|
198
|
+
fill: textFill,
|
|
199
|
+
fontSize,
|
|
141
200
|
fontFamily: bx.text_font_family === 'monospace' ? 'monospace' : 'sans-serif',
|
|
142
|
-
fontWeight:
|
|
201
|
+
fontWeight: isBold ? 'bold' : 'normal',
|
|
143
202
|
fontStyle: (bx.text_formatting === 'format_italic') ? 'italic' : 'normal',
|
|
144
203
|
textAlign: this.mapHAlign(bx.text_halign),
|
|
145
204
|
textVerticalAlign: this.mapVAlign(bx.text_valign),
|
|
@@ -150,9 +209,11 @@ export class BoxRenderer implements SeriesRenderer {
|
|
|
150
209
|
|
|
151
210
|
return { type: 'group', children };
|
|
152
211
|
},
|
|
153
|
-
data: [[0, lastBarIndex
|
|
212
|
+
data: [[0, lastBarIndex]],
|
|
154
213
|
clip: true,
|
|
155
|
-
encode: { x: [0, 1]
|
|
214
|
+
encode: { x: [0, 1] },
|
|
215
|
+
// Prevent ECharts visual system from overriding element colors with palette
|
|
216
|
+
itemStyle: { color: 'transparent', borderColor: 'transparent' },
|
|
156
217
|
z: 14,
|
|
157
218
|
silent: true,
|
|
158
219
|
emphasis: { disabled: true },
|
|
@@ -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 {
|
|
@@ -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
|
|