@shumoku/renderer 0.2.0 → 0.2.3
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.
- package/dist/html/index.js +265 -157
- package/dist/html/index.js.map +1 -1
- package/dist/html/runtime.d.ts +2 -1
- package/dist/html/runtime.d.ts.map +1 -1
- package/dist/html/runtime.js +212 -486
- package/dist/html/runtime.js.map +1 -1
- package/dist/html/spotlight.d.ts +9 -0
- package/dist/html/spotlight.d.ts.map +1 -0
- package/dist/html/spotlight.js +119 -0
- package/dist/html/spotlight.js.map +1 -0
- package/dist/html/tooltip.d.ts +14 -0
- package/dist/html/tooltip.d.ts.map +1 -0
- package/dist/html/tooltip.js +105 -0
- package/dist/html/tooltip.js.map +1 -0
- package/dist/html/viewbox.d.ts +14 -0
- package/dist/html/viewbox.d.ts.map +1 -0
- package/dist/html/viewbox.js +21 -0
- package/dist/html/viewbox.js.map +1 -0
- package/dist/iife-string.js +1 -1
- package/dist/shumoku-interactive.iife.js +23 -20
- package/package.json +1 -1
- package/src/html/index.ts +334 -226
- package/src/html/runtime.ts +370 -654
- package/src/html/spotlight.ts +135 -0
- package/src/html/tooltip.ts +115 -0
- package/src/html/viewbox.ts +28 -0
package/src/html/runtime.ts
CHANGED
|
@@ -1,654 +1,370 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive Runtime -
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
updateHighlightPosition
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
x: number
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
// Convert screen delta to viewBox delta
|
|
372
|
-
const scaleX = panStartViewBox.width / rect.width
|
|
373
|
-
const scaleY = panStartViewBox.height / rect.height
|
|
374
|
-
|
|
375
|
-
setViewBox(svg, {
|
|
376
|
-
x: panStartViewBox.x - dx * scaleX,
|
|
377
|
-
y: panStartViewBox.y - dy * scaleY,
|
|
378
|
-
width: panStartViewBox.width,
|
|
379
|
-
height: panStartViewBox.height,
|
|
380
|
-
})
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Mouse up - end pan
|
|
384
|
-
const handleMouseUp = () => {
|
|
385
|
-
if (isPanning) {
|
|
386
|
-
isPanning = false
|
|
387
|
-
panStartViewBox = null
|
|
388
|
-
svg.style.cursor = ''
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Wheel zoom
|
|
393
|
-
const handleWheel = (e: Event) => {
|
|
394
|
-
if (!panZoomEnabled) return
|
|
395
|
-
const we = e as WheelEvent
|
|
396
|
-
we.preventDefault()
|
|
397
|
-
|
|
398
|
-
const vb = parseViewBox(svg)
|
|
399
|
-
if (!vb || !originalViewBox) return
|
|
400
|
-
|
|
401
|
-
const rect = svg.getBoundingClientRect()
|
|
402
|
-
const mouseXRatio = (we.clientX - rect.left) / rect.width
|
|
403
|
-
const mouseYRatio = (we.clientY - rect.top) / rect.height
|
|
404
|
-
|
|
405
|
-
const mouseX = vb.x + vb.width * mouseXRatio
|
|
406
|
-
const mouseY = vb.y + vb.height * mouseYRatio
|
|
407
|
-
|
|
408
|
-
const zoomFactor = we.deltaY > 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR
|
|
409
|
-
const newWidth = vb.width * zoomFactor
|
|
410
|
-
const newHeight = vb.height * zoomFactor
|
|
411
|
-
|
|
412
|
-
// Check scale limits
|
|
413
|
-
const newScale = originalViewBox.width / newWidth
|
|
414
|
-
if (newScale < minScale || newScale > maxScale) return
|
|
415
|
-
|
|
416
|
-
setViewBox(svg, {
|
|
417
|
-
x: mouseX - newWidth * mouseXRatio,
|
|
418
|
-
y: mouseY - newHeight * mouseYRatio,
|
|
419
|
-
width: newWidth,
|
|
420
|
-
height: newHeight,
|
|
421
|
-
})
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Touch start handler
|
|
425
|
-
const handleTouchStart = (e: Event) => {
|
|
426
|
-
isTouchDevice = true
|
|
427
|
-
const te = e as TouchEvent
|
|
428
|
-
|
|
429
|
-
if (te.touches.length === 1 && panZoomEnabled) {
|
|
430
|
-
// Single touch - check if on interactive element first
|
|
431
|
-
const touch = te.touches[0]
|
|
432
|
-
const targetEl = document.elementFromPoint(touch.clientX, touch.clientY)
|
|
433
|
-
const info = targetEl ? getTooltipInfo(targetEl) : null
|
|
434
|
-
|
|
435
|
-
if (!info) {
|
|
436
|
-
// Start pan
|
|
437
|
-
isPanning = true
|
|
438
|
-
panStartX = touch.clientX
|
|
439
|
-
panStartY = touch.clientY
|
|
440
|
-
panStartViewBox = parseViewBox(svg)
|
|
441
|
-
}
|
|
442
|
-
} else if (te.touches.length === 2 && panZoomEnabled) {
|
|
443
|
-
// Two touches - start pinch
|
|
444
|
-
isPanning = false
|
|
445
|
-
const t1 = te.touches[0]
|
|
446
|
-
const t2 = te.touches[1]
|
|
447
|
-
initialPinchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
|
|
448
|
-
pinchStartViewBox = parseViewBox(svg)
|
|
449
|
-
|
|
450
|
-
// Calculate pinch center in viewBox coordinates
|
|
451
|
-
const rect = svg.getBoundingClientRect()
|
|
452
|
-
const centerX = (t1.clientX + t2.clientX) / 2
|
|
453
|
-
const centerY = (t1.clientY + t2.clientY) / 2
|
|
454
|
-
const vb = pinchStartViewBox
|
|
455
|
-
if (vb) {
|
|
456
|
-
const xRatio = (centerX - rect.left) / rect.width
|
|
457
|
-
const yRatio = (centerY - rect.top) / rect.height
|
|
458
|
-
pinchCenter = {
|
|
459
|
-
x: vb.x + vb.width * xRatio,
|
|
460
|
-
y: vb.y + vb.height * yRatio,
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Touch move handler
|
|
467
|
-
const handleTouchMove = (e: Event) => {
|
|
468
|
-
const te = e as TouchEvent
|
|
469
|
-
|
|
470
|
-
if (te.touches.length === 1 && isPanning && panStartViewBox) {
|
|
471
|
-
// Pan
|
|
472
|
-
const touch = te.touches[0]
|
|
473
|
-
const rect = svg.getBoundingClientRect()
|
|
474
|
-
const dx = touch.clientX - panStartX
|
|
475
|
-
const dy = touch.clientY - panStartY
|
|
476
|
-
|
|
477
|
-
const scaleX = panStartViewBox.width / rect.width
|
|
478
|
-
const scaleY = panStartViewBox.height / rect.height
|
|
479
|
-
|
|
480
|
-
setViewBox(svg, {
|
|
481
|
-
x: panStartViewBox.x - dx * scaleX,
|
|
482
|
-
y: panStartViewBox.y - dy * scaleY,
|
|
483
|
-
width: panStartViewBox.width,
|
|
484
|
-
height: panStartViewBox.height,
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
te.preventDefault()
|
|
488
|
-
} else if (te.touches.length === 2 && pinchStartViewBox && pinchCenter && originalViewBox) {
|
|
489
|
-
// Pinch zoom
|
|
490
|
-
const t1 = te.touches[0]
|
|
491
|
-
const t2 = te.touches[1]
|
|
492
|
-
const distance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
|
|
493
|
-
const scale = distance / initialPinchDistance
|
|
494
|
-
|
|
495
|
-
const newWidth = pinchStartViewBox.width / scale
|
|
496
|
-
const newHeight = pinchStartViewBox.height / scale
|
|
497
|
-
|
|
498
|
-
// Check scale limits
|
|
499
|
-
const newScale = originalViewBox.width / newWidth
|
|
500
|
-
if (newScale < minScale || newScale > maxScale) return
|
|
501
|
-
|
|
502
|
-
// Zoom towards pinch center
|
|
503
|
-
const rect = svg.getBoundingClientRect()
|
|
504
|
-
const centerX = (t1.clientX + t2.clientX) / 2
|
|
505
|
-
const centerY = (t1.clientY + t2.clientY) / 2
|
|
506
|
-
const xRatio = (centerX - rect.left) / rect.width
|
|
507
|
-
const yRatio = (centerY - rect.top) / rect.height
|
|
508
|
-
|
|
509
|
-
setViewBox(svg, {
|
|
510
|
-
x: pinchCenter.x - newWidth * xRatio,
|
|
511
|
-
y: pinchCenter.y - newHeight * yRatio,
|
|
512
|
-
width: newWidth,
|
|
513
|
-
height: newHeight,
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
te.preventDefault()
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Touch end handler
|
|
521
|
-
const handleTouchEnd = (e: Event) => {
|
|
522
|
-
const te = e as TouchEvent
|
|
523
|
-
|
|
524
|
-
if (te.touches.length === 0) {
|
|
525
|
-
isPanning = false
|
|
526
|
-
panStartViewBox = null
|
|
527
|
-
pinchStartViewBox = null
|
|
528
|
-
pinchCenter = null
|
|
529
|
-
} else if (te.touches.length === 1) {
|
|
530
|
-
// Switched from pinch to pan
|
|
531
|
-
pinchStartViewBox = null
|
|
532
|
-
pinchCenter = null
|
|
533
|
-
isPanning = true
|
|
534
|
-
const touch = te.touches[0]
|
|
535
|
-
panStartX = touch.clientX
|
|
536
|
-
panStartY = touch.clientY
|
|
537
|
-
panStartViewBox = parseViewBox(svg)
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Touch/click handler for mobile tooltip
|
|
542
|
-
const handleTap = (e: Event) => {
|
|
543
|
-
if (!isTouchDevice) return
|
|
544
|
-
if (isPanning) return
|
|
545
|
-
|
|
546
|
-
const me = e as MouseEvent
|
|
547
|
-
const targetEl = e.target as Element
|
|
548
|
-
const info = getTooltipInfo(targetEl)
|
|
549
|
-
|
|
550
|
-
if (info) {
|
|
551
|
-
// Show tooltip at tap position
|
|
552
|
-
showTooltip(info.text, me.clientX, me.clientY)
|
|
553
|
-
highlightElement(info.element)
|
|
554
|
-
touchTooltipActive = true
|
|
555
|
-
e.preventDefault()
|
|
556
|
-
} else if (touchTooltipActive) {
|
|
557
|
-
// Tap on empty area - hide tooltip
|
|
558
|
-
hideTooltip()
|
|
559
|
-
highlightElement(null)
|
|
560
|
-
touchTooltipActive = false
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Position update handler for scroll/resize events
|
|
565
|
-
const handlePositionUpdate = () => {
|
|
566
|
-
updateHighlightPosition()
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Track viewBox changes for pan/zoom
|
|
570
|
-
let rafId: number | null = null
|
|
571
|
-
let lastViewBox = ''
|
|
572
|
-
const trackViewBox = () => {
|
|
573
|
-
if (currentHighlight) {
|
|
574
|
-
const viewBox = svg.getAttribute('viewBox') || ''
|
|
575
|
-
if (viewBox !== lastViewBox) {
|
|
576
|
-
lastViewBox = viewBox
|
|
577
|
-
updateHighlightPosition()
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
rafId = requestAnimationFrame(trackViewBox)
|
|
581
|
-
}
|
|
582
|
-
rafId = requestAnimationFrame(trackViewBox)
|
|
583
|
-
|
|
584
|
-
// Reset view
|
|
585
|
-
const resetView = () => {
|
|
586
|
-
if (originalViewBox) {
|
|
587
|
-
setViewBox(svg, originalViewBox)
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Set touch-action to prevent browser gestures
|
|
592
|
-
if (panZoomEnabled) {
|
|
593
|
-
svg.style.touchAction = 'none'
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Add event listeners
|
|
597
|
-
svg.addEventListener('mousemove', handleMouseMove)
|
|
598
|
-
svg.addEventListener('mouseleave', handleMouseLeave)
|
|
599
|
-
svg.addEventListener('mousedown', handleMouseDown)
|
|
600
|
-
svg.addEventListener('click', handleTap)
|
|
601
|
-
svg.addEventListener('wheel', handleWheel, { passive: false })
|
|
602
|
-
svg.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
603
|
-
svg.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
604
|
-
svg.addEventListener('touchend', handleTouchEnd)
|
|
605
|
-
|
|
606
|
-
// Global mouse events for pan
|
|
607
|
-
document.addEventListener('mousemove', handlePan)
|
|
608
|
-
document.addEventListener('mouseup', handleMouseUp)
|
|
609
|
-
|
|
610
|
-
// Listen for scroll/resize to update highlight position
|
|
611
|
-
window.addEventListener('scroll', handlePositionUpdate, true)
|
|
612
|
-
window.addEventListener('resize', handlePositionUpdate)
|
|
613
|
-
|
|
614
|
-
return {
|
|
615
|
-
destroy: () => {
|
|
616
|
-
svg.removeEventListener('mousemove', handleMouseMove)
|
|
617
|
-
svg.removeEventListener('mouseleave', handleMouseLeave)
|
|
618
|
-
svg.removeEventListener('mousedown', handleMouseDown)
|
|
619
|
-
svg.removeEventListener('click', handleTap)
|
|
620
|
-
svg.removeEventListener('wheel', handleWheel)
|
|
621
|
-
svg.removeEventListener('touchstart', handleTouchStart)
|
|
622
|
-
svg.removeEventListener('touchmove', handleTouchMove)
|
|
623
|
-
svg.removeEventListener('touchend', handleTouchEnd)
|
|
624
|
-
document.removeEventListener('mousemove', handlePan)
|
|
625
|
-
document.removeEventListener('mouseup', handleMouseUp)
|
|
626
|
-
window.removeEventListener('scroll', handlePositionUpdate, true)
|
|
627
|
-
window.removeEventListener('resize', handlePositionUpdate)
|
|
628
|
-
if (rafId !== null) cancelAnimationFrame(rafId)
|
|
629
|
-
highlightElement(null)
|
|
630
|
-
if (tooltip) {
|
|
631
|
-
tooltip.remove()
|
|
632
|
-
tooltip = null
|
|
633
|
-
}
|
|
634
|
-
if (overlay) {
|
|
635
|
-
overlay.remove()
|
|
636
|
-
overlay = null
|
|
637
|
-
}
|
|
638
|
-
if (highlightContainer) {
|
|
639
|
-
highlightContainer.remove()
|
|
640
|
-
highlightContainer = null
|
|
641
|
-
}
|
|
642
|
-
},
|
|
643
|
-
showDeviceModal: () => {},
|
|
644
|
-
hideModal: () => {},
|
|
645
|
-
showLinkTooltip: () => {},
|
|
646
|
-
hideTooltip: () => {
|
|
647
|
-
hideTooltip()
|
|
648
|
-
highlightElement(null)
|
|
649
|
-
touchTooltipActive = false
|
|
650
|
-
},
|
|
651
|
-
resetView,
|
|
652
|
-
getScale,
|
|
653
|
-
}
|
|
654
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Runtime - Mobile-first pan/zoom with tap tooltips and spotlight effect
|
|
3
|
+
* Google Maps style touch: 1 finger = page scroll (in HTML) / pan (here), 2 fingers = pinch zoom
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { InteractiveInstance, InteractiveOptions } from '../types.js'
|
|
7
|
+
import {
|
|
8
|
+
destroySpotlight,
|
|
9
|
+
getCurrentHighlight,
|
|
10
|
+
highlightElement,
|
|
11
|
+
updateHighlightPosition,
|
|
12
|
+
} from './spotlight.js'
|
|
13
|
+
import { destroyTooltip, getTooltipInfo, hideTooltip, showTooltip } from './tooltip.js'
|
|
14
|
+
import { cloneViewBox, parseViewBox, setViewBox, type ViewBox } from './viewbox.js'
|
|
15
|
+
|
|
16
|
+
const ZOOM_FACTOR = 1.2
|
|
17
|
+
|
|
18
|
+
export function initInteractive(options: InteractiveOptions): InteractiveInstance {
|
|
19
|
+
const target =
|
|
20
|
+
typeof options.target === 'string' ? document.querySelector(options.target) : options.target
|
|
21
|
+
|
|
22
|
+
if (!target) throw new Error('Target not found')
|
|
23
|
+
|
|
24
|
+
const svg = target.closest('svg') || target.querySelector('svg') || (target as SVGSVGElement)
|
|
25
|
+
if (!(svg instanceof SVGSVGElement)) throw new Error('SVG element not found')
|
|
26
|
+
|
|
27
|
+
const panZoomEnabled = options.panZoom?.enabled ?? true
|
|
28
|
+
const minScale = options.panZoom?.minScale ?? 0.1
|
|
29
|
+
const maxScale = options.panZoom?.maxScale ?? 10
|
|
30
|
+
|
|
31
|
+
let originalViewBox: ViewBox | null = parseViewBox(svg)
|
|
32
|
+
if (!originalViewBox) {
|
|
33
|
+
const bbox = svg.getBBox()
|
|
34
|
+
originalViewBox = { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }
|
|
35
|
+
setViewBox(svg, originalViewBox, updateHighlightPosition)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let tooltipActive = false
|
|
39
|
+
const mouseDrag = {
|
|
40
|
+
active: false,
|
|
41
|
+
startX: 0,
|
|
42
|
+
startY: 0,
|
|
43
|
+
startViewBox: null as ViewBox | null,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let pinchState: {
|
|
47
|
+
initialDist: number
|
|
48
|
+
startViewBox: ViewBox
|
|
49
|
+
centerX: number
|
|
50
|
+
centerY: number
|
|
51
|
+
} | null = null
|
|
52
|
+
|
|
53
|
+
const getScale = (): number => {
|
|
54
|
+
if (!originalViewBox) return 1
|
|
55
|
+
const current = parseViewBox(svg)
|
|
56
|
+
if (!current) return 1
|
|
57
|
+
return originalViewBox.width / current.width
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resetView = () => {
|
|
61
|
+
if (originalViewBox) {
|
|
62
|
+
setViewBox(svg, originalViewBox, updateHighlightPosition)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================
|
|
67
|
+
// Touch Events (Mobile: 2-finger for pan/zoom)
|
|
68
|
+
// ============================================
|
|
69
|
+
|
|
70
|
+
const getTouchDistance = (touches: TouchList): number => {
|
|
71
|
+
if (touches.length < 2) return 0
|
|
72
|
+
const dx = touches[1].clientX - touches[0].clientX
|
|
73
|
+
const dy = touches[1].clientY - touches[0].clientY
|
|
74
|
+
return Math.hypot(dx, dy)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const getTouchCenter = (touches: TouchList): { x: number; y: number } => ({
|
|
78
|
+
x: (touches[0].clientX + touches[1].clientX) / 2,
|
|
79
|
+
y: (touches[0].clientY + touches[1].clientY) / 2,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
83
|
+
if (e.touches.length >= 2 && panZoomEnabled) {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
const dist = getTouchDistance(e.touches)
|
|
86
|
+
const center = getTouchCenter(e.touches)
|
|
87
|
+
const vb = parseViewBox(svg)
|
|
88
|
+
if (vb) {
|
|
89
|
+
const rect = svg.getBoundingClientRect()
|
|
90
|
+
pinchState = {
|
|
91
|
+
initialDist: dist,
|
|
92
|
+
startViewBox: cloneViewBox(vb),
|
|
93
|
+
centerX: vb.x + vb.width * ((center.x - rect.left) / rect.width),
|
|
94
|
+
centerY: vb.y + vb.height * ((center.y - rect.top) / rect.height),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (tooltipActive) {
|
|
98
|
+
hideTooltip()
|
|
99
|
+
highlightElement(null)
|
|
100
|
+
tooltipActive = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
106
|
+
if (e.touches.length >= 2 && pinchState && panZoomEnabled) {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
|
|
109
|
+
const dist = getTouchDistance(e.touches)
|
|
110
|
+
const center = getTouchCenter(e.touches)
|
|
111
|
+
|
|
112
|
+
if (dist === 0 || pinchState.initialDist === 0) return
|
|
113
|
+
|
|
114
|
+
const scale = dist / pinchState.initialDist
|
|
115
|
+
const newWidth = pinchState.startViewBox.width / scale
|
|
116
|
+
const newHeight = pinchState.startViewBox.height / scale
|
|
117
|
+
|
|
118
|
+
if (!originalViewBox) return
|
|
119
|
+
const newScale = originalViewBox.width / newWidth
|
|
120
|
+
if (newScale < minScale || newScale > maxScale) return
|
|
121
|
+
|
|
122
|
+
const rect = svg.getBoundingClientRect()
|
|
123
|
+
const mx = (center.x - rect.left) / rect.width
|
|
124
|
+
const my = (center.y - rect.top) / rect.height
|
|
125
|
+
|
|
126
|
+
setViewBox(
|
|
127
|
+
svg,
|
|
128
|
+
{
|
|
129
|
+
x: pinchState.centerX - newWidth * mx,
|
|
130
|
+
y: pinchState.centerY - newHeight * my,
|
|
131
|
+
width: newWidth,
|
|
132
|
+
height: newHeight,
|
|
133
|
+
},
|
|
134
|
+
updateHighlightPosition,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleTouchEnd = (e: TouchEvent) => {
|
|
140
|
+
if (e.touches.length < 2) {
|
|
141
|
+
pinchState = null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================
|
|
146
|
+
// Mouse Events (Desktop)
|
|
147
|
+
// ============================================
|
|
148
|
+
|
|
149
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
150
|
+
if (e.button !== 0 || !panZoomEnabled) return
|
|
151
|
+
|
|
152
|
+
const vb = parseViewBox(svg)
|
|
153
|
+
if (!vb) return
|
|
154
|
+
|
|
155
|
+
mouseDrag.active = true
|
|
156
|
+
mouseDrag.startX = e.clientX
|
|
157
|
+
mouseDrag.startY = e.clientY
|
|
158
|
+
mouseDrag.startViewBox = cloneViewBox(vb)
|
|
159
|
+
svg.style.cursor = 'grabbing'
|
|
160
|
+
|
|
161
|
+
if (tooltipActive) {
|
|
162
|
+
hideTooltip()
|
|
163
|
+
highlightElement(null)
|
|
164
|
+
tooltipActive = false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
169
|
+
if (mouseDrag.active && mouseDrag.startViewBox && panZoomEnabled) {
|
|
170
|
+
const dx = e.clientX - mouseDrag.startX
|
|
171
|
+
const dy = e.clientY - mouseDrag.startY
|
|
172
|
+
const rect = svg.getBoundingClientRect()
|
|
173
|
+
const scaleX = mouseDrag.startViewBox.width / rect.width
|
|
174
|
+
const scaleY = mouseDrag.startViewBox.height / rect.height
|
|
175
|
+
|
|
176
|
+
setViewBox(
|
|
177
|
+
svg,
|
|
178
|
+
{
|
|
179
|
+
x: mouseDrag.startViewBox.x - dx * scaleX,
|
|
180
|
+
y: mouseDrag.startViewBox.y - dy * scaleY,
|
|
181
|
+
width: mouseDrag.startViewBox.width,
|
|
182
|
+
height: mouseDrag.startViewBox.height,
|
|
183
|
+
},
|
|
184
|
+
updateHighlightPosition,
|
|
185
|
+
)
|
|
186
|
+
} else if (!mouseDrag.active) {
|
|
187
|
+
// Hover: show tooltip and highlight
|
|
188
|
+
const info = getTooltipInfo(e.target as Element)
|
|
189
|
+
if (info) {
|
|
190
|
+
showTooltip(info.text, e.clientX, e.clientY)
|
|
191
|
+
highlightElement(info.element)
|
|
192
|
+
} else {
|
|
193
|
+
hideTooltip()
|
|
194
|
+
highlightElement(null)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const handleMouseUp = () => {
|
|
200
|
+
mouseDrag.active = false
|
|
201
|
+
mouseDrag.startViewBox = null
|
|
202
|
+
svg.style.cursor = ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const handleMouseLeave = () => {
|
|
206
|
+
if (!mouseDrag.active && !tooltipActive) {
|
|
207
|
+
hideTooltip()
|
|
208
|
+
highlightElement(null)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const handleWheel = (e: WheelEvent) => {
|
|
213
|
+
if (!panZoomEnabled) return
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
|
|
216
|
+
const vb = parseViewBox(svg)
|
|
217
|
+
if (!vb || !originalViewBox) return
|
|
218
|
+
|
|
219
|
+
const rect = svg.getBoundingClientRect()
|
|
220
|
+
const mouseX = vb.x + vb.width * ((e.clientX - rect.left) / rect.width)
|
|
221
|
+
const mouseY = vb.y + vb.height * ((e.clientY - rect.top) / rect.height)
|
|
222
|
+
|
|
223
|
+
const zoomFactor = e.deltaY > 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR
|
|
224
|
+
const newWidth = vb.width * zoomFactor
|
|
225
|
+
const newHeight = vb.height * zoomFactor
|
|
226
|
+
|
|
227
|
+
const newScale = originalViewBox.width / newWidth
|
|
228
|
+
if (newScale < minScale || newScale > maxScale) return
|
|
229
|
+
|
|
230
|
+
const xRatio = (e.clientX - rect.left) / rect.width
|
|
231
|
+
const yRatio = (e.clientY - rect.top) / rect.height
|
|
232
|
+
|
|
233
|
+
setViewBox(
|
|
234
|
+
svg,
|
|
235
|
+
{
|
|
236
|
+
x: mouseX - newWidth * xRatio,
|
|
237
|
+
y: mouseY - newHeight * yRatio,
|
|
238
|
+
width: newWidth,
|
|
239
|
+
height: newHeight,
|
|
240
|
+
},
|
|
241
|
+
updateHighlightPosition,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================
|
|
246
|
+
// Tap for tooltip (touch devices)
|
|
247
|
+
// ============================================
|
|
248
|
+
|
|
249
|
+
let tapStart: { x: number; y: number; time: number } | null = null
|
|
250
|
+
|
|
251
|
+
const handleTouchStartForTap = (e: TouchEvent) => {
|
|
252
|
+
if (e.touches.length === 1) {
|
|
253
|
+
tapStart = {
|
|
254
|
+
x: e.touches[0].clientX,
|
|
255
|
+
y: e.touches[0].clientY,
|
|
256
|
+
time: Date.now(),
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
tapStart = null
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const handleTouchEndForTap = (e: TouchEvent) => {
|
|
264
|
+
if (!tapStart || e.touches.length > 0) {
|
|
265
|
+
tapStart = null
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const touch = e.changedTouches[0]
|
|
270
|
+
const dx = touch.clientX - tapStart.x
|
|
271
|
+
const dy = touch.clientY - tapStart.y
|
|
272
|
+
const dt = Date.now() - tapStart.time
|
|
273
|
+
|
|
274
|
+
if (Math.hypot(dx, dy) < 10 && dt < 300) {
|
|
275
|
+
const targetEl = document.elementFromPoint(touch.clientX, touch.clientY)
|
|
276
|
+
if (targetEl) {
|
|
277
|
+
const info = getTooltipInfo(targetEl)
|
|
278
|
+
if (info) {
|
|
279
|
+
showTooltip(info.text, touch.clientX, touch.clientY)
|
|
280
|
+
highlightElement(info.element)
|
|
281
|
+
tooltipActive = true
|
|
282
|
+
} else if (tooltipActive) {
|
|
283
|
+
hideTooltip()
|
|
284
|
+
highlightElement(null)
|
|
285
|
+
tooltipActive = false
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
tapStart = null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============================================
|
|
294
|
+
// Track viewBox changes for smooth highlight during pan/zoom
|
|
295
|
+
// ============================================
|
|
296
|
+
|
|
297
|
+
let rafId: number | null = null
|
|
298
|
+
let lastViewBox = ''
|
|
299
|
+
|
|
300
|
+
const trackViewBox = () => {
|
|
301
|
+
if (getCurrentHighlight()) {
|
|
302
|
+
const viewBox = svg.getAttribute('viewBox') || ''
|
|
303
|
+
if (viewBox !== lastViewBox) {
|
|
304
|
+
lastViewBox = viewBox
|
|
305
|
+
updateHighlightPosition()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
rafId = requestAnimationFrame(trackViewBox)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const handlePositionUpdate = () => {
|
|
312
|
+
updateHighlightPosition()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Start tracking
|
|
316
|
+
rafId = requestAnimationFrame(trackViewBox)
|
|
317
|
+
|
|
318
|
+
// ============================================
|
|
319
|
+
// Setup Event Listeners
|
|
320
|
+
// ============================================
|
|
321
|
+
|
|
322
|
+
svg.addEventListener('touchstart', handleTouchStart, { passive: false })
|
|
323
|
+
svg.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
324
|
+
svg.addEventListener('touchend', handleTouchEnd)
|
|
325
|
+
svg.addEventListener('touchcancel', handleTouchEnd)
|
|
326
|
+
|
|
327
|
+
svg.addEventListener('touchstart', handleTouchStartForTap, { passive: true })
|
|
328
|
+
svg.addEventListener('touchend', handleTouchEndForTap)
|
|
329
|
+
|
|
330
|
+
svg.addEventListener('mousedown', handleMouseDown)
|
|
331
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
332
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
333
|
+
svg.addEventListener('mouseleave', handleMouseLeave)
|
|
334
|
+
svg.addEventListener('wheel', handleWheel, { passive: false })
|
|
335
|
+
|
|
336
|
+
// Listen for scroll/resize to update highlight position
|
|
337
|
+
window.addEventListener('scroll', handlePositionUpdate, true)
|
|
338
|
+
window.addEventListener('resize', handlePositionUpdate)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
destroy: () => {
|
|
342
|
+
if (rafId !== null) cancelAnimationFrame(rafId)
|
|
343
|
+
svg.removeEventListener('touchstart', handleTouchStart)
|
|
344
|
+
svg.removeEventListener('touchmove', handleTouchMove)
|
|
345
|
+
svg.removeEventListener('touchend', handleTouchEnd)
|
|
346
|
+
svg.removeEventListener('touchcancel', handleTouchEnd)
|
|
347
|
+
svg.removeEventListener('touchstart', handleTouchStartForTap)
|
|
348
|
+
svg.removeEventListener('touchend', handleTouchEndForTap)
|
|
349
|
+
svg.removeEventListener('mousedown', handleMouseDown)
|
|
350
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
351
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
352
|
+
svg.removeEventListener('mouseleave', handleMouseLeave)
|
|
353
|
+
svg.removeEventListener('wheel', handleWheel)
|
|
354
|
+
window.removeEventListener('scroll', handlePositionUpdate, true)
|
|
355
|
+
window.removeEventListener('resize', handlePositionUpdate)
|
|
356
|
+
destroyTooltip()
|
|
357
|
+
destroySpotlight()
|
|
358
|
+
},
|
|
359
|
+
showDeviceModal: () => {},
|
|
360
|
+
hideModal: () => {},
|
|
361
|
+
showLinkTooltip: () => {},
|
|
362
|
+
hideTooltip: () => {
|
|
363
|
+
hideTooltip()
|
|
364
|
+
highlightElement(null)
|
|
365
|
+
tooltipActive = false
|
|
366
|
+
},
|
|
367
|
+
resetView,
|
|
368
|
+
getScale,
|
|
369
|
+
}
|
|
370
|
+
}
|