@qfo/qfchart 0.8.0 → 0.8.2

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.
Files changed (74) hide show
  1. package/dist/index.d.ts +524 -12
  2. package/dist/qfchart.min.browser.js +34 -18
  3. package/dist/qfchart.min.es.js +34 -18
  4. package/package.json +1 -1
  5. package/src/QFChart.ts +109 -272
  6. package/src/components/AbstractPlugin.ts +234 -104
  7. package/src/components/DrawingEditor.ts +297 -248
  8. package/src/components/DrawingRendererRegistry.ts +13 -0
  9. package/src/components/GraphicBuilder.ts +2 -2
  10. package/src/components/LayoutManager.ts +92 -52
  11. package/src/components/SeriesBuilder.ts +10 -10
  12. package/src/components/TooltipFormatter.ts +1 -1
  13. package/src/index.ts +25 -6
  14. package/src/plugins/ABCDPatternTool/ABCDPatternDrawingRenderer.ts +112 -0
  15. package/src/plugins/ABCDPatternTool/ABCDPatternTool.ts +136 -0
  16. package/src/plugins/ABCDPatternTool/index.ts +2 -0
  17. package/src/plugins/CrossLineTool/CrossLineDrawingRenderer.ts +49 -0
  18. package/src/plugins/CrossLineTool/CrossLineTool.ts +52 -0
  19. package/src/plugins/CrossLineTool/index.ts +2 -0
  20. package/src/plugins/CypherPatternTool/CypherPatternDrawingRenderer.ts +80 -0
  21. package/src/plugins/CypherPatternTool/CypherPatternTool.ts +84 -0
  22. package/src/plugins/CypherPatternTool/index.ts +2 -0
  23. package/src/plugins/ExtendedLineTool/ExtendedLineDrawingRenderer.ts +73 -0
  24. package/src/plugins/ExtendedLineTool/ExtendedLineTool.ts +173 -0
  25. package/src/plugins/ExtendedLineTool/index.ts +2 -0
  26. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanDrawingRenderer.ts +163 -0
  27. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanTool.ts +210 -0
  28. package/src/plugins/FibSpeedResistanceFanTool/index.ts +2 -0
  29. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionDrawingRenderer.ts +141 -0
  30. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionTool.ts +188 -0
  31. package/src/plugins/FibTrendExtensionTool/index.ts +2 -0
  32. package/src/plugins/FibonacciChannelTool/FibonacciChannelDrawingRenderer.ts +128 -0
  33. package/src/plugins/FibonacciChannelTool/FibonacciChannelTool.ts +231 -0
  34. package/src/plugins/FibonacciChannelTool/index.ts +2 -0
  35. package/src/plugins/FibonacciTool/FibonacciDrawingRenderer.ts +107 -0
  36. package/src/plugins/{FibonacciTool.ts → FibonacciTool/FibonacciTool.ts} +195 -192
  37. package/src/plugins/FibonacciTool/index.ts +2 -0
  38. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersDrawingRenderer.ts +95 -0
  39. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersTool.ts +97 -0
  40. package/src/plugins/HeadAndShouldersTool/index.ts +2 -0
  41. package/src/plugins/HorizontalLineTool/HorizontalLineDrawingRenderer.ts +54 -0
  42. package/src/plugins/HorizontalLineTool/HorizontalLineTool.ts +52 -0
  43. package/src/plugins/HorizontalLineTool/index.ts +2 -0
  44. package/src/plugins/HorizontalRayTool/HorizontalRayDrawingRenderer.ts +34 -0
  45. package/src/plugins/HorizontalRayTool/HorizontalRayTool.ts +52 -0
  46. package/src/plugins/HorizontalRayTool/index.ts +2 -0
  47. package/src/plugins/InfoLineTool/InfoLineDrawingRenderer.ts +72 -0
  48. package/src/plugins/InfoLineTool/InfoLineTool.ts +130 -0
  49. package/src/plugins/InfoLineTool/index.ts +2 -0
  50. package/src/plugins/LineTool/LineDrawingRenderer.ts +49 -0
  51. package/src/plugins/{LineTool.ts → LineTool/LineTool.ts} +161 -190
  52. package/src/plugins/LineTool/index.ts +2 -0
  53. package/src/plugins/{MeasureTool.ts → MeasureTool/MeasureTool.ts} +324 -344
  54. package/src/plugins/MeasureTool/index.ts +1 -0
  55. package/src/plugins/RayTool/RayDrawingRenderer.ts +69 -0
  56. package/src/plugins/RayTool/RayTool.ts +162 -0
  57. package/src/plugins/RayTool/index.ts +2 -0
  58. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternDrawingRenderer.ts +106 -0
  59. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternTool.ts +98 -0
  60. package/src/plugins/ThreeDrivesPatternTool/index.ts +2 -0
  61. package/src/plugins/ToolGroup.ts +211 -0
  62. package/src/plugins/TrendAngleTool/TrendAngleDrawingRenderer.ts +87 -0
  63. package/src/plugins/TrendAngleTool/TrendAngleTool.ts +176 -0
  64. package/src/plugins/TrendAngleTool/index.ts +2 -0
  65. package/src/plugins/TrianglePatternTool/TrianglePatternDrawingRenderer.ts +107 -0
  66. package/src/plugins/TrianglePatternTool/TrianglePatternTool.ts +98 -0
  67. package/src/plugins/TrianglePatternTool/index.ts +2 -0
  68. package/src/plugins/VerticalLineTool/VerticalLineDrawingRenderer.ts +35 -0
  69. package/src/plugins/VerticalLineTool/VerticalLineTool.ts +52 -0
  70. package/src/plugins/VerticalLineTool/index.ts +2 -0
  71. package/src/plugins/XABCDPatternTool/XABCDPatternDrawingRenderer.ts +178 -0
  72. package/src/plugins/XABCDPatternTool/XABCDPatternTool.ts +213 -0
  73. package/src/plugins/XABCDPatternTool/index.ts +2 -0
  74. package/src/types.ts +39 -11
@@ -0,0 +1 @@
1
+ export { MeasureTool } from './MeasureTool';
@@ -0,0 +1,69 @@
1
+ import { DrawingRenderer, DrawingRenderContext } from '../../types';
2
+
3
+ export class RayDrawingRenderer implements DrawingRenderer {
4
+ type = 'ray';
5
+
6
+ render(ctx: DrawingRenderContext): any {
7
+ const { drawing, pixelPoints, isSelected, coordSys } = ctx;
8
+ const [x1, y1] = pixelPoints[0];
9
+ const [x2, y2] = pixelPoints[1];
10
+ const color = drawing.style?.color || '#d1d4dc';
11
+
12
+ // Extend the ray from p1 through p2 to the chart boundary
13
+ const [ex, ey] = this.extendToEdge(x1, y1, x2, y2, coordSys);
14
+
15
+ return {
16
+ type: 'group',
17
+ children: [
18
+ {
19
+ type: 'line',
20
+ name: 'line',
21
+ shape: { x1, y1, x2: ex, y2: ey },
22
+ style: {
23
+ stroke: color,
24
+ lineWidth: drawing.style?.lineWidth || 1,
25
+ },
26
+ },
27
+ {
28
+ type: 'circle',
29
+ name: 'point-0',
30
+ shape: { cx: x1, cy: y1, r: 4 },
31
+ style: { fill: '#fff', stroke: color, lineWidth: 1, opacity: isSelected ? 1 : 0 },
32
+ },
33
+ {
34
+ type: 'circle',
35
+ name: 'point-1',
36
+ shape: { cx: x2, cy: y2, r: 4 },
37
+ style: { fill: '#fff', stroke: color, lineWidth: 1, opacity: isSelected ? 1 : 0 },
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ private extendToEdge(
44
+ x1: number, y1: number, x2: number, y2: number,
45
+ cs: { x: number; y: number; width: number; height: number },
46
+ ): [number, number] {
47
+ const dx = x2 - x1;
48
+ const dy = y2 - y1;
49
+ if (dx === 0 && dy === 0) return [x2, y2];
50
+
51
+ const left = cs.x;
52
+ const right = cs.x + cs.width;
53
+ const top = cs.y;
54
+ const bottom = cs.y + cs.height;
55
+
56
+ let tMax = Infinity;
57
+ if (dx !== 0) {
58
+ const tx = dx > 0 ? (right - x1) / dx : (left - x1) / dx;
59
+ if (tx > 0) tMax = Math.min(tMax, tx);
60
+ }
61
+ if (dy !== 0) {
62
+ const ty = dy > 0 ? (bottom - y1) / dy : (top - y1) / dy;
63
+ if (ty > 0) tMax = Math.min(tMax, ty);
64
+ }
65
+ if (!isFinite(tMax)) tMax = 1;
66
+
67
+ return [x1 + tMax * dx, y1 + tMax * dy];
68
+ }
69
+ }
@@ -0,0 +1,162 @@
1
+ import { AbstractPlugin } from '../../components/AbstractPlugin';
2
+ import { RayDrawingRenderer } from './RayDrawingRenderer';
3
+ import * as echarts from 'echarts';
4
+
5
+ const COLOR = '#d1d4dc';
6
+
7
+ type PluginState = 'idle' | 'drawing' | 'finished';
8
+
9
+ export class RayTool extends AbstractPlugin {
10
+ private zr!: any;
11
+ private state: PluginState = 'idle';
12
+ private startPoint: number[] | null = null;
13
+ private endPoint: number[] | null = null;
14
+ private group: any = null;
15
+ private line: any = null;
16
+ private dashLine: any = null;
17
+ private startCircle: any = null;
18
+ private endCircle: any = null;
19
+
20
+ constructor(options: { name?: string; icon?: string } = {}) {
21
+ super({
22
+ id: 'ray-tool',
23
+ name: options?.name || 'Ray',
24
+ icon: options?.icon || `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="20" x2="21" y2="4"/><circle cx="21" cy="4" r="0" fill="currentColor"/><polyline points="16,4 21,4 21,9" stroke-width="1.5"/></svg>`,
25
+ });
26
+ }
27
+
28
+ protected onInit(): void {
29
+ this.zr = this.chart.getZr();
30
+ this.context.registerDrawingRenderer(new RayDrawingRenderer());
31
+ }
32
+
33
+ protected onActivate(): void {
34
+ this.state = 'idle';
35
+ this.chart.getZr().setCursorStyle('crosshair');
36
+ this.zr.on('click', this.onClick);
37
+ this.zr.on('mousemove', this.onMouseMove);
38
+ }
39
+
40
+ protected onDeactivate(): void {
41
+ this.state = 'idle';
42
+ this.chart.getZr().setCursorStyle('default');
43
+ this.zr.off('click', this.onClick);
44
+ this.zr.off('mousemove', this.onMouseMove);
45
+ this.removeGraphic();
46
+ }
47
+
48
+ protected onDestroy(): void {
49
+ this.removeGraphic();
50
+ }
51
+
52
+ private onClick = (params: any) => {
53
+ if (this.state === 'idle') {
54
+ this.state = 'drawing';
55
+ this.startPoint = this.getPoint(params);
56
+ this.endPoint = this.getPoint(params);
57
+ this.initGraphic();
58
+ this.updateGraphic();
59
+ } else if (this.state === 'drawing') {
60
+ this.state = 'finished';
61
+ this.endPoint = this.getPoint(params);
62
+
63
+ if (this.startPoint && this.endPoint) {
64
+ const start = this.context.coordinateConversion.pixelToData({
65
+ x: this.startPoint[0], y: this.startPoint[1],
66
+ });
67
+ const end = this.context.coordinateConversion.pixelToData({
68
+ x: this.endPoint[0], y: this.endPoint[1],
69
+ });
70
+
71
+ if (start && end) {
72
+ this.context.addDrawing({
73
+ id: `ray-${Date.now()}`,
74
+ type: 'ray',
75
+ points: [start, end],
76
+ paneIndex: start.paneIndex || 0,
77
+ style: { color: COLOR, lineWidth: 1 },
78
+ });
79
+ }
80
+ }
81
+
82
+ this.removeGraphic();
83
+ this.context.disableTools();
84
+ }
85
+ };
86
+
87
+ private onMouseMove = (params: any) => {
88
+ if (this.state !== 'drawing') return;
89
+ this.endPoint = this.getPoint(params);
90
+ this.updateGraphic();
91
+ };
92
+
93
+ private initGraphic(): void {
94
+ if (this.group) return;
95
+ this.group = new echarts.graphic.Group();
96
+ this.line = new echarts.graphic.Line({
97
+ shape: { x1: 0, y1: 0, x2: 0, y2: 0 },
98
+ style: { stroke: COLOR, lineWidth: 1 },
99
+ z: 100,
100
+ });
101
+ this.dashLine = new echarts.graphic.Line({
102
+ shape: { x1: 0, y1: 0, x2: 0, y2: 0 },
103
+ style: { stroke: COLOR, lineWidth: 1, lineDash: [4, 4], opacity: 0.5 },
104
+ z: 99,
105
+ });
106
+ this.startCircle = new echarts.graphic.Circle({
107
+ shape: { cx: 0, cy: 0, r: 4 },
108
+ style: { fill: '#fff', stroke: COLOR, lineWidth: 1 },
109
+ z: 101,
110
+ });
111
+ this.endCircle = new echarts.graphic.Circle({
112
+ shape: { cx: 0, cy: 0, r: 4 },
113
+ style: { fill: '#fff', stroke: COLOR, lineWidth: 1 },
114
+ z: 101,
115
+ });
116
+ this.group.add(this.dashLine);
117
+ this.group.add(this.line);
118
+ this.group.add(this.startCircle);
119
+ this.group.add(this.endCircle);
120
+ this.zr.add(this.group);
121
+ }
122
+
123
+ private removeGraphic(): void {
124
+ if (this.group) {
125
+ this.zr.remove(this.group);
126
+ this.group = null;
127
+ }
128
+ }
129
+
130
+ private updateGraphic(): void {
131
+ if (!this.startPoint || !this.endPoint || !this.group) return;
132
+ const [x1, y1] = this.startPoint;
133
+ const [x2, y2] = this.endPoint;
134
+ this.line.setShape({ x1, y1, x2, y2 });
135
+ this.startCircle.setShape({ cx: x1, cy: y1 });
136
+ this.endCircle.setShape({ cx: x2, cy: y2 });
137
+
138
+ // Dashed extension from p2 to chart edge
139
+ const [ex, ey] = this.extendToEdge(x1, y1, x2, y2);
140
+ this.dashLine.setShape({ x1: x2, y1: y2, x2: ex, y2: ey });
141
+ }
142
+
143
+ private extendToEdge(x1: number, y1: number, x2: number, y2: number): [number, number] {
144
+ const dx = x2 - x1;
145
+ const dy = y2 - y1;
146
+ if (dx === 0 && dy === 0) return [x2, y2];
147
+
148
+ const w = this.chart.getWidth();
149
+ const h = this.chart.getHeight();
150
+ let tMax = Infinity;
151
+ if (dx !== 0) {
152
+ const tx = dx > 0 ? (w - x1) / dx : -x1 / dx;
153
+ if (tx > 0) tMax = Math.min(tMax, tx);
154
+ }
155
+ if (dy !== 0) {
156
+ const ty = dy > 0 ? (h - y1) / dy : -y1 / dy;
157
+ if (ty > 0) tMax = Math.min(tMax, ty);
158
+ }
159
+ if (!isFinite(tMax)) tMax = 1;
160
+ return [x1 + tMax * dx, y1 + tMax * dy];
161
+ }
162
+ }
@@ -0,0 +1,2 @@
1
+ export { RayTool } from './RayTool';
2
+ export { RayDrawingRenderer } from './RayDrawingRenderer';
@@ -0,0 +1,106 @@
1
+ import { DrawingRenderer, DrawingRenderContext } from '../../types';
2
+
3
+ // Points: 0=start, 1=drive1, 2=correction1, 3=drive2, 4=correction2, 5=drive3, 6=end
4
+ const LABELS = ['0', 'D1', 'C1', 'D2', 'C2', 'D3', ''];
5
+ const LEG_COLORS = ['#2196f3', '#ff9800', '#4caf50', '#f44336', '#00bcd4', '#e91e63'];
6
+
7
+ export class ThreeDrivesPatternDrawingRenderer implements DrawingRenderer {
8
+ type = 'three_drives_pattern';
9
+
10
+ render(ctx: DrawingRenderContext): any {
11
+ const { drawing, pixelPoints, isSelected } = ctx;
12
+ const color = drawing.style?.color || '#3b82f6';
13
+ if (pixelPoints.length < 2) return;
14
+
15
+ const children: any[] = [];
16
+
17
+ // Fill drive regions
18
+ // Drive 1 zone (0,1,2)
19
+ if (pixelPoints.length >= 3) {
20
+ children.push({ type: 'polygon', name: 'line', shape: { points: pixelPoints.slice(0, 3).map(([x, y]) => [x, y]) }, style: { fill: 'rgba(33, 150, 243, 0.06)' } });
21
+ }
22
+ // Drive 2 zone (2,3,4)
23
+ if (pixelPoints.length >= 5) {
24
+ children.push({ type: 'polygon', name: 'line', shape: { points: pixelPoints.slice(2, 5).map(([x, y]) => [x, y]) }, style: { fill: 'rgba(76, 175, 80, 0.06)' } });
25
+ }
26
+ // Drive 3 zone (4,5,6)
27
+ if (pixelPoints.length >= 7) {
28
+ children.push({ type: 'polygon', name: 'line', shape: { points: pixelPoints.slice(4, 7).map(([x, y]) => [x, y]) }, style: { fill: 'rgba(0, 188, 212, 0.06)' } });
29
+ }
30
+
31
+ // Zigzag legs
32
+ for (let i = 0; i < pixelPoints.length - 1; i++) {
33
+ const [x1, y1] = pixelPoints[i];
34
+ const [x2, y2] = pixelPoints[i + 1];
35
+ children.push({
36
+ type: 'line', name: 'line',
37
+ shape: { x1, y1, x2, y2 },
38
+ style: { stroke: LEG_COLORS[i % LEG_COLORS.length], lineWidth: drawing.style?.lineWidth || 2 },
39
+ });
40
+ }
41
+
42
+ // Dashed lines connecting drives (1→3, 3→5) and corrections (2→4)
43
+ const connectors: [number, number][] = [[1, 3], [3, 5], [2, 4]];
44
+ for (const [from, to] of connectors) {
45
+ if (from < pixelPoints.length && to < pixelPoints.length) {
46
+ children.push({
47
+ type: 'line',
48
+ shape: { x1: pixelPoints[from][0], y1: pixelPoints[from][1], x2: pixelPoints[to][0], y2: pixelPoints[to][1] },
49
+ style: { stroke: '#555', lineWidth: 1, lineDash: [4, 4] },
50
+ silent: true,
51
+ });
52
+ }
53
+ }
54
+
55
+ // Ratios between drives
56
+ const pts = drawing.points;
57
+ // Drive2/Drive1
58
+ if (pts.length >= 4) {
59
+ const d1 = Math.abs(pts[1].value - pts[0].value);
60
+ const d2 = Math.abs(pts[3].value - pts[2].value);
61
+ if (d1 !== 0) {
62
+ const r = (d2 / d1).toFixed(3);
63
+ const mx = (pixelPoints[2][0] + pixelPoints[3][0]) / 2;
64
+ const my = (pixelPoints[2][1] + pixelPoints[3][1]) / 2;
65
+ children.push({ type: 'text', style: { text: `D2/D1: ${r}`, x: mx + 10, y: my, fill: '#4caf50', fontSize: 9 }, silent: true });
66
+ }
67
+ }
68
+ // Drive3/Drive2
69
+ if (pts.length >= 6) {
70
+ const d2 = Math.abs(pts[3].value - pts[2].value);
71
+ const d3 = Math.abs(pts[5].value - pts[4].value);
72
+ if (d2 !== 0) {
73
+ const r = (d3 / d2).toFixed(3);
74
+ const mx = (pixelPoints[4][0] + pixelPoints[5][0]) / 2;
75
+ const my = (pixelPoints[4][1] + pixelPoints[5][1]) / 2;
76
+ children.push({ type: 'text', style: { text: `D3/D2: ${r}`, x: mx + 10, y: my, fill: '#00bcd4', fontSize: 9 }, silent: true });
77
+ }
78
+ }
79
+ // Correction1/Drive1
80
+ if (pts.length >= 3) {
81
+ const d1 = Math.abs(pts[1].value - pts[0].value);
82
+ const c1 = Math.abs(pts[2].value - pts[1].value);
83
+ if (d1 !== 0) {
84
+ const r = (c1 / d1).toFixed(3);
85
+ const mx = (pixelPoints[1][0] + pixelPoints[2][0]) / 2;
86
+ const my = (pixelPoints[1][1] + pixelPoints[2][1]) / 2;
87
+ children.push({ type: 'text', style: { text: r, x: mx + 8, y: my, fill: '#ff9800', fontSize: 10 }, silent: true });
88
+ }
89
+ }
90
+
91
+ // Labels
92
+ for (let i = 0; i < pixelPoints.length && i < LABELS.length; i++) {
93
+ if (!LABELS[i]) continue;
94
+ const [px, py] = pixelPoints[i];
95
+ const isHigh = (i === 0 || py <= pixelPoints[i - 1][1]) && (i === pixelPoints.length - 1 || py <= pixelPoints[i + 1]?.[1]);
96
+ children.push({ type: 'text', style: { text: LABELS[i], x: px, y: isHigh ? py - 14 : py + 16, fill: '#e2e8f0', fontSize: 11, fontWeight: 'bold', align: 'center', verticalAlign: 'middle' }, silent: true });
97
+ }
98
+
99
+ // Control points
100
+ for (let i = 0; i < pixelPoints.length; i++) {
101
+ children.push({ type: 'circle', name: `point-${i}`, shape: { cx: pixelPoints[i][0], cy: pixelPoints[i][1], r: 4 }, style: { fill: '#fff', stroke: color, lineWidth: 1, opacity: isSelected ? 1 : 0 }, z: 100 });
102
+ }
103
+
104
+ return { type: 'group', children };
105
+ }
106
+ }
@@ -0,0 +1,98 @@
1
+ import * as echarts from 'echarts';
2
+ import { AbstractPlugin } from '../../components/AbstractPlugin';
3
+ import { ThreeDrivesPatternDrawingRenderer } from './ThreeDrivesPatternDrawingRenderer';
4
+
5
+ const LABELS = ['0', 'D1', 'C1', 'D2', 'C2', 'D3', ''];
6
+ const LEG_COLORS = ['#2196f3', '#ff9800', '#4caf50', '#f44336', '#00bcd4', '#e91e63'];
7
+ const TOTAL_POINTS = 7;
8
+
9
+ export class ThreeDrivesPatternTool extends AbstractPlugin {
10
+ private points: number[][] = [];
11
+ private state: 'idle' | 'drawing' | 'finished' = 'idle';
12
+ private graphicGroup: any = null;
13
+
14
+ constructor(options: { name?: string; icon?: string } = {}) {
15
+ super({
16
+ id: 'three-drives-pattern-tool',
17
+ name: options.name || 'Three Drives',
18
+ icon: options.icon || `<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e3e3e3" stroke-width="1.5"><polyline points="1,20 4,8 7,14 11,5 15,12 19,2 23,10"/></svg>`,
19
+ });
20
+ }
21
+
22
+ protected onInit(): void { this.context.registerDrawingRenderer(new ThreeDrivesPatternDrawingRenderer()); }
23
+
24
+ protected onActivate(): void {
25
+ this.state = 'idle'; this.points = [];
26
+ this.context.getChart().getZr().setCursorStyle('crosshair');
27
+ const zr = this.context.getChart().getZr();
28
+ zr.on('click', this.onClick); zr.on('mousemove', this.onMouseMove);
29
+ }
30
+
31
+ protected onDeactivate(): void {
32
+ this.state = 'idle'; this.points = []; this.removeGraphic();
33
+ const zr = this.context.getChart().getZr();
34
+ zr.off('click', this.onClick); zr.off('mousemove', this.onMouseMove);
35
+ zr.setCursorStyle('default');
36
+ }
37
+
38
+ private onClick = (params: any) => {
39
+ const pt = this.getPoint(params);
40
+ if (this.state === 'idle') {
41
+ this.state = 'drawing'; this.points = [pt, [...pt]]; this.initGraphic(); this.updateGraphic();
42
+ } else if (this.state === 'drawing') {
43
+ this.points[this.points.length - 1] = pt;
44
+ if (this.points.length >= TOTAL_POINTS) {
45
+ this.state = 'finished'; this.updateGraphic(); this.saveDrawing(); this.removeGraphic(); this.context.disableTools();
46
+ } else { this.points.push([...pt]); this.updateGraphic(); }
47
+ }
48
+ };
49
+
50
+ private onMouseMove = (params: any) => {
51
+ if (this.state !== 'drawing' || this.points.length < 2) return;
52
+ this.points[this.points.length - 1] = this.getPoint(params); this.updateGraphic();
53
+ };
54
+
55
+ private initGraphic() { this.graphicGroup = new echarts.graphic.Group(); this.context.getChart().getZr().add(this.graphicGroup); }
56
+ private removeGraphic() { if (this.graphicGroup) { this.context.getChart().getZr().remove(this.graphicGroup); this.graphicGroup = null; } }
57
+
58
+ private updateGraphic() {
59
+ if (!this.graphicGroup) return;
60
+ this.graphicGroup.removeAll();
61
+ const pts = this.points;
62
+
63
+ // Fills
64
+ if (pts.length >= 3) this.graphicGroup.add(new echarts.graphic.Polygon({ shape: { points: pts.slice(0, 3) }, style: { fill: 'rgba(33,150,243,0.06)' }, silent: true }));
65
+ if (pts.length >= 5) this.graphicGroup.add(new echarts.graphic.Polygon({ shape: { points: pts.slice(2, 5) }, style: { fill: 'rgba(76,175,80,0.06)' }, silent: true }));
66
+ if (pts.length >= 7) this.graphicGroup.add(new echarts.graphic.Polygon({ shape: { points: pts.slice(4, 7) }, style: { fill: 'rgba(0,188,212,0.06)' }, silent: true }));
67
+
68
+ // Zigzag
69
+ for (let i = 0; i < pts.length - 1; i++) {
70
+ this.graphicGroup.add(new echarts.graphic.Line({ shape: { x1: pts[i][0], y1: pts[i][1], x2: pts[i + 1][0], y2: pts[i + 1][1] }, style: { stroke: LEG_COLORS[i % LEG_COLORS.length], lineWidth: 2 }, silent: true }));
71
+ }
72
+
73
+ // Dashed connectors
74
+ const conn: [number, number][] = [[1, 3], [3, 5], [2, 4]];
75
+ for (const [f, t] of conn) {
76
+ if (f < pts.length && t < pts.length) {
77
+ this.graphicGroup.add(new echarts.graphic.Line({ shape: { x1: pts[f][0], y1: pts[f][1], x2: pts[t][0], y2: pts[t][1] }, style: { stroke: '#555', lineWidth: 1, lineDash: [4, 4] }, silent: true }));
78
+ }
79
+ }
80
+
81
+ // Labels & circles
82
+ for (let i = 0; i < pts.length && i < LABELS.length; i++) {
83
+ const [px, py] = pts[i];
84
+ const isHigh = (i === 0 || py <= pts[i - 1][1]) && (i === pts.length - 1 || py <= pts[i + 1]?.[1]);
85
+ if (LABELS[i]) {
86
+ this.graphicGroup.add(new echarts.graphic.Text({ style: { text: LABELS[i], x: px, y: isHigh ? py - 14 : py + 16, fill: '#e2e8f0', fontSize: 11, fontWeight: 'bold', align: 'center', verticalAlign: 'middle' }, silent: true }));
87
+ }
88
+ this.graphicGroup.add(new echarts.graphic.Circle({ shape: { cx: px, cy: py, r: 4 }, style: { fill: '#fff', stroke: '#3b82f6', lineWidth: 1.5 }, z: 101, silent: true }));
89
+ }
90
+ }
91
+
92
+ private saveDrawing() {
93
+ const dataPoints = this.points.map((pt) => this.context.coordinateConversion.pixelToData({ x: pt[0], y: pt[1] }));
94
+ if (dataPoints.every((p) => p !== null)) {
95
+ this.context.addDrawing({ id: `3drives-${Date.now()}`, type: 'three_drives_pattern', points: dataPoints as any[], paneIndex: dataPoints[0]!.paneIndex || 0, style: { color: '#3b82f6', lineWidth: 2 } });
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,2 @@
1
+ export { ThreeDrivesPatternTool } from './ThreeDrivesPatternTool';
2
+ export { ThreeDrivesPatternDrawingRenderer } from './ThreeDrivesPatternDrawingRenderer';
@@ -0,0 +1,211 @@
1
+ import { ChartContext, Plugin, PluginConfig } from '../types';
2
+ import { AbstractPlugin } from '../components/AbstractPlugin';
3
+
4
+ export interface ToolGroupConfig extends Omit<PluginConfig, 'id'> {
5
+ id?: string;
6
+ name: string;
7
+ icon?: string;
8
+ }
9
+
10
+ export class ToolGroup extends AbstractPlugin {
11
+ private plugins: Plugin[] = [];
12
+ private activeSubPlugin: Plugin | null = null;
13
+ private menuElement: HTMLElement | null = null;
14
+ private buttonElement: HTMLElement | null = null;
15
+ private originalIcon: string = '';
16
+ private arrowSvg: string = '';
17
+
18
+ constructor(config: ToolGroupConfig) {
19
+ // Create a small right-facing chevron arrow to indicate a dropdown menu
20
+ const arrowSvg = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position: absolute; right: -4px; top: 50%; transform: translateY(-50%); opacity: 0.6;"><polyline points="9 18 15 12 9 6"></polyline></svg>`;
21
+
22
+ let enhancedIcon = '';
23
+ if (config.icon) {
24
+ enhancedIcon = `<div style="position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
25
+ <div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
26
+ ${config.icon}
27
+ </div>
28
+ ${arrowSvg}
29
+ </div>`;
30
+ } else {
31
+ enhancedIcon = `<div style="position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
32
+ <span>${config.name.substring(0, 2).toUpperCase()}</span>
33
+ ${arrowSvg}
34
+ </div>`;
35
+ }
36
+
37
+ super({
38
+ id: config.id || `group-${config.name.toLowerCase().replace(/\s+/g, '-')}`,
39
+ name: config.name,
40
+ icon: enhancedIcon
41
+ });
42
+
43
+ this.originalIcon = enhancedIcon;
44
+ this.arrowSvg = arrowSvg;
45
+ }
46
+
47
+ public add(plugin: Plugin): void {
48
+ this.plugins.push(plugin);
49
+ }
50
+
51
+ protected onInit(): void {
52
+ this.plugins.forEach(p => p.init(this.context));
53
+ }
54
+
55
+ protected onActivate(): void {
56
+ this.showMenu();
57
+ }
58
+
59
+ protected onDeactivate(): void {
60
+ this.hideMenu();
61
+ if (this.activeSubPlugin) {
62
+ this.activeSubPlugin.deactivate?.();
63
+ this.activeSubPlugin = null;
64
+ }
65
+
66
+ // Restore original icon
67
+ if (this.buttonElement) {
68
+ this.buttonElement.innerHTML = this.originalIcon;
69
+ }
70
+ }
71
+
72
+ protected onDestroy(): void {
73
+ this.hideMenu();
74
+ this.plugins.forEach(p => p.destroy?.());
75
+ }
76
+
77
+ private showMenu(): void {
78
+ this.buttonElement = document.getElementById(`qfchart-plugin-btn-${this.id}`);
79
+ if (!this.buttonElement) return;
80
+
81
+ if (this.menuElement) {
82
+ this.hideMenu();
83
+ }
84
+
85
+ this.menuElement = document.createElement('div');
86
+ Object.assign(this.menuElement.style, {
87
+ position: 'fixed',
88
+ backgroundColor: '#1e293b',
89
+ border: '1px solid #334155',
90
+ borderRadius: '6px',
91
+ padding: '4px',
92
+ display: 'flex',
93
+ flexDirection: 'column',
94
+ gap: '2px',
95
+ zIndex: '10000',
96
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.3)',
97
+ minWidth: '150px'
98
+ });
99
+
100
+ this.plugins.forEach(plugin => {
101
+ const item = document.createElement('div');
102
+ Object.assign(item.style, {
103
+ display: 'flex',
104
+ alignItems: 'center',
105
+ padding: '8px 12px',
106
+ cursor: 'pointer',
107
+ color: '#cbd5e1',
108
+ borderRadius: '4px',
109
+ fontSize: '13px',
110
+ fontFamily: this.context.getOptions().fontFamily || 'sans-serif',
111
+ transition: 'background-color 0.2s'
112
+ });
113
+
114
+ item.addEventListener('mouseenter', () => {
115
+ item.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
116
+ });
117
+ item.addEventListener('mouseleave', () => {
118
+ item.style.backgroundColor = 'transparent';
119
+ });
120
+
121
+ if (plugin.icon) {
122
+ const iconContainer = document.createElement('div');
123
+ iconContainer.innerHTML = plugin.icon;
124
+ Object.assign(iconContainer.style, {
125
+ width: '20px',
126
+ height: '20px',
127
+ marginRight: '10px',
128
+ display: 'flex',
129
+ alignItems: 'center',
130
+ justifyContent: 'center'
131
+ });
132
+ const svg = iconContainer.querySelector('svg');
133
+ if (svg) {
134
+ svg.style.width = '100%';
135
+ svg.style.height = '100%';
136
+ }
137
+ item.appendChild(iconContainer);
138
+ }
139
+
140
+ const nameSpan = document.createElement('span');
141
+ nameSpan.textContent = plugin.name || plugin.id;
142
+ item.appendChild(nameSpan);
143
+
144
+ item.addEventListener('click', (e) => {
145
+ e.stopPropagation();
146
+ this.activateSubPlugin(plugin);
147
+ });
148
+
149
+ this.menuElement!.appendChild(item);
150
+ });
151
+
152
+ document.body.appendChild(this.menuElement);
153
+
154
+ const rect = this.buttonElement.getBoundingClientRect();
155
+ this.menuElement.style.top = `${rect.top}px`;
156
+ this.menuElement.style.left = `${rect.right + 5}px`;
157
+
158
+ // Delay attaching the outside click listener so it doesn't fire on the current click
159
+ setTimeout(() => {
160
+ document.addEventListener('click', this.handleOutsideClick);
161
+ }, 0);
162
+ }
163
+
164
+ private hideMenu(): void {
165
+ if (this.menuElement && this.menuElement.parentNode) {
166
+ this.menuElement.parentNode.removeChild(this.menuElement);
167
+ }
168
+ this.menuElement = null;
169
+ document.removeEventListener('click', this.handleOutsideClick);
170
+ }
171
+
172
+ private handleOutsideClick = (e: MouseEvent): void => {
173
+ if (this.menuElement && !this.menuElement.contains(e.target as Node)) {
174
+ this.hideMenu();
175
+ if (!this.activeSubPlugin) {
176
+ // If clicked outside and no sub-plugin is active, deactivate the group
177
+ this.buttonElement?.click();
178
+ }
179
+ }
180
+ };
181
+
182
+ private activateSubPlugin(plugin: Plugin): void {
183
+ this.hideMenu();
184
+
185
+ if (this.activeSubPlugin) {
186
+ this.activeSubPlugin.deactivate?.();
187
+ }
188
+
189
+ this.activeSubPlugin = plugin;
190
+ this.activeSubPlugin.activate?.();
191
+
192
+ // Update the group's button icon to match the active plugin
193
+ if (this.buttonElement) {
194
+ let subIcon = '';
195
+ if (plugin.icon) {
196
+ subIcon = `<div style="position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
197
+ <div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
198
+ ${plugin.icon}
199
+ </div>
200
+ ${this.arrowSvg}
201
+ </div>`;
202
+ } else {
203
+ subIcon = `<div style="position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
204
+ <span>${(plugin.name || plugin.id).substring(0, 2).toUpperCase()}</span>
205
+ ${this.arrowSvg}
206
+ </div>`;
207
+ }
208
+ this.buttonElement.innerHTML = subIcon;
209
+ }
210
+ }
211
+ }