jmgraph 3.2.25 → 3.2.27

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,4 +1,4 @@
1
- import {jmUtils} from "./jmUtils.js";
1
+ import {jmUtils, colorKeywords} from "./jmUtils.js";
2
2
  import {jmList} from "./jmList.js";
3
3
 
4
4
  /**
@@ -66,28 +66,65 @@ export default class jmGradient {
66
66
  d = location.radius * 2;
67
67
  }
68
68
  if(!d) {
69
- d = Math.min(location.width,location.height);
69
+ d = Math.min(location.width || 0, location.height || 0);
70
70
  }
71
+ if(d <= 0) {
72
+ d = Math.max(bounds.width || 0, bounds.height || 0, 100);
73
+ }
74
+
75
+ const width = bounds.width || d;
76
+ const height = bounds.height || d;
71
77
 
72
- //let offsetLine = 1;//渐变长度或半径
73
- //处理百分比参数
74
78
  if(jmUtils.checkPercent(x1)) {
75
- x1 = jmUtils.percentToNumber(x1) * (bounds.width || d);
79
+ x1 = jmUtils.percentToNumber(x1) * width;
80
+ }
81
+ else if(typeof x1 === 'number' && x1 >= 0 && x1 <= 1) {
82
+ x1 = x1 * width;
83
+ }
84
+ else if(typeof x1 === 'string') {
85
+ const num = parseFloat(x1);
86
+ if(!isNaN(num)) x1 = num;
76
87
  }
77
88
  if(jmUtils.checkPercent(x2)) {
78
- x2 = jmUtils.percentToNumber(x2) * (bounds.width || d);
89
+ x2 = jmUtils.percentToNumber(x2) * width;
90
+ }
91
+ else if(typeof x2 === 'number' && x2 >= 0 && x2 <= 1) {
92
+ x2 = x2 * width;
93
+ }
94
+ else if(typeof x2 === 'string') {
95
+ const num = parseFloat(x2);
96
+ if(!isNaN(num)) x2 = num;
79
97
  }
80
98
  if(jmUtils.checkPercent(y1)) {
81
- y1 = jmUtils.percentToNumber(y1) * (bounds.height || d);
99
+ y1 = jmUtils.percentToNumber(y1) * height;
100
+ }
101
+ else if(typeof y1 === 'number' && y1 >= 0 && y1 <= 1) {
102
+ y1 = y1 * height;
103
+ }
104
+ else if(typeof y1 === 'string') {
105
+ const num = parseFloat(y1);
106
+ if(!isNaN(num)) y1 = num;
82
107
  }
83
108
  if(jmUtils.checkPercent(y2)) {
84
- y2 = jmUtils.percentToNumber(y2) * (bounds.height || d);
85
- }
109
+ y2 = jmUtils.percentToNumber(y2) * height;
110
+ }
111
+ else if(typeof y2 === 'number' && y2 >= 0 && y2 <= 1) {
112
+ y2 = y2 * height;
113
+ }
114
+ else if(typeof y2 === 'string') {
115
+ const num = parseFloat(y2);
116
+ if(!isNaN(num)) y2 = num;
117
+ }
118
+
119
+ x1 = Number(x1) || 0;
120
+ y1 = Number(y1) || 0;
121
+ x2 = Number(x2) || 0;
122
+ y2 = Number(y2) || 0;
86
123
 
87
- let sx1 = Number(x1) + bounds.left;
88
- let sy1 = Number(y1) + bounds.top;
89
- let sx2 = Number(x2) + bounds.left;
90
- let sy2 = Number(y2) + bounds.top;
124
+ let sx1 = x1 + (bounds.left || 0);
125
+ let sy1 = y1 + (bounds.top || 0);
126
+ let sx2 = x2 + (bounds.left || 0);
127
+ let sy2 = y2 + (bounds.top || 0);
91
128
  if(this.type === 'linear') {
92
129
  if(control.mode === 'webgl' && control.webglControl) {
93
130
  // WebGL 着色器中 v_text_coord 是绝对坐标,需要传递绝对坐标
@@ -101,24 +138,47 @@ export default class jmGradient {
101
138
  else if(this.type === 'radial') {
102
139
  let r1 = this.r1||0;
103
140
  let r2 = this.r2;
141
+
142
+ if(d <= 0) {
143
+ d = Math.max(bounds.width || 0, bounds.height || 0, 1);
144
+ }
145
+
104
146
  if(jmUtils.checkPercent(r1)) {
105
147
  r1 = jmUtils.percentToNumber(r1);
106
148
  r1 = d * r1;
107
149
  }
150
+ else if(typeof r1 === 'number' && r1 >= 0 && r1 <= 1) {
151
+ r1 = r1 * d;
152
+ }
153
+ else if(typeof r1 === 'string') {
154
+ r1 = parseFloat(r1) || 0;
155
+ }
156
+
108
157
  if(jmUtils.checkPercent(r2)) {
109
158
  r2 = jmUtils.percentToNumber(r2);
110
159
  r2 = d * r2;
111
160
  }
161
+ else if(typeof r2 === 'number' && r2 >= 0 && r2 <= 1) {
162
+ r2 = r2 * d;
163
+ }
164
+ else if(typeof r2 === 'string') {
165
+ r2 = parseFloat(r2);
166
+ }
167
+
168
+ if(r2 === undefined || r2 === null || isNaN(Number(r2)) || Number(r2) <= 0) {
169
+ r2 = d / 2;
170
+ }
171
+
172
+ r1 = Number(r1) || 0;
173
+ r2 = Number(r2) || d / 2;
174
+
112
175
  if(control.mode === 'webgl' && control.webglControl) {
113
- // WebGL 着色器中 v_text_coord 是绝对坐标,需要传递绝对坐标
114
176
  gradient = control.webglControl.createRadialGradient(sx1, sy1, r1, sx2, sy2, r2, bounds);
115
177
  gradient.key = this.toString();
116
178
  }
117
- //offsetLine = Math.abs(r2 - r1);//二圆半径差
118
179
  else if(context.createRadialGradient) {
119
180
  gradient = context.createRadialGradient(sx1, sy1, r1, sx2, sy2, r2);
120
181
  }
121
- //小程序的接口特殊
122
182
  else if(context.createCircularGradient) {
123
183
  gradient = context.createCircularGradient(sx1, sy1, r2);
124
184
  }
@@ -141,67 +201,539 @@ export default class jmGradient {
141
201
  }
142
202
 
143
203
  /**
144
- * 变换为字条串格式
145
- * linear-gradient(x1 y1 x2 y2, color1 step, color2 step, ...); //radial-gradient(x1 y1 r1 x2 y2 r2, color1 step,color2 step, ...);
146
- * linear-gradient线性渐变,x1 y1表示起点,x2 y2表示结束点,color表颜色,step为当前颜色偏移
147
- * radial-gradient径向渐变,x1 y1 r1分别表示内圆中心和半径,x2 y2 r2为结束圆 中心和半径,颜色例似线性渐变 step为0-1之间
204
+ * 解析渐变字符串
205
+ * 支持的格式:
206
+ * - linear-gradient(180deg, #8b5cf6 0%, #6366f1 50%, #4f46e5 100%)
207
+ * - linear-gradient(to top, red, blue)
208
+ * - linear-gradient(to right bottom, red, blue)
209
+ * - linear-gradient(45deg, red, blue)
210
+ * - linear-gradient(0.5turn, red, blue)
211
+ * - radial-gradient(circle, red, blue)
212
+ * - radial-gradient(ellipse at top, red, blue)
148
213
  *
149
214
  * @method fromString
150
215
  * @for jmGradient
151
- * @return {string}
216
+ * @param {string} s 渐变字符串
152
217
  */
153
218
  fromString(s) {
154
- if(!s) return;
155
- let ms = s.match(/(linear|radial)-gradient\s*\(\s*([^,]+)\s*,\s*((.|\s)+)\)/i);
156
- if(!ms || ms.length < 3) return;
157
- this.type = ms[1].toLowerCase();
158
-
159
- const ps = jmUtils.trim(ms[2]).split(/\s+/);
160
- //线性渐变
161
- if(this.type == 'linear') {
162
- if(ps.length <= 2) {
163
- this.x2 = ps[0];
164
- this.y2 = ps[1]||0;
219
+ if(!s) {
220
+ console.warn('jmGradient: 渐变字符串为空');
221
+ return;
222
+ }
223
+ // 使用 [\s\S] 匹配任意字符(包括换行符),支持多行渐变字符串
224
+ const gradientMatch = s.match(/(linear|radial)-gradient\s*\(\s*([\s\S]+)\)/i);
225
+ if(!gradientMatch || gradientMatch.length < 3) {
226
+ console.warn('jmGradient: 无效的渐变字符串格式: "' + s + '"');
227
+ return;
228
+ }
229
+
230
+ const type = gradientMatch[1].toLowerCase();
231
+ if(type !== 'linear' && type !== 'radial') {
232
+ console.warn('jmGradient: 不支持的渐变类型 "' + type + '",仅支持 linear 和 radial');
233
+ return;
234
+ }
235
+
236
+ this.type = type;
237
+ const content = jmUtils.trim(gradientMatch[2]);
238
+
239
+ const splitIndex = this._findSplitIndex(content);
240
+ if(splitIndex < 0) {
241
+ console.warn('jmGradient: 无法解析渐变内容: "' + content + '"');
242
+ return;
243
+ }
244
+
245
+ const params = content.substring(0, splitIndex).trim();
246
+ const colorPart = content.substring(splitIndex + 1).trim();
247
+
248
+ if(!colorPart) {
249
+ console.warn('jmGradient: 未找到颜色停止点');
250
+ return;
251
+ }
252
+
253
+ if(this.type === 'linear') {
254
+ this._parseLinearParams(params);
255
+ }
256
+ else {
257
+ this._parseRadialParams(params);
258
+ }
259
+
260
+ const colorCount = this._parseColorStops(colorPart);
261
+ if(colorCount === 0) {
262
+ console.warn('jmGradient: 未找到有效的颜色停止点: "' + colorPart + '"');
263
+ }
264
+ else if(colorCount < 2) {
265
+ console.warn('jmGradient: 颜色停止点至少需要2个,当前只有 ' + colorCount + ' 个');
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 找到参数和颜色的分割位置(第一个不在括号内的逗号)
271
+ * @param {string} content 内容字符串
272
+ * @returns {number} 分割位置索引
273
+ */
274
+ _findSplitIndex(content) {
275
+ let depth = 0;
276
+
277
+ for(let i = 0; i < content.length; i++) {
278
+ const char = content[i];
279
+ if(char === '(') {
280
+ depth++;
165
281
  }
166
- else {
167
- this.x1 = ps[0];
168
- this.y1 = ps[1];
169
- this.x2 = ps[2];
170
- this.y2 = ps[3];
282
+ else if(char === ')') {
283
+ depth--;
284
+ }
285
+ else if(char === ',' && depth === 0) {
286
+ return i;
287
+ }
288
+ }
289
+
290
+ return -1;
291
+ }
292
+
293
+ /**
294
+ * 验证渐变配置是否有效
295
+ * @returns {boolean} 是否有效
296
+ */
297
+ isValid() {
298
+ if(!this.type) return false;
299
+ if(this.type === 'linear') {
300
+ return typeof this.x1 !== 'undefined' ||
301
+ typeof this.x2 !== 'undefined' ||
302
+ typeof this._angle !== 'undefined';
303
+ }
304
+ if(this.type === 'radial') {
305
+ return this.stops && this.stops.length >= 2;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ /**
311
+ * 解析线性渐变参数
312
+ * @param {string} params 参数字符串
313
+ */
314
+ _parseLinearParams(params) {
315
+ const trimmed = jmUtils.trim(params);
316
+
317
+ if(trimmed.startsWith('to ')) {
318
+ const direction = trimmed.substring(3).toLowerCase().trim();
319
+ const dir = this._directionToAngle(direction);
320
+ this._angle = dir.angle;
321
+ this.x1 = dir.x1;
322
+ this.y1 = dir.y1;
323
+ this.x2 = dir.x2;
324
+ this.y2 = dir.y2;
325
+ }
326
+ else if(this._hasAngleUnit(trimmed)) {
327
+ const angle = this._parseAngle(trimmed);
328
+ this._angle = angle;
329
+ const coords = this._angleToCoords(angle);
330
+ this.x1 = coords.x1;
331
+ this.y1 = coords.y1;
332
+ this.x2 = coords.x2;
333
+ this.y2 = coords.y2;
334
+ }
335
+ else if(trimmed.startsWith('at ')) {
336
+ const radialMatch = trimmed.match(/at\s+(.+)/i);
337
+ if(radialMatch) {
338
+ this._parseRadialParams(trimmed);
171
339
  }
172
340
  }
173
- //径向渐变
174
341
  else {
175
- if(ps.length <= 3) {
176
- this.x2 = ps[0];
177
- this.y2 = ps[1]||0;
178
- this.r2 = ps[2]||0;
342
+ const parts = trimmed.split(/\s+/);
343
+ if(parts.length >= 4) {
344
+ this.x1 = parts[0];
345
+ this.y1 = parts[1];
346
+ this.x2 = parts[2];
347
+ this.y2 = parts[3];
179
348
  }
180
- else {
181
- this.x1 = ps[0];
182
- this.y1 = ps[1];
183
- this.r1 = ps[2];
184
- this.x2 = ps[3];
185
- this.y2 = ps[3];
186
- this.r2 = ps[3];
187
- }
188
- }
189
- //解析颜色偏移
190
- //color step
191
- const pars = ms[3].match(/((rgb(a)?\s*\([\d,\.\s]+\))|(#[a-zA-Z\d]+))\s+([\d\.]+)/ig);
192
- if(pars && pars.length) {
193
- for(let i=0;i<pars.length;i++) {
194
- const par = jmUtils.trim(pars[i]);
195
- const spindex = par.lastIndexOf(' ');
196
- if(spindex > -1) {
197
- const offset = Number(par.substr(spindex + 1));
198
- const color = jmUtils.trim(par.substr(0, spindex));
199
- if(!isNaN(offset) && color) {
200
- this.addStop(offset, color);
349
+ else if(parts.length === 2) {
350
+ this.x1 = 0;
351
+ this.y1 = 0;
352
+ this.x2 = parts[0];
353
+ this.y2 = parts[1];
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 解析径向渐变参数
360
+ * @param {string} params 参数字符串
361
+ */
362
+ _parseRadialParams(params) {
363
+ const trimmed = jmUtils.trim(params);
364
+
365
+ this.shape = 'ellipse';
366
+ this.position = { x: '50%', y: '50%' };
367
+
368
+ const atMatch = trimmed.match(/^(.+?)\s+at\s+(.+)$/i);
369
+ if(atMatch) {
370
+ const shapePart = jmUtils.trim(atMatch[1]);
371
+ const posPart = jmUtils.trim(atMatch[2]);
372
+ this._parseRadialShape(shapePart);
373
+ this._parseRadialPosition(posPart);
374
+ }
375
+ else if(trimmed.startsWith('circle') || trimmed.startsWith('ellipse')) {
376
+ this._parseRadialShape(trimmed);
377
+ this.x1 = '50%';
378
+ this.y1 = '50%';
379
+ this.x2 = '50%';
380
+ this.y2 = '50%';
381
+ }
382
+ else {
383
+ const parts = trimmed.split(/\s+/);
384
+ if(parts.length >= 3) {
385
+ this.x1 = parts[0];
386
+ this.y1 = parts[1];
387
+ this.r1 = parts[2];
388
+ }
389
+ if(parts.length >= 6) {
390
+ this.x2 = parts[3];
391
+ this.y2 = parts[4];
392
+ this.r2 = parts[5];
393
+ }
394
+ }
395
+
396
+ if(this.x1 === undefined && this.y1 === undefined) {
397
+ this.x1 = '50%';
398
+ this.y1 = '50%';
399
+ }
400
+ if(this.x2 === undefined && this.y2 === undefined) {
401
+ this.x2 = '50%';
402
+ this.y2 = '50%';
403
+ }
404
+ if(this.r2 === undefined) {
405
+ this.r2 = '50%';
406
+ }
407
+ }
408
+
409
+ /**
410
+ * 解析径向渐变形状
411
+ * @param {string} shapePart 形状描述
412
+ */
413
+ _parseRadialShape(shapePart) {
414
+ if(shapePart.startsWith('circle')) {
415
+ this.shape = 'circle';
416
+ const sizeMatch = shapePart.match(/circle\s*\(\s*([^)]+)\s*\)/i);
417
+ if(sizeMatch) {
418
+ this.r2 = jmUtils.trim(sizeMatch[1]);
419
+ }
420
+ }
421
+ else if(shapePart.startsWith('ellipse')) {
422
+ this.shape = 'ellipse';
423
+ const sizeMatch = shapePart.match(/ellipse\s*\(\s*([^)]+)\s*\)/i);
424
+ if(sizeMatch) {
425
+ const sizes = jmUtils.trim(sizeMatch[1]).split(/\s+/);
426
+ if(sizes.length >= 2) {
427
+ this.rx = sizes[0];
428
+ this.ry = sizes[1];
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ /**
435
+ * 解析径向渐变位置
436
+ * @param {string} posPart 位置描述
437
+ */
438
+ _parseRadialPosition(posPart) {
439
+ const parts = posPart.split(/\s+/);
440
+ if(parts.length >= 2) {
441
+ this.x1 = parts[0];
442
+ this.y1 = parts[1];
443
+ this.x2 = parts[0];
444
+ this.y2 = parts[1];
445
+ }
446
+ }
447
+
448
+ /**
449
+ * 解析颜色停止点
450
+ * @param {string} colorPart 颜色部分字符串
451
+ * @returns {number} 成功解析的颜色数量
452
+ */
453
+ _parseColorStops(colorPart) {
454
+ if(!colorPart) {
455
+ return 0;
456
+ }
457
+
458
+ const stops = this._splitColorStops(colorPart);
459
+ let lastOffset = -1;
460
+ let colorCount = 0;
461
+
462
+ for(let i = 0; i < stops.length; i++) {
463
+ const stop = jmUtils.trim(stops[i]);
464
+ if(!stop) continue;
465
+
466
+ const parsed = this._parseSingleColorStop(stop);
467
+ if(!parsed) {
468
+ continue;
469
+ }
470
+
471
+ let { color, offset } = parsed;
472
+
473
+ if(color === 'transparent') {
474
+ color = 'rgba(0,0,0,0)';
475
+ }
476
+
477
+ if(!this._isValidColor(color)) {
478
+ console.warn('jmGradient: 无效的颜色格式 "' + color + '"');
479
+ continue;
480
+ }
481
+
482
+ const normalizedOffset = this._normalizeOffset(offset);
483
+ let finalOffset = normalizedOffset;
484
+
485
+ if(finalOffset === null) {
486
+ if(i === 0) {
487
+ finalOffset = 0;
488
+ }
489
+ else if(i === stops.length - 1) {
490
+ finalOffset = 1;
491
+ }
492
+ else {
493
+ const nextOffset = this._findNextOffset(stops, i);
494
+ if(nextOffset !== null) {
495
+ finalOffset = (lastOffset + nextOffset) / 2;
496
+ }
497
+ else {
498
+ finalOffset = Math.min(1, lastOffset + (1 - lastOffset) / (stops.length - i));
201
499
  }
202
500
  }
203
501
  }
502
+
503
+ if(finalOffset !== null) {
504
+ if(finalOffset < 0 || finalOffset > 1) {
505
+ console.warn('jmGradient: 颜色偏移量 ' + finalOffset + ' 超出有效范围 [0, 1],将调整为有效范围');
506
+ finalOffset = Math.max(0, Math.min(1, finalOffset));
507
+ }
508
+ lastOffset = finalOffset;
509
+ this.addStop(finalOffset, color);
510
+ colorCount++;
511
+ }
204
512
  }
513
+
514
+ return colorCount;
515
+ }
516
+
517
+ /**
518
+ * 分割颜色停止点字符串
519
+ * @param {string} colorPart 颜色部分字符串
520
+ * @returns {string[]} 颜色停止点数组
521
+ */
522
+ _splitColorStops(colorPart) {
523
+ const stops = [];
524
+ let depth = 0;
525
+ let current = '';
526
+
527
+ for(let i = 0; i < colorPart.length; i++) {
528
+ const char = colorPart[i];
529
+ if(char === '(') {
530
+ depth++;
531
+ current += char;
532
+ }
533
+ else if(char === ')') {
534
+ depth--;
535
+ current += char;
536
+ }
537
+ else if(char === ',' && depth === 0) {
538
+ stops.push(current.trim());
539
+ current = '';
540
+ }
541
+ else {
542
+ current += char;
543
+ }
544
+ }
545
+
546
+ if(current.trim()) {
547
+ stops.push(current.trim());
548
+ }
549
+
550
+ return stops;
551
+ }
552
+
553
+ /**
554
+ * 解析单个颜色停止点
555
+ * @param {string} stop 单个颜色停止点字符串
556
+ * @returns {object|null} {color, offset} 或 null
557
+ */
558
+ _parseSingleColorStop(stop) {
559
+ const hexMatch = stop.match(/^(#[a-fA-F0-9]{3,8})\s*(\d+(?:\.\d+)?%?)?$/i);
560
+ if(hexMatch) {
561
+ return { color: hexMatch[1], offset: hexMatch[2] || null };
562
+ }
563
+
564
+ const rgbaMatch = stop.match(/^(rgba?\s*\([^)]+\))\s*(\d+(?:\.\d+)?%?)?$/i);
565
+ if(rgbaMatch) {
566
+ return { color: rgbaMatch[1], offset: rgbaMatch[2] || null };
567
+ }
568
+
569
+ const hslaMatch = stop.match(/^(hsla?\s*\([^)]+\))\s*(\d+(?:\.\d+)?%?)?$/i);
570
+ if(hslaMatch) {
571
+ return { color: hslaMatch[1], offset: hslaMatch[2] || null };
572
+ }
573
+
574
+ const namedMatch = stop.match(/^([a-zA-Z]+)\s*(\d+(?:\.\d+)?%?)?$/i);
575
+ if(namedMatch && this._isValidColor(namedMatch[1])) {
576
+ return { color: namedMatch[1], offset: namedMatch[2] || null };
577
+ }
578
+
579
+ return null;
580
+ }
581
+
582
+ /**
583
+ * 查找下一个有偏移量的颜色停止点
584
+ * @param {string[]} stops 颜色停止点数组
585
+ * @param {number} currentIndex 当前索引
586
+ * @returns {number|null} 下一个偏移量或null
587
+ */
588
+ _findNextOffset(stops, currentIndex) {
589
+ for(let i = currentIndex + 1; i < stops.length; i++) {
590
+ const parsed = this._parseSingleColorStop(jmUtils.trim(stops[i]));
591
+ if(parsed && parsed.offset) {
592
+ return this._normalizeOffset(parsed.offset);
593
+ }
594
+ }
595
+ return null;
596
+ }
597
+
598
+ /**
599
+ * 验证颜色格式是否有效
600
+ * @param {string} color 颜色字符串
601
+ * @returns {boolean} 是否有效
602
+ */
603
+ _isValidColor(color) {
604
+ if(!color) return false;
605
+
606
+ const hexPattern = /^#([a-fA-F0-9]{3,8})$/;
607
+ if(hexPattern.test(color)) return true;
608
+
609
+ // 支持 rgba(r,g,b,a) 和 rgba(r, g, b, a) 等各种空格格式
610
+ const rgbPattern = /^rgba?\s*\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*[\d.]+\s*)?\)$/i;
611
+ if(rgbPattern.test(color)) return true;
612
+
613
+ const hslPattern = /^hsla?\s*\(\s*\d{1,3}\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*(,\s*[\d.]+\s*)?\)$/i;
614
+ if(hslPattern.test(color)) return true;
615
+
616
+ // 使用 jmUtils 中的完整 CSS 颜色关键字表
617
+ if(colorKeywords && colorKeywords[color.toLowerCase()]) return true;
618
+
619
+ // 宽松处理:符合 CSS 关键字命名规则的字符串也视为有效颜色
620
+ // (纯字母,可能在运行时被浏览器或其他环境解析)
621
+ if(/^[a-zA-Z]+$/.test(color)) return true;
622
+
623
+ return false;
624
+ }
625
+
626
+ /**
627
+ * 标准化偏移值
628
+ * @param {string} offset 偏移字符串
629
+ * @returns {number|null} 0-1之间的数值或null
630
+ */
631
+ _normalizeOffset(offset) {
632
+ if(!offset) return null;
633
+ offset = jmUtils.trim(offset);
634
+ if(offset.endsWith('%')) {
635
+ return parseFloat(offset) / 100;
636
+ }
637
+ const num = parseFloat(offset);
638
+ if(isNaN(num)) return null;
639
+ if(num > 1) {
640
+ return num / 100;
641
+ }
642
+ return num;
643
+ }
644
+
645
+ /**
646
+ * 检查字符串是否包含角度单位
647
+ * @param {string} str 待检查字符串
648
+ * @returns {boolean}
649
+ */
650
+ _hasAngleUnit(str) {
651
+ return /^-?\d+(\.\d+)?\s*(deg|rad|grad|turn)$/i.test(str);
652
+ }
653
+
654
+ /**
655
+ * 解析角度值
656
+ * @param {string} angleStr 角度字符串
657
+ * @returns {number} 弧度值
658
+ */
659
+ _parseAngle(angleStr) {
660
+ angleStr = jmUtils.trim(angleStr);
661
+ const match = angleStr.match(/^(-?\d+(\.\d+)?)\s*(deg|rad|grad|turn)?$/i);
662
+ if(!match) return 0;
663
+
664
+ let value = parseFloat(match[1]);
665
+ const unit = (match[3] || 'deg').toLowerCase();
666
+
667
+ switch(unit) {
668
+ case 'deg':
669
+ return value * Math.PI / 180;
670
+ case 'rad':
671
+ return value;
672
+ case 'grad':
673
+ return value * Math.PI / 200;
674
+ case 'turn':
675
+ return value * 2 * Math.PI;
676
+ default:
677
+ return value * Math.PI / 180;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * 将角度转换为起点和终点坐标
683
+ * @param {number} angle 弧度值
684
+ * @returns {object} 坐标对象
685
+ */
686
+ _angleToCoords(angle) {
687
+ const x = Math.cos(angle);
688
+ const y = -Math.sin(angle);
689
+
690
+ return {
691
+ x1: Math.round((0.5 - x * 0.5) * 1000) / 1000,
692
+ y1: Math.round((0.5 + y * 0.5) * 1000) / 1000,
693
+ x2: Math.round((0.5 + x * 0.5) * 1000) / 1000,
694
+ y2: Math.round((0.5 - y * 0.5) * 1000) / 1000
695
+ };
696
+ }
697
+
698
+ /**
699
+ * 将方向关键词转换为角度和坐标
700
+ * @param {string} direction 方向描述
701
+ * @returns {object} 包含angle和坐标的对象
702
+ */
703
+ _directionToAngle(direction) {
704
+ const directions = {
705
+ 'to top': { angle: 0, x1: '50%', y1: '100%', x2: '50%', y2: '0%' },
706
+ 'to bottom': { angle: Math.PI, x1: '50%', y1: '0%', x2: '50%', y2: '100%' },
707
+ 'to left': { angle: -Math.PI / 2, x1: '100%', y1: '50%', x2: '0%', y2: '50%' },
708
+ 'to right': { angle: Math.PI / 2, x1: '0%', y1: '50%', x2: '100%', y2: '50%' },
709
+ 'to top left': { angle: -Math.PI * 3 / 4, x1: '100%', y1: '100%', x2: '0%', y2: '0%' },
710
+ 'to top right': { angle: -Math.PI / 4, x1: '0%', y1: '100%', x2: '100%', y2: '0%' },
711
+ 'to bottom left': { angle: Math.PI * 3 / 4, x1: '100%', y1: '0%', x2: '0%', y2: '100%' },
712
+ 'to bottom right': { angle: Math.PI / 4, x1: '0%', y1: '0%', x2: '100%', y2: '100%' },
713
+ 'top': { angle: 0, x1: '50%', y1: '100%', x2: '50%', y2: '0%' },
714
+ 'bottom': { angle: Math.PI, x1: '50%', y1: '0%', x2: '50%', y2: '100%' },
715
+ 'left': { angle: -Math.PI / 2, x1: '100%', y1: '50%', x2: '0%', y2: '50%' },
716
+ 'right': { angle: Math.PI / 2, x1: '0%', y1: '50%', x2: '100%', y2: '50%' }
717
+ };
718
+
719
+ const dir = directions[direction];
720
+ if(dir) {
721
+ return dir;
722
+ }
723
+
724
+ const keywordMatch = direction.match(/to\s+(top|bottom|left|right)/i);
725
+ if(keywordMatch) {
726
+ const mainDir = keywordMatch[1].toLowerCase();
727
+ const secDir = direction.replace(keywordMatch[0], '').trim();
728
+ if(secDir) {
729
+ const combined = `to ${mainDir} ${secDir}`;
730
+ if(directions[combined]) {
731
+ return directions[combined];
732
+ }
733
+ }
734
+ }
735
+
736
+ return { angle: 0, x1: '50%', y1: '100%', x2: '50%', y2: '0%' };
205
737
  }
206
738
 
207
739
  /**