@qfo/qfchart 0.7.3 → 0.8.1

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 (59) hide show
  1. package/dist/index.d.ts +368 -14
  2. package/dist/qfchart.min.browser.js +34 -16
  3. package/dist/qfchart.min.es.js +34 -16
  4. package/package.json +1 -1
  5. package/src/QFChart.ts +460 -311
  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 +284 -263
  10. package/src/components/LayoutManager.ts +72 -55
  11. package/src/components/SeriesBuilder.ts +110 -6
  12. package/src/components/TableCanvasRenderer.ts +467 -0
  13. package/src/components/TableOverlayRenderer.ts +38 -9
  14. package/src/components/TooltipFormatter.ts +97 -97
  15. package/src/components/renderers/BackgroundRenderer.ts +59 -47
  16. package/src/components/renderers/BoxRenderer.ts +113 -17
  17. package/src/components/renderers/FillRenderer.ts +118 -3
  18. package/src/components/renderers/LabelRenderer.ts +35 -9
  19. package/src/components/renderers/OHLCBarRenderer.ts +171 -161
  20. package/src/components/renderers/PolylineRenderer.ts +26 -19
  21. package/src/index.ts +17 -6
  22. package/src/plugins/ABCDPatternTool/ABCDPatternDrawingRenderer.ts +112 -0
  23. package/src/plugins/ABCDPatternTool/ABCDPatternTool.ts +136 -0
  24. package/src/plugins/ABCDPatternTool/index.ts +2 -0
  25. package/src/plugins/CypherPatternTool/CypherPatternDrawingRenderer.ts +80 -0
  26. package/src/plugins/CypherPatternTool/CypherPatternTool.ts +84 -0
  27. package/src/plugins/CypherPatternTool/index.ts +2 -0
  28. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanDrawingRenderer.ts +163 -0
  29. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanTool.ts +210 -0
  30. package/src/plugins/FibSpeedResistanceFanTool/index.ts +2 -0
  31. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionDrawingRenderer.ts +141 -0
  32. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionTool.ts +188 -0
  33. package/src/plugins/FibTrendExtensionTool/index.ts +2 -0
  34. package/src/plugins/FibonacciChannelTool/FibonacciChannelDrawingRenderer.ts +128 -0
  35. package/src/plugins/FibonacciChannelTool/FibonacciChannelTool.ts +231 -0
  36. package/src/plugins/FibonacciChannelTool/index.ts +2 -0
  37. package/src/plugins/FibonacciTool/FibonacciDrawingRenderer.ts +107 -0
  38. package/src/plugins/{FibonacciTool.ts → FibonacciTool/FibonacciTool.ts} +195 -192
  39. package/src/plugins/FibonacciTool/index.ts +2 -0
  40. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersDrawingRenderer.ts +95 -0
  41. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersTool.ts +97 -0
  42. package/src/plugins/HeadAndShouldersTool/index.ts +2 -0
  43. package/src/plugins/LineTool/LineDrawingRenderer.ts +49 -0
  44. package/src/plugins/{LineTool.ts → LineTool/LineTool.ts} +161 -190
  45. package/src/plugins/LineTool/index.ts +2 -0
  46. package/src/plugins/{MeasureTool.ts → MeasureTool/MeasureTool.ts} +324 -344
  47. package/src/plugins/MeasureTool/index.ts +1 -0
  48. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternDrawingRenderer.ts +106 -0
  49. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternTool.ts +98 -0
  50. package/src/plugins/ThreeDrivesPatternTool/index.ts +2 -0
  51. package/src/plugins/ToolGroup.ts +211 -0
  52. package/src/plugins/TrianglePatternTool/TrianglePatternDrawingRenderer.ts +107 -0
  53. package/src/plugins/TrianglePatternTool/TrianglePatternTool.ts +98 -0
  54. package/src/plugins/TrianglePatternTool/index.ts +2 -0
  55. package/src/plugins/XABCDPatternTool/XABCDPatternDrawingRenderer.ts +178 -0
  56. package/src/plugins/XABCDPatternTool/XABCDPatternTool.ts +213 -0
  57. package/src/plugins/XABCDPatternTool/index.ts +2 -0
  58. package/src/types.ts +39 -4
  59. package/src/utils/ColorUtils.ts +1 -1
@@ -0,0 +1,107 @@
1
+ import { DrawingRenderer, DrawingRenderContext } from '../../types';
2
+
3
+ const LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
4
+ const COLORS = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
5
+
6
+ export class FibonacciDrawingRenderer implements DrawingRenderer {
7
+ type = 'fibonacci';
8
+
9
+ render(ctx: DrawingRenderContext): any {
10
+ const { drawing, pixelPoints, isSelected } = ctx;
11
+ const [x1, y1] = pixelPoints[0];
12
+ const [x2, y2] = pixelPoints[1];
13
+ const color = drawing.style?.color || '#3b82f6';
14
+
15
+ const startX = Math.min(x1, x2);
16
+ const endX = Math.max(x1, x2);
17
+ const width = endX - startX;
18
+ const diffY = y2 - y1;
19
+
20
+ const startVal = drawing.points[0].value;
21
+ const endVal = drawing.points[1].value;
22
+ const valDiff = endVal - startVal;
23
+
24
+ const backgrounds: any[] = [];
25
+ const linesAndText: any[] = [];
26
+
27
+ LEVELS.forEach((level, index) => {
28
+ const levelY = y2 - diffY * level;
29
+ const levelColor = COLORS[index % COLORS.length];
30
+
31
+ linesAndText.push({
32
+ type: 'line',
33
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
34
+ style: { stroke: levelColor, lineWidth: 1 },
35
+ silent: true,
36
+ });
37
+
38
+ const price = endVal - valDiff * level;
39
+ linesAndText.push({
40
+ type: 'text',
41
+ style: {
42
+ text: `${level} (${price.toFixed(2)})`,
43
+ x: startX + 5,
44
+ y: levelY - 10,
45
+ fill: levelColor,
46
+ fontSize: 10,
47
+ },
48
+ silent: true,
49
+ });
50
+
51
+ if (index < LEVELS.length - 1) {
52
+ const nextLevel = LEVELS[index + 1];
53
+ const nextY = y2 - diffY * nextLevel;
54
+ const rectH = Math.abs(nextY - levelY);
55
+ const rectY = Math.min(levelY, nextY);
56
+
57
+ backgrounds.push({
58
+ type: 'rect',
59
+ name: 'line', // Enable dragging by clicking background
60
+ shape: { x: startX, y: rectY, width, height: rectH },
61
+ style: {
62
+ fill: COLORS[(index + 1) % COLORS.length],
63
+ opacity: 0.1,
64
+ },
65
+ });
66
+ }
67
+ });
68
+
69
+ return {
70
+ type: 'group',
71
+ children: [
72
+ ...backgrounds,
73
+ ...linesAndText,
74
+ {
75
+ type: 'line',
76
+ name: 'line',
77
+ shape: { x1, y1, x2, y2 },
78
+ style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
79
+ },
80
+ {
81
+ type: 'circle',
82
+ name: 'point-0',
83
+ shape: { cx: x1, cy: y1, r: 4 },
84
+ style: {
85
+ fill: '#fff',
86
+ stroke: color,
87
+ lineWidth: 1,
88
+ opacity: isSelected ? 1 : 0,
89
+ },
90
+ z: 100,
91
+ },
92
+ {
93
+ type: 'circle',
94
+ name: 'point-1',
95
+ shape: { cx: x2, cy: y2, r: 4 },
96
+ style: {
97
+ fill: '#fff',
98
+ stroke: color,
99
+ lineWidth: 1,
100
+ opacity: isSelected ? 1 : 0,
101
+ },
102
+ z: 100,
103
+ },
104
+ ],
105
+ };
106
+ }
107
+ }
@@ -1,192 +1,195 @@
1
- import * as echarts from "echarts";
2
- import { AbstractPlugin } from "../components/AbstractPlugin";
3
-
4
- export class FibonacciTool extends AbstractPlugin {
5
- private startPoint: number[] | null = null;
6
- private endPoint: number[] | null = null;
7
- private state: "idle" | "drawing" | "finished" = "idle";
8
-
9
- // Temporary ZRender elements
10
- private graphicGroup: any = null;
11
-
12
- // Fib levels config
13
- private readonly levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
14
- private readonly colors = [
15
- "#787b86", // 0
16
- "#f44336", // 0.236
17
- "#ff9800", // 0.382
18
- "#4caf50", // 0.5
19
- "#2196f3", // 0.618
20
- "#00bcd4", // 0.786
21
- "#787b86", // 1
22
- ];
23
-
24
- constructor(options: { name?: string; icon?: string } = {}) {
25
- super({
26
- id: "fibonacci-tool",
27
- name: options.name || "Fibonacci Retracement",
28
- icon:
29
- options.icon ||
30
- `<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M120-80v-80h720v80H120Zm0-240v-80h720v80H120Zm0-240v-80h720v80H120Zm0-240v-80h720v80H120Z"/></svg>`,
31
- });
32
- }
33
-
34
- public onActivate(): void {
35
- this.state = "idle";
36
- this.startPoint = null;
37
- this.endPoint = null;
38
- this.context.getChart().getZr().setCursorStyle("crosshair");
39
- this.bindEvents();
40
- }
41
-
42
- public onDeactivate(): void {
43
- this.state = "idle";
44
- this.startPoint = null;
45
- this.endPoint = null;
46
- this.removeGraphic();
47
- this.unbindEvents();
48
- this.context.getChart().getZr().setCursorStyle("default");
49
- }
50
-
51
- private bindEvents() {
52
- const zr = this.context.getChart().getZr();
53
- zr.on("click", this.onClick);
54
- zr.on("mousemove", this.onMouseMove);
55
- }
56
-
57
- private unbindEvents() {
58
- const zr = this.context.getChart().getZr();
59
- zr.off("click", this.onClick);
60
- zr.off("mousemove", this.onMouseMove);
61
- }
62
-
63
- private onClick = (params: any) => {
64
- if (this.state === "idle") {
65
- this.state = "drawing";
66
- this.startPoint = [params.offsetX, params.offsetY];
67
- this.endPoint = [params.offsetX, params.offsetY];
68
- this.initGraphic();
69
- this.updateGraphic();
70
- } else if (this.state === "drawing") {
71
- this.state = "finished";
72
- this.endPoint = [params.offsetX, params.offsetY];
73
- this.updateGraphic();
74
- this.saveDrawing();
75
-
76
- // Cleanup local graphic and deactivate
77
- this.removeGraphic();
78
- this.context.disableTools();
79
- }
80
- };
81
-
82
- private onMouseMove = (params: any) => {
83
- if (this.state === "drawing") {
84
- this.endPoint = [params.offsetX, params.offsetY];
85
- this.updateGraphic();
86
- }
87
- };
88
-
89
- private initGraphic() {
90
- this.graphicGroup = new echarts.graphic.Group();
91
- this.context.getChart().getZr().add(this.graphicGroup);
92
- }
93
-
94
- private removeGraphic() {
95
- if (this.graphicGroup) {
96
- this.context.getChart().getZr().remove(this.graphicGroup);
97
- this.graphicGroup = null;
98
- }
99
- }
100
-
101
- private updateGraphic() {
102
- if (!this.graphicGroup || !this.startPoint || !this.endPoint) return;
103
- this.graphicGroup.removeAll();
104
-
105
- const x1 = this.startPoint[0];
106
- const y1 = this.startPoint[1];
107
- const x2 = this.endPoint[0];
108
- const y2 = this.endPoint[1];
109
-
110
- // Diagonal trend line
111
- const trendLine = new echarts.graphic.Line({
112
- shape: { x1, y1, x2, y2 },
113
- style: {
114
- stroke: "#999",
115
- lineWidth: 1,
116
- lineDash: [4, 4],
117
- },
118
- silent: true,
119
- });
120
- this.graphicGroup.add(trendLine);
121
-
122
- // Levels
123
- const startX = Math.min(x1, x2);
124
- const endX = Math.max(x1, x2);
125
- const width = endX - startX;
126
-
127
- // Y range
128
- const diffY = y2 - y1; // Pixel difference
129
-
130
- this.levels.forEach((level, index) => {
131
- const levelY = y2 - diffY * level;
132
-
133
- const color = this.colors[index % this.colors.length];
134
-
135
- // Line
136
- const line = new echarts.graphic.Line({
137
- shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
138
- style: {
139
- stroke: color,
140
- lineWidth: 1,
141
- },
142
- silent: true,
143
- });
144
- this.graphicGroup.add(line);
145
-
146
- if (index < this.levels.length - 1) {
147
- const nextLevel = this.levels[index + 1];
148
- const nextY = y2 - diffY * nextLevel;
149
- const rectH = Math.abs(nextY - levelY);
150
- const rectY = Math.min(levelY, nextY);
151
-
152
- const rect = new echarts.graphic.Rect({
153
- shape: { x: startX, y: rectY, width, height: rectH },
154
- style: {
155
- fill: this.colors[(index + 1) % this.colors.length], // Use next level's color
156
- opacity: 0.1,
157
- },
158
- silent: true,
159
- });
160
- this.graphicGroup.add(rect);
161
- }
162
- });
163
- }
164
-
165
- private saveDrawing() {
166
- if (!this.startPoint || !this.endPoint) return;
167
-
168
- const start = this.context.coordinateConversion.pixelToData({
169
- x: this.startPoint[0],
170
- y: this.startPoint[1],
171
- });
172
- const end = this.context.coordinateConversion.pixelToData({
173
- x: this.endPoint[0],
174
- y: this.endPoint[1],
175
- });
176
-
177
- if (start && end) {
178
- const paneIndex = start.paneIndex || 0;
179
-
180
- this.context.addDrawing({
181
- id: `fib-${Date.now()}`,
182
- type: "fibonacci",
183
- points: [start, end],
184
- paneIndex: paneIndex,
185
- style: {
186
- color: "#3b82f6", // Default color, though individual lines use specific colors
187
- lineWidth: 1,
188
- },
189
- });
190
- }
191
- }
192
- }
1
+ import * as echarts from "echarts";
2
+ import { AbstractPlugin } from "../../components/AbstractPlugin";
3
+ import { FibonacciDrawingRenderer } from "./FibonacciDrawingRenderer";
4
+
5
+ export class FibonacciTool extends AbstractPlugin {
6
+ private startPoint: number[] | null = null;
7
+ private endPoint: number[] | null = null;
8
+ private state: "idle" | "drawing" | "finished" = "idle";
9
+
10
+ // Temporary ZRender elements
11
+ private graphicGroup: any = null;
12
+
13
+ // Fib levels config
14
+ private readonly levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
15
+ private readonly colors = [
16
+ "#787b86", // 0
17
+ "#f44336", // 0.236
18
+ "#ff9800", // 0.382
19
+ "#4caf50", // 0.5
20
+ "#2196f3", // 0.618
21
+ "#00bcd4", // 0.786
22
+ "#787b86", // 1
23
+ ];
24
+
25
+ constructor(options: { name?: string; icon?: string } = {}) {
26
+ super({
27
+ id: "fibonacci-tool",
28
+ name: options.name || "Fibonacci Retracement",
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="M120-80v-80h720v80H120Zm0-240v-80h720v80H120Zm0-240v-80h720v80H120Zm0-240v-80h720v80H120Z"/></svg>`,
32
+ });
33
+ }
34
+
35
+ protected onInit(): void {
36
+ this.context.registerDrawingRenderer(new FibonacciDrawingRenderer());
37
+ }
38
+
39
+ public onActivate(): void {
40
+ this.state = "idle";
41
+ this.startPoint = null;
42
+ this.endPoint = null;
43
+ this.context.getChart().getZr().setCursorStyle("crosshair");
44
+ this.bindEvents();
45
+ }
46
+
47
+ public onDeactivate(): void {
48
+ this.state = "idle";
49
+ this.startPoint = null;
50
+ this.endPoint = null;
51
+ this.removeGraphic();
52
+ this.unbindEvents();
53
+ this.context.getChart().getZr().setCursorStyle("default");
54
+ }
55
+
56
+ private bindEvents() {
57
+ const zr = this.context.getChart().getZr();
58
+ zr.on("click", this.onClick);
59
+ zr.on("mousemove", this.onMouseMove);
60
+ }
61
+
62
+ private unbindEvents() {
63
+ const zr = this.context.getChart().getZr();
64
+ zr.off("click", this.onClick);
65
+ zr.off("mousemove", this.onMouseMove);
66
+ }
67
+
68
+ private onClick = (params: any) => {
69
+ if (this.state === "idle") {
70
+ this.state = "drawing";
71
+ this.startPoint = this.getPoint(params);
72
+ this.endPoint = this.getPoint(params);
73
+ this.initGraphic();
74
+ this.updateGraphic();
75
+ } else if (this.state === "drawing") {
76
+ this.state = "finished";
77
+ this.endPoint = this.getPoint(params);
78
+ this.updateGraphic();
79
+ this.saveDrawing();
80
+
81
+ // Cleanup local graphic and deactivate
82
+ this.removeGraphic();
83
+ this.context.disableTools();
84
+ }
85
+ };
86
+
87
+ private onMouseMove = (params: any) => {
88
+ if (this.state === "drawing") {
89
+ this.endPoint = this.getPoint(params);
90
+ this.updateGraphic();
91
+ }
92
+ };
93
+
94
+ private initGraphic() {
95
+ this.graphicGroup = new echarts.graphic.Group();
96
+ this.context.getChart().getZr().add(this.graphicGroup);
97
+ }
98
+
99
+ private removeGraphic() {
100
+ if (this.graphicGroup) {
101
+ this.context.getChart().getZr().remove(this.graphicGroup);
102
+ this.graphicGroup = null;
103
+ }
104
+ }
105
+
106
+ private updateGraphic() {
107
+ if (!this.graphicGroup || !this.startPoint || !this.endPoint) return;
108
+ this.graphicGroup.removeAll();
109
+
110
+ const x1 = this.startPoint[0];
111
+ const y1 = this.startPoint[1];
112
+ const x2 = this.endPoint[0];
113
+ const y2 = this.endPoint[1];
114
+
115
+ // Diagonal trend line
116
+ const trendLine = new echarts.graphic.Line({
117
+ shape: { x1, y1, x2, y2 },
118
+ style: {
119
+ stroke: "#999",
120
+ lineWidth: 1,
121
+ lineDash: [4, 4],
122
+ },
123
+ silent: true,
124
+ });
125
+ this.graphicGroup.add(trendLine);
126
+
127
+ // Levels
128
+ const startX = Math.min(x1, x2);
129
+ const endX = Math.max(x1, x2);
130
+ const width = endX - startX;
131
+
132
+ const diffY = y2 - y1;
133
+
134
+ this.levels.forEach((level, index) => {
135
+ const levelY = y2 - diffY * level;
136
+
137
+ const color = this.colors[index % this.colors.length];
138
+
139
+ const line = new echarts.graphic.Line({
140
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
141
+ style: {
142
+ stroke: color,
143
+ lineWidth: 1,
144
+ },
145
+ silent: true,
146
+ });
147
+ this.graphicGroup.add(line);
148
+
149
+ if (index < this.levels.length - 1) {
150
+ const nextLevel = this.levels[index + 1];
151
+ const nextY = y2 - diffY * nextLevel;
152
+ const rectH = Math.abs(nextY - levelY);
153
+ const rectY = Math.min(levelY, nextY);
154
+
155
+ const rect = new echarts.graphic.Rect({
156
+ shape: { x: startX, y: rectY, width, height: rectH },
157
+ style: {
158
+ fill: this.colors[(index + 1) % this.colors.length],
159
+ opacity: 0.1,
160
+ },
161
+ silent: true,
162
+ });
163
+ this.graphicGroup.add(rect);
164
+ }
165
+ });
166
+ }
167
+
168
+ private saveDrawing() {
169
+ if (!this.startPoint || !this.endPoint) return;
170
+
171
+ const start = this.context.coordinateConversion.pixelToData({
172
+ x: this.startPoint[0],
173
+ y: this.startPoint[1],
174
+ });
175
+ const end = this.context.coordinateConversion.pixelToData({
176
+ x: this.endPoint[0],
177
+ y: this.endPoint[1],
178
+ });
179
+
180
+ if (start && end) {
181
+ const paneIndex = start.paneIndex || 0;
182
+
183
+ this.context.addDrawing({
184
+ id: `fib-${Date.now()}`,
185
+ type: "fibonacci",
186
+ points: [start, end],
187
+ paneIndex: paneIndex,
188
+ style: {
189
+ color: "#3b82f6",
190
+ lineWidth: 1,
191
+ },
192
+ });
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,2 @@
1
+ export { FibonacciTool } from './FibonacciTool';
2
+ export { FibonacciDrawingRenderer } from './FibonacciDrawingRenderer';
@@ -0,0 +1,95 @@
1
+ import { DrawingRenderer, DrawingRenderContext } from '../../types';
2
+
3
+ // Points: 0=left base, 1=left shoulder, 2=neckline left, 3=head, 4=neckline right, 5=right shoulder, 6=right base
4
+ const LABELS = ['', 'LS', '', 'H', '', 'RS', ''];
5
+
6
+ export class HeadAndShouldersDrawingRenderer implements DrawingRenderer {
7
+ type = 'head_and_shoulders';
8
+
9
+ render(ctx: DrawingRenderContext): any {
10
+ const { drawing, pixelPoints, isSelected } = ctx;
11
+ const color = drawing.style?.color || '#3b82f6';
12
+ if (pixelPoints.length < 2) return;
13
+
14
+ const children: any[] = [];
15
+
16
+ // Fill left shoulder region (points 0,1,2)
17
+ if (pixelPoints.length >= 3) {
18
+ 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)' } });
19
+ }
20
+ // Fill head region (points 2,3,4)
21
+ if (pixelPoints.length >= 5) {
22
+ children.push({ type: 'polygon', name: 'line', shape: { points: pixelPoints.slice(2, 5).map(([x, y]) => [x, y]) }, style: { fill: 'rgba(244, 67, 54, 0.08)' } });
23
+ }
24
+ // Fill right shoulder region (points 4,5,6)
25
+ if (pixelPoints.length >= 7) {
26
+ children.push({ type: 'polygon', name: 'line', shape: { points: pixelPoints.slice(4, 7).map(([x, y]) => [x, y]) }, style: { fill: 'rgba(33, 150, 243, 0.06)' } });
27
+ }
28
+
29
+ // Zigzag through all points
30
+ for (let i = 0; i < pixelPoints.length - 1; i++) {
31
+ const [x1, y1] = pixelPoints[i];
32
+ const [x2, y2] = pixelPoints[i + 1];
33
+ children.push({
34
+ type: 'line', name: 'line',
35
+ shape: { x1, y1, x2, y2 },
36
+ style: { stroke: '#2196f3', lineWidth: drawing.style?.lineWidth || 2 },
37
+ });
38
+ }
39
+
40
+ // Neckline: connect neckline-left (2) and neckline-right (4), extended
41
+ if (pixelPoints.length >= 5) {
42
+ const [nx1, ny1] = pixelPoints[2];
43
+ const [nx2, ny2] = pixelPoints[4];
44
+ const dx = nx2 - nx1;
45
+ const dy = ny2 - ny1;
46
+
47
+ // Extended neckline (0.3 beyond each side)
48
+ const extL = 0.3;
49
+ const extR = 0.3;
50
+ const exlx = nx1 - dx * extL;
51
+ const exly = ny1 - dy * extL;
52
+ const exrx = nx2 + dx * extR;
53
+ const exry = ny2 + dy * extR;
54
+
55
+ children.push({
56
+ type: 'line',
57
+ shape: { x1: exlx, y1: exly, x2: exrx, y2: exry },
58
+ style: { stroke: '#ff9800', lineWidth: 2, lineDash: [6, 4] },
59
+ silent: true,
60
+ });
61
+
62
+ // Neckline label
63
+ children.push({
64
+ type: 'text',
65
+ style: { text: 'Neckline', x: (nx1 + nx2) / 2, y: (ny1 + ny2) / 2 + 14, fill: '#ff9800', fontSize: 10, align: 'center' },
66
+ silent: true,
67
+ });
68
+ }
69
+
70
+ // Labels
71
+ for (let i = 0; i < pixelPoints.length && i < LABELS.length; i++) {
72
+ if (!LABELS[i]) continue;
73
+ const [px, py] = pixelPoints[i];
74
+ // Shoulders and head are peaks (above neighbors)
75
+ const isHigh = (i === 0 || py <= pixelPoints[i - 1][1]) && (i === pixelPoints.length - 1 || py <= pixelPoints[i + 1]?.[1]);
76
+ children.push({
77
+ type: 'text',
78
+ style: { text: LABELS[i], x: px, y: isHigh ? py - 14 : py + 16, fill: '#e2e8f0', fontSize: 12, fontWeight: 'bold', align: 'center', verticalAlign: 'middle' },
79
+ silent: true,
80
+ });
81
+ }
82
+
83
+ // Control points
84
+ for (let i = 0; i < pixelPoints.length; i++) {
85
+ children.push({
86
+ type: 'circle', name: `point-${i}`,
87
+ shape: { cx: pixelPoints[i][0], cy: pixelPoints[i][1], r: 4 },
88
+ style: { fill: '#fff', stroke: color, lineWidth: 1, opacity: isSelected ? 1 : 0 },
89
+ z: 100,
90
+ });
91
+ }
92
+
93
+ return { type: 'group', children };
94
+ }
95
+ }