@qfo/qfchart 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,229 +1,229 @@
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: '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.15)',
36
- fontFamily: this.context.getOptions().fontFamily || 'sans-serif',
37
- transition: 'opacity 0.15s ease-in-out, transform 0.15s ease-in-out',
38
- opacity: '0',
39
- transform: 'translateX(-5px)',
40
- });
41
- document.body.appendChild(this.tooltipElement);
42
- }
43
-
44
- public destroy() {
45
- if (this.tooltipElement && this.tooltipElement.parentNode) {
46
- this.tooltipElement.parentNode.removeChild(this.tooltipElement);
47
- }
48
- this.tooltipElement = null;
49
- }
50
-
51
- private showTooltip(target: HTMLElement, text: string) {
52
- if (!this.tooltipElement) return;
53
-
54
- // Clear any pending hide to prevent race conditions
55
- if (this.hideTimeout) {
56
- clearTimeout(this.hideTimeout);
57
- this.hideTimeout = null;
58
- }
59
-
60
- const rect = target.getBoundingClientRect();
61
- this.tooltipElement.textContent = text;
62
- this.tooltipElement.style.display = 'block';
63
-
64
- // Position to the right of the button, centered vertically
65
- const tooltipRect = this.tooltipElement.getBoundingClientRect();
66
- const top = rect.top + (rect.height - tooltipRect.height) / 2;
67
- const left = rect.right + 10; // 10px gap
68
-
69
- this.tooltipElement.style.top = `${top}px`;
70
- this.tooltipElement.style.left = `${left}px`;
71
-
72
- // Trigger animation
73
- requestAnimationFrame(() => {
74
- if (this.tooltipElement) {
75
- this.tooltipElement.style.opacity = '1';
76
- this.tooltipElement.style.transform = 'translateX(0)';
77
- }
78
- });
79
- }
80
-
81
- private hideTooltip() {
82
- if (!this.tooltipElement) return;
83
- this.tooltipElement.style.opacity = '0';
84
- this.tooltipElement.style.transform = 'translateX(-5px)';
85
-
86
- if (this.hideTimeout) {
87
- clearTimeout(this.hideTimeout);
88
- }
89
-
90
- // Wait for transition to finish before hiding
91
- this.hideTimeout = setTimeout(() => {
92
- if (this.tooltipElement) {
93
- this.tooltipElement.style.display = 'none';
94
- }
95
- this.hideTimeout = null;
96
- }, 150);
97
- }
98
-
99
- public register(plugin: Plugin): void {
100
- if (this.plugins.has(plugin.id)) {
101
- console.warn(`Plugin with id ${plugin.id} is already registered.`);
102
- return;
103
- }
104
- this.plugins.set(plugin.id, plugin);
105
- plugin.init(this.context);
106
- this.addButton(plugin);
107
- }
108
-
109
- public unregister(pluginId: string): void {
110
- const plugin = this.plugins.get(pluginId);
111
- if (plugin) {
112
- if (this.activePluginId === pluginId) {
113
- this.deactivatePlugin();
114
- }
115
- plugin.destroy?.();
116
- this.plugins.delete(pluginId);
117
- this.removeButton(pluginId);
118
- }
119
- }
120
-
121
- public activatePlugin(pluginId: string): void {
122
- // If same plugin is clicked, deactivate it (toggle)
123
- if (this.activePluginId === pluginId) {
124
- this.deactivatePlugin();
125
- return;
126
- }
127
-
128
- // Deactivate current active plugin
129
- if (this.activePluginId) {
130
- this.deactivatePlugin();
131
- }
132
-
133
- const plugin = this.plugins.get(pluginId);
134
- if (plugin) {
135
- this.activePluginId = pluginId;
136
- this.setButtonActive(pluginId, true);
137
- plugin.activate?.();
138
- }
139
- }
140
-
141
- public deactivatePlugin(): void {
142
- if (this.activePluginId) {
143
- const plugin = this.plugins.get(this.activePluginId);
144
- plugin?.deactivate?.();
145
- this.setButtonActive(this.activePluginId, false);
146
- this.activePluginId = null;
147
- }
148
- }
149
-
150
- // --- UI Handling ---
151
-
152
- private renderToolbar(): void {
153
- this.toolbarContainer.innerHTML = '';
154
- this.toolbarContainer.classList.add('qfchart-toolbar');
155
- this.toolbarContainer.style.display = 'flex';
156
- this.toolbarContainer.style.flexDirection = 'column';
157
- this.toolbarContainer.style.width = '40px';
158
- this.toolbarContainer.style.backgroundColor = this.context.getOptions().backgroundColor || '#1e293b';
159
- this.toolbarContainer.style.borderRight = '1px solid #334155';
160
- this.toolbarContainer.style.padding = '5px';
161
- this.toolbarContainer.style.boxSizing = 'border-box';
162
- this.toolbarContainer.style.gap = '5px';
163
- this.toolbarContainer.style.flexShrink = '0';
164
- }
165
-
166
- private addButton(plugin: Plugin): void {
167
- const btn = document.createElement('button');
168
- btn.id = `qfchart-plugin-btn-${plugin.id}`;
169
- // Removed native title to use custom tooltip
170
- // btn.title = plugin.name || plugin.id;
171
- btn.style.width = '30px';
172
- btn.style.height = '30px';
173
- btn.style.padding = '4px';
174
- btn.style.border = '1px solid transparent';
175
- btn.style.borderRadius = '4px';
176
- btn.style.backgroundColor = 'transparent';
177
- btn.style.cursor = 'pointer';
178
- btn.style.color = this.context.getOptions().fontColor || '#cbd5e1';
179
- btn.style.display = 'flex';
180
- btn.style.alignItems = 'center';
181
- btn.style.justifyContent = 'center';
182
-
183
- // Icon
184
- if (plugin.icon) {
185
- btn.innerHTML = plugin.icon;
186
- } else {
187
- btn.innerText = (plugin.name || plugin.id).substring(0, 2).toUpperCase();
188
- }
189
-
190
- // Hover effects and Tooltip
191
- btn.addEventListener('mouseenter', () => {
192
- if (this.activePluginId !== plugin.id) {
193
- btn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
194
- }
195
- this.showTooltip(btn, plugin.name || plugin.id);
196
- });
197
-
198
- btn.addEventListener('mouseleave', () => {
199
- if (this.activePluginId !== plugin.id) {
200
- btn.style.backgroundColor = 'transparent';
201
- }
202
- this.hideTooltip();
203
- });
204
-
205
- btn.onclick = () => this.activatePlugin(plugin.id);
206
-
207
- this.toolbarContainer.appendChild(btn);
208
- }
209
-
210
- private removeButton(pluginId: string): void {
211
- const btn = this.toolbarContainer.querySelector(`#qfchart-plugin-btn-${pluginId}`);
212
- if (btn) {
213
- btn.remove();
214
- }
215
- }
216
-
217
- private setButtonActive(pluginId: string, active: boolean): void {
218
- const btn = this.toolbarContainer.querySelector(`#qfchart-plugin-btn-${pluginId}`) as HTMLElement;
219
- if (btn) {
220
- if (active) {
221
- btn.style.backgroundColor = '#2563eb'; // Blue highlight
222
- btn.style.color = '#ffffff';
223
- } else {
224
- btn.style.backgroundColor = 'transparent';
225
- btn.style.color = this.context.getOptions().fontColor || '#cbd5e1';
226
- }
227
- }
228
- }
229
- }
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: '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.15)',
36
+ fontFamily: this.context.getOptions().fontFamily || 'sans-serif',
37
+ transition: 'opacity 0.15s ease-in-out, transform 0.15s ease-in-out',
38
+ opacity: '0',
39
+ transform: 'translateX(-5px)',
40
+ });
41
+ document.body.appendChild(this.tooltipElement);
42
+ }
43
+
44
+ public destroy() {
45
+ if (this.tooltipElement && this.tooltipElement.parentNode) {
46
+ this.tooltipElement.parentNode.removeChild(this.tooltipElement);
47
+ }
48
+ this.tooltipElement = null;
49
+ }
50
+
51
+ private showTooltip(target: HTMLElement, text: string) {
52
+ if (!this.tooltipElement) return;
53
+
54
+ // Clear any pending hide to prevent race conditions
55
+ if (this.hideTimeout) {
56
+ clearTimeout(this.hideTimeout);
57
+ this.hideTimeout = null;
58
+ }
59
+
60
+ const rect = target.getBoundingClientRect();
61
+ this.tooltipElement.textContent = text;
62
+ this.tooltipElement.style.display = 'block';
63
+
64
+ // Position to the right of the button, centered vertically
65
+ const tooltipRect = this.tooltipElement.getBoundingClientRect();
66
+ const top = rect.top + (rect.height - tooltipRect.height) / 2;
67
+ const left = rect.right + 10; // 10px gap
68
+
69
+ this.tooltipElement.style.top = `${top}px`;
70
+ this.tooltipElement.style.left = `${left}px`;
71
+
72
+ // Trigger animation
73
+ requestAnimationFrame(() => {
74
+ if (this.tooltipElement) {
75
+ this.tooltipElement.style.opacity = '1';
76
+ this.tooltipElement.style.transform = 'translateX(0)';
77
+ }
78
+ });
79
+ }
80
+
81
+ private hideTooltip() {
82
+ if (!this.tooltipElement) return;
83
+ this.tooltipElement.style.opacity = '0';
84
+ this.tooltipElement.style.transform = 'translateX(-5px)';
85
+
86
+ if (this.hideTimeout) {
87
+ clearTimeout(this.hideTimeout);
88
+ }
89
+
90
+ // Wait for transition to finish before hiding
91
+ this.hideTimeout = setTimeout(() => {
92
+ if (this.tooltipElement) {
93
+ this.tooltipElement.style.display = 'none';
94
+ }
95
+ this.hideTimeout = null;
96
+ }, 150);
97
+ }
98
+
99
+ public register(plugin: Plugin): void {
100
+ if (this.plugins.has(plugin.id)) {
101
+ console.warn(`Plugin with id ${plugin.id} is already registered.`);
102
+ return;
103
+ }
104
+ this.plugins.set(plugin.id, plugin);
105
+ plugin.init(this.context);
106
+ this.addButton(plugin);
107
+ }
108
+
109
+ public unregister(pluginId: string): void {
110
+ const plugin = this.plugins.get(pluginId);
111
+ if (plugin) {
112
+ if (this.activePluginId === pluginId) {
113
+ this.deactivatePlugin();
114
+ }
115
+ plugin.destroy?.();
116
+ this.plugins.delete(pluginId);
117
+ this.removeButton(pluginId);
118
+ }
119
+ }
120
+
121
+ public activatePlugin(pluginId: string): void {
122
+ // If same plugin is clicked, deactivate it (toggle)
123
+ if (this.activePluginId === pluginId) {
124
+ this.deactivatePlugin();
125
+ return;
126
+ }
127
+
128
+ // Deactivate current active plugin
129
+ if (this.activePluginId) {
130
+ this.deactivatePlugin();
131
+ }
132
+
133
+ const plugin = this.plugins.get(pluginId);
134
+ if (plugin) {
135
+ this.activePluginId = pluginId;
136
+ this.setButtonActive(pluginId, true);
137
+ plugin.activate?.();
138
+ }
139
+ }
140
+
141
+ public deactivatePlugin(): void {
142
+ if (this.activePluginId) {
143
+ const plugin = this.plugins.get(this.activePluginId);
144
+ plugin?.deactivate?.();
145
+ this.setButtonActive(this.activePluginId, false);
146
+ this.activePluginId = null;
147
+ }
148
+ }
149
+
150
+ // --- UI Handling ---
151
+
152
+ private renderToolbar(): void {
153
+ this.toolbarContainer.innerHTML = '';
154
+ this.toolbarContainer.classList.add('qfchart-toolbar');
155
+ this.toolbarContainer.style.display = 'flex';
156
+ this.toolbarContainer.style.flexDirection = 'column';
157
+ this.toolbarContainer.style.width = '40px';
158
+ this.toolbarContainer.style.backgroundColor = this.context.getOptions().backgroundColor || '#1e293b';
159
+ this.toolbarContainer.style.borderRight = '1px solid #334155';
160
+ this.toolbarContainer.style.padding = '5px';
161
+ this.toolbarContainer.style.boxSizing = 'border-box';
162
+ this.toolbarContainer.style.gap = '5px';
163
+ this.toolbarContainer.style.flexShrink = '0';
164
+ }
165
+
166
+ private addButton(plugin: Plugin): void {
167
+ const btn = document.createElement('button');
168
+ btn.id = `qfchart-plugin-btn-${plugin.id}`;
169
+ // Removed native title to use custom tooltip
170
+ // btn.title = plugin.name || plugin.id;
171
+ btn.style.width = '30px';
172
+ btn.style.height = '30px';
173
+ btn.style.padding = '4px';
174
+ btn.style.border = '1px solid transparent';
175
+ btn.style.borderRadius = '4px';
176
+ btn.style.backgroundColor = 'transparent';
177
+ btn.style.cursor = 'pointer';
178
+ btn.style.color = this.context.getOptions().fontColor || '#cbd5e1';
179
+ btn.style.display = 'flex';
180
+ btn.style.alignItems = 'center';
181
+ btn.style.justifyContent = 'center';
182
+
183
+ // Icon
184
+ if (plugin.icon) {
185
+ btn.innerHTML = plugin.icon;
186
+ } else {
187
+ btn.innerText = (plugin.name || plugin.id).substring(0, 2).toUpperCase();
188
+ }
189
+
190
+ // Hover effects and Tooltip
191
+ btn.addEventListener('mouseenter', () => {
192
+ if (this.activePluginId !== plugin.id) {
193
+ btn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
194
+ }
195
+ this.showTooltip(btn, plugin.name || plugin.id);
196
+ });
197
+
198
+ btn.addEventListener('mouseleave', () => {
199
+ if (this.activePluginId !== plugin.id) {
200
+ btn.style.backgroundColor = 'transparent';
201
+ }
202
+ this.hideTooltip();
203
+ });
204
+
205
+ btn.onclick = () => this.activatePlugin(plugin.id);
206
+
207
+ this.toolbarContainer.appendChild(btn);
208
+ }
209
+
210
+ private removeButton(pluginId: string): void {
211
+ const btn = this.toolbarContainer.querySelector(`#qfchart-plugin-btn-${pluginId}`);
212
+ if (btn) {
213
+ btn.remove();
214
+ }
215
+ }
216
+
217
+ private setButtonActive(pluginId: string, active: boolean): void {
218
+ const btn = this.toolbarContainer.querySelector(`#qfchart-plugin-btn-${pluginId}`) as HTMLElement;
219
+ if (btn) {
220
+ if (active) {
221
+ btn.style.backgroundColor = '#2563eb'; // Blue highlight
222
+ btn.style.color = '#ffffff';
223
+ } else {
224
+ btn.style.backgroundColor = 'transparent';
225
+ btn.style.color = this.context.getOptions().fontColor || '#cbd5e1';
226
+ }
227
+ }
228
+ }
229
+ }
@@ -146,7 +146,7 @@ export class SeriesBuilder {
146
146
  let plotOverlay = plot.options.overlay;
147
147
 
148
148
  // Fill plots inherit overlay from their referenced plots.
149
- // If both referenced plots are overlay, the fill should render on the
149
+ // If either referenced plot is overlay, the fill should render on the
150
150
  // overlay pane too — otherwise its price-scale data stretches the
151
151
  // indicator sub-pane's y-axis to extreme ranges.
152
152
  if (plot.options.style === 'fill' && plotOverlay === undefined) {
@@ -155,7 +155,7 @@ export class SeriesBuilder {
155
155
  if (p1Name && p2Name) {
156
156
  const p1 = indicator.plots[p1Name];
157
157
  const p2 = indicator.plots[p2Name];
158
- if (p1?.options?.overlay === true && p2?.options?.overlay === true) {
158
+ if (p1?.options?.overlay === true || p2?.options?.overlay === true) {
159
159
  plotOverlay = true;
160
160
  }
161
161
  }
@@ -309,21 +309,28 @@ export class SeriesBuilder {
309
309
  }
310
310
  }
311
311
 
312
- // Skip fully transparent plots — they exist only as data sources for fills.
312
+ // Skip fully transparent / invisible plots — they exist only as data sources for fills.
313
313
  // Their data is already stored in plotDataArrays for fill references.
314
- if (plot.options.color && typeof plot.options.color === 'string') {
315
- const parsed = ColorUtils.parseColor(plot.options.color);
316
- if (parsed.opacity < 0.01) {
317
- // Check that ALL per-bar colors are also transparent (or absent)
318
- const hasVisibleBarColor = colorArray.some((c: any) => {
319
- if (c == null) return false;
320
- const pc = ColorUtils.parseColor(c);
321
- return pc.opacity >= 0.01;
322
- });
323
- if (!hasVisibleBarColor) {
324
- return; // Skip rendering — data already in plotDataArrays for fills
314
+ // Covers: color(na) null, color.new(x, 100) → fully transparent string, etc.
315
+ {
316
+ const plotColor = plot.options.color;
317
+ let skipPlot = false;
318
+ if (plotColor == null) {
319
+ // color(na) plot-level color is null/undefined; skip if no bar has a visible color
320
+ const hasVisibleBarColor = colorArray.some((c: any) => c != null);
321
+ skipPlot = !hasVisibleBarColor;
322
+ } else if (typeof plotColor === 'string') {
323
+ const parsed = ColorUtils.parseColor(plotColor);
324
+ if (parsed.opacity < 0.01) {
325
+ const hasVisibleBarColor = colorArray.some((c: any) => {
326
+ if (c == null) return false;
327
+ const pc = ColorUtils.parseColor(c);
328
+ return pc.opacity >= 0.01;
329
+ });
330
+ skipPlot = !hasVisibleBarColor;
325
331
  }
326
332
  }
333
+ if (skipPlot) return;
327
334
  }
328
335
 
329
336
  // Use Factory to get appropriate renderer
@@ -77,11 +77,14 @@ export class LabelRenderer implements SeriesRenderer {
77
77
  let labelTextOffset: [number, number] = [0, 0];
78
78
 
79
79
  if (isBubble) {
80
- // Approximate text width: chars * fontSize * avgCharWidthRatio (bold)
81
- const textWidth = text.length * fontSize * 0.65;
80
+ // For multi-line text, size based on the longest line and number of lines
81
+ const lines = text.split('\n');
82
+ const longestLine = lines.reduce((a: string, b: string) => a.length > b.length ? a : b, '');
83
+ const textWidth = longestLine.length * fontSize * 0.65;
82
84
  const minWidth = fontSize * 2.5;
83
85
  const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
84
- const bubbleHeight = fontSize * 2.8;
86
+ const lineHeight = fontSize * 1.4;
87
+ const bubbleHeight = Math.max(fontSize * 2.8, lines.length * lineHeight + fontSize * 1.2);
85
88
 
86
89
  // SVG pointer takes 3/24 = 12.5% of the path dimension
87
90
  const pointerRatio = 3 / 24;