vector-mirror 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * geom.js — AABB Geometry Mathematics for Vector Mirror v2.5
3
+ *
4
+ * Source of truth for axis-aligned bounding-box mathematics.
5
+ * All inputs are boxes of shape `{x, y, w, h}` (top-left + extent).
6
+ *
7
+ * Pure math: Node-only, no Playwright / DOM dependency.
8
+ *
9
+ * References:
10
+ * - RB-02 Pattern 4.2 — Pomona / dyn4j AABB Signed-Gap formulation
11
+ * - FIX_PLAN_2026-04-18 §1.2 P1-01
12
+ * - ADR-024 Kernel-Vertrag (bbox is the inviolable geometric authority)
13
+ *
14
+ * Notes:
15
+ * - SVG y-axis grows downward → "north" overflow means a sticks out *above* b.
16
+ * - Touching edges (signedDist = 0) count as separated, not overlapping.
17
+ */
18
+
19
+ /**
20
+ * Per-axis signed gaps between two AABBs.
21
+ * Negative on an axis ⇒ the boxes overlap on that axis.
22
+ * Used internally; exposed indirectly via signedGapComponents.
23
+ *
24
+ * @param {{x:number,y:number,w:number,h:number}} a
25
+ * @param {{x:number,y:number,w:number,h:number}} b
26
+ * @returns {{dx:number, dy:number}}
27
+ */
28
+ function axisGaps(a, b) {
29
+ const dx = Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w));
30
+ const dy = Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h));
31
+ return { dx, dy };
32
+ }
33
+
34
+ /**
35
+ * Signed Euclidean gap between two AABBs (Pomona / dyn4j formulation).
36
+ *
37
+ * > 0 separated, value = shortest empty-space distance between rectangles
38
+ * = 0 touching (edge or corner aligned, no overlap)
39
+ * < 0 overlapping, value = penetration depth along the dominant axis
40
+ *
41
+ * @param {{x:number,y:number,w:number,h:number}} a
42
+ * @param {{x:number,y:number,w:number,h:number}} b
43
+ * @returns {number}
44
+ */
45
+ export function signedGapBoxToBox(a, b) {
46
+ const { dx, dy } = axisGaps(a, b);
47
+ if (dx < 0 && dy < 0) return Math.max(dx, dy);
48
+ return Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
49
+ }
50
+
51
+ /**
52
+ * Signed gap with per-axis components, for vector-based corrections
53
+ * (Spotter "dx=-14px, dy=+3px" output).
54
+ *
55
+ * Components dx / dy are negative when the boxes overlap on that axis,
56
+ * zero when edges align, positive when there is empty space.
57
+ *
58
+ * `signedDist` follows {@link signedGapBoxToBox}.
59
+ *
60
+ * @returns {{dx:number, dy:number, signedDist:number}}
61
+ */
62
+ export function signedGapComponents(a, b) {
63
+ const { dx, dy } = axisGaps(a, b);
64
+ const signedDist =
65
+ dx < 0 && dy < 0
66
+ ? Math.max(dx, dy)
67
+ : Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
68
+ return { dx, dy, signedDist };
69
+ }
70
+
71
+ /**
72
+ * Containment ratio: how much of subject `a` lies within reference `b`.
73
+ * 0 ⇒ a is fully outside b (no intersection)
74
+ * 1 ⇒ a is fully inside b
75
+ * 0..1 partial — subject straddles a boundary
76
+ *
77
+ * Subjects with zero area collapse to 0 (they have no area to contain).
78
+ *
79
+ * @param {{x:number,y:number,w:number,h:number}} a subject (typically element bbox)
80
+ * @param {{x:number,y:number,w:number,h:number}} b reference (typically canvas bbox)
81
+ * @returns {number}
82
+ */
83
+ export function containmentRatio(a, b) {
84
+ const ix = Math.max(a.x, b.x);
85
+ const iy = Math.max(a.y, b.y);
86
+ const ix2 = Math.min(a.x + a.w, b.x + b.w);
87
+ const iy2 = Math.min(a.y + a.h, b.y + b.h);
88
+ const intersection = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
89
+ const area = a.w * a.h;
90
+ return area === 0 ? 0 : intersection / area;
91
+ }
92
+
93
+ /**
94
+ * Overflow per cardinal edge — by how many pixels subject `a` exceeds
95
+ * container `b` on each side. Each component is non-negative.
96
+ *
97
+ * Compass mapping (SVG y-axis grows downward):
98
+ * north — a's top edge is above b's top edge
99
+ * south — a's bottom edge is below b's bottom edge
100
+ * east — a's right edge is right of b's right edge
101
+ * west — a's left edge is left of b's left edge
102
+ *
103
+ * @returns {{north:number, east:number, south:number, west:number}}
104
+ */
105
+ export function overflowPerEdge(a, b) {
106
+ return {
107
+ north: Math.max(0, b.y - a.y),
108
+ east: Math.max(0, a.x + a.w - (b.x + b.w)),
109
+ south: Math.max(0, a.y + a.h - (b.y + b.h)),
110
+ west: Math.max(0, b.x - a.x),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Minimum Translation Vector — the smallest delta to apply to `a` so that
116
+ * `a` and `b` no longer overlap. Returns `{dx:0, dy:0, abs:0}` if the boxes
117
+ * are already separated (or merely touching).
118
+ *
119
+ * Resolution rules when overlapping:
120
+ * - Push along the axis with the smaller penetration depth.
121
+ * - On equal penetration, prefer the x-axis (deterministic tie-break).
122
+ * - Sign points away from b: a moves away from b's center.
123
+ * - When centers coincide, prefer +x (deterministic for fully congruent boxes).
124
+ *
125
+ * @returns {{dx:number, dy:number, abs:number}}
126
+ */
127
+ export function mtv(a, b) {
128
+ const { dx, dy } = axisGaps(a, b);
129
+ if (dx >= 0 || dy >= 0) return { dx: 0, dy: 0, abs: 0 };
130
+
131
+ const penX = -dx;
132
+ const penY = -dy;
133
+
134
+ if (penX <= penY) {
135
+ const aCx = a.x + a.w / 2;
136
+ const bCx = b.x + b.w / 2;
137
+ const sign = aCx < bCx ? -1 : 1;
138
+ return { dx: sign * penX, dy: 0, abs: penX };
139
+ }
140
+ const aCy = a.y + a.h / 2;
141
+ const bCy = b.y + b.h / 2;
142
+ const sign = aCy < bCy ? -1 : 1;
143
+ return { dx: 0, dy: sign * penY, abs: penY };
144
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * palette.js - W3C CSS Named Colors & CIELAB Delta-E Logic
3
+ * Migrated from color-names.js (v1.6) + parseColor from grid.js
4
+ * Vector Mirror v2.0
5
+ */
6
+
7
+ const PALETTE = [
8
+ { name: 'aliceblue', r: 240, g: 248, b: 255 },
9
+ { name: 'antiquewhite', r: 250, g: 235, b: 215 },
10
+ { name: 'aqua', r: 0, g: 255, b: 255 },
11
+ { name: 'aquamarine', r: 127, g: 255, b: 212 },
12
+ { name: 'azure', r: 240, g: 255, b: 255 },
13
+ { name: 'beige', r: 245, g: 245, b: 220 },
14
+ { name: 'bisque', r: 255, g: 228, b: 196 },
15
+ { name: 'black', r: 0, g: 0, b: 0 },
16
+ { name: 'blanchedalmond', r: 255, g: 235, b: 205 },
17
+ { name: 'blue', r: 0, g: 0, b: 255 },
18
+ { name: 'blueviolet', r: 138, g: 43, b: 226 },
19
+ { name: 'brown', r: 165, g: 42, b: 42 },
20
+ { name: 'burlywood', r: 222, g: 184, b: 135 },
21
+ { name: 'cadetblue', r: 95, g: 158, b: 160 },
22
+ { name: 'chartreuse', r: 127, g: 255, b: 0 },
23
+ { name: 'chocolate', r: 210, g: 105, b: 30 },
24
+ { name: 'coral', r: 255, g: 127, b: 80 },
25
+ { name: 'cornflowerblue', r: 100, g: 149, b: 237 },
26
+ { name: 'cornsilk', r: 255, g: 248, b: 220 },
27
+ { name: 'crimson', r: 220, g: 20, b: 60 },
28
+ { name: 'cyan', r: 0, g: 255, b: 255 },
29
+ { name: 'darkblue', r: 0, g: 0, b: 139 },
30
+ { name: 'darkcyan', r: 0, g: 139, b: 139 },
31
+ { name: 'darkgoldenrod', r: 184, g: 134, b: 11 },
32
+ { name: 'darkgray', r: 169, g: 169, b: 169 },
33
+ { name: 'darkgreen', r: 0, g: 100, b: 0 },
34
+ { name: 'darkkhaki', r: 189, g: 183, b: 107 },
35
+ { name: 'darkmagenta', r: 139, g: 0, b: 139 },
36
+ { name: 'darkolivegreen', r: 85, g: 107, b: 47 },
37
+ { name: 'darkorange', r: 255, g: 140, b: 0 },
38
+ { name: 'darkorchid', r: 153, g: 50, b: 204 },
39
+ { name: 'darkred', r: 139, g: 0, b: 0 },
40
+ { name: 'darksalmon', r: 233, g: 150, b: 122 },
41
+ { name: 'darkseagreen', r: 143, g: 188, b: 143 },
42
+ { name: 'darkslateblue', r: 72, g: 61, b: 139 },
43
+ { name: 'darkslategray', r: 47, g: 79, b: 79 },
44
+ { name: 'darkturquoise', r: 0, g: 206, b: 209 },
45
+ { name: 'darkviolet', r: 148, g: 0, b: 211 },
46
+ { name: 'deeppink', r: 255, g: 20, b: 147 },
47
+ { name: 'deepskyblue', r: 0, g: 191, b: 255 },
48
+ { name: 'dimgray', r: 105, g: 105, b: 105 },
49
+ { name: 'dodgerblue', r: 30, g: 144, b: 255 },
50
+ { name: 'firebrick', r: 178, g: 34, b: 34 },
51
+ { name: 'floralwhite', r: 255, g: 250, b: 240 },
52
+ { name: 'forestgreen', r: 34, g: 139, b: 34 },
53
+ { name: 'fuchsia', r: 255, g: 0, b: 255 },
54
+ { name: 'gainsboro', r: 220, g: 220, b: 220 },
55
+ { name: 'ghostwhite', r: 248, g: 248, b: 255 },
56
+ { name: 'gold', r: 255, g: 215, b: 0 },
57
+ { name: 'goldenrod', r: 218, g: 165, b: 32 },
58
+ { name: 'gray', r: 128, g: 128, b: 128 },
59
+ { name: 'green', r: 0, g: 128, b: 0 },
60
+ { name: 'greenyellow', r: 173, g: 255, b: 47 },
61
+ { name: 'honeydew', r: 240, g: 255, b: 240 },
62
+ { name: 'hotpink', r: 255, g: 105, b: 180 },
63
+ { name: 'indianred', r: 205, g: 92, b: 92 },
64
+ { name: 'indigo', r: 75, g: 0, b: 130 },
65
+ { name: 'ivory', r: 255, g: 255, b: 240 },
66
+ { name: 'khaki', r: 240, g: 230, b: 140 },
67
+ { name: 'lavender', r: 230, g: 230, b: 250 },
68
+ { name: 'lavenderblush', r: 255, g: 240, b: 245 },
69
+ { name: 'lawngreen', r: 124, g: 252, b: 0 },
70
+ { name: 'lemonchiffon', r: 255, g: 250, b: 205 },
71
+ { name: 'lightblue', r: 173, g: 216, b: 230 },
72
+ { name: 'lightcoral', r: 240, g: 128, b: 128 },
73
+ { name: 'lightcyan', r: 224, g: 255, b: 255 },
74
+ { name: 'lightgoldenrodyellow', r: 250, g: 250, b: 210 },
75
+ { name: 'lightgray', r: 211, g: 211, b: 211 },
76
+ { name: 'lightgreen', r: 144, g: 238, b: 144 },
77
+ { name: 'lightpink', r: 255, g: 182, b: 193 },
78
+ { name: 'lightsalmon', r: 255, g: 160, b: 122 },
79
+ { name: 'lightseagreen', r: 32, g: 178, b: 170 },
80
+ { name: 'lightskyblue', r: 135, g: 206, b: 250 },
81
+ { name: 'lightslategray', r: 119, g: 136, b: 153 },
82
+ { name: 'lightsteelblue', r: 176, g: 196, b: 222 },
83
+ { name: 'lightyellow', r: 255, g: 255, b: 224 },
84
+ { name: 'lime', r: 0, g: 255, b: 0 },
85
+ { name: 'limegreen', r: 50, g: 205, b: 50 },
86
+ { name: 'linen', r: 250, g: 240, b: 230 },
87
+ { name: 'magenta', r: 255, g: 0, b: 255 },
88
+ { name: 'maroon', r: 128, g: 0, b: 0 },
89
+ { name: 'mediumaquamarine', r: 102, g: 205, b: 170 },
90
+ { name: 'mediumblue', r: 0, g: 0, b: 205 },
91
+ { name: 'mediumorchid', r: 186, g: 85, b: 211 },
92
+ { name: 'mediumpurple', r: 147, g: 112, b: 219 },
93
+ { name: 'mediumseagreen', r: 60, g: 179, b: 113 },
94
+ { name: 'mediumslateblue', r: 123, g: 104, b: 238 },
95
+ { name: 'mediumspringgreen', r: 0, g: 250, b: 154 },
96
+ { name: 'mediumturquoise', r: 72, g: 209, b: 204 },
97
+ { name: 'mediumvioletred', r: 199, g: 21, b: 133 },
98
+ { name: 'midnightblue', r: 25, g: 25, b: 112 },
99
+ { name: 'mintcream', r: 245, g: 255, b: 250 },
100
+ { name: 'mistyrose', r: 255, g: 228, b: 225 },
101
+ { name: 'moccasin', r: 255, g: 228, b: 181 },
102
+ { name: 'navajowhite', r: 255, g: 222, b: 173 },
103
+ { name: 'navy', r: 0, g: 0, b: 128 },
104
+ { name: 'oldlace', r: 253, g: 245, b: 230 },
105
+ { name: 'olive', r: 128, g: 128, b: 0 },
106
+ { name: 'olivedrab', r: 107, g: 142, b: 35 },
107
+ { name: 'orange', r: 255, g: 165, b: 0 },
108
+ { name: 'orangered', r: 255, g: 69, b: 0 },
109
+ { name: 'orchid', r: 218, g: 112, b: 214 },
110
+ { name: 'palegoldenrod', r: 238, g: 232, b: 170 },
111
+ { name: 'palegreen', r: 152, g: 251, b: 152 },
112
+ { name: 'paleturquoise', r: 175, g: 238, b: 238 },
113
+ { name: 'palevioletred', r: 219, g: 112, b: 147 },
114
+ { name: 'papayawhip', r: 255, g: 239, b: 213 },
115
+ { name: 'peachpuff', r: 255, g: 218, b: 185 },
116
+ { name: 'peru', r: 205, g: 133, b: 63 },
117
+ { name: 'pink', r: 255, g: 192, b: 203 },
118
+ { name: 'plum', r: 221, g: 160, b: 221 },
119
+ { name: 'powderblue', r: 176, g: 224, b: 230 },
120
+ { name: 'purple', r: 128, g: 0, b: 128 },
121
+ { name: 'rebeccapurple', r: 102, g: 51, b: 153 },
122
+ { name: 'red', r: 255, g: 0, b: 0 },
123
+ { name: 'rosybrown', r: 188, g: 143, b: 143 },
124
+ { name: 'royalblue', r: 65, g: 105, b: 225 },
125
+ { name: 'saddlebrown', r: 139, g: 69, b: 19 },
126
+ { name: 'salmon', r: 250, g: 128, b: 114 },
127
+ { name: 'sandybrown', r: 244, g: 164, b: 96 },
128
+ { name: 'seagreen', r: 46, g: 139, b: 87 },
129
+ { name: 'seashell', r: 255, g: 245, b: 238 },
130
+ { name: 'sienna', r: 160, g: 82, b: 45 },
131
+ { name: 'silver', r: 192, g: 192, b: 192 },
132
+ { name: 'skyblue', r: 135, g: 206, b: 235 },
133
+ { name: 'slateblue', r: 106, g: 90, b: 205 },
134
+ { name: 'slategray', r: 112, g: 128, b: 144 },
135
+ { name: 'snow', r: 255, g: 250, b: 250 },
136
+ { name: 'springgreen', r: 0, g: 255, b: 127 },
137
+ { name: 'steelblue', r: 70, g: 130, b: 180 },
138
+ { name: 'tan', r: 210, g: 180, b: 140 },
139
+ { name: 'teal', r: 0, g: 128, b: 128 },
140
+ { name: 'thistle', r: 216, g: 191, b: 216 },
141
+ { name: 'tomato', r: 255, g: 99, b: 71 },
142
+ { name: 'turquoise', r: 64, g: 224, b: 208 },
143
+ { name: 'violet', r: 238, g: 130, b: 238 },
144
+ { name: 'wheat', r: 245, g: 222, b: 179 },
145
+ { name: 'white', r: 255, g: 255, b: 255 },
146
+ { name: 'whitesmoke', r: 245, g: 245, b: 245 },
147
+ { name: 'yellow', r: 255, g: 255, b: 0 },
148
+ { name: 'yellowgreen', r: 154, g: 205, b: 50 },
149
+ ];
150
+
151
+ /**
152
+ * sRGB (0-255) -> CIELAB (D65)
153
+ */
154
+ function srgbToLab(r, g, b) {
155
+ let lr = r / 255,
156
+ lg = g / 255,
157
+ lb = b / 255;
158
+ lr = lr > 0.04045 ? ((lr + 0.055) / 1.055) ** 2.4 : lr / 12.92;
159
+ lg = lg > 0.04045 ? ((lg + 0.055) / 1.055) ** 2.4 : lg / 12.92;
160
+ lb = lb > 0.04045 ? ((lb + 0.055) / 1.055) ** 2.4 : lb / 12.92;
161
+
162
+ const x = (lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375) / 0.95047;
163
+ const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
164
+ const z = (lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041) / 1.08883;
165
+
166
+ const f = (t) => (t > 0.008856 ? t ** (1 / 3) : 7.787 * t + 16 / 116);
167
+ return [116 * f(y) - 16, 500 * (f(x) - f(y)), 200 * (f(y) - f(z))];
168
+ }
169
+
170
+ const PALETTE_LAB = PALETTE.map((c) => ({
171
+ ...c,
172
+ lab: srgbToLab(c.r, c.g, c.b),
173
+ }));
174
+
175
+ /**
176
+ * Returns the closest W3C color name for given RGB values.
177
+ */
178
+ export function rgbToColorName(r, g, b) {
179
+ const [L1, a1, b1] = srgbToLab(r, g, b);
180
+ let minDe = Infinity,
181
+ closest = 'black';
182
+
183
+ for (const c of PALETTE_LAB) {
184
+ const [L2, a2, b2] = c.lab;
185
+ const de = Math.sqrt((L1 - L2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2);
186
+ if (de < minDe) {
187
+ minDe = de;
188
+ closest = c.name;
189
+ }
190
+ }
191
+ return closest;
192
+ }
193
+
194
+ /**
195
+ * Parses CSS color string to W3C named color.
196
+ * Migrated from grid.js parseColor().
197
+ *
198
+ * §H9 P3 MESS-GRANULARITÄT: parseColor IST der Mess-Farbraum des Systems —
199
+ * jede numerische Farbe (hex/rgb) wird auf den NÄCHSTEN der 140 W3C-Namen
200
+ * quantisiert (CIELAB-Distanz, rgbToColorName). Konsequenz für Vergleiche
201
+ * (z.B. COLOR-Constraint): Hexes derselben Palette-Zelle sind GLEICH
202
+ * (#ff0000 ≡ #ff0001 ≡ 'red'), Hexes verschiedener Zellen verschieden
203
+ * (#ff0000 'red' ≢ #ff6347 'tomato'). Bewusste Mess-Granularität — eine
204
+ * hex-exakte Unterscheidung wäre ein eigener Kanal (Design-Runde), keine
205
+ * stille Änderung dieses Quantisierers.
206
+ */
207
+ export function parseColor(css) {
208
+ if (!css || css === 'none' || css === 'rgba(0, 0, 0, 0)')
209
+ return 'transparent';
210
+ if (css === 'indeterminate') return 'indeterminate'; // §HEAL3b: ehrlicher Nicht-Mess-Token, erstklassig
211
+ if (css.startsWith('url(')) return 'gradient';
212
+ // §H9 K-08a: Hex-Formen (#rgb/#rrggbb) sind verlustfrei nach rgb auflösbar
213
+ // und werden in DENSELBEN Namensraum quantisiert wie die Mess-Seite
214
+ // (rgbToColorName/CIELAB) — kein zweites Farbsystem, keine Toleranz-Schwelle.
215
+ // Andere Token fallen wie bisher unverändert durch (Fallback unten).
216
+ const hex = css.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
217
+ if (hex) {
218
+ const h =
219
+ hex[1].length === 3
220
+ ? hex[1].replace(/./g, (c) => c + c)
221
+ : hex[1];
222
+ return rgbToColorName(
223
+ parseInt(h.slice(0, 2), 16),
224
+ parseInt(h.slice(2, 4), 16),
225
+ parseInt(h.slice(4, 6), 16),
226
+ );
227
+ }
228
+ const match = css.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
229
+ if (match)
230
+ return rgbToColorName(
231
+ parseInt(match[1], 10),
232
+ parseInt(match[2], 10),
233
+ parseInt(match[3], 10),
234
+ );
235
+ return css;
236
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * transforms.js — SVG transform-Token-Algebra (Hexagonal-rein, lib/-Schicht)
3
+ * Vector Mirror v2.6 · Sprint §1.5 Transform-Fallback
4
+ *
5
+ * lib-Modul (REGEL-4 Hexagonal): pure Funktionen, KEINE Importe aus adapters/
6
+ * oder interface/. Konsumenten: pipeline.js (arrange-Pfad) + structured.js
7
+ * (Emitter §1.5 Transform-Fallback).
8
+ *
9
+ * Extraktion aus pipeline.js (§1.5 Block B): buildTransform + hasTranslateTransform
10
+ * lebten im Orchestrator; der Emitter braucht sie für den Transform-Fallback.
11
+ * Verschiebung in lib/ macht sie für beide Schichten importierbar, ohne dass der
12
+ * Emitter den Orchestrator zieht (Hexagonal-Layering).
13
+ *
14
+ * ── Zwei Translate-Merge-Semantiken (R-A-Auflösung §1.5, bewusst getrennt) ──
15
+ * buildTransform (REPLACE-IN-PLACE): translate ersetzt den bestehenden
16
+ * translate-Token an dessen Original-Position (bzw. Listenanfang, wenn keiner
17
+ * existiert). Korrekt für arrange(): dort sind dx/dy Deltas im LOKALEN,
18
+ * un-transformierten Element-Koordinatensystem (state.bbox ist die deklarierte
19
+ * Local-BBox, NICHT die CTM-projizierte). Der Autor-transform (scale/rotate)
20
+ * bleibt als Kontext erhalten — Erhalt der Autor-Intention.
21
+ * Empirie-Anker: test_arrange.js:428 'rotate(45) translate(5 6) scale(2)'
22
+ * → 'rotate(45) translate(140 280) scale(2)'.
23
+ *
24
+ * prependTranslate (FRONT-PREPEND): translate steht IMMER am Listenanfang
25
+ * (zuletzt angewendet = wirkt in finaler Welt-Koordinate). Korrekt für den
26
+ * §1.5-Position-Fix: dort kommen dx/dy vom Spotter, berechnet auf der
27
+ * CTM-PROJIZIERTEN Welt-BBox. Die Gegenbewegung muss außen wrappen, damit sie
28
+ * unabhängig von einem Autor-scale()/rotate() in Welt-px wirkt (SOTA Präzision 1).
29
+ *
30
+ * Beide Funktionen entfernen vorhandene translate-Tokens VOR dem Insert →
31
+ * Idempotenz (G-14): wiederholte Anwendung mit gleichem dx/dy ist Fixpunkt.
32
+ */
33
+
34
+ /**
35
+ * True, wenn der transform-String einen translate/translateX/translateY/
36
+ * translateZ-Token trägt. Case-insensitiv; tolerant gegen null/undefined.
37
+ */
38
+ export function hasTranslateTransform(transform) {
39
+ return /\btranslate[XYZ]?\s*\(/i.test(transform ?? '');
40
+ }
41
+
42
+ /**
43
+ * Zerlegt einen transform-String in Funktions-Tokens (z.B. ['rotate(45)',
44
+ * 'scale(2)']). Liefert null, wenn kein Token erkennbar ist.
45
+ */
46
+ function tokenize(transform) {
47
+ const trimmed = transform?.trim();
48
+ if (!trimmed) return [];
49
+ return trimmed.match(/[a-zA-Z]+\s*\([^)]*\)/g);
50
+ }
51
+
52
+ /**
53
+ * buildTransform (REPLACE-IN-PLACE) — siehe Modul-Doku.
54
+ * Setzt translate(dx dy) an die Position des ersten bestehenden translate-Tokens
55
+ * (oder an den Listenanfang, wenn keiner existiert) und entfernt alle übrigen
56
+ * translate-Tokens. Erhält die relative Ordnung der Nicht-translate-Tokens.
57
+ *
58
+ * @param {string|undefined} existingTransform - bestehender transform-Attribut-Wert.
59
+ * @param {number} dx - X-Verschiebung.
60
+ * @param {number} dy - Y-Verschiebung.
61
+ * @returns {string} neuer transform-String.
62
+ */
63
+ export function buildTransform(existingTransform, dx, dy) {
64
+ const translate = `translate(${dx} ${dy})`;
65
+ const trimmed = existingTransform?.trim();
66
+ if (!trimmed) return translate;
67
+
68
+ const tokens = tokenize(trimmed);
69
+ if (!tokens) return `${translate} ${trimmed}`;
70
+
71
+ const nextTokens = [];
72
+ let translateIndex = null;
73
+
74
+ for (const token of tokens) {
75
+ if (/^translate[XYZ]?\s*\(/i.test(token)) {
76
+ if (translateIndex === null) translateIndex = nextTokens.length;
77
+ continue;
78
+ }
79
+ nextTokens.push(token.trim());
80
+ }
81
+
82
+ const insertAt = translateIndex ?? 0;
83
+ nextTokens.splice(insertAt, 0, translate);
84
+ return nextTokens.join(' ').trim();
85
+ }
86
+
87
+ /**
88
+ * prependTranslate (FRONT-PREPEND) — siehe Modul-Doku.
89
+ * Stellt translate(dx dy) IMMER an den Listenanfang und entfernt alle
90
+ * bestehenden translate-Tokens (Idempotenz). Erhält die relative Ordnung der
91
+ * Nicht-translate-Tokens hinter dem neuen translate.
92
+ *
93
+ * Für den §1.5-Position-Fix: dx/dy sind Welt-px-Deltas, die außen wrappen
94
+ * müssen, damit sie unabhängig von Autor-scale()/rotate() wirken.
95
+ *
96
+ * @param {string|undefined} existingTransform - bestehender transform-Attribut-Wert.
97
+ * @param {number} dx - X-Verschiebung in Welt-px.
98
+ * @param {number} dy - Y-Verschiebung in Welt-px.
99
+ * @returns {string} neuer transform-String, beginnt mit 'translate('.
100
+ */
101
+ export function prependTranslate(existingTransform, dx, dy) {
102
+ const translate = `translate(${dx} ${dy})`;
103
+ const tokens = tokenize(existingTransform);
104
+ if (!tokens || tokens.length === 0) return translate;
105
+
106
+ const kept = tokens
107
+ .filter((token) => !/^translate[XYZ]?\s*\(/i.test(token))
108
+ .map((token) => token.trim());
109
+
110
+ return [translate, ...kept].join(' ').trim();
111
+ }