@shumoku/renderer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/iife-entry.d.ts +9 -0
- package/dist/html/iife-entry.d.ts.map +1 -0
- package/dist/html/iife-entry.js +9 -0
- package/dist/html/iife-entry.js.map +1 -0
- package/dist/html/index.d.ts +23 -0
- package/dist/html/index.d.ts.map +1 -0
- package/dist/html/index.js +210 -0
- package/dist/html/index.js.map +1 -0
- package/dist/html/runtime.d.ts +6 -0
- package/dist/html/runtime.d.ts.map +1 -0
- package/dist/html/runtime.js +582 -0
- package/dist/html/runtime.js.map +1 -0
- package/dist/iife-string.js +2 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/shumoku-interactive.iife.js +38 -0
- package/dist/style.css +136 -0
- package/dist/svg.d.ts +158 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +1174 -0
- package/dist/svg.js.map +1 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/build-css.ts +159 -0
- package/src/build-iife-string.ts +19 -0
- package/src/build-iife.ts +24 -0
- package/src/html/iife-entry.ts +12 -0
- package/src/html/index.ts +226 -0
- package/src/html/runtime.ts +654 -0
- package/src/index.ts +22 -0
- package/src/svg.ts +1502 -0
- package/src/types.ts +125 -0
|
@@ -0,0 +1,654 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shumoku/renderer - SVG and HTML renderers for network diagrams
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as html from './html/index.js'
|
|
6
|
+
// Namespace exports
|
|
7
|
+
import * as svg from './svg.js'
|
|
8
|
+
|
|
9
|
+
export { svg, html }
|
|
10
|
+
|
|
11
|
+
// Types
|
|
12
|
+
export type {
|
|
13
|
+
DataAttributeOptions,
|
|
14
|
+
DeviceInfo,
|
|
15
|
+
EndpointInfo,
|
|
16
|
+
HTMLRendererOptions,
|
|
17
|
+
InteractiveInstance,
|
|
18
|
+
InteractiveOptions,
|
|
19
|
+
LinkInfo,
|
|
20
|
+
PortInfo,
|
|
21
|
+
RenderMode,
|
|
22
|
+
} from './types.js'
|