@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.
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +162 -0
- package/dist/cli.js.map +1 -0
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +590 -587
- package/dist/html/index.js.map +1 -1
- package/dist/html/navigation.js +138 -138
- package/dist/html/spotlight.js +21 -21
- package/dist/html/tooltip.js +18 -18
- package/dist/html/tooltip.js.map +1 -1
- package/dist/iife-string.d.ts.map +1 -0
- package/dist/iife-string.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/svg.d.ts +6 -3
- package/dist/svg.d.ts.map +1 -1
- package/dist/svg.js +169 -119
- package/dist/svg.js.map +1 -1
- package/package.json +8 -3
- package/src/build-iife-string.ts +26 -26
- package/src/cli.ts +185 -0
- package/src/html/index.ts +883 -880
- package/src/html/navigation.ts +256 -256
- package/src/html/runtime.ts +412 -412
- package/src/html/spotlight.ts +135 -135
- package/src/html/tooltip.ts +141 -141
- package/src/html/viewbox.ts +28 -28
- package/src/iife-string.ts +8 -0
- package/src/index.ts +24 -25
- package/src/svg.ts +1707 -1640
- package/src/types.ts +127 -127
package/src/html/runtime.ts
CHANGED
|
@@ -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
|
+
}
|