engagelab-captcha-sdk 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.
Files changed (73) hide show
  1. package/dist/captcha-sdk.esm.js +1 -0
  2. package/dist/captcha-sdk.umd.js +1 -0
  3. package/dist/types/api.d.ts +12 -0
  4. package/dist/types/challenges/dragsort.d.ts +24 -0
  5. package/dist/types/challenges/grid.d.ts +20 -0
  6. package/dist/types/challenges/icons.d.ts +18 -0
  7. package/dist/types/challenges/invisible.d.ts +8 -0
  8. package/dist/types/challenges/physics.d.ts +40 -0
  9. package/dist/types/challenges/rotate.d.ts +14 -0
  10. package/dist/types/challenges/slider.d.ts +17 -0
  11. package/dist/types/challenges/spatial.d.ts +12 -0
  12. package/dist/types/compat/hcaptcha.d.ts +1 -0
  13. package/dist/types/compat/recaptcha.d.ts +1 -0
  14. package/dist/types/crypto.d.ts +11 -0
  15. package/dist/types/deep_probes.d.ts +13 -0
  16. package/dist/types/degradation.d.ts +27 -0
  17. package/dist/types/device_attestation.d.ts +19 -0
  18. package/dist/types/fingerprint.d.ts +2 -0
  19. package/dist/types/headless.d.ts +2 -0
  20. package/dist/types/i18n.d.ts +7 -0
  21. package/dist/types/index.d.ts +36 -0
  22. package/dist/types/integrity.d.ts +3 -0
  23. package/dist/types/logger.d.ts +49 -0
  24. package/dist/types/native_monitor.d.ts +21 -0
  25. package/dist/types/passive_signals.d.ts +119 -0
  26. package/dist/types/pow.d.ts +2 -0
  27. package/dist/types/reporter.d.ts +8 -0
  28. package/dist/types/session_persist.d.ts +18 -0
  29. package/dist/types/theme.d.ts +11 -0
  30. package/dist/types/timing.d.ts +2 -0
  31. package/dist/types/trajectory.d.ts +18 -0
  32. package/dist/types/types.d.ts +182 -0
  33. package/dist/types/ui/container.d.ts +20 -0
  34. package/dist/types/ui/styles.d.ts +1 -0
  35. package/dist/types/wasm_core.d.ts +44 -0
  36. package/dist/types/web-component.d.ts +11 -0
  37. package/package.json +22 -0
  38. package/rollup.config.mjs +62 -0
  39. package/src/api.ts +180 -0
  40. package/src/challenges/dragsort.ts +168 -0
  41. package/src/challenges/grid.ts +147 -0
  42. package/src/challenges/icons.ts +106 -0
  43. package/src/challenges/invisible.ts +57 -0
  44. package/src/challenges/physics.ts +437 -0
  45. package/src/challenges/rotate.ts +81 -0
  46. package/src/challenges/slider.ts +168 -0
  47. package/src/challenges/spatial.ts +91 -0
  48. package/src/compat/hcaptcha.ts +112 -0
  49. package/src/compat/recaptcha.ts +108 -0
  50. package/src/crypto.ts +69 -0
  51. package/src/deep_probes.ts +690 -0
  52. package/src/degradation.ts +113 -0
  53. package/src/device_attestation.ts +109 -0
  54. package/src/fingerprint.ts +247 -0
  55. package/src/headless.ts +233 -0
  56. package/src/i18n.ts +455 -0
  57. package/src/index.ts +527 -0
  58. package/src/integrity.ts +100 -0
  59. package/src/logger.ts +170 -0
  60. package/src/native_monitor.ts +100 -0
  61. package/src/passive_signals.ts +544 -0
  62. package/src/pow.ts +120 -0
  63. package/src/reporter.ts +75 -0
  64. package/src/session_persist.ts +90 -0
  65. package/src/theme.ts +41 -0
  66. package/src/timing.ts +79 -0
  67. package/src/trajectory.ts +110 -0
  68. package/src/types.ts +199 -0
  69. package/src/ui/container.ts +161 -0
  70. package/src/ui/styles.ts +153 -0
  71. package/src/wasm_core.ts +189 -0
  72. package/src/web-component.ts +103 -0
  73. package/tsconfig.json +18 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Physics-based interactive CAPTCHA challenge.
3
+ *
4
+ * Renders 2D physics simulations using Canvas:
5
+ * - Tilt Ball: tilt a platform to guide a ball into a target hole
6
+ * - Gravity Drop: predict where an object lands
7
+ * - Balance Scale: place weights to balance a scale
8
+ *
9
+ * These challenges are extremely difficult for AI/ML because they require
10
+ * real-time spatial-physical reasoning, not just image recognition.
11
+ */
12
+
13
+ import { sdkLogger } from '../logger';
14
+
15
+ interface PhysicsResult {
16
+ answer: Record<string, any> | string;
17
+ passtime: number;
18
+ interactionEvents?: Array<{ type: string; time: number; value: any }>;
19
+ events?: any[];
20
+ error?: string;
21
+ }
22
+
23
+ export class PhysicsChallenge {
24
+ private canvas: HTMLCanvasElement | null = null;
25
+ private ctx: CanvasRenderingContext2D | null = null;
26
+ private startTime = 0;
27
+ private events: Array<{ type: string; time: number; value: any }> = [];
28
+ private animFrameId: number | null = null;
29
+ private boundListeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
30
+
31
+ create(
32
+ challengeConfig: any,
33
+ previewBase64: string,
34
+ ): { element: HTMLElement; promise: Promise<PhysicsResult> } {
35
+ sdkLogger.info('PhysicsChallenge', `create: variant=${challengeConfig.variant}`);
36
+
37
+ const wrapper = document.createElement('div');
38
+ wrapper.style.cssText = 'width:100%;max-width:370px;margin:0 auto;text-align:center;';
39
+
40
+ const title = document.createElement('div');
41
+ title.textContent = challengeConfig.instruction_zh || challengeConfig.instruction || '完成物理挑战';
42
+ title.style.cssText = 'font-size:14px;color:#333;margin-bottom:12px;padding:0 8px;';
43
+ wrapper.appendChild(title);
44
+
45
+ this.canvas = document.createElement('canvas');
46
+ this.canvas.width = 360;
47
+ this.canvas.height = 270;
48
+ this.canvas.style.cssText = 'border:1px solid #ddd;border-radius:8px;background:#f0f5fa;cursor:pointer;display:block;margin:0 auto;max-width:100%;';
49
+ this.canvas.setAttribute('role', 'img');
50
+ this.canvas.setAttribute('aria-label', challengeConfig.instruction_zh || challengeConfig.instruction || '物理挑战');
51
+ this.canvas.setAttribute('tabindex', '0');
52
+ wrapper.appendChild(this.canvas);
53
+
54
+ this.ctx = this.canvas.getContext('2d')!;
55
+ this.startTime = performance.now();
56
+
57
+ const promise = new Promise<PhysicsResult>((resolve) => {
58
+ const variant = challengeConfig.variant;
59
+ sdkLogger.info('PhysicsChallenge', `setup variant=${variant}, keys=${Object.keys(challengeConfig).join(',')}`);
60
+
61
+ if (variant === 'tilt_ball') {
62
+ this.setupTiltBall(challengeConfig, resolve);
63
+ } else if (variant === 'gravity_drop') {
64
+ this.setupGravityDrop(challengeConfig, resolve);
65
+ } else if (variant === 'balance_scale') {
66
+ this.setupBalanceScale(challengeConfig, resolve);
67
+ } else {
68
+ const ctx = this.ctx!;
69
+ const W = this.canvas!.width;
70
+ const H = this.canvas!.height;
71
+ ctx.fillStyle = '#f0f5fa';
72
+ ctx.fillRect(0, 0, W, H);
73
+ ctx.fillStyle = '#666';
74
+ ctx.font = '14px sans-serif';
75
+ ctx.textAlign = 'center';
76
+ ctx.fillText('加载物理挑战...', W / 2, H / 2);
77
+ resolve({ answer: '', events: [], passtime: 0, error: `unknown_variant:${variant}` });
78
+ }
79
+ });
80
+
81
+ return { element: wrapper, promise };
82
+ }
83
+
84
+ destroy(): void {
85
+ if (this.animFrameId != null) {
86
+ cancelAnimationFrame(this.animFrameId);
87
+ this.animFrameId = null;
88
+ }
89
+ for (const { el, type, fn } of this.boundListeners) {
90
+ el.removeEventListener(type, fn);
91
+ }
92
+ this.boundListeners = [];
93
+ this.canvas = null;
94
+ this.ctx = null;
95
+ }
96
+
97
+ private addListener(el: EventTarget, type: string, fn: EventListener, opts?: AddEventListenerOptions): void {
98
+ el.addEventListener(type, fn, opts);
99
+ this.boundListeners.push({ el, type, fn });
100
+ }
101
+
102
+ private setupTiltBall(config: any, resolve: (r: PhysicsResult) => void): void {
103
+ const canvas = this.canvas!;
104
+ const ctx = this.ctx!;
105
+ const W = canvas.width;
106
+ const H = canvas.height;
107
+
108
+ const ball = { x: config.ball_start.x * W, y: config.ball_start.y * H, vx: 0, vy: 0 };
109
+ const holes = config.holes.map((h: any) => ({
110
+ x: h.x * W, y: h.y * H, r: h.radius * W,
111
+ }));
112
+ const targetIdx = config.target_hole;
113
+ const friction = config.friction;
114
+ let tiltAngle = 0;
115
+ let running = true;
116
+
117
+ const render = () => {
118
+ ctx.clearRect(0, 0, W, H);
119
+
120
+ // Background
121
+ ctx.fillStyle = '#f0f5fa';
122
+ ctx.fillRect(0, 0, W, H);
123
+
124
+ // Platform (tilted)
125
+ ctx.save();
126
+ ctx.translate(W / 2, H * 0.85);
127
+ ctx.rotate(tiltAngle * Math.PI / 180);
128
+ ctx.fillStyle = '#8B7355';
129
+ ctx.fillRect(-W * 0.45, -4, W * 0.9, 8);
130
+ ctx.restore();
131
+
132
+ // Holes
133
+ holes.forEach((h: any, i: number) => {
134
+ ctx.beginPath();
135
+ ctx.arc(h.x, h.y, h.r, 0, Math.PI * 2);
136
+ ctx.fillStyle = i === targetIdx ? '#4CAF50' : '#666';
137
+ ctx.fill();
138
+ ctx.strokeStyle = i === targetIdx ? '#2E7D32' : '#444';
139
+ ctx.lineWidth = 2;
140
+ ctx.stroke();
141
+ if (i === targetIdx) {
142
+ ctx.fillStyle = '#fff';
143
+ ctx.font = '10px sans-serif';
144
+ ctx.textAlign = 'center';
145
+ ctx.fillText('TARGET', h.x, h.y + 4);
146
+ }
147
+ });
148
+
149
+ // Ball
150
+ ctx.beginPath();
151
+ ctx.arc(ball.x, ball.y, 10, 0, Math.PI * 2);
152
+ ctx.fillStyle = '#E53935';
153
+ ctx.fill();
154
+ ctx.strokeStyle = '#B71C1C';
155
+ ctx.lineWidth = 2;
156
+ ctx.stroke();
157
+
158
+ ctx.fillStyle = '#333';
159
+ ctx.font = '12px sans-serif';
160
+ ctx.textAlign = 'left';
161
+ ctx.fillText(`倾斜: ${tiltAngle.toFixed(1)}°`, 10, H - 10);
162
+
163
+ ctx.fillStyle = '#888';
164
+ ctx.font = '11px sans-serif';
165
+ ctx.textAlign = 'center';
166
+ ctx.fillText('← 左右移动鼠标来倾斜平台 →', W / 2, 20);
167
+ };
168
+
169
+ const physics = () => {
170
+ if (!running) return;
171
+ const gravity = 0.3;
172
+ const ax = gravity * Math.sin(tiltAngle * Math.PI / 180);
173
+ ball.vx += ax;
174
+ ball.vx *= (1 - friction);
175
+ ball.vy += gravity * Math.cos(tiltAngle * Math.PI / 180) * 0.1;
176
+ ball.vy *= 0.98;
177
+ ball.x += ball.vx;
178
+ ball.y += ball.vy;
179
+
180
+ // Boundary
181
+ if (ball.x < 15) { ball.x = 15; ball.vx *= -0.3; }
182
+ if (ball.x > W - 15) { ball.x = W - 15; ball.vx *= -0.3; }
183
+ if (ball.y > H * 0.82) { ball.y = H * 0.82; ball.vy *= -0.3; }
184
+ if (ball.y < 15) { ball.y = 15; ball.vy *= -0.3; }
185
+
186
+ // Check hole collision
187
+ for (let i = 0; i < holes.length; i++) {
188
+ const h = holes[i];
189
+ const dist = Math.sqrt((ball.x - h.x) ** 2 + (ball.y - h.y) ** 2);
190
+ if (dist < h.r + 5 && Math.abs(ball.vx) < 3) {
191
+ running = false;
192
+ sdkLogger.info('PhysicsChallenge', `Ball entered hole ${i} (target=${targetIdx})`);
193
+ resolve({
194
+ answer: { target_hole_index: i },
195
+ passtime: performance.now() - this.startTime,
196
+ interactionEvents: this.events,
197
+ });
198
+ return;
199
+ }
200
+ }
201
+
202
+ render();
203
+ this.animFrameId = requestAnimationFrame(physics);
204
+ };
205
+
206
+ this.addListener(canvas, 'mousemove', ((e: MouseEvent) => {
207
+ const rect = canvas.getBoundingClientRect();
208
+ const mx = e.clientX - rect.left;
209
+ tiltAngle = ((mx / W) - 0.5) * 60;
210
+ this.events.push({
211
+ type: 'tilt',
212
+ time: performance.now() - this.startTime,
213
+ value: tiltAngle,
214
+ });
215
+ }) as EventListener);
216
+
217
+ render();
218
+ this.animFrameId = requestAnimationFrame(physics);
219
+ }
220
+
221
+ private setupGravityDrop(config: any, resolve: (r: PhysicsResult) => void): void {
222
+ const canvas = this.canvas!;
223
+ const ctx = this.ctx!;
224
+ const W = canvas.width;
225
+ const H = canvas.height;
226
+
227
+ const dropX = config.drop_position.x * W;
228
+ const platforms = config.platforms.map((p: any) => ({
229
+ x: p.x * W, y: p.y * H, w: p.width * W, angle: p.angle,
230
+ }));
231
+
232
+ // Render static scene
233
+ const render = () => {
234
+ ctx.clearRect(0, 0, W, H);
235
+ ctx.fillStyle = '#f0f5fa';
236
+ ctx.fillRect(0, 0, W, H);
237
+
238
+ // Drop position
239
+ ctx.beginPath();
240
+ ctx.arc(dropX, 20, 8, 0, Math.PI * 2);
241
+ ctx.fillStyle = '#E53935';
242
+ ctx.fill();
243
+ ctx.fillStyle = '#E53935';
244
+ ctx.font = '10px sans-serif';
245
+ ctx.textAlign = 'center';
246
+ ctx.fillText('↓ DROP', dropX, 42);
247
+
248
+ // Platforms
249
+ platforms.forEach((p: any) => {
250
+ ctx.save();
251
+ ctx.translate(p.x, p.y);
252
+ ctx.rotate(p.angle * Math.PI / 180);
253
+ ctx.fillStyle = '#5C6BC0';
254
+ ctx.fillRect(-p.w / 2, -3, p.w, 6);
255
+ ctx.restore();
256
+ });
257
+
258
+ // Ground
259
+ ctx.fillStyle = '#795548';
260
+ ctx.fillRect(0, H - 10, W, 10);
261
+
262
+ ctx.fillStyle = '#333';
263
+ ctx.font = '12px sans-serif';
264
+ ctx.textAlign = 'center';
265
+ ctx.fillText('点击球落地的位置', W / 2, H - 20);
266
+ };
267
+
268
+ render();
269
+
270
+ const clickHandler = ((e: MouseEvent) => {
271
+ const rect = canvas.getBoundingClientRect();
272
+ const clickX = (e.clientX - rect.left) / W;
273
+ const clickY = (e.clientY - rect.top) / H;
274
+
275
+ sdkLogger.info('PhysicsChallenge', `Gravity drop click: x=${clickX.toFixed(3)}, y=${clickY.toFixed(3)}`);
276
+
277
+ ctx.beginPath();
278
+ ctx.arc(e.clientX - rect.left, e.clientY - rect.top, 6, 0, Math.PI * 2);
279
+ ctx.fillStyle = '#FF9800';
280
+ ctx.fill();
281
+ ctx.strokeStyle = '#E65100';
282
+ ctx.lineWidth = 2;
283
+ ctx.stroke();
284
+
285
+ this.events.push({
286
+ type: 'click',
287
+ time: performance.now() - this.startTime,
288
+ value: { x: clickX, y: clickY },
289
+ });
290
+
291
+ resolve({
292
+ answer: { click_x: clickX, click_y: clickY },
293
+ passtime: performance.now() - this.startTime,
294
+ interactionEvents: this.events,
295
+ });
296
+ }) as EventListener;
297
+ this.addListener(canvas, 'click', clickHandler);
298
+ }
299
+
300
+ private setupBalanceScale(config: any, resolve: (r: PhysicsResult) => void): void {
301
+ const canvas = this.canvas!;
302
+ const ctx = this.ctx!;
303
+ const W = canvas.width;
304
+ const H = canvas.height;
305
+
306
+ const existing = config.existing_weights || [];
307
+ const available = config.available_weights || [1, 2, 3, 5];
308
+ let selectedWeight = available[0];
309
+ let placeSide: 'left' | 'right' = 'left';
310
+ let placeDistance = 0.5;
311
+
312
+ const render = () => {
313
+ ctx.clearRect(0, 0, W, H);
314
+ ctx.fillStyle = '#f0f5fa';
315
+ ctx.fillRect(0, 0, W, H);
316
+
317
+ // Fulcrum
318
+ ctx.beginPath();
319
+ ctx.moveTo(W / 2 - 15, H * 0.75);
320
+ ctx.lineTo(W / 2 + 15, H * 0.75);
321
+ ctx.lineTo(W / 2, H * 0.65);
322
+ ctx.closePath();
323
+ ctx.fillStyle = '#5D4037';
324
+ ctx.fill();
325
+
326
+ // Beam
327
+ ctx.fillStyle = '#8D6E63';
328
+ ctx.fillRect(W * 0.1, H * 0.63, W * 0.8, 6);
329
+
330
+ // Existing weights
331
+ existing.forEach((w: any) => {
332
+ const wx = W / 2 + (w.side === 'right' ? 1 : -1) * w.distance * W * 0.35;
333
+ const wy = H * 0.58;
334
+ ctx.fillStyle = '#1565C0';
335
+ ctx.fillRect(wx - 12, wy, 24, 20);
336
+ ctx.fillStyle = '#fff';
337
+ ctx.font = 'bold 12px sans-serif';
338
+ ctx.textAlign = 'center';
339
+ ctx.fillText(String(w.weight), wx, wy + 14);
340
+ });
341
+
342
+ // Preview of user's placement
343
+ const previewX = W / 2 + (placeSide === 'right' ? 1 : -1) * placeDistance * W * 0.35;
344
+ ctx.fillStyle = 'rgba(255, 152, 0, 0.6)';
345
+ ctx.fillRect(previewX - 12, H * 0.43, 24, 20);
346
+ ctx.fillStyle = '#fff';
347
+ ctx.font = 'bold 12px sans-serif';
348
+ ctx.textAlign = 'center';
349
+ ctx.fillText(String(selectedWeight), previewX, H * 0.43 + 14);
350
+
351
+ ctx.fillStyle = '#333';
352
+ ctx.font = '11px sans-serif';
353
+ ctx.textAlign = 'center';
354
+ ctx.fillText('选择砝码重量(点击数字):', W / 2, 25);
355
+
356
+ available.forEach((w: number, i: number) => {
357
+ const bx = W / 2 - (available.length * 25) / 2 + i * 30;
358
+ ctx.fillStyle = w === selectedWeight ? '#FF9800' : '#ddd';
359
+ ctx.fillRect(bx, 35, 25, 25);
360
+ ctx.fillStyle = w === selectedWeight ? '#fff' : '#333';
361
+ ctx.font = 'bold 12px sans-serif';
362
+ ctx.fillText(String(w), bx + 12, 52);
363
+ });
364
+
365
+ // Side selector
366
+ ctx.fillStyle = placeSide === 'left' ? '#4CAF50' : '#ddd';
367
+ ctx.fillRect(30, H * 0.85, 60, 25);
368
+ ctx.fillStyle = placeSide === 'left' ? '#fff' : '#333';
369
+ ctx.font = '11px sans-serif';
370
+ ctx.fillText('左边', 60, H * 0.85 + 16);
371
+
372
+ ctx.fillStyle = placeSide === 'right' ? '#4CAF50' : '#ddd';
373
+ ctx.fillRect(W - 90, H * 0.85, 60, 25);
374
+ ctx.fillStyle = placeSide === 'right' ? '#fff' : '#333';
375
+ ctx.fillText('右边', W - 60, H * 0.85 + 16);
376
+
377
+ ctx.fillStyle = '#1890ff';
378
+ ctx.fillRect(W / 2 - 40, H - 35, 80, 28);
379
+ ctx.fillStyle = '#fff';
380
+ ctx.font = 'bold 12px sans-serif';
381
+ ctx.fillText('确认', W / 2, H - 17);
382
+
383
+ ctx.fillStyle = '#666';
384
+ ctx.font = '10px sans-serif';
385
+ ctx.fillText(`距离: ${placeDistance.toFixed(2)}`, W / 2, H * 0.42 - 5);
386
+ };
387
+
388
+ render();
389
+
390
+ this.addListener(canvas, 'click', ((e: MouseEvent) => {
391
+ const rect = canvas.getBoundingClientRect();
392
+ const mx = e.clientX - rect.left;
393
+ const my = e.clientY - rect.top;
394
+
395
+ // Weight selector
396
+ available.forEach((w: number, i: number) => {
397
+ const bx = W / 2 - (available.length * 25) / 2 + i * 30;
398
+ if (mx >= bx && mx <= bx + 25 && my >= 35 && my <= 60) {
399
+ selectedWeight = w;
400
+ this.events.push({ type: 'select_weight', time: performance.now() - this.startTime, value: w });
401
+ }
402
+ });
403
+
404
+ // Side selector
405
+ if (mx >= 30 && mx <= 90 && my >= H * 0.85 && my <= H * 0.85 + 25) {
406
+ placeSide = 'left';
407
+ this.events.push({ type: 'select_side', time: performance.now() - this.startTime, value: 'left' });
408
+ }
409
+ if (mx >= W - 90 && mx <= W - 30 && my >= H * 0.85 && my <= H * 0.85 + 25) {
410
+ placeSide = 'right';
411
+ this.events.push({ type: 'select_side', time: performance.now() - this.startTime, value: 'right' });
412
+ }
413
+
414
+ // Distance adjustment (click on beam area)
415
+ if (my >= H * 0.5 && my <= H * 0.7) {
416
+ const relX = (mx - W * 0.15) / (W * 0.7);
417
+ if (relX >= 0 && relX <= 1) {
418
+ placeDistance = Math.abs(relX - 0.5) * 2;
419
+ this.events.push({ type: 'set_distance', time: performance.now() - this.startTime, value: placeDistance });
420
+ }
421
+ }
422
+
423
+ // Confirm button
424
+ if (mx >= W / 2 - 40 && mx <= W / 2 + 40 && my >= H - 35 && my <= H - 7) {
425
+ sdkLogger.info('PhysicsChallenge', `Balance scale confirm: weight=${selectedWeight}, side=${placeSide}, dist=${placeDistance.toFixed(2)}`);
426
+ resolve({
427
+ answer: { weight: selectedWeight, side: placeSide, distance: placeDistance },
428
+ passtime: performance.now() - this.startTime,
429
+ interactionEvents: this.events,
430
+ });
431
+ return;
432
+ }
433
+
434
+ render();
435
+ }) as EventListener);
436
+ }
437
+ }
@@ -0,0 +1,81 @@
1
+ import { TrajectoryRecorder } from '../trajectory';
2
+ import { t } from '../i18n';
3
+
4
+ export interface RotateResult {
5
+ angle: number;
6
+ trajectory: any[];
7
+ passtime: number;
8
+ }
9
+
10
+ export class RotateChallenge {
11
+ private recorder = new TrajectoryRecorder();
12
+ private resolve?: (result: RotateResult) => void;
13
+ private currentAngle = 0;
14
+
15
+ create(imageBase64: string): {
16
+ element: HTMLElement;
17
+ promise: Promise<RotateResult>;
18
+ } {
19
+ const startTime = performance.now();
20
+ const wrapper = document.createElement('div');
21
+ wrapper.className = 'elc-rotate-container';
22
+
23
+ const title = document.createElement('div');
24
+ title.className = 'elc-title';
25
+ title.textContent = t('rotate.hint');
26
+
27
+ const imgWrapper = document.createElement('div');
28
+ imgWrapper.className = 'elc-rotate-image';
29
+ const img = document.createElement('img');
30
+ img.src = `data:image/png;base64,${imageBase64}`;
31
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
32
+ img.alt = t('challenge.imageAlt');
33
+ img.draggable = false;
34
+ imgWrapper.appendChild(img);
35
+
36
+ const slider = document.createElement('input');
37
+ slider.type = 'range';
38
+ slider.className = 'elc-rotate-slider';
39
+ slider.min = '0';
40
+ slider.max = '360';
41
+ slider.value = '0';
42
+ slider.setAttribute('aria-label', t('rotate.hint'));
43
+
44
+ const angleLabel = document.createElement('div');
45
+ angleLabel.style.cssText = 'font-size:13px;color:#888;';
46
+ angleLabel.textContent = '0°';
47
+
48
+ this.recorder.start(wrapper);
49
+
50
+ slider.addEventListener('input', () => {
51
+ this.currentAngle = Number(slider.value);
52
+ img.style.transform = `rotate(${this.currentAngle}deg)`;
53
+ angleLabel.textContent = `${this.currentAngle}°`;
54
+ });
55
+
56
+ const submitBtn = document.createElement('button');
57
+ submitBtn.className = 'elc-submit';
58
+ submitBtn.textContent = t('icons.confirm');
59
+
60
+ const promise = new Promise<RotateResult>((resolve) => {
61
+ this.resolve = resolve;
62
+ });
63
+
64
+ submitBtn.addEventListener('click', () => {
65
+ const trajectory = this.recorder.stop();
66
+ this.resolve?.({
67
+ angle: this.currentAngle,
68
+ trajectory,
69
+ passtime: performance.now() - startTime,
70
+ });
71
+ });
72
+
73
+ wrapper.appendChild(title);
74
+ wrapper.appendChild(imgWrapper);
75
+ wrapper.appendChild(slider);
76
+ wrapper.appendChild(angleLabel);
77
+ wrapper.appendChild(submitBtn);
78
+
79
+ return { element: wrapper, promise };
80
+ }
81
+ }
@@ -0,0 +1,168 @@
1
+ import { TrajectoryRecorder } from '../trajectory';
2
+ import { t } from '../i18n';
3
+
4
+ export interface SliderResult {
5
+ sliderX: number;
6
+ trajectory: any[];
7
+ passtime: number;
8
+ }
9
+
10
+ export class SliderChallenge {
11
+ private recorder = new TrajectoryRecorder();
12
+ private resolve?: (result: SliderResult) => void;
13
+ private targetX = 0;
14
+ private cleanupFns: (() => void)[] = [];
15
+
16
+ create(targetX: number): { element: HTMLElement; promise: Promise<SliderResult> } {
17
+ this.targetX = targetX;
18
+
19
+ const wrapper = document.createElement('div');
20
+ const title = document.createElement('div');
21
+ title.className = 'elc-title';
22
+ title.textContent = t('slider.hint');
23
+
24
+ const track = document.createElement('div');
25
+ track.className = 'elc-slider-track';
26
+
27
+ const fill = document.createElement('div');
28
+ fill.className = 'elc-slider-fill';
29
+
30
+ const thumb = document.createElement('div');
31
+ thumb.className = 'elc-slider-thumb';
32
+ thumb.innerHTML = '→';
33
+ thumb.setAttribute('role', 'slider');
34
+ thumb.setAttribute('tabindex', '0');
35
+ thumb.setAttribute('aria-label', t('slider.hint'));
36
+ thumb.setAttribute('aria-valuemin', '0');
37
+ thumb.setAttribute('aria-valuemax', '100');
38
+ thumb.setAttribute('aria-valuenow', '0');
39
+
40
+ const hint = document.createElement('div');
41
+ hint.className = 'elc-slider-hint';
42
+ hint.textContent = t('slider.hint');
43
+
44
+ track.appendChild(fill);
45
+ track.appendChild(hint);
46
+ track.appendChild(thumb);
47
+ wrapper.appendChild(title);
48
+ wrapper.appendChild(track);
49
+
50
+ const promise = new Promise<SliderResult>((resolve) => {
51
+ this.resolve = resolve;
52
+ });
53
+
54
+ this.setupDrag(track, thumb, fill, hint);
55
+ return { element: wrapper, promise };
56
+ }
57
+
58
+ destroy(): void {
59
+ this.cleanupFns.forEach(fn => fn());
60
+ this.cleanupFns = [];
61
+ }
62
+
63
+ private setupDrag(track: HTMLElement, thumb: HTMLElement, fill: HTMLElement, hint: HTMLElement) {
64
+ let isDragging = false;
65
+ let startX = 0;
66
+ let startTime = 0;
67
+ const trackWidth = () => track.offsetWidth - 40;
68
+
69
+ const start = (clientX: number) => {
70
+ isDragging = true;
71
+ startX = clientX;
72
+ startTime = performance.now();
73
+ hint.style.display = 'none';
74
+ this.recorder.start(track);
75
+ };
76
+
77
+ const move = (clientX: number) => {
78
+ if (!isDragging) return;
79
+ const dx = clientX - startX;
80
+ const maxX = trackWidth();
81
+ const x = Math.max(0, Math.min(dx, maxX));
82
+ const pct = (x / maxX) * 100;
83
+ thumb.style.left = `${x}px`;
84
+ fill.style.width = `${pct}%`;
85
+ };
86
+
87
+ const end = (clientX: number) => {
88
+ if (!isDragging) return;
89
+ isDragging = false;
90
+ const trajectory = this.recorder.stop();
91
+ const dx = clientX - startX;
92
+ const maxX = trackWidth();
93
+ const x = Math.max(0, Math.min(dx, maxX));
94
+ const pct = (x / maxX) * 100;
95
+ const passtime = performance.now() - startTime;
96
+
97
+ this.destroy();
98
+ this.resolve?.({ sliderX: pct, trajectory, passtime });
99
+ };
100
+
101
+ const onMouseDown = (e: MouseEvent) => { e.preventDefault(); start(e.clientX); };
102
+ const onMouseMove = (e: MouseEvent) => move(e.clientX);
103
+ const onMouseUp = (e: MouseEvent) => end(e.clientX);
104
+ const onTouchStart = (e: TouchEvent) => { start(e.touches[0].clientX); };
105
+ const onTouchMove = (e: TouchEvent) => { if (isDragging) move(e.touches[0].clientX); };
106
+ const onTouchEnd = (e: TouchEvent) => { if (isDragging) end(e.changedTouches[0].clientX); };
107
+
108
+ const onKeyDown = (e: KeyboardEvent) => {
109
+ const maxX = trackWidth();
110
+ const step = maxX * 0.02;
111
+ const currentX = parseFloat(thumb.style.left || '0');
112
+ let newX = currentX;
113
+
114
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
115
+ e.preventDefault();
116
+ newX = Math.min(currentX + step, maxX);
117
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
118
+ e.preventDefault();
119
+ newX = Math.max(currentX - step, 0);
120
+ } else if (e.key === 'Enter' || e.key === ' ') {
121
+ e.preventDefault();
122
+ if (!isDragging) {
123
+ startTime = performance.now();
124
+ this.recorder.start(track);
125
+ }
126
+ isDragging = false;
127
+ const trajectory = this.recorder.stop();
128
+ const pct = (newX / maxX) * 100;
129
+ const passtime = performance.now() - startTime;
130
+ this.destroy();
131
+ this.resolve?.({ sliderX: pct, trajectory, passtime });
132
+ return;
133
+ } else {
134
+ return;
135
+ }
136
+
137
+ if (!isDragging) {
138
+ isDragging = true;
139
+ startTime = performance.now();
140
+ hint.style.display = 'none';
141
+ this.recorder.start(track);
142
+ }
143
+
144
+ const pct = (newX / maxX) * 100;
145
+ thumb.style.left = `${newX}px`;
146
+ fill.style.width = `${pct}%`;
147
+ thumb.setAttribute('aria-valuenow', String(Math.round(pct)));
148
+ };
149
+
150
+ thumb.addEventListener('mousedown', onMouseDown);
151
+ document.addEventListener('mousemove', onMouseMove);
152
+ document.addEventListener('mouseup', onMouseUp);
153
+ thumb.addEventListener('touchstart', onTouchStart, { passive: true });
154
+ document.addEventListener('touchmove', onTouchMove, { passive: true });
155
+ document.addEventListener('touchend', onTouchEnd);
156
+ thumb.addEventListener('keydown', onKeyDown);
157
+
158
+ this.cleanupFns.push(
159
+ () => thumb.removeEventListener('mousedown', onMouseDown),
160
+ () => document.removeEventListener('mousemove', onMouseMove),
161
+ () => document.removeEventListener('mouseup', onMouseUp),
162
+ () => thumb.removeEventListener('touchstart', onTouchStart),
163
+ () => document.removeEventListener('touchmove', onTouchMove),
164
+ () => document.removeEventListener('touchend', onTouchEnd),
165
+ () => thumb.removeEventListener('keydown', onKeyDown),
166
+ );
167
+ }
168
+ }