@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.
package/src/Utils.ts ADDED
@@ -0,0 +1,31 @@
1
+ const imageCache = new Map<string, string>();
2
+
3
+ export function textToBase64Image(
4
+ text: string,
5
+ color: string = "#00da3c",
6
+ fontSize: string = "64px"
7
+ ): string {
8
+ if (typeof document === "undefined") return "";
9
+
10
+ const cacheKey = `${text}-${color}-${fontSize}`;
11
+ if (imageCache.has(cacheKey)) {
12
+ return imageCache.get(cacheKey)!;
13
+ }
14
+
15
+ const canvas = document.createElement("canvas");
16
+ const ctx = canvas.getContext("2d");
17
+ canvas.width = 32;
18
+ canvas.height = 32;
19
+
20
+ if (ctx) {
21
+ ctx.font = "bold " + fontSize + " Arial";
22
+ ctx.fillStyle = color;
23
+ ctx.textAlign = "center";
24
+ ctx.textBaseline = "middle";
25
+ ctx.fillText(text, 16, 16);
26
+ const dataUrl = canvas.toDataURL("image/png");
27
+ imageCache.set(cacheKey, dataUrl);
28
+ return dataUrl;
29
+ }
30
+ return "";
31
+ }
@@ -0,0 +1,104 @@
1
+ import { ChartContext, Plugin, PluginConfig, OHLCV } from "../types";
2
+ import { EventType, EventHandler } from "../utils/EventBus";
3
+
4
+ export abstract class AbstractPlugin implements Plugin {
5
+ public id: string;
6
+ public name?: string;
7
+ public icon?: string;
8
+
9
+ protected context!: ChartContext;
10
+ private eventListeners: Array<{ event: EventType; handler: EventHandler }> =
11
+ [];
12
+
13
+ constructor(config: PluginConfig) {
14
+ this.id = config.id;
15
+ this.name = config.name;
16
+ this.icon = config.icon;
17
+ }
18
+
19
+ public init(context: ChartContext): void {
20
+ this.context = context;
21
+ this.onInit();
22
+ }
23
+
24
+ /**
25
+ * Lifecycle hook called after context is initialized.
26
+ * Override this instead of init().
27
+ */
28
+ protected onInit(): void {}
29
+
30
+ public activate(): void {
31
+ this.onActivate();
32
+ this.context.events.emit("plugin:activated", this.id);
33
+ }
34
+
35
+ /**
36
+ * Lifecycle hook called when the plugin is activated.
37
+ */
38
+ protected onActivate(): void {}
39
+
40
+ public deactivate(): void {
41
+ this.onDeactivate();
42
+ this.context.events.emit("plugin:deactivated", this.id);
43
+ }
44
+
45
+ /**
46
+ * Lifecycle hook called when the plugin is deactivated.
47
+ */
48
+ protected onDeactivate(): void {}
49
+
50
+ public destroy(): void {
51
+ this.removeAllListeners();
52
+ this.onDestroy();
53
+ }
54
+
55
+ /**
56
+ * Lifecycle hook called when the plugin is destroyed.
57
+ */
58
+ protected onDestroy(): void {}
59
+
60
+ // --- Helper Methods ---
61
+
62
+ /**
63
+ * Register an event listener that will be automatically cleaned up on destroy.
64
+ */
65
+ protected on(event: EventType, handler: EventHandler): void {
66
+ this.context.events.on(event, handler);
67
+ this.eventListeners.push({ event, handler });
68
+ }
69
+
70
+ /**
71
+ * Remove a specific event listener.
72
+ */
73
+ protected off(event: EventType, handler: EventHandler): void {
74
+ this.context.events.off(event, handler);
75
+ this.eventListeners = this.eventListeners.filter(
76
+ (l) => l.event !== event || l.handler !== handler
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Remove all listeners registered by this plugin.
82
+ */
83
+ protected removeAllListeners(): void {
84
+ this.eventListeners.forEach(({ event, handler }) => {
85
+ this.context.events.off(event, handler);
86
+ });
87
+ this.eventListeners = [];
88
+ }
89
+
90
+ /**
91
+ * Access to the ECharts instance.
92
+ */
93
+ protected get chart() {
94
+ return this.context.getChart();
95
+ }
96
+
97
+ /**
98
+ * Access to market data.
99
+ */
100
+ protected get marketData(): OHLCV[] {
101
+ return this.context.getMarketData();
102
+ }
103
+ }
104
+
@@ -0,0 +1,248 @@
1
+ import { ChartContext, DrawingElement, DataCoordinate } from "../types";
2
+ import * as echarts from "echarts";
3
+
4
+ export class DrawingEditor {
5
+ private context: ChartContext;
6
+ private isEditing: boolean = false;
7
+ private currentDrawing: DrawingElement | null = null;
8
+ private editingPointIndex: number | null = null;
9
+ private zr: any;
10
+
11
+ // Temporary ZRender elements for visual feedback during drag
12
+ private editGroup: any = null;
13
+ private editLine: any = null;
14
+ private editStartPoint: any = null;
15
+ private editEndPoint: any = null;
16
+
17
+ private isMovingShape: boolean = false;
18
+ private dragStart: { x: number; y: number } | null = null;
19
+ private initialPixelPoints: { x: number; y: number }[] = [];
20
+
21
+ constructor(context: ChartContext) {
22
+ this.context = context;
23
+ this.zr = this.context.getChart().getZr();
24
+ this.bindEvents();
25
+ }
26
+
27
+ private bindEvents() {
28
+ this.context.events.on("drawing:point:mousedown", this.onPointMouseDown);
29
+ this.context.events.on("drawing:mousedown", this.onDrawingMouseDown);
30
+ }
31
+
32
+ private onDrawingMouseDown = (payload: {
33
+ id: string;
34
+ x: number;
35
+ y: number;
36
+ }) => {
37
+ if (this.isEditing) return;
38
+
39
+ const drawing = this.context.getDrawing(payload.id);
40
+ if (!drawing) return;
41
+
42
+ this.isEditing = true;
43
+ this.isMovingShape = true;
44
+ this.currentDrawing = JSON.parse(JSON.stringify(drawing));
45
+ this.dragStart = { x: payload.x, y: payload.y };
46
+
47
+ // Capture initial pixel positions
48
+ this.initialPixelPoints = drawing.points.map((p) => {
49
+ const pixel = this.context.coordinateConversion.dataToPixel(p);
50
+ return pixel ? { x: pixel.x, y: pixel.y } : { x: 0, y: 0 }; // Fallback
51
+ });
52
+
53
+ this.context.lockChart();
54
+ this.createEditGraphic();
55
+
56
+ this.zr.on("mousemove", this.onMouseMove);
57
+ this.zr.on("mouseup", this.onMouseUp);
58
+ };
59
+
60
+ private onPointMouseDown = (payload: { id: string; pointIndex: number }) => {
61
+ if (this.isEditing) return;
62
+
63
+ const drawing = this.context.getDrawing(payload.id);
64
+ if (!drawing) return;
65
+
66
+ // Start Editing
67
+ this.isEditing = true;
68
+ this.currentDrawing = JSON.parse(JSON.stringify(drawing)); // Deep copy
69
+ this.editingPointIndex = payload.pointIndex;
70
+
71
+ this.context.lockChart();
72
+
73
+ // Create visual feedback (overlay)
74
+ this.createEditGraphic();
75
+
76
+ // Hide the actual drawing (optional, but good for UX so we don't see double)
77
+ // Actually, we can just drag the overlay and update the drawing on mouseup.
78
+ // The underlying drawing remains visible but static until updated.
79
+
80
+ // Bind temporary drag listeners
81
+ this.zr.on("mousemove", this.onMouseMove);
82
+ this.zr.on("mouseup", this.onMouseUp);
83
+ // Global mouseup to catch releases outside chart area if needed (window listener better?)
84
+ // ZRender usually handles global mouseup if initiated within.
85
+ };
86
+
87
+ private createEditGraphic() {
88
+ if (!this.currentDrawing) return;
89
+
90
+ this.editGroup = new echarts.graphic.Group();
91
+
92
+ // We need current pixel coordinates
93
+ const p1Data = this.currentDrawing.points[0];
94
+ const p2Data = this.currentDrawing.points[1];
95
+
96
+ const p1 = this.context.coordinateConversion.dataToPixel(p1Data);
97
+ const p2 = this.context.coordinateConversion.dataToPixel(p2Data);
98
+
99
+ if (!p1 || !p2) return;
100
+
101
+ // Create Line
102
+ this.editLine = new echarts.graphic.Line({
103
+ shape: { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y },
104
+ style: {
105
+ stroke: this.currentDrawing.style?.color || "#3b82f6",
106
+ lineWidth: this.currentDrawing.style?.lineWidth || 2,
107
+ lineDash: [4, 4], // Dashed to indicate editing
108
+ },
109
+ silent: true, // Events pass through to handlers
110
+ });
111
+
112
+ // Create Points (we only really need to visualize the one being dragged, but showing both is fine)
113
+ this.editStartPoint = new echarts.graphic.Circle({
114
+ shape: { cx: p1.x, cy: p1.y, r: 5 },
115
+ style: { fill: "#fff", stroke: "#3b82f6", lineWidth: 2 },
116
+ z: 1000,
117
+ });
118
+
119
+ this.editEndPoint = new echarts.graphic.Circle({
120
+ shape: { cx: p2.x, cy: p2.y, r: 5 },
121
+ style: { fill: "#fff", stroke: "#3b82f6", lineWidth: 2 },
122
+ z: 1000,
123
+ });
124
+
125
+ this.editGroup.add(this.editLine);
126
+ this.editGroup.add(this.editStartPoint);
127
+ this.editGroup.add(this.editEndPoint);
128
+
129
+ this.zr.add(this.editGroup);
130
+ }
131
+
132
+ private onMouseMove = (e: any) => {
133
+ if (!this.isEditing || !this.currentDrawing) return;
134
+
135
+ const x = e.offsetX;
136
+ const y = e.offsetY;
137
+
138
+ if (this.isMovingShape && this.dragStart) {
139
+ const dx = x - this.dragStart.x;
140
+ const dy = y - this.dragStart.y;
141
+
142
+ // Apply delta to all points
143
+ const newP1 = {
144
+ x: this.initialPixelPoints[0].x + dx,
145
+ y: this.initialPixelPoints[0].y + dy,
146
+ };
147
+ const newP2 = {
148
+ x: this.initialPixelPoints[1].x + dx,
149
+ y: this.initialPixelPoints[1].y + dy,
150
+ };
151
+
152
+ this.editLine.setShape({
153
+ x1: newP1.x,
154
+ y1: newP1.y,
155
+ x2: newP2.x,
156
+ y2: newP2.y,
157
+ });
158
+ this.editStartPoint.setShape({ cx: newP1.x, cy: newP1.y });
159
+ this.editEndPoint.setShape({ cx: newP2.x, cy: newP2.y });
160
+ } else if (this.editingPointIndex !== null) {
161
+ // Update the pixel position of the edited point in the overlay
162
+ if (this.editingPointIndex === 0) {
163
+ this.editLine.setShape({ x1: x, y1: y });
164
+ this.editStartPoint.setShape({ cx: x, cy: y });
165
+ } else {
166
+ this.editLine.setShape({ x2: x, y2: y });
167
+ this.editEndPoint.setShape({ cx: x, cy: y });
168
+ }
169
+ }
170
+ };
171
+
172
+ private onMouseUp = (e: any) => {
173
+ if (!this.isEditing) return;
174
+
175
+ // Commit changes
176
+ this.finishEditing(e.offsetX, e.offsetY);
177
+ };
178
+
179
+ private finishEditing(finalX: number, finishY: number) {
180
+ if (!this.currentDrawing) return;
181
+
182
+ if (this.isMovingShape && this.dragStart) {
183
+ const dx = finalX - this.dragStart.x;
184
+ const dy = finishY - this.dragStart.y;
185
+
186
+ // Update all points
187
+ const newPoints = this.initialPixelPoints.map((p, i) => {
188
+ const newX = p.x + dx;
189
+ const newY = p.y + dy;
190
+ return this.context.coordinateConversion.pixelToData({
191
+ x: newX,
192
+ y: newY,
193
+ });
194
+ });
195
+
196
+ // Check if conversion succeeded
197
+ if (newPoints.every((p) => p !== null)) {
198
+ // Update points
199
+ // Assuming 2 points for line tool
200
+ if (newPoints[0] && newPoints[1]) {
201
+ this.currentDrawing.points[0] = newPoints[0];
202
+ this.currentDrawing.points[1] = newPoints[1];
203
+
204
+ // Update pane index if we moved significantly (using start point as ref)
205
+ if (newPoints[0].paneIndex !== undefined) {
206
+ this.currentDrawing.paneIndex = newPoints[0].paneIndex;
207
+ }
208
+
209
+ this.context.updateDrawing(this.currentDrawing);
210
+ }
211
+ }
212
+ } else if (this.editingPointIndex !== null) {
213
+ // Convert final pixel to data
214
+ const newData = this.context.coordinateConversion.pixelToData({
215
+ x: finalX,
216
+ y: finishY,
217
+ });
218
+
219
+ if (newData) {
220
+ this.currentDrawing.points[this.editingPointIndex] = newData;
221
+
222
+ if (this.editingPointIndex === 0 && newData.paneIndex !== undefined) {
223
+ this.currentDrawing.paneIndex = newData.paneIndex;
224
+ }
225
+
226
+ this.context.updateDrawing(this.currentDrawing);
227
+ }
228
+ }
229
+
230
+ // Cleanup
231
+ this.isEditing = false;
232
+ this.isMovingShape = false;
233
+ this.dragStart = null;
234
+ this.initialPixelPoints = [];
235
+ this.currentDrawing = null;
236
+ this.editingPointIndex = null;
237
+
238
+ if (this.editGroup) {
239
+ this.zr.remove(this.editGroup);
240
+ this.editGroup = null;
241
+ }
242
+
243
+ this.zr.off("mousemove", this.onMouseMove);
244
+ this.zr.off("mouseup", this.onMouseUp);
245
+
246
+ this.context.unlockChart();
247
+ }
248
+ }
@@ -0,0 +1,263 @@
1
+ import { LayoutResult } from './LayoutManager';
2
+ import { QFChartOptions } from '../types';
3
+
4
+ export class GraphicBuilder {
5
+ public static build(
6
+ layout: LayoutResult,
7
+ options: QFChartOptions,
8
+ onToggle: (id: string, action?: 'collapse' | 'maximize' | 'fullscreen') => void,
9
+ isMainCollapsed: boolean = false,
10
+ maximizedPaneId: string | null = null
11
+ ): any[] {
12
+ const graphic: any[] = [];
13
+ const pixelToPercent = layout.pixelToPercent;
14
+ const mainPaneTop = layout.mainPaneTop;
15
+
16
+ // Main Chart Title (Only if main chart is visible or maximized)
17
+ // If maximizedPaneId is set and NOT main, main title should be hidden?
18
+ // With current LayoutManager logic, if maximizedPaneId !== main, mainPaneHeight is 0.
19
+ // We should check heights or IDs.
20
+
21
+ const showMain = !maximizedPaneId || maximizedPaneId === 'main';
22
+
23
+ if (showMain) {
24
+ const titleTopMargin = 10 * pixelToPercent;
25
+ graphic.push({
26
+ type: 'text',
27
+ left: '8.5%',
28
+ top: mainPaneTop + titleTopMargin + '%',
29
+ z: 10,
30
+ style: {
31
+ text: options.title || 'Market',
32
+ fill: options.titleColor || '#fff',
33
+ font: `bold 16px ${options.fontFamily || 'sans-serif'}`,
34
+ textVerticalAlign: 'top',
35
+ },
36
+ });
37
+
38
+ // Watermark
39
+ if (options.watermark !== false) {
40
+ const bottomY = layout.mainPaneTop + layout.mainPaneHeight;
41
+ graphic.push({
42
+ type: 'text',
43
+ right: '11%',
44
+ top: bottomY - 3 + '%', // Position 5% from bottom of main chart
45
+ z: 10,
46
+ style: {
47
+ text: 'QFChart',
48
+ fill: options.fontColor || '#cbd5e1',
49
+ font: `bold 16px sans-serif`,
50
+ opacity: 0.1,
51
+ },
52
+ cursor: 'pointer',
53
+ onclick: () => {
54
+ window.open('https://quantforge.org', '_blank');
55
+ },
56
+ });
57
+ }
58
+
59
+ // Main Controls Group
60
+ const controls: any[] = [];
61
+
62
+ // Collapse Button
63
+ if (options.controls?.collapse) {
64
+ controls.push({
65
+ type: 'group',
66
+ children: [
67
+ {
68
+ type: 'rect',
69
+ shape: { width: 20, height: 20, r: 2 },
70
+ style: { fill: '#334155', stroke: '#475569', lineWidth: 1 },
71
+ onclick: () => onToggle('main', 'collapse'),
72
+ },
73
+ {
74
+ type: 'text',
75
+ style: {
76
+ text: isMainCollapsed ? '+' : '−',
77
+ fill: '#cbd5e1',
78
+ font: `bold 14px ${options.fontFamily}`,
79
+ x: 10,
80
+ y: 10,
81
+ textAlign: 'center',
82
+ textVerticalAlign: 'middle',
83
+ },
84
+ silent: true,
85
+ },
86
+ ],
87
+ });
88
+ }
89
+
90
+ // Maximize Button
91
+ if (options.controls?.maximize) {
92
+ const isMaximized = maximizedPaneId === 'main';
93
+ // Shift x position if collapse button exists
94
+ const xOffset = options.controls?.collapse ? 25 : 0;
95
+
96
+ controls.push({
97
+ type: 'group',
98
+ x: xOffset,
99
+ children: [
100
+ {
101
+ type: 'rect',
102
+ shape: { width: 20, height: 20, r: 2 },
103
+ style: { fill: '#334155', stroke: '#475569', lineWidth: 1 },
104
+ onclick: () => onToggle('main', 'maximize'),
105
+ },
106
+ {
107
+ type: 'text',
108
+ style: {
109
+ text: isMaximized ? '❐' : '□', // Simple chars for now
110
+ fill: '#cbd5e1',
111
+ font: `14px ${options.fontFamily}`,
112
+ x: 10,
113
+ y: 10,
114
+ textAlign: 'center',
115
+ textVerticalAlign: 'middle',
116
+ },
117
+ silent: true,
118
+ },
119
+ ],
120
+ });
121
+ }
122
+
123
+ // Fullscreen Button
124
+ if (options.controls?.fullscreen) {
125
+ let xOffset = 0;
126
+ if (options.controls?.collapse) xOffset += 25;
127
+ if (options.controls?.maximize) xOffset += 25;
128
+
129
+ controls.push({
130
+ type: 'group',
131
+ x: xOffset,
132
+ children: [
133
+ {
134
+ type: 'rect',
135
+ shape: { width: 20, height: 20, r: 2 },
136
+ style: { fill: '#334155', stroke: '#475569', lineWidth: 1 },
137
+ onclick: () => onToggle('main', 'fullscreen'),
138
+ },
139
+ {
140
+ type: 'text',
141
+ style: {
142
+ text: '⛶',
143
+ fill: '#cbd5e1',
144
+ font: `14px ${options.fontFamily}`,
145
+ x: 10,
146
+ y: 10,
147
+ textAlign: 'center',
148
+ textVerticalAlign: 'middle',
149
+ },
150
+ silent: true,
151
+ },
152
+ ],
153
+ });
154
+ }
155
+
156
+ if (controls.length > 0) {
157
+ graphic.push({
158
+ type: 'group',
159
+ right: '10.5%',
160
+ top: mainPaneTop + '%',
161
+ children: controls,
162
+ });
163
+ }
164
+ }
165
+
166
+ // Indicator Panes
167
+ layout.paneLayout.forEach((pane) => {
168
+ // If maximizedPaneId is set, and this is NOT the maximized pane, skip rendering its controls
169
+ if (maximizedPaneId && pane.indicatorId !== maximizedPaneId) {
170
+ return;
171
+ }
172
+
173
+ // Title
174
+ graphic.push({
175
+ type: 'text',
176
+ left: '8.5%',
177
+ top: pane.top + 10 * pixelToPercent + '%',
178
+ z: 10,
179
+ style: {
180
+ text: pane.indicatorId || '',
181
+ fill: pane.titleColor || '#fff',
182
+ font: `bold 12px ${options.fontFamily || 'sans-serif'}`,
183
+ textVerticalAlign: 'top',
184
+ },
185
+ });
186
+
187
+ // Controls
188
+ const controls: any[] = [];
189
+
190
+ // Collapse
191
+ if (pane.controls?.collapse) {
192
+ controls.push({
193
+ type: 'group',
194
+ children: [
195
+ {
196
+ type: 'rect',
197
+ shape: { width: 20, height: 20, r: 2 },
198
+ style: { fill: '#334155', stroke: '#475569', lineWidth: 1 },
199
+ onclick: () => pane.indicatorId && onToggle(pane.indicatorId, 'collapse'),
200
+ },
201
+ {
202
+ type: 'text',
203
+ style: {
204
+ text: pane.isCollapsed ? '+' : '−',
205
+ fill: '#cbd5e1',
206
+ font: `bold 14px ${options.fontFamily}`,
207
+ x: 10,
208
+ y: 10,
209
+ textAlign: 'center',
210
+ textVerticalAlign: 'middle',
211
+ },
212
+ silent: true,
213
+ },
214
+ ],
215
+ });
216
+ }
217
+
218
+ // Maximize
219
+ if (pane.controls?.maximize) {
220
+ // Assuming we add maximize to Indicator controls
221
+ const isMaximized = maximizedPaneId === pane.indicatorId;
222
+ const xOffset = pane.controls?.collapse ? 25 : 0;
223
+
224
+ controls.push({
225
+ type: 'group',
226
+ x: xOffset,
227
+ children: [
228
+ {
229
+ type: 'rect',
230
+ shape: { width: 20, height: 20, r: 2 },
231
+ style: { fill: '#334155', stroke: '#475569', lineWidth: 1 },
232
+ onclick: () => pane.indicatorId && onToggle(pane.indicatorId, 'maximize'),
233
+ },
234
+ {
235
+ type: 'text',
236
+ style: {
237
+ text: isMaximized ? '❐' : '□',
238
+ fill: '#cbd5e1',
239
+ font: `14px ${options.fontFamily}`,
240
+ x: 10,
241
+ y: 10,
242
+ textAlign: 'center',
243
+ textVerticalAlign: 'middle',
244
+ },
245
+ silent: true,
246
+ },
247
+ ],
248
+ });
249
+ }
250
+
251
+ if (controls.length > 0) {
252
+ graphic.push({
253
+ type: 'group',
254
+ right: '10.5%',
255
+ top: pane.top + '%',
256
+ children: controls,
257
+ });
258
+ }
259
+ });
260
+
261
+ return graphic;
262
+ }
263
+ }