@shumoku/renderer 0.2.6 → 0.2.15

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,412 +1,412 @@
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
- // Hierarchical Navigation
247
- // ============================================
248
-
249
- const handleSubgraphClick = (e: MouseEvent) => {
250
- const target = e.target as Element
251
- const subgraph = target.closest('.subgraph[data-has-sheet]')
252
- if (subgraph) {
253
- const sheetId = subgraph.getAttribute('data-sheet-id')
254
- if (sheetId) {
255
- e.preventDefault()
256
- e.stopPropagation()
257
- dispatchNavigateEvent(sheetId)
258
- }
259
- }
260
- }
261
-
262
- const dispatchNavigateEvent = (sheetId: string) => {
263
- const event = new CustomEvent('shumoku:navigate', {
264
- detail: { sheetId },
265
- bubbles: true,
266
- })
267
- document.dispatchEvent(event)
268
- }
269
-
270
- // ============================================
271
- // Tap for tooltip (touch devices)
272
- // ============================================
273
-
274
- let tapStart: { x: number; y: number; time: number } | null = null
275
-
276
- const handleTouchStartForTap = (e: TouchEvent) => {
277
- if (e.touches.length === 1) {
278
- tapStart = {
279
- x: e.touches[0].clientX,
280
- y: e.touches[0].clientY,
281
- time: Date.now(),
282
- }
283
- } else {
284
- tapStart = null
285
- }
286
- }
287
-
288
- const handleTouchEndForTap = (e: TouchEvent) => {
289
- if (!tapStart || e.touches.length > 0) {
290
- tapStart = null
291
- return
292
- }
293
-
294
- const touch = e.changedTouches[0]
295
- const dx = touch.clientX - tapStart.x
296
- const dy = touch.clientY - tapStart.y
297
- const dt = Date.now() - tapStart.time
298
-
299
- if (Math.hypot(dx, dy) < 10 && dt < 300) {
300
- const targetEl = document.elementFromPoint(touch.clientX, touch.clientY)
301
- if (targetEl) {
302
- // Check for hierarchical navigation first
303
- const subgraph = targetEl.closest('.subgraph[data-has-sheet]')
304
- if (subgraph) {
305
- const sheetId = subgraph.getAttribute('data-sheet-id')
306
- if (sheetId) {
307
- dispatchNavigateEvent(sheetId)
308
- tapStart = null
309
- return
310
- }
311
- }
312
-
313
- // Otherwise show tooltip
314
- const info = getTooltipInfo(targetEl)
315
- if (info) {
316
- showTooltip(info.text, touch.clientX, touch.clientY)
317
- highlightElement(info.element)
318
- tooltipActive = true
319
- } else if (tooltipActive) {
320
- hideTooltip()
321
- highlightElement(null)
322
- tooltipActive = false
323
- }
324
- }
325
- }
326
-
327
- tapStart = null
328
- }
329
-
330
- // ============================================
331
- // Track viewBox changes for smooth highlight during pan/zoom
332
- // ============================================
333
-
334
- let rafId: number | null = null
335
- let lastViewBox = ''
336
-
337
- const trackViewBox = () => {
338
- if (getCurrentHighlight()) {
339
- const viewBox = svg.getAttribute('viewBox') || ''
340
- if (viewBox !== lastViewBox) {
341
- lastViewBox = viewBox
342
- updateHighlightPosition()
343
- }
344
- }
345
- rafId = requestAnimationFrame(trackViewBox)
346
- }
347
-
348
- const handlePositionUpdate = () => {
349
- updateHighlightPosition()
350
- }
351
-
352
- // Start tracking
353
- rafId = requestAnimationFrame(trackViewBox)
354
-
355
- // ============================================
356
- // Setup Event Listeners
357
- // ============================================
358
-
359
- svg.addEventListener('touchstart', handleTouchStart, { passive: false })
360
- svg.addEventListener('touchmove', handleTouchMove, { passive: false })
361
- svg.addEventListener('touchend', handleTouchEnd)
362
- svg.addEventListener('touchcancel', handleTouchEnd)
363
-
364
- svg.addEventListener('touchstart', handleTouchStartForTap, { passive: true })
365
- svg.addEventListener('touchend', handleTouchEndForTap)
366
-
367
- svg.addEventListener('mousedown', handleMouseDown)
368
- document.addEventListener('mousemove', handleMouseMove)
369
- document.addEventListener('mouseup', handleMouseUp)
370
- svg.addEventListener('mouseleave', handleMouseLeave)
371
- svg.addEventListener('wheel', handleWheel, { passive: false })
372
-
373
- // Hierarchical navigation: click on subgraph with sheet reference
374
- svg.addEventListener('click', handleSubgraphClick)
375
-
376
- // Listen for scroll/resize to update highlight position
377
- window.addEventListener('scroll', handlePositionUpdate, true)
378
- window.addEventListener('resize', handlePositionUpdate)
379
-
380
- return {
381
- destroy: () => {
382
- if (rafId !== null) cancelAnimationFrame(rafId)
383
- svg.removeEventListener('touchstart', handleTouchStart)
384
- svg.removeEventListener('touchmove', handleTouchMove)
385
- svg.removeEventListener('touchend', handleTouchEnd)
386
- svg.removeEventListener('touchcancel', handleTouchEnd)
387
- svg.removeEventListener('touchstart', handleTouchStartForTap)
388
- svg.removeEventListener('touchend', handleTouchEndForTap)
389
- svg.removeEventListener('mousedown', handleMouseDown)
390
- document.removeEventListener('mousemove', handleMouseMove)
391
- document.removeEventListener('mouseup', handleMouseUp)
392
- svg.removeEventListener('mouseleave', handleMouseLeave)
393
- svg.removeEventListener('wheel', handleWheel)
394
- svg.removeEventListener('click', handleSubgraphClick)
395
- window.removeEventListener('scroll', handlePositionUpdate, true)
396
- window.removeEventListener('resize', handlePositionUpdate)
397
- destroyTooltip()
398
- destroySpotlight()
399
- },
400
- showDeviceModal: () => {},
401
- hideModal: () => {},
402
- showLinkTooltip: () => {},
403
- hideTooltip: () => {
404
- hideTooltip()
405
- highlightElement(null)
406
- tooltipActive = false
407
- },
408
- resetView,
409
- getScale,
410
- navigateToSheet: dispatchNavigateEvent,
411
- }
412
- }
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
+ // Hierarchical Navigation
247
+ // ============================================
248
+
249
+ const handleSubgraphClick = (e: MouseEvent) => {
250
+ const target = e.target as Element
251
+ const subgraph = target.closest('.subgraph[data-has-sheet]')
252
+ if (subgraph) {
253
+ const sheetId = subgraph.getAttribute('data-sheet-id')
254
+ if (sheetId) {
255
+ e.preventDefault()
256
+ e.stopPropagation()
257
+ dispatchNavigateEvent(sheetId)
258
+ }
259
+ }
260
+ }
261
+
262
+ const dispatchNavigateEvent = (sheetId: string) => {
263
+ const event = new CustomEvent('shumoku:navigate', {
264
+ detail: { sheetId },
265
+ bubbles: true,
266
+ })
267
+ document.dispatchEvent(event)
268
+ }
269
+
270
+ // ============================================
271
+ // Tap for tooltip (touch devices)
272
+ // ============================================
273
+
274
+ let tapStart: { x: number; y: number; time: number } | null = null
275
+
276
+ const handleTouchStartForTap = (e: TouchEvent) => {
277
+ if (e.touches.length === 1) {
278
+ tapStart = {
279
+ x: e.touches[0].clientX,
280
+ y: e.touches[0].clientY,
281
+ time: Date.now(),
282
+ }
283
+ } else {
284
+ tapStart = null
285
+ }
286
+ }
287
+
288
+ const handleTouchEndForTap = (e: TouchEvent) => {
289
+ if (!tapStart || e.touches.length > 0) {
290
+ tapStart = null
291
+ return
292
+ }
293
+
294
+ const touch = e.changedTouches[0]
295
+ const dx = touch.clientX - tapStart.x
296
+ const dy = touch.clientY - tapStart.y
297
+ const dt = Date.now() - tapStart.time
298
+
299
+ if (Math.hypot(dx, dy) < 10 && dt < 300) {
300
+ const targetEl = document.elementFromPoint(touch.clientX, touch.clientY)
301
+ if (targetEl) {
302
+ // Check for hierarchical navigation first
303
+ const subgraph = targetEl.closest('.subgraph[data-has-sheet]')
304
+ if (subgraph) {
305
+ const sheetId = subgraph.getAttribute('data-sheet-id')
306
+ if (sheetId) {
307
+ dispatchNavigateEvent(sheetId)
308
+ tapStart = null
309
+ return
310
+ }
311
+ }
312
+
313
+ // Otherwise show tooltip
314
+ const info = getTooltipInfo(targetEl)
315
+ if (info) {
316
+ showTooltip(info.text, touch.clientX, touch.clientY)
317
+ highlightElement(info.element)
318
+ tooltipActive = true
319
+ } else if (tooltipActive) {
320
+ hideTooltip()
321
+ highlightElement(null)
322
+ tooltipActive = false
323
+ }
324
+ }
325
+ }
326
+
327
+ tapStart = null
328
+ }
329
+
330
+ // ============================================
331
+ // Track viewBox changes for smooth highlight during pan/zoom
332
+ // ============================================
333
+
334
+ let rafId: number | null = null
335
+ let lastViewBox = ''
336
+
337
+ const trackViewBox = () => {
338
+ if (getCurrentHighlight()) {
339
+ const viewBox = svg.getAttribute('viewBox') || ''
340
+ if (viewBox !== lastViewBox) {
341
+ lastViewBox = viewBox
342
+ updateHighlightPosition()
343
+ }
344
+ }
345
+ rafId = requestAnimationFrame(trackViewBox)
346
+ }
347
+
348
+ const handlePositionUpdate = () => {
349
+ updateHighlightPosition()
350
+ }
351
+
352
+ // Start tracking
353
+ rafId = requestAnimationFrame(trackViewBox)
354
+
355
+ // ============================================
356
+ // Setup Event Listeners
357
+ // ============================================
358
+
359
+ svg.addEventListener('touchstart', handleTouchStart, { passive: false })
360
+ svg.addEventListener('touchmove', handleTouchMove, { passive: false })
361
+ svg.addEventListener('touchend', handleTouchEnd)
362
+ svg.addEventListener('touchcancel', handleTouchEnd)
363
+
364
+ svg.addEventListener('touchstart', handleTouchStartForTap, { passive: true })
365
+ svg.addEventListener('touchend', handleTouchEndForTap)
366
+
367
+ svg.addEventListener('mousedown', handleMouseDown)
368
+ document.addEventListener('mousemove', handleMouseMove)
369
+ document.addEventListener('mouseup', handleMouseUp)
370
+ svg.addEventListener('mouseleave', handleMouseLeave)
371
+ svg.addEventListener('wheel', handleWheel, { passive: false })
372
+
373
+ // Hierarchical navigation: click on subgraph with sheet reference
374
+ svg.addEventListener('click', handleSubgraphClick)
375
+
376
+ // Listen for scroll/resize to update highlight position
377
+ window.addEventListener('scroll', handlePositionUpdate, true)
378
+ window.addEventListener('resize', handlePositionUpdate)
379
+
380
+ return {
381
+ destroy: () => {
382
+ if (rafId !== null) cancelAnimationFrame(rafId)
383
+ svg.removeEventListener('touchstart', handleTouchStart)
384
+ svg.removeEventListener('touchmove', handleTouchMove)
385
+ svg.removeEventListener('touchend', handleTouchEnd)
386
+ svg.removeEventListener('touchcancel', handleTouchEnd)
387
+ svg.removeEventListener('touchstart', handleTouchStartForTap)
388
+ svg.removeEventListener('touchend', handleTouchEndForTap)
389
+ svg.removeEventListener('mousedown', handleMouseDown)
390
+ document.removeEventListener('mousemove', handleMouseMove)
391
+ document.removeEventListener('mouseup', handleMouseUp)
392
+ svg.removeEventListener('mouseleave', handleMouseLeave)
393
+ svg.removeEventListener('wheel', handleWheel)
394
+ svg.removeEventListener('click', handleSubgraphClick)
395
+ window.removeEventListener('scroll', handlePositionUpdate, true)
396
+ window.removeEventListener('resize', handlePositionUpdate)
397
+ destroyTooltip()
398
+ destroySpotlight()
399
+ },
400
+ showDeviceModal: () => {},
401
+ hideModal: () => {},
402
+ showLinkTooltip: () => {},
403
+ hideTooltip: () => {
404
+ hideTooltip()
405
+ highlightElement(null)
406
+ tooltipActive = false
407
+ },
408
+ resetView,
409
+ getScale,
410
+ navigateToSheet: dispatchNavigateEvent,
411
+ }
412
+ }