@qfo/qfchart 0.5.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.
@@ -0,0 +1,234 @@
1
+ import { ChartContext, Plugin } from "../types";
2
+ // We need to import AbstractPlugin if we check instanceof, or just treat all as Plugin interface
3
+
4
+ export class PluginManager {
5
+ private plugins: Map<string, Plugin> = new Map();
6
+ private activePluginId: string | null = null;
7
+ private context: ChartContext;
8
+ private toolbarContainer: HTMLElement;
9
+ private tooltipElement: HTMLElement | null = null;
10
+ private hideTimeout: any = null;
11
+
12
+ constructor(context: ChartContext, toolbarContainer: HTMLElement) {
13
+ this.context = context;
14
+ this.toolbarContainer = toolbarContainer;
15
+ this.createTooltip();
16
+ this.renderToolbar();
17
+ }
18
+
19
+ private createTooltip() {
20
+ this.tooltipElement = document.createElement("div");
21
+ Object.assign(this.tooltipElement.style, {
22
+ position: "fixed",
23
+ display: "none",
24
+ backgroundColor: "#1e293b",
25
+ color: "#e2e8f0",
26
+ padding: "6px 10px",
27
+ borderRadius: "6px",
28
+ fontSize: "13px",
29
+ lineHeight: "1.4",
30
+ fontWeight: "500",
31
+ border: "1px solid #334155",
32
+ zIndex: "9999",
33
+ pointerEvents: "none",
34
+ whiteSpace: "nowrap",
35
+ boxShadow:
36
+ "0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.15)",
37
+ fontFamily: this.context.getOptions().fontFamily || "sans-serif",
38
+ transition: "opacity 0.15s ease-in-out, transform 0.15s ease-in-out",
39
+ opacity: "0",
40
+ transform: "translateX(-5px)",
41
+ });
42
+ document.body.appendChild(this.tooltipElement);
43
+ }
44
+
45
+ public destroy() {
46
+ if (this.tooltipElement && this.tooltipElement.parentNode) {
47
+ this.tooltipElement.parentNode.removeChild(this.tooltipElement);
48
+ }
49
+ this.tooltipElement = null;
50
+ }
51
+
52
+ private showTooltip(target: HTMLElement, text: string) {
53
+ if (!this.tooltipElement) return;
54
+
55
+ // Clear any pending hide to prevent race conditions
56
+ if (this.hideTimeout) {
57
+ clearTimeout(this.hideTimeout);
58
+ this.hideTimeout = null;
59
+ }
60
+
61
+ const rect = target.getBoundingClientRect();
62
+ this.tooltipElement.textContent = text;
63
+ this.tooltipElement.style.display = "block";
64
+
65
+ // Position to the right of the button, centered vertically
66
+ const tooltipRect = this.tooltipElement.getBoundingClientRect();
67
+ const top = rect.top + (rect.height - tooltipRect.height) / 2;
68
+ const left = rect.right + 10; // 10px gap
69
+
70
+ this.tooltipElement.style.top = `${top}px`;
71
+ this.tooltipElement.style.left = `${left}px`;
72
+
73
+ // Trigger animation
74
+ requestAnimationFrame(() => {
75
+ if (this.tooltipElement) {
76
+ this.tooltipElement.style.opacity = "1";
77
+ this.tooltipElement.style.transform = "translateX(0)";
78
+ }
79
+ });
80
+ }
81
+
82
+ private hideTooltip() {
83
+ if (!this.tooltipElement) return;
84
+ this.tooltipElement.style.opacity = "0";
85
+ this.tooltipElement.style.transform = "translateX(-5px)";
86
+
87
+ if (this.hideTimeout) {
88
+ clearTimeout(this.hideTimeout);
89
+ }
90
+
91
+ // Wait for transition to finish before hiding
92
+ this.hideTimeout = setTimeout(() => {
93
+ if (this.tooltipElement) {
94
+ this.tooltipElement.style.display = "none";
95
+ }
96
+ this.hideTimeout = null;
97
+ }, 150);
98
+ }
99
+
100
+ public register(plugin: Plugin): void {
101
+ if (this.plugins.has(plugin.id)) {
102
+ console.warn(`Plugin with id ${plugin.id} is already registered.`);
103
+ return;
104
+ }
105
+ this.plugins.set(plugin.id, plugin);
106
+ plugin.init(this.context);
107
+ this.addButton(plugin);
108
+ }
109
+
110
+ public unregister(pluginId: string): void {
111
+ const plugin = this.plugins.get(pluginId);
112
+ if (plugin) {
113
+ if (this.activePluginId === pluginId) {
114
+ this.deactivatePlugin();
115
+ }
116
+ plugin.destroy?.();
117
+ this.plugins.delete(pluginId);
118
+ this.removeButton(pluginId);
119
+ }
120
+ }
121
+
122
+ public activatePlugin(pluginId: string): void {
123
+ // If same plugin is clicked, deactivate it (toggle)
124
+ if (this.activePluginId === pluginId) {
125
+ this.deactivatePlugin();
126
+ return;
127
+ }
128
+
129
+ // Deactivate current active plugin
130
+ if (this.activePluginId) {
131
+ this.deactivatePlugin();
132
+ }
133
+
134
+ const plugin = this.plugins.get(pluginId);
135
+ if (plugin) {
136
+ this.activePluginId = pluginId;
137
+ this.setButtonActive(pluginId, true);
138
+ plugin.activate?.();
139
+ }
140
+ }
141
+
142
+ public deactivatePlugin(): void {
143
+ if (this.activePluginId) {
144
+ const plugin = this.plugins.get(this.activePluginId);
145
+ plugin?.deactivate?.();
146
+ this.setButtonActive(this.activePluginId, false);
147
+ this.activePluginId = null;
148
+ }
149
+ }
150
+
151
+ // --- UI Handling ---
152
+
153
+ private renderToolbar(): void {
154
+ this.toolbarContainer.innerHTML = "";
155
+ this.toolbarContainer.style.display = "flex";
156
+ this.toolbarContainer.style.flexDirection = "column";
157
+ this.toolbarContainer.style.width = "40px";
158
+ this.toolbarContainer.style.backgroundColor =
159
+ this.context.getOptions().backgroundColor || "#1e293b";
160
+ this.toolbarContainer.style.borderRight = "1px solid #334155";
161
+ this.toolbarContainer.style.padding = "5px";
162
+ this.toolbarContainer.style.boxSizing = "border-box";
163
+ this.toolbarContainer.style.gap = "5px";
164
+ this.toolbarContainer.style.flexShrink = "0";
165
+ }
166
+
167
+ private addButton(plugin: Plugin): void {
168
+ const btn = document.createElement("button");
169
+ btn.id = `qfchart-plugin-btn-${plugin.id}`;
170
+ // Removed native title to use custom tooltip
171
+ // btn.title = plugin.name || plugin.id;
172
+ btn.style.width = "30px";
173
+ btn.style.height = "30px";
174
+ btn.style.padding = "4px";
175
+ btn.style.border = "1px solid transparent";
176
+ btn.style.borderRadius = "4px";
177
+ btn.style.backgroundColor = "transparent";
178
+ btn.style.cursor = "pointer";
179
+ btn.style.color = this.context.getOptions().fontColor || "#cbd5e1";
180
+ btn.style.display = "flex";
181
+ btn.style.alignItems = "center";
182
+ btn.style.justifyContent = "center";
183
+
184
+ // Icon
185
+ if (plugin.icon) {
186
+ btn.innerHTML = plugin.icon;
187
+ } else {
188
+ btn.innerText = (plugin.name || plugin.id).substring(0, 2).toUpperCase();
189
+ }
190
+
191
+ // Hover effects and Tooltip
192
+ btn.addEventListener("mouseenter", () => {
193
+ if (this.activePluginId !== plugin.id) {
194
+ btn.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
195
+ }
196
+ this.showTooltip(btn, plugin.name || plugin.id);
197
+ });
198
+
199
+ btn.addEventListener("mouseleave", () => {
200
+ if (this.activePluginId !== plugin.id) {
201
+ btn.style.backgroundColor = "transparent";
202
+ }
203
+ this.hideTooltip();
204
+ });
205
+
206
+ btn.onclick = () => this.activatePlugin(plugin.id);
207
+
208
+ this.toolbarContainer.appendChild(btn);
209
+ }
210
+
211
+ private removeButton(pluginId: string): void {
212
+ const btn = this.toolbarContainer.querySelector(
213
+ `#qfchart-plugin-btn-${pluginId}`
214
+ );
215
+ if (btn) {
216
+ btn.remove();
217
+ }
218
+ }
219
+
220
+ private setButtonActive(pluginId: string, active: boolean): void {
221
+ const btn = this.toolbarContainer.querySelector(
222
+ `#qfchart-plugin-btn-${pluginId}`
223
+ ) as HTMLElement;
224
+ if (btn) {
225
+ if (active) {
226
+ btn.style.backgroundColor = "#2563eb"; // Blue highlight
227
+ btn.style.color = "#ffffff";
228
+ } else {
229
+ btn.style.backgroundColor = "transparent";
230
+ btn.style.color = this.context.getOptions().fontColor || "#cbd5e1";
231
+ }
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,192 @@
1
+ import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot } from '../types';
2
+ import { PaneConfiguration } from './LayoutManager';
3
+ import { textToBase64Image } from '../Utils';
4
+
5
+ export class SeriesBuilder {
6
+ public static buildCandlestickSeries(marketData: OHLCV[], options: QFChartOptions, totalLength?: number): any {
7
+ const upColor = options.upColor || '#00da3c';
8
+ const downColor = options.downColor || '#ec0000';
9
+
10
+ const data = marketData.map((d) => [d.open, d.close, d.low, d.high]);
11
+
12
+ // Pad with nulls if totalLength is provided and greater than current data length
13
+ if (totalLength && totalLength > data.length) {
14
+ const padding = totalLength - data.length;
15
+ for (let i = 0; i < padding; i++) {
16
+ data.push(null as any);
17
+ }
18
+ }
19
+
20
+ return {
21
+ type: 'candlestick',
22
+ name: options.title || 'Market',
23
+ data: data,
24
+ itemStyle: {
25
+ color: upColor,
26
+ color0: downColor,
27
+ borderColor: upColor,
28
+ borderColor0: downColor,
29
+ },
30
+ xAxisIndex: 0,
31
+ yAxisIndex: 0,
32
+ z: 5,
33
+ };
34
+ }
35
+
36
+ public static buildIndicatorSeries(
37
+ indicators: Map<string, IndicatorType>,
38
+ timeToIndex: Map<number, number>,
39
+ paneLayout: PaneConfiguration[],
40
+ totalDataLength: number,
41
+ dataIndexOffset: number = 0
42
+ ): any[] {
43
+ const series: any[] = [];
44
+
45
+ indicators.forEach((indicator, id) => {
46
+ if (indicator.collapsed) return; // Skip if collapsed
47
+
48
+ // Find axis index
49
+ let xAxisIndex = 0;
50
+ let yAxisIndex = 0;
51
+
52
+ if (indicator.paneIndex > 0) {
53
+ // paneLayout contains only separate panes.
54
+ // The index in xAxis/yAxis array is 1 + index_in_paneLayout
55
+ const confIndex = paneLayout.findIndex((p) => p.index === indicator.paneIndex);
56
+ if (confIndex !== -1) {
57
+ xAxisIndex = confIndex + 1;
58
+ yAxisIndex = confIndex + 1;
59
+ }
60
+ }
61
+
62
+ Object.keys(indicator.plots).forEach((plotName) => {
63
+ const plot = indicator.plots[plotName];
64
+ const seriesName = `${id}::${plotName}`;
65
+
66
+ const dataArray = new Array(totalDataLength).fill(null);
67
+ const colorArray = new Array(totalDataLength).fill(null);
68
+
69
+ plot.data.forEach((point) => {
70
+ const index = timeToIndex.get(point.time);
71
+ if (index !== undefined) {
72
+ const offsetIndex = index + dataIndexOffset;
73
+ dataArray[offsetIndex] = point.value;
74
+ colorArray[offsetIndex] = point.options?.color || plot.options.color;
75
+ }
76
+ });
77
+
78
+ switch (plot.options.style) {
79
+ case 'histogram':
80
+ case 'columns':
81
+ series.push({
82
+ name: seriesName,
83
+ type: 'bar',
84
+ xAxisIndex: xAxisIndex,
85
+ yAxisIndex: yAxisIndex,
86
+ data: dataArray.map((val, i) => ({
87
+ value: val,
88
+ itemStyle: colorArray[i] ? { color: colorArray[i] } : undefined,
89
+ })),
90
+ itemStyle: { color: plot.options.color },
91
+ });
92
+ break;
93
+
94
+ case 'circles':
95
+ case 'cross':
96
+ // Scatter
97
+ const scatterData = dataArray
98
+ .map((val, i) => {
99
+ if (val === null) return null;
100
+ const pointColor = colorArray[i] || plot.options.color;
101
+ const item: any = {
102
+ value: [i, val],
103
+ itemStyle: { color: pointColor },
104
+ };
105
+
106
+ if (plot.options.style === 'cross') {
107
+ item.symbol = `image://${textToBase64Image('+', pointColor, '24px')}`;
108
+ item.symbolSize = 16;
109
+ } else {
110
+ item.symbol = 'circle';
111
+ item.symbolSize = 6;
112
+ }
113
+ return item;
114
+ })
115
+ .filter((item) => item !== null);
116
+
117
+ series.push({
118
+ name: seriesName,
119
+ type: 'scatter',
120
+ xAxisIndex: xAxisIndex,
121
+ yAxisIndex: yAxisIndex,
122
+ data: scatterData,
123
+ });
124
+ break;
125
+
126
+ case 'background':
127
+ series.push({
128
+ name: seriesName,
129
+ type: 'custom',
130
+ xAxisIndex: xAxisIndex,
131
+ yAxisIndex: yAxisIndex,
132
+ z: -10,
133
+ renderItem: (params: any, api: any) => {
134
+ const xVal = api.value(0);
135
+ if (isNaN(xVal)) return;
136
+
137
+ const start = api.coord([xVal, 0]);
138
+ const size = api.size([1, 0]);
139
+ const width = size[0];
140
+ const sys = params.coordSys;
141
+ const x = start[0] - width / 2;
142
+ const barColor = colorArray[params.dataIndex];
143
+ const val = api.value(1);
144
+
145
+ if (!barColor || !val) return;
146
+
147
+ return {
148
+ type: 'rect',
149
+ shape: {
150
+ x: x,
151
+ y: sys.y,
152
+ width: width,
153
+ height: sys.height,
154
+ },
155
+ style: {
156
+ fill: barColor,
157
+ opacity: 0.3,
158
+ },
159
+ silent: true,
160
+ };
161
+ },
162
+ data: dataArray.map((val, i) => [i, val]),
163
+ });
164
+ break;
165
+
166
+ case 'line':
167
+ default:
168
+ series.push({
169
+ name: seriesName,
170
+ type: 'line',
171
+ xAxisIndex: xAxisIndex,
172
+ yAxisIndex: yAxisIndex,
173
+ smooth: true,
174
+ showSymbol: false,
175
+ data: dataArray.map((val, i) => ({
176
+ value: val,
177
+ itemStyle: colorArray[i] ? { color: colorArray[i] } : undefined,
178
+ })),
179
+ itemStyle: { color: plot.options.color },
180
+ lineStyle: {
181
+ width: plot.options.linewidth || 1,
182
+ color: plot.options.color,
183
+ },
184
+ });
185
+ break;
186
+ }
187
+ });
188
+ });
189
+
190
+ return series;
191
+ }
192
+ }
@@ -0,0 +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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./types";
2
+ export * from "./QFChart";
3
+ export * from "./plugins/MeasureTool";
4
+ export * from "./plugins/LineTool";
5
+ export * from "./plugins/FibonacciTool";
6
+ export * from "./components/AbstractPlugin";