@shumoku/renderer 0.2.1 → 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.
@@ -1,654 +1,370 @@
1
- /**
2
- * Interactive Runtime - Hover tooltip with touch support and pan/zoom
3
- */
4
-
5
- import type { InteractiveInstance, InteractiveOptions } from '../types.js'
6
-
7
- let tooltip: HTMLDivElement | null = null
8
- let overlay: HTMLDivElement | null = null
9
- let currentHighlight: Element | null = null
10
- let isTouchDevice = false
11
-
12
- function getTooltip(): HTMLDivElement {
13
- if (!tooltip) {
14
- tooltip = document.createElement('div')
15
- tooltip.style.cssText = `
16
- position: fixed;
17
- z-index: 10000;
18
- padding: 6px 10px;
19
- background: #1e293b;
20
- color: #fff;
21
- font-size: 12px;
22
- border-radius: 4px;
23
- pointer-events: none;
24
- opacity: 0;
25
- transition: opacity 0.15s;
26
- font-family: system-ui, sans-serif;
27
- max-width: 300px;
28
- white-space: pre-line;
29
- `
30
- document.body.appendChild(tooltip)
31
- }
32
- return tooltip
33
- }
34
-
35
- function showTooltip(text: string, x: number, y: number): void {
36
- const t = getTooltip()
37
- t.textContent = text
38
-
39
- // Position tooltip, keeping it within viewport
40
- const pad = 12
41
- let left = x + pad
42
- let top = y + pad
43
-
44
- // Adjust if tooltip would go off-screen
45
- requestAnimationFrame(() => {
46
- const rect = t.getBoundingClientRect()
47
- if (left + rect.width > window.innerWidth - pad) {
48
- left = x - rect.width - pad
49
- }
50
- if (top + rect.height > window.innerHeight - pad) {
51
- top = y - rect.height - pad
52
- }
53
- t.style.left = `${Math.max(pad, left)}px`
54
- t.style.top = `${Math.max(pad, top)}px`
55
- })
56
-
57
- t.style.left = `${left}px`
58
- t.style.top = `${top}px`
59
- t.style.opacity = '1'
60
- }
61
-
62
- function hideTooltip(): void {
63
- if (tooltip) {
64
- tooltip.style.opacity = '0'
65
- }
66
- }
67
-
68
- function getOverlay(): HTMLDivElement {
69
- if (!overlay) {
70
- overlay = document.createElement('div')
71
- overlay.style.cssText = `
72
- position: fixed;
73
- top: 0;
74
- left: 0;
75
- right: 0;
76
- bottom: 0;
77
- background: rgba(0, 0, 0, 0.5);
78
- pointer-events: none;
79
- opacity: 0;
80
- transition: opacity 0.15s ease;
81
- z-index: 9998;
82
- `
83
- document.body.appendChild(overlay)
84
- }
85
- return overlay
86
- }
87
-
88
- let highlightContainer: HTMLDivElement | null = null
89
- let currentMiniSvg: SVGSVGElement | null = null
90
-
91
- function getHighlightContainer(): HTMLDivElement {
92
- if (!highlightContainer) {
93
- highlightContainer = document.createElement('div')
94
- highlightContainer.style.cssText = `
95
- position: fixed;
96
- top: 0;
97
- left: 0;
98
- pointer-events: none;
99
- z-index: 9999;
100
- `
101
- document.body.appendChild(highlightContainer)
102
- }
103
- return highlightContainer
104
- }
105
-
106
- function updateHighlightPosition(): void {
107
- if (!currentHighlight || !currentMiniSvg) return
108
-
109
- // Check if element is still in DOM (React may have replaced it)
110
- if (!document.contains(currentHighlight)) {
111
- // Element was removed, clear highlight
112
- highlightElement(null)
113
- return
114
- }
115
-
116
- const svg = currentHighlight.closest('svg') as SVGSVGElement | null
117
- if (!svg) return
118
-
119
- // Use getBoundingClientRect for screen position
120
- const rect = svg.getBoundingClientRect()
121
-
122
- // Update viewBox to match current zoom level
123
- const viewBox = svg.getAttribute('viewBox')
124
- if (viewBox) {
125
- currentMiniSvg.setAttribute('viewBox', viewBox)
126
- }
127
-
128
- currentMiniSvg.style.left = `${rect.left}px`
129
- currentMiniSvg.style.top = `${rect.top}px`
130
- currentMiniSvg.style.width = `${rect.width}px`
131
- currentMiniSvg.style.height = `${rect.height}px`
132
- }
133
-
134
- function highlightElement(el: Element | null): void {
135
- // Skip if already highlighting the same element
136
- if (el === currentHighlight) {
137
- return
138
- }
139
-
140
- // Remove previous highlight
141
- if (currentHighlight) {
142
- currentHighlight.classList.remove('shumoku-highlight')
143
- }
144
-
145
- // Clear previous mini SVG
146
- const container = getHighlightContainer()
147
- container.innerHTML = ''
148
- currentMiniSvg = null
149
-
150
- currentHighlight = el
151
- const ov = getOverlay()
152
-
153
- if (el) {
154
- el.classList.add('shumoku-highlight')
155
-
156
- const svg = el.closest('svg') as SVGSVGElement | null
157
- if (svg) {
158
- const viewBox = svg.getAttribute('viewBox')
159
- if (viewBox) {
160
- // Clone the highlighted element
161
- const clone = el.cloneNode(true) as Element
162
- clone.classList.remove('shumoku-highlight')
163
-
164
- // Create mini SVG with same viewBox
165
- const miniSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
166
- miniSvg.setAttribute('viewBox', viewBox)
167
- miniSvg.style.cssText = `
168
- position: absolute;
169
- overflow: visible;
170
- filter: drop-shadow(0 0 4px #fff) drop-shadow(0 0 8px #fff) drop-shadow(0 0 12px rgba(100, 150, 255, 0.8));
171
- `
172
-
173
- // Copy defs (for gradients, patterns, etc.)
174
- const defs = svg.querySelector('defs')
175
- if (defs) {
176
- miniSvg.appendChild(defs.cloneNode(true))
177
- }
178
-
179
- miniSvg.appendChild(clone)
180
- container.appendChild(miniSvg)
181
- currentMiniSvg = miniSvg
182
-
183
- // Update position
184
- updateHighlightPosition()
185
- }
186
- }
187
-
188
- ov.style.opacity = '1'
189
- } else {
190
- ov.style.opacity = '0'
191
- }
192
- }
193
-
194
- function injectHighlightStyles(): void {
195
- // No styles needed for current approach
196
- }
197
-
198
- interface TooltipInfo {
199
- text: string
200
- element: Element
201
- }
202
-
203
- function getTooltipInfo(el: Element): TooltipInfo | null {
204
- // Port tooltip (ports are now separate layer)
205
- const port = el.closest('.port[data-port]')
206
- if (port) {
207
- const portId = port.getAttribute('data-port') || ''
208
- const deviceId = port.getAttribute('data-port-device') || ''
209
- return { text: `${deviceId}:${portId}`, element: port }
210
- }
211
-
212
- // Link tooltip
213
- const linkGroup = el.closest('.link-group[data-link-id]')
214
- if (linkGroup) {
215
- const from = linkGroup.getAttribute('data-link-from') || ''
216
- const to = linkGroup.getAttribute('data-link-to') || ''
217
- const bw = linkGroup.getAttribute('data-link-bandwidth')
218
- const vlan = linkGroup.getAttribute('data-link-vlan')
219
-
220
- let text = `${from} ${to}`
221
- if (bw) text += `\n${bw}`
222
- if (vlan) text += `\nVLAN: ${vlan}`
223
- return { text, element: linkGroup }
224
- }
225
-
226
- // Device tooltip (single label only)
227
- const node = el.closest('.node[data-id]')
228
- if (node) {
229
- const json = node.getAttribute('data-device-json')
230
- let text: string
231
- if (json) {
232
- try {
233
- const data = JSON.parse(json)
234
- text = data.label || data.id
235
- } catch {
236
- text = node.getAttribute('data-id') || ''
237
- }
238
- } else {
239
- text = node.getAttribute('data-id') || ''
240
- }
241
- return { text, element: node }
242
- }
243
-
244
- return null
245
- }
246
-
247
- // ViewBox utilities
248
- interface ViewBox {
249
- x: number
250
- y: number
251
- width: number
252
- height: number
253
- }
254
-
255
- function parseViewBox(svg: SVGSVGElement): ViewBox | null {
256
- const vb = svg.getAttribute('viewBox')
257
- if (!vb) return null
258
- const parts = vb.split(/\s+|,/).map(Number)
259
- if (parts.length !== 4 || parts.some(Number.isNaN)) return null
260
- return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
261
- }
262
-
263
- function setViewBox(svg: SVGSVGElement, vb: ViewBox): void {
264
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`)
265
- }
266
-
267
- export function initInteractive(options: InteractiveOptions): InteractiveInstance {
268
- const target =
269
- typeof options.target === 'string' ? document.querySelector(options.target) : options.target
270
-
271
- if (!target) throw new Error('Target not found')
272
-
273
- const svg = target.closest('svg') || target.querySelector('svg') || (target as SVGSVGElement)
274
- if (!(svg instanceof SVGSVGElement)) throw new Error('SVG element not found')
275
-
276
- // Inject highlight styles
277
- injectHighlightStyles()
278
-
279
- // Pan/Zoom settings
280
- const panZoomEnabled = options.panZoom?.enabled ?? true
281
- const minScale = options.panZoom?.minScale ?? 0.1
282
- const maxScale = options.panZoom?.maxScale ?? 10
283
- const ZOOM_FACTOR = 1.2
284
-
285
- // Store original viewBox for reset and scale calculation
286
- let originalViewBox: ViewBox | null = null
287
- const initViewBox = () => {
288
- originalViewBox = parseViewBox(svg)
289
- if (!originalViewBox) {
290
- // Fallback: use SVG bounding box
291
- const bbox = svg.getBBox()
292
- originalViewBox = { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }
293
- setViewBox(svg, originalViewBox)
294
- }
295
- }
296
- initViewBox()
297
-
298
- // Calculate current scale
299
- const getScale = (): number => {
300
- if (!originalViewBox) return 1
301
- const current = parseViewBox(svg)
302
- if (!current) return 1
303
- return originalViewBox.width / current.width
304
- }
305
-
306
- // Track if we're currently showing a touch tooltip
307
- let touchTooltipActive = false
308
-
309
- // Pan state
310
- let isPanning = false
311
- let panStartX = 0
312
- let panStartY = 0
313
- let panStartViewBox: ViewBox | null = null
314
-
315
- // Pinch state
316
- let initialPinchDistance = 0
317
- let pinchStartViewBox: ViewBox | null = null
318
- let pinchCenter: { x: number; y: number } | null = null
319
-
320
- // Mouse move handler (desktop hover)
321
- const handleMouseMove = (e: Event) => {
322
- if (isPanning) return
323
- if (isTouchDevice) return // Skip on touch devices
324
-
325
- const me = e as MouseEvent
326
- const info = getTooltipInfo(me.target as Element)
327
- if (info) {
328
- showTooltip(info.text, me.clientX, me.clientY)
329
- highlightElement(info.element)
330
- } else {
331
- hideTooltip()
332
- highlightElement(null)
333
- }
334
- }
335
-
336
- // Mouse leave handler
337
- const handleMouseLeave = () => {
338
- if (isTouchDevice) return
339
- if (isPanning) return
340
- hideTooltip()
341
- highlightElement(null)
342
- }
343
-
344
- // Mouse down - start pan
345
- const handleMouseDown = (e: Event) => {
346
- if (!panZoomEnabled) return
347
- const me = e as MouseEvent
348
- if (me.button !== 0) return // Left button only
349
-
350
- // Don't start pan if clicking on interactive element
351
- const info = getTooltipInfo(me.target as Element)
352
- if (info) return
353
-
354
- isPanning = true
355
- panStartX = me.clientX
356
- panStartY = me.clientY
357
- panStartViewBox = parseViewBox(svg)
358
- svg.style.cursor = 'grabbing'
359
- e.preventDefault()
360
- }
361
-
362
- // Mouse move - pan
363
- const handlePan = (e: Event) => {
364
- if (!isPanning || !panStartViewBox) return
365
- const me = e as MouseEvent
366
-
367
- const rect = svg.getBoundingClientRect()
368
- const dx = me.clientX - panStartX
369
- const dy = me.clientY - panStartY
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
+ }