@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,344 @@
1
+ import { ChartContext, PluginConfig } from '../types';
2
+ import { AbstractPlugin } from '../components/AbstractPlugin';
3
+ import * as echarts from 'echarts';
4
+
5
+ type PluginState = 'idle' | 'drawing' | 'finished';
6
+
7
+ export class MeasureTool extends AbstractPlugin {
8
+ private zr!: any;
9
+
10
+ private state: PluginState = 'idle';
11
+
12
+ private startPoint: number[] | null = null;
13
+ private endPoint: number[] | null = null;
14
+
15
+ // ZRender Elements
16
+ private group: any = null;
17
+ private rect: any = null; // Measurement Box
18
+ private labelRect: any = null; // Label Background
19
+ private labelText: any = null; // Label Text
20
+ private lineV: any = null; // Vertical Arrow Line
21
+ private lineH: any = null; // Horizontal Arrow Line
22
+ private arrowStart: any = null; // Start Arrow
23
+ private arrowEnd: any = null; // End Arrow
24
+
25
+ constructor(options: { name?: string; icon?: string }) {
26
+ super({
27
+ id: 'measure',
28
+ name: options?.name || 'Measure',
29
+ icon:
30
+ options?.icon ||
31
+ `<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M160-240q-33 0-56.5-23.5T80-320v-320q0-33 23.5-56.5T160-720h640q33 0 56.5 23.5T880-640v320q0 33-23.5 56.5T800-240H160Zm0-80h640v-320H680v160h-80v-160h-80v160h-80v-160h-80v160h-80v-160H160v320Zm120-160h80-80Zm160 0h80-80Zm160 0h80-80Zm-120 0Z"/></svg>`,
32
+ });
33
+ }
34
+
35
+ protected onInit(): void {
36
+ this.zr = this.chart.getZr();
37
+ }
38
+
39
+ protected onActivate(): void {
40
+ this.state = 'idle';
41
+ this.chart.getZr().setCursorStyle('crosshair');
42
+
43
+ // We can use this.on() to register listeners that will be cleaned up automatically on destroy
44
+ // BUT we need manual control over add/remove for deactivate() logic.
45
+ // AbstractPlugin.on() is for lifecycle-bound listeners.
46
+ // Here we toggle listeners based on tool state.
47
+ // So we'll use ZRender direct listeners as before, but maybe cleaner.
48
+
49
+ this.zr.on('click', this.onClick);
50
+ this.zr.on('mousemove', this.onMouseMove);
51
+
52
+ // We can still use the Event Bus for internal communication if needed
53
+ }
54
+
55
+ protected onDeactivate(): void {
56
+ this.state = 'idle';
57
+ this.chart.getZr().setCursorStyle('default');
58
+
59
+ this.zr.off('click', this.onClick);
60
+ this.zr.off('mousemove', this.onMouseMove);
61
+
62
+ // Clean up clear listeners if any
63
+ this.disableClearListeners();
64
+
65
+ // @ts-ignore - state type comparison
66
+ if (this.state === 'drawing') {
67
+ this.removeGraphic();
68
+ }
69
+ }
70
+
71
+ protected onDestroy(): void {
72
+ this.removeGraphic();
73
+ }
74
+
75
+ // --- Interaction Handlers ---
76
+
77
+ private onMouseDown = () => {
78
+ if (this.state === 'finished') {
79
+ this.removeGraphic();
80
+ }
81
+ };
82
+
83
+ private onChartInteraction = () => {
84
+ if (this.group) {
85
+ this.removeGraphic();
86
+ }
87
+ };
88
+
89
+ private onClick = (params: any) => {
90
+ if (this.state === 'idle') {
91
+ this.state = 'drawing';
92
+ this.startPoint = [params.offsetX, params.offsetY];
93
+ this.endPoint = [params.offsetX, params.offsetY];
94
+ this.initGraphic();
95
+ this.updateGraphic();
96
+ } else if (this.state === 'drawing') {
97
+ this.state = 'finished';
98
+ this.endPoint = [params.offsetX, params.offsetY];
99
+ this.updateGraphic();
100
+ this.context.disableTools();
101
+
102
+ // Enable listeners to clear the graphic on interaction
103
+ this.enableClearListeners();
104
+ }
105
+ };
106
+
107
+ private enableClearListeners(): void {
108
+ const clickHandler = () => {
109
+ this.removeGraphic();
110
+ };
111
+ setTimeout(() => {
112
+ this.zr.on('click', clickHandler);
113
+ }, 10);
114
+
115
+ this.zr.on('mousedown', this.onMouseDown);
116
+ this.context.events.on('chart:dataZoom', this.onChartInteraction);
117
+
118
+ this.clearHandlers = {
119
+ click: clickHandler,
120
+ mousedown: this.onMouseDown,
121
+ dataZoom: this.onChartInteraction,
122
+ };
123
+ }
124
+
125
+ private clearHandlers: any = {};
126
+
127
+ private disableClearListeners(): void {
128
+ if (this.clearHandlers.click) this.zr.off('click', this.clearHandlers.click);
129
+ if (this.clearHandlers.mousedown) this.zr.off('mousedown', this.clearHandlers.mousedown);
130
+ if (this.clearHandlers.dataZoom) {
131
+ this.context.events.off('chart:dataZoom', this.clearHandlers.dataZoom);
132
+ }
133
+ this.clearHandlers = {};
134
+ }
135
+
136
+ private onMouseMove = (params: any) => {
137
+ if (this.state !== 'drawing') return;
138
+ this.endPoint = [params.offsetX, params.offsetY];
139
+ this.updateGraphic();
140
+ };
141
+
142
+ // --- Graphics ---
143
+
144
+ private initGraphic(): void {
145
+ if (this.group) return;
146
+
147
+ this.group = new echarts.graphic.Group();
148
+
149
+ // 1. Rectangle (Box)
150
+ this.rect = new echarts.graphic.Rect({
151
+ shape: { x: 0, y: 0, width: 0, height: 0 },
152
+ style: { fill: 'rgba(0,0,0,0)', stroke: 'transparent', lineWidth: 0 },
153
+ z: 100,
154
+ });
155
+
156
+ // 2. Lines (Arrows)
157
+ this.lineV = new echarts.graphic.Line({
158
+ shape: { x1: 0, y1: 0, x2: 0, y2: 0 },
159
+ style: { stroke: '#fff', lineWidth: 1, lineDash: [4, 4] },
160
+ z: 101,
161
+ });
162
+ this.lineH = new echarts.graphic.Line({
163
+ shape: { x1: 0, y1: 0, x2: 0, y2: 0 },
164
+ style: { stroke: '#fff', lineWidth: 1, lineDash: [4, 4] },
165
+ z: 101,
166
+ });
167
+
168
+ // Arrows
169
+ this.arrowStart = new echarts.graphic.Polygon({
170
+ shape: {
171
+ points: [
172
+ [0, 0],
173
+ [-5, 10],
174
+ [5, 10],
175
+ ],
176
+ },
177
+ style: { fill: '#fff' },
178
+ z: 102,
179
+ });
180
+ this.arrowEnd = new echarts.graphic.Polygon({
181
+ shape: {
182
+ points: [
183
+ [0, 0],
184
+ [-5, -10],
185
+ [5, -10],
186
+ ],
187
+ },
188
+ style: { fill: '#fff' },
189
+ z: 102,
190
+ });
191
+
192
+ // 3. Label
193
+ this.labelRect = new echarts.graphic.Rect({
194
+ shape: { x: 0, y: 0, width: 0, height: 0, r: 4 },
195
+ style: {
196
+ fill: 'transparent',
197
+ stroke: 'transparent',
198
+ lineWidth: 0,
199
+ shadowBlur: 5,
200
+ shadowColor: 'rgba(0,0,0,0.3)',
201
+ },
202
+ z: 102,
203
+ });
204
+
205
+ this.labelText = new echarts.graphic.Text({
206
+ style: {
207
+ x: 0,
208
+ y: 0,
209
+ text: '',
210
+ fill: '#fff',
211
+ font: '12px sans-serif',
212
+ align: 'center',
213
+ verticalAlign: 'middle',
214
+ },
215
+ z: 103,
216
+ });
217
+
218
+ this.group.add(this.rect);
219
+ this.group.add(this.lineV);
220
+ this.group.add(this.lineH);
221
+ this.group.add(this.arrowStart);
222
+ this.group.add(this.arrowEnd);
223
+ this.group.add(this.labelRect);
224
+ this.group.add(this.labelText);
225
+
226
+ this.zr.add(this.group);
227
+ }
228
+
229
+ private removeGraphic(): void {
230
+ if (this.group) {
231
+ this.zr.remove(this.group);
232
+ this.group = null;
233
+ // ... clear refs
234
+ this.disableClearListeners();
235
+ }
236
+ }
237
+
238
+ private updateGraphic(): void {
239
+ if (!this.startPoint || !this.endPoint || !this.group) return;
240
+
241
+ const [x1, y1] = this.startPoint;
242
+ const [x2, y2] = this.endPoint;
243
+
244
+ // Use Context Helper
245
+ const p1 = this.context.coordinateConversion.pixelToData({ x: x1, y: y1 });
246
+ const p2 = this.context.coordinateConversion.pixelToData({ x: x2, y: y2 });
247
+
248
+ if (!p1 || !p2) return;
249
+
250
+ const idx1 = Math.round(p1.timeIndex);
251
+ const idx2 = Math.round(p2.timeIndex);
252
+ const val1 = p1.value;
253
+ const val2 = p2.value;
254
+
255
+ const bars = idx2 - idx1;
256
+ const priceDiff = val2 - val1;
257
+ const priceChangePercent = (priceDiff / val1) * 100;
258
+ const isUp = priceDiff >= 0;
259
+
260
+ const color = isUp ? 'rgba(33, 150, 243, 0.2)' : 'rgba(236, 0, 0, 0.2)';
261
+ const strokeColor = isUp ? '#2196F3' : '#ec0000';
262
+
263
+ // --- Visuals ---
264
+ this.rect.setShape({
265
+ x: Math.min(x1, x2),
266
+ y: Math.min(y1, y2),
267
+ width: Math.abs(x2 - x1),
268
+ height: Math.abs(y2 - y1),
269
+ });
270
+ this.rect.setStyle({ fill: color });
271
+
272
+ const midX = (x1 + x2) / 2;
273
+ const midY = (y1 + y2) / 2;
274
+
275
+ this.lineV.setShape({ x1: midX, y1: y1, x2: midX, y2: y2 });
276
+ this.lineV.setStyle({ stroke: strokeColor });
277
+
278
+ this.lineH.setShape({ x1: x1, y1: midY, x2: x2, y2: midY });
279
+ this.lineH.setStyle({ stroke: strokeColor });
280
+
281
+ // Arrows
282
+ const topY = Math.min(y1, y2);
283
+ const bottomY = Math.max(y1, y2);
284
+
285
+ this.arrowStart.setStyle({ fill: 'none' });
286
+ this.arrowEnd.setStyle({ fill: 'none' });
287
+
288
+ if (isUp) {
289
+ this.arrowStart.setShape({
290
+ points: [
291
+ [midX, topY],
292
+ [midX - 4, topY + 6],
293
+ [midX + 4, topY + 6],
294
+ ],
295
+ });
296
+ this.arrowStart.setStyle({ fill: strokeColor });
297
+ } else {
298
+ this.arrowEnd.setShape({
299
+ points: [
300
+ [midX, bottomY],
301
+ [midX - 4, bottomY - 6],
302
+ [midX + 4, bottomY - 6],
303
+ ],
304
+ });
305
+ this.arrowEnd.setStyle({ fill: strokeColor });
306
+ }
307
+
308
+ // Label
309
+ const textContent = [`${priceDiff.toFixed(2)} (${priceChangePercent.toFixed(2)}%)`, `${bars} bars, ${(bars * 0).toFixed(0)}d`].join('\n');
310
+
311
+ const labelW = 140;
312
+ const labelH = 40;
313
+ const rectBottomY = Math.max(y1, y2);
314
+ const rectTopY = Math.min(y1, y2);
315
+ const rectCenterX = (x1 + x2) / 2;
316
+
317
+ let labelX = rectCenterX - labelW / 2;
318
+ let labelY = rectBottomY + 10;
319
+
320
+ const canvasHeight = this.chart.getHeight();
321
+ if (labelY + labelH > canvasHeight) {
322
+ labelY = rectTopY - labelH - 10;
323
+ }
324
+
325
+ this.labelRect.setShape({
326
+ x: labelX,
327
+ y: labelY,
328
+ width: labelW,
329
+ height: labelH,
330
+ });
331
+ this.labelRect.setStyle({
332
+ fill: '#1e293b',
333
+ stroke: strokeColor,
334
+ lineWidth: 1,
335
+ });
336
+
337
+ this.labelText.setStyle({
338
+ x: labelX + labelW / 2,
339
+ y: labelY + labelH / 2,
340
+ text: textContent,
341
+ fill: '#fff',
342
+ });
343
+ }
344
+ }
package/src/types.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { EventBus } from './utils/EventBus';
2
+
3
+ export interface OHLCV {
4
+ time: number;
5
+ open: number;
6
+ high: number;
7
+ low: number;
8
+ close: number;
9
+ volume: number;
10
+ }
11
+
12
+ export interface IndicatorPoint {
13
+ time: number;
14
+ value: number | null;
15
+ options?: {
16
+ color?: string;
17
+ };
18
+ }
19
+
20
+ export type IndicatorStyle = 'line' | 'columns' | 'histogram' | 'circles' | 'cross' | 'background';
21
+
22
+ export interface IndicatorOptions {
23
+ style: IndicatorStyle;
24
+ color: string;
25
+ linewidth?: number;
26
+ }
27
+
28
+ export interface IndicatorPlot {
29
+ data: IndicatorPoint[];
30
+ options: IndicatorOptions;
31
+ }
32
+
33
+ // A collection of plots that make up a single indicator (e.g. MACD has macd line, signal line, histogram)
34
+ export interface Indicator {
35
+ id: string;
36
+ plots: { [name: string]: IndicatorPlot };
37
+ paneIndex: number;
38
+ height?: number; // Desired height in percentage (e.g. 15 for 15%)
39
+ collapsed?: boolean;
40
+ titleColor?: string;
41
+ controls?: {
42
+ collapse?: boolean;
43
+ maximize?: boolean;
44
+ };
45
+ }
46
+
47
+ export interface QFChartOptions {
48
+ title?: string; // Title for the main chart (e.g. "BTC/USDT")
49
+ titleColor?: string;
50
+ backgroundColor?: string;
51
+ upColor?: string;
52
+ downColor?: string;
53
+ fontColor?: string;
54
+ fontFamily?: string;
55
+ padding?: number; // Defaults to 0.2
56
+ height?: string | number;
57
+ controls?: {
58
+ collapse?: boolean;
59
+ maximize?: boolean;
60
+ fullscreen?: boolean;
61
+ };
62
+ dataZoom?: {
63
+ visible?: boolean;
64
+ position?: 'top' | 'bottom';
65
+ height?: number; // height in %, default 6
66
+ start?: number; // 0-100, default 50
67
+ end?: number; // 0-100, default 100
68
+ };
69
+ databox?: {
70
+ position: 'floating' | 'left' | 'right';
71
+ };
72
+ layout?: {
73
+ mainPaneHeight: string; // e.g. "60%"
74
+ gap: number; // e.g. 5 (percent)
75
+ };
76
+ watermark?: boolean; // Default true
77
+ }
78
+
79
+ // Plugin System Types
80
+
81
+ export interface Coordinate {
82
+ x: number;
83
+ y: number;
84
+ }
85
+
86
+ export interface DataCoordinate {
87
+ timeIndex: number;
88
+ value: number;
89
+ paneIndex?: number; // Optional pane index
90
+ }
91
+
92
+ export interface ChartContext {
93
+ // Core Access
94
+ getChart(): any; // echarts.ECharts instance
95
+ getMarketData(): OHLCV[];
96
+ getTimeToIndex(): Map<number, number>;
97
+ getOptions(): QFChartOptions;
98
+
99
+ // Event Bus
100
+ events: EventBus;
101
+
102
+ // Helpers
103
+ coordinateConversion: {
104
+ pixelToData: (point: Coordinate) => DataCoordinate | null;
105
+ dataToPixel: (point: DataCoordinate) => Coordinate | null;
106
+ };
107
+
108
+ // Interaction Control
109
+ disableTools(): void; // To disable other active tools
110
+
111
+ // Zoom Control
112
+ setZoom(start: number, end: number): void;
113
+
114
+ // Drawing Management
115
+ addDrawing(drawing: DrawingElement): void;
116
+ removeDrawing(id: string): void;
117
+ getDrawing(id: string): DrawingElement | undefined;
118
+ updateDrawing(drawing: DrawingElement): void;
119
+
120
+ // Interaction Locking
121
+ lockChart(): void;
122
+ unlockChart(): void;
123
+ }
124
+
125
+ export type DrawingType = 'line' | 'fibonacci';
126
+
127
+ export interface DrawingElement {
128
+ id: string;
129
+ type: DrawingType;
130
+ points: DataCoordinate[]; // [start, end]
131
+ paneIndex?: number; // Pane where this drawing belongs (default 0)
132
+ style?: {
133
+ color?: string;
134
+ lineWidth?: number;
135
+ };
136
+ }
137
+
138
+ export interface PluginConfig {
139
+ id: string;
140
+ name?: string;
141
+ icon?: string;
142
+ hotkey?: string;
143
+ }
144
+
145
+ export interface Plugin {
146
+ id: string;
147
+ name?: string;
148
+ icon?: string;
149
+
150
+ init(context: ChartContext): void;
151
+
152
+ // Called when the tool button is clicked/activated
153
+ activate?(): void;
154
+
155
+ // Called when the tool is deactivated
156
+ deactivate?(): void;
157
+
158
+ // Cleanup when plugin is removed
159
+ destroy?(): void;
160
+ }
@@ -0,0 +1,67 @@
1
+ export type EventType =
2
+ | 'mouse:down'
3
+ | 'mouse:move'
4
+ | 'mouse:up'
5
+ | 'mouse:click'
6
+ | 'chart:resize'
7
+ | 'chart:dataZoom'
8
+ | 'chart:updated'
9
+ | 'plugin:activated'
10
+ | 'plugin:deactivated'
11
+ | 'drawing:hover'
12
+ | 'drawing:mouseout'
13
+ | 'drawing:mousedown'
14
+ | 'drawing:click'
15
+ | 'drawing:point:hover'
16
+ | 'drawing:point:mouseout'
17
+ | 'drawing:point:mousedown'
18
+ | 'drawing:point:click'
19
+ | 'drawing:selected'
20
+ | 'drawing:deselected'
21
+ | 'drawing:deleted';
22
+
23
+ export interface DrawingEventPayload {
24
+ id: string;
25
+ type?: string;
26
+ pointIndex?: number;
27
+ event?: any;
28
+ x?: number;
29
+ y?: number;
30
+ }
31
+
32
+ export type EventHandler<T = any> = (payload: T) => void;
33
+
34
+ export class EventBus {
35
+ private handlers: Map<EventType, Set<EventHandler>> = new Map();
36
+
37
+ public on<T = any>(event: EventType, handler: EventHandler<T>): void {
38
+ if (!this.handlers.has(event)) {
39
+ this.handlers.set(event, new Set());
40
+ }
41
+ this.handlers.get(event)!.add(handler);
42
+ }
43
+
44
+ public off<T = any>(event: EventType, handler: EventHandler<T>): void {
45
+ const handlers = this.handlers.get(event);
46
+ if (handlers) {
47
+ handlers.delete(handler);
48
+ }
49
+ }
50
+
51
+ public emit<T = any>(event: EventType, payload?: T): void {
52
+ const handlers = this.handlers.get(event);
53
+ if (handlers) {
54
+ handlers.forEach((handler) => {
55
+ try {
56
+ handler(payload);
57
+ } catch (e) {
58
+ console.error(`Error in EventBus handler for ${event}:`, e);
59
+ }
60
+ });
61
+ }
62
+ }
63
+
64
+ public clear(): void {
65
+ this.handlers.clear();
66
+ }
67
+ }