@emasoft/svg-matrix 1.0.4 → 1.0.6

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,1006 @@
1
+ /**
2
+ * Marker Resolver Module - Resolve and apply SVG markers to paths
3
+ *
4
+ * Resolves SVG marker elements and applies them to path endpoints and vertices.
5
+ * Markers are symbols placed at vertices of paths, lines, polylines, and polygons.
6
+ *
7
+ * Supports:
8
+ * - marker-start, marker-mid, marker-end attributes
9
+ * - markerWidth, markerHeight, refX, refY
10
+ * - orient (auto, auto-start-reverse, fixed angles)
11
+ * - markerUnits (strokeWidth, userSpaceOnUse)
12
+ * - viewBox and preserveAspectRatio
13
+ * - Transform calculation and application
14
+ *
15
+ * @module marker-resolver
16
+ */
17
+
18
+ import Decimal from 'decimal.js';
19
+ import { Matrix } from './matrix.js';
20
+ import * as Transforms2D from './transforms2d.js';
21
+
22
+ Decimal.set({ precision: 80 });
23
+
24
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
25
+
26
+ /**
27
+ * Parse an SVG marker element to extract all marker properties.
28
+ *
29
+ * SVG markers are symbols that can be attached to the vertices of paths, lines,
30
+ * polylines, and polygons. The marker element defines the appearance and how
31
+ * the marker should be positioned and oriented at each vertex.
32
+ *
33
+ * The refX and refY attributes define the reference point within the marker
34
+ * coordinate system that should be aligned with the path vertex.
35
+ *
36
+ * The orient attribute controls marker rotation:
37
+ * - "auto": Marker is rotated to align with the path direction at the vertex
38
+ * - "auto-start-reverse": Like "auto" but rotated 180° for start markers
39
+ * - Angle value (e.g., "45"): Fixed rotation angle in degrees
40
+ *
41
+ * @param {Element} markerElement - SVG marker DOM element
42
+ * @returns {Object} Parsed marker definition with properties:
43
+ * - id {string} - Marker element id
44
+ * - markerWidth {number} - Marker viewport width (default: 3)
45
+ * - markerHeight {number} - Marker viewport height (default: 3)
46
+ * - refX {number} - Reference point X coordinate (default: 0)
47
+ * - refY {number} - Reference point Y coordinate (default: 0)
48
+ * - orient {string|number} - Orientation: "auto", "auto-start-reverse", or angle in degrees
49
+ * - markerUnits {string} - Coordinate system: "strokeWidth" or "userSpaceOnUse" (default: "strokeWidth")
50
+ * - viewBox {Object|null} - Parsed viewBox {x, y, width, height} or null
51
+ * - preserveAspectRatio {string} - preserveAspectRatio value (default: "xMidYMid meet")
52
+ * - children {Array} - Array of child element data
53
+ *
54
+ * @example
55
+ * // Parse an arrow marker
56
+ * const marker = document.querySelector('marker#arrow');
57
+ * // <marker id="arrow" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto">
58
+ * // <path d="M 0 0 L 10 5 L 0 10 z" fill="black"/>
59
+ * // </marker>
60
+ * const parsed = parseMarkerElement(marker);
61
+ * // {
62
+ * // id: 'arrow',
63
+ * // markerWidth: 10, markerHeight: 10,
64
+ * // refX: 5, refY: 5,
65
+ * // orient: 'auto',
66
+ * // markerUnits: 'strokeWidth',
67
+ * // viewBox: null,
68
+ * // preserveAspectRatio: 'xMidYMid meet',
69
+ * // children: [{ type: 'path', d: 'M 0 0 L 10 5 L 0 10 z', ... }]
70
+ * // }
71
+ */
72
+ export function parseMarkerElement(markerElement) {
73
+ const data = {
74
+ id: markerElement.getAttribute('id') || '',
75
+ markerWidth: parseFloat(markerElement.getAttribute('markerWidth') || '3'),
76
+ markerHeight: parseFloat(markerElement.getAttribute('markerHeight') || '3'),
77
+ refX: parseFloat(markerElement.getAttribute('refX') || '0'),
78
+ refY: parseFloat(markerElement.getAttribute('refY') || '0'),
79
+ orient: markerElement.getAttribute('orient') || 'auto',
80
+ markerUnits: markerElement.getAttribute('markerUnits') || 'strokeWidth',
81
+ viewBox: null,
82
+ preserveAspectRatio: markerElement.getAttribute('preserveAspectRatio') || 'xMidYMid meet',
83
+ children: []
84
+ };
85
+
86
+ // Parse viewBox if present
87
+ const viewBoxStr = markerElement.getAttribute('viewBox');
88
+ if (viewBoxStr) {
89
+ const parts = viewBoxStr.trim().split(/[\s,]+/).map(Number);
90
+ if (parts.length === 4) {
91
+ data.viewBox = {
92
+ x: parts[0],
93
+ y: parts[1],
94
+ width: parts[2],
95
+ height: parts[3]
96
+ };
97
+ }
98
+ }
99
+
100
+ // Parse orient attribute
101
+ if (data.orient !== 'auto' && data.orient !== 'auto-start-reverse') {
102
+ // Parse as angle in degrees
103
+ const angle = parseFloat(data.orient);
104
+ if (!isNaN(angle)) {
105
+ data.orient = angle;
106
+ }
107
+ }
108
+
109
+ // Parse child elements
110
+ for (const child of markerElement.children) {
111
+ data.children.push(parseMarkerChild(child));
112
+ }
113
+
114
+ return data;
115
+ }
116
+
117
+ /**
118
+ * Parse a child element within a marker.
119
+ *
120
+ * @param {Element} element - SVG DOM element
121
+ * @returns {Object} Parsed element data
122
+ */
123
+ export function parseMarkerChild(element) {
124
+ const tagName = element.tagName.toLowerCase();
125
+ const data = {
126
+ type: tagName,
127
+ id: element.getAttribute('id') || null,
128
+ transform: element.getAttribute('transform') || null,
129
+ fill: element.getAttribute('fill') || null,
130
+ stroke: element.getAttribute('stroke') || null,
131
+ strokeWidth: element.getAttribute('stroke-width') || null,
132
+ opacity: element.getAttribute('opacity') || null
133
+ };
134
+
135
+ switch (tagName) {
136
+ case 'path':
137
+ data.d = element.getAttribute('d') || '';
138
+ break;
139
+ case 'rect':
140
+ data.x = parseFloat(element.getAttribute('x') || '0');
141
+ data.y = parseFloat(element.getAttribute('y') || '0');
142
+ data.width = parseFloat(element.getAttribute('width') || '0');
143
+ data.height = parseFloat(element.getAttribute('height') || '0');
144
+ break;
145
+ case 'circle':
146
+ data.cx = parseFloat(element.getAttribute('cx') || '0');
147
+ data.cy = parseFloat(element.getAttribute('cy') || '0');
148
+ data.r = parseFloat(element.getAttribute('r') || '0');
149
+ break;
150
+ case 'ellipse':
151
+ data.cx = parseFloat(element.getAttribute('cx') || '0');
152
+ data.cy = parseFloat(element.getAttribute('cy') || '0');
153
+ data.rx = parseFloat(element.getAttribute('rx') || '0');
154
+ data.ry = parseFloat(element.getAttribute('ry') || '0');
155
+ break;
156
+ case 'line':
157
+ data.x1 = parseFloat(element.getAttribute('x1') || '0');
158
+ data.y1 = parseFloat(element.getAttribute('y1') || '0');
159
+ data.x2 = parseFloat(element.getAttribute('x2') || '0');
160
+ data.y2 = parseFloat(element.getAttribute('y2') || '0');
161
+ break;
162
+ case 'polygon':
163
+ case 'polyline':
164
+ data.points = element.getAttribute('points') || '';
165
+ break;
166
+ default:
167
+ // Store any additional attributes for unknown elements
168
+ data.attributes = {};
169
+ for (const attr of element.attributes) {
170
+ data.attributes[attr.name] = attr.value;
171
+ }
172
+ }
173
+
174
+ return data;
175
+ }
176
+
177
+ /**
178
+ * Calculate the transformation matrix for a marker instance.
179
+ *
180
+ * The marker transform combines several transformations in order:
181
+ * 1. Translate to the path vertex position
182
+ * 2. Rotate according to orient attribute (auto, auto-start-reverse, or fixed angle)
183
+ * 3. Scale according to markerUnits (strokeWidth or userSpaceOnUse)
184
+ * 4. Apply viewBox transformation if present
185
+ * 5. Translate by -refX, -refY to align the reference point
186
+ *
187
+ * For markerUnits="strokeWidth", the marker is scaled by the stroke width.
188
+ * For markerUnits="userSpaceOnUse", no additional scaling is applied.
189
+ *
190
+ * @param {Object} markerDef - Parsed marker definition from parseMarkerElement
191
+ * @param {Object} position - Vertex position {x, y} in user coordinates
192
+ * @param {number} tangentAngle - Tangent angle at vertex in radians (for orient="auto")
193
+ * @param {number} strokeWidth - Stroke width for scaling (default: 1)
194
+ * @param {boolean} [isStart=false] - Whether this is a start marker (for auto-start-reverse)
195
+ * @returns {Matrix} 3x3 transformation matrix in homogeneous coordinates
196
+ *
197
+ * @example
198
+ * // Calculate transform for an arrow marker at path start
199
+ * const markerDef = parseMarkerElement(markerEl);
200
+ * const position = { x: 100, y: 200 };
201
+ * const tangentAngle = Math.PI / 4; // 45 degrees
202
+ * const strokeWidth = 2;
203
+ * const transform = getMarkerTransform(markerDef, position, tangentAngle, strokeWidth, true);
204
+ */
205
+ export function getMarkerTransform(markerDef, position, tangentAngle, strokeWidth = 1, isStart = false) {
206
+ const { markerWidth, markerHeight, refX, refY, orient, markerUnits, viewBox } = markerDef;
207
+
208
+ // Start with identity matrix
209
+ let transform = Matrix.identity(3);
210
+
211
+ // Step 1: Translate to position
212
+ const translateToPosition = Transforms2D.translation(position.x, position.y);
213
+ transform = transform.mul(translateToPosition);
214
+
215
+ // Step 2: Calculate rotation angle
216
+ let rotationAngle = 0;
217
+ if (orient === 'auto') {
218
+ rotationAngle = tangentAngle;
219
+ } else if (orient === 'auto-start-reverse' && isStart) {
220
+ rotationAngle = tangentAngle + Math.PI; // Add 180 degrees
221
+ } else if (typeof orient === 'number') {
222
+ rotationAngle = orient * (Math.PI / 180); // Convert degrees to radians
223
+ }
224
+
225
+ // Apply rotation
226
+ if (rotationAngle !== 0) {
227
+ const rotation = Transforms2D.rotate(rotationAngle);
228
+ transform = transform.mul(rotation);
229
+ }
230
+
231
+ // Step 3: Apply markerUnits scaling
232
+ let scaleX = 1;
233
+ let scaleY = 1;
234
+ if (markerUnits === 'strokeWidth') {
235
+ scaleX = strokeWidth;
236
+ scaleY = strokeWidth;
237
+ }
238
+
239
+ // Step 4: Apply viewBox transformation if present
240
+ if (viewBox) {
241
+ // Calculate scale factors to fit viewBox into marker viewport
242
+ const vbWidth = viewBox.width;
243
+ const vbHeight = viewBox.height;
244
+
245
+ if (vbWidth > 0 && vbHeight > 0) {
246
+ // Calculate uniform scale factor based on preserveAspectRatio
247
+ const scaleFactorX = markerWidth / vbWidth;
248
+ const scaleFactorY = markerHeight / vbHeight;
249
+
250
+ // For now, use uniform scaling (can be enhanced with full preserveAspectRatio parsing)
251
+ const scaleFactor = Math.min(scaleFactorX, scaleFactorY);
252
+
253
+ scaleX *= scaleFactor;
254
+ scaleY *= scaleFactor;
255
+
256
+ // Translate to account for viewBox origin
257
+ const viewBoxTranslate = Transforms2D.translation(-viewBox.x, -viewBox.y);
258
+ transform = transform.mul(viewBoxTranslate);
259
+ }
260
+ }
261
+
262
+ // Apply combined scaling
263
+ if (scaleX !== 1 || scaleY !== 1) {
264
+ const scale = Transforms2D.scale(scaleX, scaleY);
265
+ transform = transform.mul(scale);
266
+ }
267
+
268
+ // Step 5: Translate by -refX, -refY
269
+ const refTranslate = Transforms2D.translation(-refX, -refY);
270
+ transform = transform.mul(refTranslate);
271
+
272
+ return transform;
273
+ }
274
+
275
+ /**
276
+ * Extract vertices and tangent angles from SVG path data.
277
+ *
278
+ * Analyzes path commands to identify all vertices (points where markers can be placed)
279
+ * and calculates the incoming and outgoing tangent angles at each vertex.
280
+ *
281
+ * Vertices are identified at:
282
+ * - M (moveto) commands - path starts
283
+ * - L (lineto) endpoints
284
+ * - C (cubic bezier) endpoints
285
+ * - Q (quadratic bezier) endpoints
286
+ * - A (arc) endpoints
287
+ * - Z (closepath) creates a vertex at the path start
288
+ *
289
+ * For marker-start: use first vertex
290
+ * For marker-end: use last vertex
291
+ * For marker-mid: use all vertices except first and last
292
+ *
293
+ * @param {string} pathData - SVG path d attribute
294
+ * @returns {Array<Object>} Array of vertex objects, each with:
295
+ * - x {number} - X coordinate
296
+ * - y {number} - Y coordinate
297
+ * - tangentIn {number} - Incoming tangent angle in radians
298
+ * - tangentOut {number} - Outgoing tangent angle in radians
299
+ * - index {number} - Vertex index in path
300
+ *
301
+ * @example
302
+ * // Extract vertices from a path
303
+ * const vertices = getPathVertices("M 0 0 L 100 0 L 100 100 L 0 100 Z");
304
+ * // Returns 5 vertices (including closing vertex):
305
+ * // [
306
+ * // { x: 0, y: 0, tangentIn: 0, tangentOut: 0, index: 0 },
307
+ * // { x: 100, y: 0, tangentIn: 0, tangentOut: Math.PI/2, index: 1 },
308
+ * // { x: 100, y: 100, tangentIn: Math.PI/2, tangentOut: Math.PI, index: 2 },
309
+ * // { x: 0, y: 100, tangentIn: Math.PI, tangentOut: -Math.PI/2, index: 3 },
310
+ * // { x: 0, y: 0, tangentIn: -Math.PI/2, tangentOut: 0, index: 4 }
311
+ * // ]
312
+ */
313
+ export function getPathVertices(pathData) {
314
+ const vertices = [];
315
+ let currentX = 0;
316
+ let currentY = 0;
317
+ let startX = 0;
318
+ let startY = 0;
319
+ let lastControlX = 0;
320
+ let lastControlY = 0;
321
+
322
+ // Parse path commands
323
+ const commands = parsePathCommands(pathData);
324
+
325
+ for (let i = 0; i < commands.length; i++) {
326
+ const cmd = commands[i];
327
+ const prevX = currentX;
328
+ const prevY = currentY;
329
+
330
+ switch (cmd.type) {
331
+ case 'M': // moveto
332
+ currentX = cmd.x;
333
+ currentY = cmd.y;
334
+ startX = currentX;
335
+ startY = currentY;
336
+
337
+ // Calculate tangent for previous vertex if exists
338
+ if (vertices.length > 0) {
339
+ const prev = vertices[vertices.length - 1];
340
+ const angle = Math.atan2(currentY - prevY, currentX - prevX);
341
+ prev.tangentOut = angle;
342
+ }
343
+
344
+ // Add new vertex (tangents will be calculated later)
345
+ vertices.push({
346
+ x: currentX,
347
+ y: currentY,
348
+ tangentIn: 0,
349
+ tangentOut: 0,
350
+ index: vertices.length
351
+ });
352
+ break;
353
+
354
+ case 'L': // lineto
355
+ currentX = cmd.x;
356
+ currentY = cmd.y;
357
+
358
+ // Calculate tangent angle
359
+ const lineAngle = Math.atan2(currentY - prevY, currentX - prevX);
360
+
361
+ // Update previous vertex's outgoing tangent
362
+ if (vertices.length > 0) {
363
+ vertices[vertices.length - 1].tangentOut = lineAngle;
364
+ }
365
+
366
+ // Add new vertex
367
+ vertices.push({
368
+ x: currentX,
369
+ y: currentY,
370
+ tangentIn: lineAngle,
371
+ tangentOut: lineAngle,
372
+ index: vertices.length
373
+ });
374
+ break;
375
+
376
+ case 'C': // cubic bezier
377
+ lastControlX = cmd.x2;
378
+ lastControlY = cmd.y2;
379
+ currentX = cmd.x;
380
+ currentY = cmd.y;
381
+
382
+ // Calculate tangent at start (direction to first control point)
383
+ const startTangent = Math.atan2(cmd.y1 - prevY, cmd.x1 - prevX);
384
+
385
+ // Calculate tangent at end (direction from last control point)
386
+ const endTangent = Math.atan2(currentY - cmd.y2, currentX - cmd.x2);
387
+
388
+ // Update previous vertex's outgoing tangent
389
+ if (vertices.length > 0) {
390
+ vertices[vertices.length - 1].tangentOut = startTangent;
391
+ }
392
+
393
+ // Add new vertex
394
+ vertices.push({
395
+ x: currentX,
396
+ y: currentY,
397
+ tangentIn: endTangent,
398
+ tangentOut: endTangent,
399
+ index: vertices.length
400
+ });
401
+ break;
402
+
403
+ case 'Q': // quadratic bezier
404
+ lastControlX = cmd.x1;
405
+ lastControlY = cmd.y1;
406
+ currentX = cmd.x;
407
+ currentY = cmd.y;
408
+
409
+ // Calculate tangent at start
410
+ const qStartTangent = Math.atan2(cmd.y1 - prevY, cmd.x1 - prevX);
411
+
412
+ // Calculate tangent at end
413
+ const qEndTangent = Math.atan2(currentY - cmd.y1, currentX - cmd.x1);
414
+
415
+ // Update previous vertex's outgoing tangent
416
+ if (vertices.length > 0) {
417
+ vertices[vertices.length - 1].tangentOut = qStartTangent;
418
+ }
419
+
420
+ // Add new vertex
421
+ vertices.push({
422
+ x: currentX,
423
+ y: currentY,
424
+ tangentIn: qEndTangent,
425
+ tangentOut: qEndTangent,
426
+ index: vertices.length
427
+ });
428
+ break;
429
+
430
+ case 'A': // arc
431
+ currentX = cmd.x;
432
+ currentY = cmd.y;
433
+
434
+ // Simplified tangent calculation (could be improved with arc geometry)
435
+ const arcAngle = Math.atan2(currentY - prevY, currentX - prevX);
436
+
437
+ // Update previous vertex's outgoing tangent
438
+ if (vertices.length > 0) {
439
+ vertices[vertices.length - 1].tangentOut = arcAngle;
440
+ }
441
+
442
+ // Add new vertex
443
+ vertices.push({
444
+ x: currentX,
445
+ y: currentY,
446
+ tangentIn: arcAngle,
447
+ tangentOut: arcAngle,
448
+ index: vertices.length
449
+ });
450
+ break;
451
+
452
+ case 'Z': // closepath
453
+ currentX = startX;
454
+ currentY = startY;
455
+
456
+ // Calculate tangent from last point to start
457
+ const closeAngle = Math.atan2(currentY - prevY, currentX - prevX);
458
+
459
+ // Update previous vertex's outgoing tangent
460
+ if (vertices.length > 0) {
461
+ vertices[vertices.length - 1].tangentOut = closeAngle;
462
+ }
463
+
464
+ // Update first vertex's incoming tangent
465
+ if (vertices.length > 0) {
466
+ vertices[0].tangentIn = closeAngle;
467
+ }
468
+ break;
469
+ }
470
+ }
471
+
472
+ return vertices;
473
+ }
474
+
475
+ /**
476
+ * Parse SVG path data into structured commands.
477
+ *
478
+ * @param {string} pathData - SVG path d attribute
479
+ * @returns {Array<Object>} Array of command objects
480
+ */
481
+ export function parsePathCommands(pathData) {
482
+ const commands = [];
483
+
484
+ // Normalize path data: add spaces around command letters
485
+ const normalized = pathData
486
+ .replace(/([MmLlHhVvCcSsQqTtAaZz])/g, ' $1 ')
487
+ .trim();
488
+
489
+ // Split into tokens
490
+ const tokens = normalized.split(/[\s,]+/).filter(t => t.length > 0);
491
+
492
+ let i = 0;
493
+ let currentX = 0;
494
+ let currentY = 0;
495
+
496
+ while (i < tokens.length) {
497
+ const cmdType = tokens[i];
498
+
499
+ switch (cmdType.toUpperCase()) {
500
+ case 'M': // moveto
501
+ const mx = parseFloat(tokens[i + 1]);
502
+ const my = parseFloat(tokens[i + 2]);
503
+ commands.push({
504
+ type: 'M',
505
+ x: cmdType === 'M' ? mx : currentX + mx,
506
+ y: cmdType === 'M' ? my : currentY + my
507
+ });
508
+ currentX = commands[commands.length - 1].x;
509
+ currentY = commands[commands.length - 1].y;
510
+ i += 3;
511
+ break;
512
+
513
+ case 'L': // lineto
514
+ const lx = parseFloat(tokens[i + 1]);
515
+ const ly = parseFloat(tokens[i + 2]);
516
+ commands.push({
517
+ type: 'L',
518
+ x: cmdType === 'L' ? lx : currentX + lx,
519
+ y: cmdType === 'L' ? ly : currentY + ly
520
+ });
521
+ currentX = commands[commands.length - 1].x;
522
+ currentY = commands[commands.length - 1].y;
523
+ i += 3;
524
+ break;
525
+
526
+ case 'H': // horizontal lineto
527
+ const hx = parseFloat(tokens[i + 1]);
528
+ commands.push({
529
+ type: 'L',
530
+ x: cmdType === 'H' ? hx : currentX + hx,
531
+ y: currentY
532
+ });
533
+ currentX = commands[commands.length - 1].x;
534
+ i += 2;
535
+ break;
536
+
537
+ case 'V': // vertical lineto
538
+ const vy = parseFloat(tokens[i + 1]);
539
+ commands.push({
540
+ type: 'L',
541
+ x: currentX,
542
+ y: cmdType === 'V' ? vy : currentY + vy
543
+ });
544
+ currentY = commands[commands.length - 1].y;
545
+ i += 2;
546
+ break;
547
+
548
+ case 'C': // cubic bezier
549
+ const c1x = parseFloat(tokens[i + 1]);
550
+ const c1y = parseFloat(tokens[i + 2]);
551
+ const c2x = parseFloat(tokens[i + 3]);
552
+ const c2y = parseFloat(tokens[i + 4]);
553
+ const cx = parseFloat(tokens[i + 5]);
554
+ const cy = parseFloat(tokens[i + 6]);
555
+ commands.push({
556
+ type: 'C',
557
+ x1: cmdType === 'C' ? c1x : currentX + c1x,
558
+ y1: cmdType === 'C' ? c1y : currentY + c1y,
559
+ x2: cmdType === 'C' ? c2x : currentX + c2x,
560
+ y2: cmdType === 'C' ? c2y : currentY + c2y,
561
+ x: cmdType === 'C' ? cx : currentX + cx,
562
+ y: cmdType === 'C' ? cy : currentY + cy
563
+ });
564
+ currentX = commands[commands.length - 1].x;
565
+ currentY = commands[commands.length - 1].y;
566
+ i += 7;
567
+ break;
568
+
569
+ case 'Q': // quadratic bezier
570
+ const q1x = parseFloat(tokens[i + 1]);
571
+ const q1y = parseFloat(tokens[i + 2]);
572
+ const qx = parseFloat(tokens[i + 3]);
573
+ const qy = parseFloat(tokens[i + 4]);
574
+ commands.push({
575
+ type: 'Q',
576
+ x1: cmdType === 'Q' ? q1x : currentX + q1x,
577
+ y1: cmdType === 'Q' ? q1y : currentY + q1y,
578
+ x: cmdType === 'Q' ? qx : currentX + qx,
579
+ y: cmdType === 'Q' ? qy : currentY + qy
580
+ });
581
+ currentX = commands[commands.length - 1].x;
582
+ currentY = commands[commands.length - 1].y;
583
+ i += 5;
584
+ break;
585
+
586
+ case 'A': // arc
587
+ const rx = parseFloat(tokens[i + 1]);
588
+ const ry = parseFloat(tokens[i + 2]);
589
+ const xAxisRotation = parseFloat(tokens[i + 3]);
590
+ const largeArcFlag = parseInt(tokens[i + 4]);
591
+ const sweepFlag = parseInt(tokens[i + 5]);
592
+ const ax = parseFloat(tokens[i + 6]);
593
+ const ay = parseFloat(tokens[i + 7]);
594
+ commands.push({
595
+ type: 'A',
596
+ rx,
597
+ ry,
598
+ xAxisRotation,
599
+ largeArcFlag,
600
+ sweepFlag,
601
+ x: cmdType === 'A' ? ax : currentX + ax,
602
+ y: cmdType === 'A' ? ay : currentY + ay
603
+ });
604
+ currentX = commands[commands.length - 1].x;
605
+ currentY = commands[commands.length - 1].y;
606
+ i += 8;
607
+ break;
608
+
609
+ case 'Z': // closepath
610
+ commands.push({ type: 'Z' });
611
+ i += 1;
612
+ break;
613
+
614
+ default:
615
+ // Skip unknown commands
616
+ i += 1;
617
+ }
618
+ }
619
+
620
+ return commands;
621
+ }
622
+
623
+ /**
624
+ * Resolve markers for a path element.
625
+ *
626
+ * Finds marker-start, marker-mid, and marker-end references on the path,
627
+ * extracts path vertices, and creates marker instances with appropriate
628
+ * transforms for each vertex.
629
+ *
630
+ * The defsMap should contain parsed marker definitions keyed by id.
631
+ *
632
+ * @param {Element} pathElement - SVG path DOM element with marker attributes
633
+ * @param {Object} defsMap - Map of marker id to parsed marker definition
634
+ * @returns {Array<Object>} Array of resolved marker instances, each with:
635
+ * - markerDef {Object} - The marker definition
636
+ * - position {Object} - Vertex position {x, y}
637
+ * - transform {Matrix} - Transform matrix for this marker instance
638
+ * - type {string} - Marker type: "start", "mid", or "end"
639
+ * - vertex {Object} - Full vertex data with tangent angles
640
+ *
641
+ * @example
642
+ * // Resolve markers on a path
643
+ * const path = document.querySelector('path[marker-end]');
644
+ * const defs = document.querySelector('defs');
645
+ * const defsMap = {};
646
+ * for (const marker of defs.querySelectorAll('marker')) {
647
+ * const parsed = parseMarkerElement(marker);
648
+ * defsMap[parsed.id] = parsed;
649
+ * }
650
+ * const instances = resolveMarkers(path, defsMap);
651
+ * // Returns array of marker instances ready to be rendered
652
+ */
653
+ export function resolveMarkers(pathElement, defsMap) {
654
+ const instances = [];
655
+
656
+ // Get marker references
657
+ const markerStart = pathElement.getAttribute('marker-start');
658
+ const markerMid = pathElement.getAttribute('marker-mid');
659
+ const markerEnd = pathElement.getAttribute('marker-end');
660
+
661
+ // Get stroke width for scaling
662
+ const strokeWidth = parseFloat(pathElement.getAttribute('stroke-width') || '1');
663
+
664
+ // Get path data and extract vertices
665
+ const pathData = pathElement.getAttribute('d') || '';
666
+ const vertices = getPathVertices(pathData);
667
+
668
+ if (vertices.length === 0) {
669
+ return instances;
670
+ }
671
+
672
+ // Helper to extract marker id from url() reference
673
+ const getMarkerId = (markerRef) => {
674
+ if (!markerRef) return null;
675
+ const match = markerRef.match(/url\(#([^)]+)\)/);
676
+ return match ? match[1] : null;
677
+ };
678
+
679
+ // Apply marker-start to first vertex
680
+ if (markerStart) {
681
+ const markerId = getMarkerId(markerStart);
682
+ const markerDef = markerId ? defsMap[markerId] : null;
683
+
684
+ if (markerDef && vertices.length > 0) {
685
+ const vertex = vertices[0];
686
+ const tangent = vertices.length > 1 ? vertex.tangentOut : vertex.tangentIn;
687
+
688
+ instances.push({
689
+ markerDef,
690
+ position: { x: vertex.x, y: vertex.y },
691
+ transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, true),
692
+ type: 'start',
693
+ vertex
694
+ });
695
+ }
696
+ }
697
+
698
+ // Apply marker-mid to all middle vertices
699
+ if (markerMid && vertices.length > 2) {
700
+ const markerId = getMarkerId(markerMid);
701
+ const markerDef = markerId ? defsMap[markerId] : null;
702
+
703
+ if (markerDef) {
704
+ for (let i = 1; i < vertices.length - 1; i++) {
705
+ const vertex = vertices[i];
706
+ // Use average of incoming and outgoing tangents for mid markers
707
+ const tangent = (vertex.tangentIn + vertex.tangentOut) / 2;
708
+
709
+ instances.push({
710
+ markerDef,
711
+ position: { x: vertex.x, y: vertex.y },
712
+ transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, false),
713
+ type: 'mid',
714
+ vertex
715
+ });
716
+ }
717
+ }
718
+ }
719
+
720
+ // Apply marker-end to last vertex
721
+ if (markerEnd) {
722
+ const markerId = getMarkerId(markerEnd);
723
+ const markerDef = markerId ? defsMap[markerId] : null;
724
+
725
+ if (markerDef && vertices.length > 0) {
726
+ const vertex = vertices[vertices.length - 1];
727
+ const tangent = vertex.tangentIn;
728
+
729
+ instances.push({
730
+ markerDef,
731
+ position: { x: vertex.x, y: vertex.y },
732
+ transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, false),
733
+ type: 'end',
734
+ vertex
735
+ });
736
+ }
737
+ }
738
+
739
+ return instances;
740
+ }
741
+
742
+ /**
743
+ * Convert a marker instance to polygon representations.
744
+ *
745
+ * Transforms marker content (paths, shapes) into polygon arrays by:
746
+ * 1. Converting marker children to path commands or polygons
747
+ * 2. Applying the marker transform to all coordinates
748
+ * 3. Returning array of polygons (each polygon is array of {x, y} points)
749
+ *
750
+ * This is useful for rendering markers or performing geometric operations.
751
+ *
752
+ * @param {Object} markerInstance - Marker instance from resolveMarkers()
753
+ * @param {Object} [options={}] - Conversion options
754
+ * @param {number} [options.precision=2] - Decimal precision for coordinates
755
+ * @param {number} [options.curveSegments=10] - Number of segments for curve approximation
756
+ * @returns {Array<Array<Object>>} Array of polygons, each polygon is array of {x, y} points
757
+ *
758
+ * @example
759
+ * // Convert marker to polygons
760
+ * const instances = resolveMarkers(pathEl, defsMap);
761
+ * const polygons = markerToPolygons(instances[0], { precision: 3 });
762
+ * // Returns: [[{x: 100.123, y: 200.456}, ...], ...]
763
+ */
764
+ export function markerToPolygons(markerInstance, options = {}) {
765
+ const { precision = 2, curveSegments = 10 } = options;
766
+ const polygons = [];
767
+ const { markerDef, transform } = markerInstance;
768
+
769
+ // Process each child element
770
+ for (const child of markerDef.children) {
771
+ let points = [];
772
+
773
+ switch (child.type) {
774
+ case 'path':
775
+ // Parse path and convert to points
776
+ points = pathToPoints(child.d, curveSegments);
777
+ break;
778
+
779
+ case 'rect':
780
+ // Convert rect to 4 corner points
781
+ points = [
782
+ { x: child.x, y: child.y },
783
+ { x: child.x + child.width, y: child.y },
784
+ { x: child.x + child.width, y: child.y + child.height },
785
+ { x: child.x, y: child.y + child.height }
786
+ ];
787
+ break;
788
+
789
+ case 'circle':
790
+ // Approximate circle with polygon
791
+ points = circleToPoints(child.cx, child.cy, child.r, curveSegments * 4);
792
+ break;
793
+
794
+ case 'ellipse':
795
+ // Approximate ellipse with polygon
796
+ points = ellipseToPoints(child.cx, child.cy, child.rx, child.ry, curveSegments * 4);
797
+ break;
798
+
799
+ case 'line':
800
+ points = [
801
+ { x: child.x1, y: child.y1 },
802
+ { x: child.x2, y: child.y2 }
803
+ ];
804
+ break;
805
+
806
+ case 'polygon':
807
+ case 'polyline':
808
+ points = parsePoints(child.points);
809
+ break;
810
+ }
811
+
812
+ // Apply transform to all points
813
+ if (points.length > 0) {
814
+ const transformedPoints = points.map(p => {
815
+ const result = Transforms2D.applyTransform(transform, p.x, p.y);
816
+ // result is an array [x, y] with Decimal values
817
+ return {
818
+ x: parseFloat(result[0].toFixed(precision)),
819
+ y: parseFloat(result[1].toFixed(precision))
820
+ };
821
+ });
822
+
823
+ polygons.push(transformedPoints);
824
+ }
825
+ }
826
+
827
+ return polygons;
828
+ }
829
+
830
+ /**
831
+ * Convert path data to array of points by linearizing curves.
832
+ *
833
+ * @param {string} pathData - SVG path d attribute
834
+ * @param {number} segments - Number of segments for curve approximation
835
+ * @returns {Array<Object>} Array of {x, y} points
836
+ */
837
+ export function pathToPoints(pathData, segments = 10) {
838
+ const points = [];
839
+ const commands = parsePathCommands(pathData);
840
+ let currentX = 0;
841
+ let currentY = 0;
842
+
843
+ for (const cmd of commands) {
844
+ switch (cmd.type) {
845
+ case 'M':
846
+ currentX = cmd.x;
847
+ currentY = cmd.y;
848
+ points.push({ x: currentX, y: currentY });
849
+ break;
850
+
851
+ case 'L':
852
+ currentX = cmd.x;
853
+ currentY = cmd.y;
854
+ points.push({ x: currentX, y: currentY });
855
+ break;
856
+
857
+ case 'C':
858
+ // Approximate cubic bezier with line segments
859
+ for (let i = 1; i <= segments; i++) {
860
+ const t = i / segments;
861
+ const t1 = 1 - t;
862
+ const x = t1 * t1 * t1 * currentX +
863
+ 3 * t1 * t1 * t * cmd.x1 +
864
+ 3 * t1 * t * t * cmd.x2 +
865
+ t * t * t * cmd.x;
866
+ const y = t1 * t1 * t1 * currentY +
867
+ 3 * t1 * t1 * t * cmd.y1 +
868
+ 3 * t1 * t * t * cmd.y2 +
869
+ t * t * t * cmd.y;
870
+ points.push({ x, y });
871
+ }
872
+ currentX = cmd.x;
873
+ currentY = cmd.y;
874
+ break;
875
+
876
+ case 'Q':
877
+ // Approximate quadratic bezier with line segments
878
+ for (let i = 1; i <= segments; i++) {
879
+ const t = i / segments;
880
+ const t1 = 1 - t;
881
+ const x = t1 * t1 * currentX +
882
+ 2 * t1 * t * cmd.x1 +
883
+ t * t * cmd.x;
884
+ const y = t1 * t1 * currentY +
885
+ 2 * t1 * t * cmd.y1 +
886
+ t * t * cmd.y;
887
+ points.push({ x, y });
888
+ }
889
+ currentX = cmd.x;
890
+ currentY = cmd.y;
891
+ break;
892
+
893
+ case 'A':
894
+ // Simplified arc approximation
895
+ currentX = cmd.x;
896
+ currentY = cmd.y;
897
+ points.push({ x: currentX, y: currentY });
898
+ break;
899
+ }
900
+ }
901
+
902
+ return points;
903
+ }
904
+
905
+ /**
906
+ * Convert circle to array of points.
907
+ *
908
+ * @param {number} cx - Center X
909
+ * @param {number} cy - Center Y
910
+ * @param {number} r - Radius
911
+ * @param {number} segments - Number of segments
912
+ * @returns {Array<Object>} Array of {x, y} points
913
+ */
914
+ export function circleToPoints(cx, cy, r, segments = 32) {
915
+ const points = [];
916
+ for (let i = 0; i < segments; i++) {
917
+ const angle = (i / segments) * 2 * Math.PI;
918
+ points.push({
919
+ x: cx + r * Math.cos(angle),
920
+ y: cy + r * Math.sin(angle)
921
+ });
922
+ }
923
+ return points;
924
+ }
925
+
926
+ /**
927
+ * Convert ellipse to array of points.
928
+ *
929
+ * @param {number} cx - Center X
930
+ * @param {number} cy - Center Y
931
+ * @param {number} rx - X radius
932
+ * @param {number} ry - Y radius
933
+ * @param {number} segments - Number of segments
934
+ * @returns {Array<Object>} Array of {x, y} points
935
+ */
936
+ export function ellipseToPoints(cx, cy, rx, ry, segments = 32) {
937
+ const points = [];
938
+ for (let i = 0; i < segments; i++) {
939
+ const angle = (i / segments) * 2 * Math.PI;
940
+ points.push({
941
+ x: cx + rx * Math.cos(angle),
942
+ y: cy + ry * Math.sin(angle)
943
+ });
944
+ }
945
+ return points;
946
+ }
947
+
948
+ /**
949
+ * Parse SVG points attribute (for polygon/polyline).
950
+ *
951
+ * @param {string} pointsStr - Points attribute value
952
+ * @returns {Array<Object>} Array of {x, y} points
953
+ */
954
+ export function parsePoints(pointsStr) {
955
+ const points = [];
956
+ const coords = pointsStr.trim().split(/[\s,]+/).map(Number);
957
+
958
+ for (let i = 0; i < coords.length - 1; i += 2) {
959
+ points.push({ x: coords[i], y: coords[i + 1] });
960
+ }
961
+
962
+ return points;
963
+ }
964
+
965
+ /**
966
+ * Convert marker instances to SVG path data.
967
+ *
968
+ * Generates SVG path commands from all marker polygons, useful for
969
+ * exporting markers as standalone paths.
970
+ *
971
+ * @param {Array<Object>} markerInstances - Array of marker instances from resolveMarkers()
972
+ * @param {number} [precision=2] - Decimal precision for coordinates
973
+ * @returns {string} SVG path data (d attribute value)
974
+ *
975
+ * @example
976
+ * // Convert all markers to a single path
977
+ * const instances = resolveMarkers(pathEl, defsMap);
978
+ * const pathData = markersToPathData(instances, 3);
979
+ * // Returns: "M 100.000 200.000 L 105.000 205.000 ... Z M ..."
980
+ */
981
+ export function markersToPathData(markerInstances, precision = 2) {
982
+ const pathParts = [];
983
+
984
+ for (const instance of markerInstances) {
985
+ const polygons = markerToPolygons(instance, { precision });
986
+
987
+ for (const polygon of polygons) {
988
+ if (polygon.length === 0) continue;
989
+
990
+ // Start with M (moveto) command
991
+ const first = polygon[0];
992
+ pathParts.push(`M ${first.x.toFixed(precision)} ${first.y.toFixed(precision)}`);
993
+
994
+ // Add L (lineto) commands for remaining points
995
+ for (let i = 1; i < polygon.length; i++) {
996
+ const pt = polygon[i];
997
+ pathParts.push(`L ${pt.x.toFixed(precision)} ${pt.y.toFixed(precision)}`);
998
+ }
999
+
1000
+ // Close path
1001
+ pathParts.push('Z');
1002
+ }
1003
+ }
1004
+
1005
+ return pathParts.join(' ');
1006
+ }