@shumoku/renderer 0.2.1 → 0.2.4

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.
Files changed (46) hide show
  1. package/dist/html/index.d.ts +25 -0
  2. package/dist/html/index.d.ts.map +1 -1
  3. package/dist/html/index.js +742 -158
  4. package/dist/html/index.js.map +1 -1
  5. package/dist/html/navigation.d.ts +54 -0
  6. package/dist/html/navigation.d.ts.map +1 -0
  7. package/dist/html/navigation.js +210 -0
  8. package/dist/html/navigation.js.map +1 -0
  9. package/dist/html/runtime.d.ts +2 -1
  10. package/dist/html/runtime.d.ts.map +1 -1
  11. package/dist/html/runtime.js +245 -482
  12. package/dist/html/runtime.js.map +1 -1
  13. package/dist/html/spotlight.d.ts +9 -0
  14. package/dist/html/spotlight.d.ts.map +1 -0
  15. package/dist/html/spotlight.js +119 -0
  16. package/dist/html/spotlight.js.map +1 -0
  17. package/dist/html/tooltip.d.ts +14 -0
  18. package/dist/html/tooltip.d.ts.map +1 -0
  19. package/dist/html/tooltip.js +133 -0
  20. package/dist/html/tooltip.js.map +1 -0
  21. package/dist/html/viewbox.d.ts +14 -0
  22. package/dist/html/viewbox.d.ts.map +1 -0
  23. package/dist/html/viewbox.js +21 -0
  24. package/dist/html/viewbox.js.map +1 -0
  25. package/dist/iife-string.d.ts +2 -0
  26. package/dist/iife-string.js +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/shumoku-interactive.iife.js +25 -20
  30. package/dist/svg.d.ts +27 -0
  31. package/dist/svg.d.ts.map +1 -1
  32. package/dist/svg.js +202 -101
  33. package/dist/svg.js.map +1 -1
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +5 -2
  37. package/src/build-iife-string.ts +26 -19
  38. package/src/html/index.ts +880 -226
  39. package/src/html/navigation.ts +256 -0
  40. package/src/html/runtime.ts +412 -654
  41. package/src/html/spotlight.ts +135 -0
  42. package/src/html/tooltip.ts +141 -0
  43. package/src/html/viewbox.ts +28 -0
  44. package/src/index.ts +25 -22
  45. package/src/svg.ts +1640 -1502
  46. package/src/types.ts +127 -125
@@ -1,227 +1,11 @@
1
1
  /**
2
- * Interactive Runtime - Hover tooltip with touch support and pan/zoom
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
3
4
  */
4
- let tooltip = null;
5
- let overlay = null;
6
- let currentHighlight = null;
7
- let isTouchDevice = false;
8
- function getTooltip() {
9
- if (!tooltip) {
10
- tooltip = document.createElement('div');
11
- tooltip.style.cssText = `
12
- position: fixed;
13
- z-index: 10000;
14
- padding: 6px 10px;
15
- background: #1e293b;
16
- color: #fff;
17
- font-size: 12px;
18
- border-radius: 4px;
19
- pointer-events: none;
20
- opacity: 0;
21
- transition: opacity 0.15s;
22
- font-family: system-ui, sans-serif;
23
- max-width: 300px;
24
- white-space: pre-line;
25
- `;
26
- document.body.appendChild(tooltip);
27
- }
28
- return tooltip;
29
- }
30
- function showTooltip(text, x, y) {
31
- const t = getTooltip();
32
- t.textContent = text;
33
- // Position tooltip, keeping it within viewport
34
- const pad = 12;
35
- let left = x + pad;
36
- let top = y + pad;
37
- // Adjust if tooltip would go off-screen
38
- requestAnimationFrame(() => {
39
- const rect = t.getBoundingClientRect();
40
- if (left + rect.width > window.innerWidth - pad) {
41
- left = x - rect.width - pad;
42
- }
43
- if (top + rect.height > window.innerHeight - pad) {
44
- top = y - rect.height - pad;
45
- }
46
- t.style.left = `${Math.max(pad, left)}px`;
47
- t.style.top = `${Math.max(pad, top)}px`;
48
- });
49
- t.style.left = `${left}px`;
50
- t.style.top = `${top}px`;
51
- t.style.opacity = '1';
52
- }
53
- function hideTooltip() {
54
- if (tooltip) {
55
- tooltip.style.opacity = '0';
56
- }
57
- }
58
- function getOverlay() {
59
- if (!overlay) {
60
- overlay = document.createElement('div');
61
- overlay.style.cssText = `
62
- position: fixed;
63
- top: 0;
64
- left: 0;
65
- right: 0;
66
- bottom: 0;
67
- background: rgba(0, 0, 0, 0.5);
68
- pointer-events: none;
69
- opacity: 0;
70
- transition: opacity 0.15s ease;
71
- z-index: 9998;
72
- `;
73
- document.body.appendChild(overlay);
74
- }
75
- return overlay;
76
- }
77
- let highlightContainer = null;
78
- let currentMiniSvg = null;
79
- function getHighlightContainer() {
80
- if (!highlightContainer) {
81
- highlightContainer = document.createElement('div');
82
- highlightContainer.style.cssText = `
83
- position: fixed;
84
- top: 0;
85
- left: 0;
86
- pointer-events: none;
87
- z-index: 9999;
88
- `;
89
- document.body.appendChild(highlightContainer);
90
- }
91
- return highlightContainer;
92
- }
93
- function updateHighlightPosition() {
94
- if (!currentHighlight || !currentMiniSvg)
95
- return;
96
- // Check if element is still in DOM (React may have replaced it)
97
- if (!document.contains(currentHighlight)) {
98
- // Element was removed, clear highlight
99
- highlightElement(null);
100
- return;
101
- }
102
- const svg = currentHighlight.closest('svg');
103
- if (!svg)
104
- return;
105
- // Use getBoundingClientRect for screen position
106
- const rect = svg.getBoundingClientRect();
107
- // Update viewBox to match current zoom level
108
- const viewBox = svg.getAttribute('viewBox');
109
- if (viewBox) {
110
- currentMiniSvg.setAttribute('viewBox', viewBox);
111
- }
112
- currentMiniSvg.style.left = `${rect.left}px`;
113
- currentMiniSvg.style.top = `${rect.top}px`;
114
- currentMiniSvg.style.width = `${rect.width}px`;
115
- currentMiniSvg.style.height = `${rect.height}px`;
116
- }
117
- function highlightElement(el) {
118
- // Skip if already highlighting the same element
119
- if (el === currentHighlight) {
120
- return;
121
- }
122
- // Remove previous highlight
123
- if (currentHighlight) {
124
- currentHighlight.classList.remove('shumoku-highlight');
125
- }
126
- // Clear previous mini SVG
127
- const container = getHighlightContainer();
128
- container.innerHTML = '';
129
- currentMiniSvg = null;
130
- currentHighlight = el;
131
- const ov = getOverlay();
132
- if (el) {
133
- el.classList.add('shumoku-highlight');
134
- const svg = el.closest('svg');
135
- if (svg) {
136
- const viewBox = svg.getAttribute('viewBox');
137
- if (viewBox) {
138
- // Clone the highlighted element
139
- const clone = el.cloneNode(true);
140
- clone.classList.remove('shumoku-highlight');
141
- // Create mini SVG with same viewBox
142
- const miniSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
143
- miniSvg.setAttribute('viewBox', viewBox);
144
- miniSvg.style.cssText = `
145
- position: absolute;
146
- overflow: visible;
147
- filter: drop-shadow(0 0 4px #fff) drop-shadow(0 0 8px #fff) drop-shadow(0 0 12px rgba(100, 150, 255, 0.8));
148
- `;
149
- // Copy defs (for gradients, patterns, etc.)
150
- const defs = svg.querySelector('defs');
151
- if (defs) {
152
- miniSvg.appendChild(defs.cloneNode(true));
153
- }
154
- miniSvg.appendChild(clone);
155
- container.appendChild(miniSvg);
156
- currentMiniSvg = miniSvg;
157
- // Update position
158
- updateHighlightPosition();
159
- }
160
- }
161
- ov.style.opacity = '1';
162
- }
163
- else {
164
- ov.style.opacity = '0';
165
- }
166
- }
167
- function injectHighlightStyles() {
168
- // No styles needed for current approach
169
- }
170
- function getTooltipInfo(el) {
171
- // Port tooltip (ports are now separate layer)
172
- const port = el.closest('.port[data-port]');
173
- if (port) {
174
- const portId = port.getAttribute('data-port') || '';
175
- const deviceId = port.getAttribute('data-port-device') || '';
176
- return { text: `${deviceId}:${portId}`, element: port };
177
- }
178
- // Link tooltip
179
- const linkGroup = el.closest('.link-group[data-link-id]');
180
- if (linkGroup) {
181
- const from = linkGroup.getAttribute('data-link-from') || '';
182
- const to = linkGroup.getAttribute('data-link-to') || '';
183
- const bw = linkGroup.getAttribute('data-link-bandwidth');
184
- const vlan = linkGroup.getAttribute('data-link-vlan');
185
- let text = `${from} ↔ ${to}`;
186
- if (bw)
187
- text += `\n${bw}`;
188
- if (vlan)
189
- text += `\nVLAN: ${vlan}`;
190
- return { text, element: linkGroup };
191
- }
192
- // Device tooltip (single label only)
193
- const node = el.closest('.node[data-id]');
194
- if (node) {
195
- const json = node.getAttribute('data-device-json');
196
- let text;
197
- if (json) {
198
- try {
199
- const data = JSON.parse(json);
200
- text = data.label || data.id;
201
- }
202
- catch {
203
- text = node.getAttribute('data-id') || '';
204
- }
205
- }
206
- else {
207
- text = node.getAttribute('data-id') || '';
208
- }
209
- return { text, element: node };
210
- }
211
- return null;
212
- }
213
- function parseViewBox(svg) {
214
- const vb = svg.getAttribute('viewBox');
215
- if (!vb)
216
- return null;
217
- const parts = vb.split(/\s+|,/).map(Number);
218
- if (parts.length !== 4 || parts.some(Number.isNaN))
219
- return null;
220
- return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
221
- }
222
- function setViewBox(svg, vb) {
223
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.width} ${vb.height}`);
224
- }
5
+ import { destroySpotlight, getCurrentHighlight, highlightElement, updateHighlightPosition, } from './spotlight.js';
6
+ import { destroyTooltip, getTooltipInfo, hideTooltip, showTooltip } from './tooltip.js';
7
+ import { cloneViewBox, parseViewBox, setViewBox } from './viewbox.js';
8
+ const ZOOM_FACTOR = 1.2;
225
9
  export function initInteractive(options) {
226
10
  const target = typeof options.target === 'string' ? document.querySelector(options.target) : options.target;
227
11
  if (!target)
@@ -229,26 +13,23 @@ export function initInteractive(options) {
229
13
  const svg = target.closest('svg') || target.querySelector('svg') || target;
230
14
  if (!(svg instanceof SVGSVGElement))
231
15
  throw new Error('SVG element not found');
232
- // Inject highlight styles
233
- injectHighlightStyles();
234
- // Pan/Zoom settings
235
16
  const panZoomEnabled = options.panZoom?.enabled ?? true;
236
17
  const minScale = options.panZoom?.minScale ?? 0.1;
237
18
  const maxScale = options.panZoom?.maxScale ?? 10;
238
- const ZOOM_FACTOR = 1.2;
239
- // Store original viewBox for reset and scale calculation
240
- let originalViewBox = null;
241
- const initViewBox = () => {
242
- originalViewBox = parseViewBox(svg);
243
- if (!originalViewBox) {
244
- // Fallback: use SVG bounding box
245
- const bbox = svg.getBBox();
246
- originalViewBox = { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height };
247
- setViewBox(svg, originalViewBox);
248
- }
19
+ let originalViewBox = parseViewBox(svg);
20
+ if (!originalViewBox) {
21
+ const bbox = svg.getBBox();
22
+ originalViewBox = { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height };
23
+ setViewBox(svg, originalViewBox, updateHighlightPosition);
24
+ }
25
+ let tooltipActive = false;
26
+ const mouseDrag = {
27
+ active: false,
28
+ startX: 0,
29
+ startY: 0,
30
+ startViewBox: null,
249
31
  };
250
- initViewBox();
251
- // Calculate current scale
32
+ let pinchState = null;
252
33
  const getScale = () => {
253
34
  if (!originalViewBox)
254
35
  return 1;
@@ -257,252 +38,244 @@ export function initInteractive(options) {
257
38
  return 1;
258
39
  return originalViewBox.width / current.width;
259
40
  };
260
- // Track if we're currently showing a touch tooltip
261
- let touchTooltipActive = false;
262
- // Pan state
263
- let isPanning = false;
264
- let panStartX = 0;
265
- let panStartY = 0;
266
- let panStartViewBox = null;
267
- // Pinch state
268
- let initialPinchDistance = 0;
269
- let pinchStartViewBox = null;
270
- let pinchCenter = null;
271
- // Mouse move handler (desktop hover)
272
- const handleMouseMove = (e) => {
273
- if (isPanning)
274
- return;
275
- if (isTouchDevice)
276
- return; // Skip on touch devices
277
- const me = e;
278
- const info = getTooltipInfo(me.target);
279
- if (info) {
280
- showTooltip(info.text, me.clientX, me.clientY);
281
- highlightElement(info.element);
41
+ const resetView = () => {
42
+ if (originalViewBox) {
43
+ setViewBox(svg, originalViewBox, updateHighlightPosition);
282
44
  }
283
- else {
284
- hideTooltip();
285
- highlightElement(null);
45
+ };
46
+ // ============================================
47
+ // Touch Events (Mobile: 2-finger for pan/zoom)
48
+ // ============================================
49
+ const getTouchDistance = (touches) => {
50
+ if (touches.length < 2)
51
+ return 0;
52
+ const dx = touches[1].clientX - touches[0].clientX;
53
+ const dy = touches[1].clientY - touches[0].clientY;
54
+ return Math.hypot(dx, dy);
55
+ };
56
+ const getTouchCenter = (touches) => ({
57
+ x: (touches[0].clientX + touches[1].clientX) / 2,
58
+ y: (touches[0].clientY + touches[1].clientY) / 2,
59
+ });
60
+ const handleTouchStart = (e) => {
61
+ if (e.touches.length >= 2 && panZoomEnabled) {
62
+ e.preventDefault();
63
+ const dist = getTouchDistance(e.touches);
64
+ const center = getTouchCenter(e.touches);
65
+ const vb = parseViewBox(svg);
66
+ if (vb) {
67
+ const rect = svg.getBoundingClientRect();
68
+ pinchState = {
69
+ initialDist: dist,
70
+ startViewBox: cloneViewBox(vb),
71
+ centerX: vb.x + vb.width * ((center.x - rect.left) / rect.width),
72
+ centerY: vb.y + vb.height * ((center.y - rect.top) / rect.height),
73
+ };
74
+ }
75
+ if (tooltipActive) {
76
+ hideTooltip();
77
+ highlightElement(null);
78
+ tooltipActive = false;
79
+ }
286
80
  }
287
81
  };
288
- // Mouse leave handler
289
- const handleMouseLeave = () => {
290
- if (isTouchDevice)
291
- return;
292
- if (isPanning)
293
- return;
294
- hideTooltip();
295
- highlightElement(null);
82
+ const handleTouchMove = (e) => {
83
+ if (e.touches.length >= 2 && pinchState && panZoomEnabled) {
84
+ e.preventDefault();
85
+ const dist = getTouchDistance(e.touches);
86
+ const center = getTouchCenter(e.touches);
87
+ if (dist === 0 || pinchState.initialDist === 0)
88
+ return;
89
+ const scale = dist / pinchState.initialDist;
90
+ const newWidth = pinchState.startViewBox.width / scale;
91
+ const newHeight = pinchState.startViewBox.height / scale;
92
+ if (!originalViewBox)
93
+ return;
94
+ const newScale = originalViewBox.width / newWidth;
95
+ if (newScale < minScale || newScale > maxScale)
96
+ return;
97
+ const rect = svg.getBoundingClientRect();
98
+ const mx = (center.x - rect.left) / rect.width;
99
+ const my = (center.y - rect.top) / rect.height;
100
+ setViewBox(svg, {
101
+ x: pinchState.centerX - newWidth * mx,
102
+ y: pinchState.centerY - newHeight * my,
103
+ width: newWidth,
104
+ height: newHeight,
105
+ }, updateHighlightPosition);
106
+ }
296
107
  };
297
- // Mouse down - start pan
108
+ const handleTouchEnd = (e) => {
109
+ if (e.touches.length < 2) {
110
+ pinchState = null;
111
+ }
112
+ };
113
+ // ============================================
114
+ // Mouse Events (Desktop)
115
+ // ============================================
298
116
  const handleMouseDown = (e) => {
299
- if (!panZoomEnabled)
117
+ if (e.button !== 0 || !panZoomEnabled)
300
118
  return;
301
- const me = e;
302
- if (me.button !== 0)
303
- return; // Left button only
304
- // Don't start pan if clicking on interactive element
305
- const info = getTooltipInfo(me.target);
306
- if (info)
119
+ const vb = parseViewBox(svg);
120
+ if (!vb)
307
121
  return;
308
- isPanning = true;
309
- panStartX = me.clientX;
310
- panStartY = me.clientY;
311
- panStartViewBox = parseViewBox(svg);
122
+ mouseDrag.active = true;
123
+ mouseDrag.startX = e.clientX;
124
+ mouseDrag.startY = e.clientY;
125
+ mouseDrag.startViewBox = cloneViewBox(vb);
312
126
  svg.style.cursor = 'grabbing';
313
- e.preventDefault();
127
+ if (tooltipActive) {
128
+ hideTooltip();
129
+ highlightElement(null);
130
+ tooltipActive = false;
131
+ }
314
132
  };
315
- // Mouse move - pan
316
- const handlePan = (e) => {
317
- if (!isPanning || !panStartViewBox)
318
- return;
319
- const me = e;
320
- const rect = svg.getBoundingClientRect();
321
- const dx = me.clientX - panStartX;
322
- const dy = me.clientY - panStartY;
323
- // Convert screen delta to viewBox delta
324
- const scaleX = panStartViewBox.width / rect.width;
325
- const scaleY = panStartViewBox.height / rect.height;
326
- setViewBox(svg, {
327
- x: panStartViewBox.x - dx * scaleX,
328
- y: panStartViewBox.y - dy * scaleY,
329
- width: panStartViewBox.width,
330
- height: panStartViewBox.height,
331
- });
133
+ const handleMouseMove = (e) => {
134
+ if (mouseDrag.active && mouseDrag.startViewBox && panZoomEnabled) {
135
+ const dx = e.clientX - mouseDrag.startX;
136
+ const dy = e.clientY - mouseDrag.startY;
137
+ const rect = svg.getBoundingClientRect();
138
+ const scaleX = mouseDrag.startViewBox.width / rect.width;
139
+ const scaleY = mouseDrag.startViewBox.height / rect.height;
140
+ setViewBox(svg, {
141
+ x: mouseDrag.startViewBox.x - dx * scaleX,
142
+ y: mouseDrag.startViewBox.y - dy * scaleY,
143
+ width: mouseDrag.startViewBox.width,
144
+ height: mouseDrag.startViewBox.height,
145
+ }, updateHighlightPosition);
146
+ }
147
+ else if (!mouseDrag.active) {
148
+ // Hover: show tooltip and highlight
149
+ const info = getTooltipInfo(e.target);
150
+ if (info) {
151
+ showTooltip(info.text, e.clientX, e.clientY);
152
+ highlightElement(info.element);
153
+ }
154
+ else {
155
+ hideTooltip();
156
+ highlightElement(null);
157
+ }
158
+ }
332
159
  };
333
- // Mouse up - end pan
334
160
  const handleMouseUp = () => {
335
- if (isPanning) {
336
- isPanning = false;
337
- panStartViewBox = null;
338
- svg.style.cursor = '';
161
+ mouseDrag.active = false;
162
+ mouseDrag.startViewBox = null;
163
+ svg.style.cursor = '';
164
+ };
165
+ const handleMouseLeave = () => {
166
+ if (!mouseDrag.active && !tooltipActive) {
167
+ hideTooltip();
168
+ highlightElement(null);
339
169
  }
340
170
  };
341
- // Wheel zoom
342
171
  const handleWheel = (e) => {
343
172
  if (!panZoomEnabled)
344
173
  return;
345
- const we = e;
346
- we.preventDefault();
174
+ e.preventDefault();
347
175
  const vb = parseViewBox(svg);
348
176
  if (!vb || !originalViewBox)
349
177
  return;
350
178
  const rect = svg.getBoundingClientRect();
351
- const mouseXRatio = (we.clientX - rect.left) / rect.width;
352
- const mouseYRatio = (we.clientY - rect.top) / rect.height;
353
- const mouseX = vb.x + vb.width * mouseXRatio;
354
- const mouseY = vb.y + vb.height * mouseYRatio;
355
- const zoomFactor = we.deltaY > 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
179
+ const mouseX = vb.x + vb.width * ((e.clientX - rect.left) / rect.width);
180
+ const mouseY = vb.y + vb.height * ((e.clientY - rect.top) / rect.height);
181
+ const zoomFactor = e.deltaY > 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
356
182
  const newWidth = vb.width * zoomFactor;
357
183
  const newHeight = vb.height * zoomFactor;
358
- // Check scale limits
359
184
  const newScale = originalViewBox.width / newWidth;
360
185
  if (newScale < minScale || newScale > maxScale)
361
186
  return;
187
+ const xRatio = (e.clientX - rect.left) / rect.width;
188
+ const yRatio = (e.clientY - rect.top) / rect.height;
362
189
  setViewBox(svg, {
363
- x: mouseX - newWidth * mouseXRatio,
364
- y: mouseY - newHeight * mouseYRatio,
190
+ x: mouseX - newWidth * xRatio,
191
+ y: mouseY - newHeight * yRatio,
365
192
  width: newWidth,
366
193
  height: newHeight,
367
- });
194
+ }, updateHighlightPosition);
368
195
  };
369
- // Touch start handler
370
- const handleTouchStart = (e) => {
371
- isTouchDevice = true;
372
- const te = e;
373
- if (te.touches.length === 1 && panZoomEnabled) {
374
- // Single touch - check if on interactive element first
375
- const touch = te.touches[0];
376
- const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
377
- const info = targetEl ? getTooltipInfo(targetEl) : null;
378
- if (!info) {
379
- // Start pan
380
- isPanning = true;
381
- panStartX = touch.clientX;
382
- panStartY = touch.clientY;
383
- panStartViewBox = parseViewBox(svg);
384
- }
385
- }
386
- else if (te.touches.length === 2 && panZoomEnabled) {
387
- // Two touches - start pinch
388
- isPanning = false;
389
- const t1 = te.touches[0];
390
- const t2 = te.touches[1];
391
- initialPinchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
392
- pinchStartViewBox = parseViewBox(svg);
393
- // Calculate pinch center in viewBox coordinates
394
- const rect = svg.getBoundingClientRect();
395
- const centerX = (t1.clientX + t2.clientX) / 2;
396
- const centerY = (t1.clientY + t2.clientY) / 2;
397
- const vb = pinchStartViewBox;
398
- if (vb) {
399
- const xRatio = (centerX - rect.left) / rect.width;
400
- const yRatio = (centerY - rect.top) / rect.height;
401
- pinchCenter = {
402
- x: vb.x + vb.width * xRatio,
403
- y: vb.y + vb.height * yRatio,
404
- };
196
+ // ============================================
197
+ // Hierarchical Navigation
198
+ // ============================================
199
+ const handleSubgraphClick = (e) => {
200
+ const target = e.target;
201
+ const subgraph = target.closest('.subgraph[data-has-sheet]');
202
+ if (subgraph) {
203
+ const sheetId = subgraph.getAttribute('data-sheet-id');
204
+ if (sheetId) {
205
+ e.preventDefault();
206
+ e.stopPropagation();
207
+ dispatchNavigateEvent(sheetId);
405
208
  }
406
209
  }
407
210
  };
408
- // Touch move handler
409
- const handleTouchMove = (e) => {
410
- const te = e;
411
- if (te.touches.length === 1 && isPanning && panStartViewBox) {
412
- // Pan
413
- const touch = te.touches[0];
414
- const rect = svg.getBoundingClientRect();
415
- const dx = touch.clientX - panStartX;
416
- const dy = touch.clientY - panStartY;
417
- const scaleX = panStartViewBox.width / rect.width;
418
- const scaleY = panStartViewBox.height / rect.height;
419
- setViewBox(svg, {
420
- x: panStartViewBox.x - dx * scaleX,
421
- y: panStartViewBox.y - dy * scaleY,
422
- width: panStartViewBox.width,
423
- height: panStartViewBox.height,
424
- });
425
- te.preventDefault();
426
- }
427
- else if (te.touches.length === 2 && pinchStartViewBox && pinchCenter && originalViewBox) {
428
- // Pinch zoom
429
- const t1 = te.touches[0];
430
- const t2 = te.touches[1];
431
- const distance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
432
- const scale = distance / initialPinchDistance;
433
- const newWidth = pinchStartViewBox.width / scale;
434
- const newHeight = pinchStartViewBox.height / scale;
435
- // Check scale limits
436
- const newScale = originalViewBox.width / newWidth;
437
- if (newScale < minScale || newScale > maxScale)
438
- return;
439
- // Zoom towards pinch center
440
- const rect = svg.getBoundingClientRect();
441
- const centerX = (t1.clientX + t2.clientX) / 2;
442
- const centerY = (t1.clientY + t2.clientY) / 2;
443
- const xRatio = (centerX - rect.left) / rect.width;
444
- const yRatio = (centerY - rect.top) / rect.height;
445
- setViewBox(svg, {
446
- x: pinchCenter.x - newWidth * xRatio,
447
- y: pinchCenter.y - newHeight * yRatio,
448
- width: newWidth,
449
- height: newHeight,
450
- });
451
- te.preventDefault();
452
- }
211
+ const dispatchNavigateEvent = (sheetId) => {
212
+ const event = new CustomEvent('shumoku:navigate', {
213
+ detail: { sheetId },
214
+ bubbles: true,
215
+ });
216
+ document.dispatchEvent(event);
453
217
  };
454
- // Touch end handler
455
- const handleTouchEnd = (e) => {
456
- const te = e;
457
- if (te.touches.length === 0) {
458
- isPanning = false;
459
- panStartViewBox = null;
460
- pinchStartViewBox = null;
461
- pinchCenter = null;
218
+ // ============================================
219
+ // Tap for tooltip (touch devices)
220
+ // ============================================
221
+ let tapStart = null;
222
+ const handleTouchStartForTap = (e) => {
223
+ if (e.touches.length === 1) {
224
+ tapStart = {
225
+ x: e.touches[0].clientX,
226
+ y: e.touches[0].clientY,
227
+ time: Date.now(),
228
+ };
462
229
  }
463
- else if (te.touches.length === 1) {
464
- // Switched from pinch to pan
465
- pinchStartViewBox = null;
466
- pinchCenter = null;
467
- isPanning = true;
468
- const touch = te.touches[0];
469
- panStartX = touch.clientX;
470
- panStartY = touch.clientY;
471
- panStartViewBox = parseViewBox(svg);
230
+ else {
231
+ tapStart = null;
472
232
  }
473
233
  };
474
- // Touch/click handler for mobile tooltip
475
- const handleTap = (e) => {
476
- if (!isTouchDevice)
477
- return;
478
- if (isPanning)
234
+ const handleTouchEndForTap = (e) => {
235
+ if (!tapStart || e.touches.length > 0) {
236
+ tapStart = null;
479
237
  return;
480
- const me = e;
481
- const targetEl = e.target;
482
- const info = getTooltipInfo(targetEl);
483
- if (info) {
484
- // Show tooltip at tap position
485
- showTooltip(info.text, me.clientX, me.clientY);
486
- highlightElement(info.element);
487
- touchTooltipActive = true;
488
- e.preventDefault();
489
238
  }
490
- else if (touchTooltipActive) {
491
- // Tap on empty area - hide tooltip
492
- hideTooltip();
493
- highlightElement(null);
494
- touchTooltipActive = false;
239
+ const touch = e.changedTouches[0];
240
+ const dx = touch.clientX - tapStart.x;
241
+ const dy = touch.clientY - tapStart.y;
242
+ const dt = Date.now() - tapStart.time;
243
+ if (Math.hypot(dx, dy) < 10 && dt < 300) {
244
+ const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
245
+ if (targetEl) {
246
+ // Check for hierarchical navigation first
247
+ const subgraph = targetEl.closest('.subgraph[data-has-sheet]');
248
+ if (subgraph) {
249
+ const sheetId = subgraph.getAttribute('data-sheet-id');
250
+ if (sheetId) {
251
+ dispatchNavigateEvent(sheetId);
252
+ tapStart = null;
253
+ return;
254
+ }
255
+ }
256
+ // Otherwise show tooltip
257
+ const info = getTooltipInfo(targetEl);
258
+ if (info) {
259
+ showTooltip(info.text, touch.clientX, touch.clientY);
260
+ highlightElement(info.element);
261
+ tooltipActive = true;
262
+ }
263
+ else if (tooltipActive) {
264
+ hideTooltip();
265
+ highlightElement(null);
266
+ tooltipActive = false;
267
+ }
268
+ }
495
269
  }
270
+ tapStart = null;
496
271
  };
497
- // Position update handler for scroll/resize events
498
- const handlePositionUpdate = () => {
499
- updateHighlightPosition();
500
- };
501
- // Track viewBox changes for pan/zoom
272
+ // ============================================
273
+ // Track viewBox changes for smooth highlight during pan/zoom
274
+ // ============================================
502
275
  let rafId = null;
503
276
  let lastViewBox = '';
504
277
  const trackViewBox = () => {
505
- if (currentHighlight) {
278
+ if (getCurrentHighlight()) {
506
279
  const viewBox = svg.getAttribute('viewBox') || '';
507
280
  if (viewBox !== lastViewBox) {
508
281
  lastViewBox = viewBox;
@@ -511,61 +284,50 @@ export function initInteractive(options) {
511
284
  }
512
285
  rafId = requestAnimationFrame(trackViewBox);
513
286
  };
514
- rafId = requestAnimationFrame(trackViewBox);
515
- // Reset view
516
- const resetView = () => {
517
- if (originalViewBox) {
518
- setViewBox(svg, originalViewBox);
519
- }
287
+ const handlePositionUpdate = () => {
288
+ updateHighlightPosition();
520
289
  };
521
- // Set touch-action to prevent browser gestures
522
- if (panZoomEnabled) {
523
- svg.style.touchAction = 'none';
524
- }
525
- // Add event listeners
526
- svg.addEventListener('mousemove', handleMouseMove);
527
- svg.addEventListener('mouseleave', handleMouseLeave);
528
- svg.addEventListener('mousedown', handleMouseDown);
529
- svg.addEventListener('click', handleTap);
530
- svg.addEventListener('wheel', handleWheel, { passive: false });
531
- svg.addEventListener('touchstart', handleTouchStart, { passive: true });
290
+ // Start tracking
291
+ rafId = requestAnimationFrame(trackViewBox);
292
+ // ============================================
293
+ // Setup Event Listeners
294
+ // ============================================
295
+ svg.addEventListener('touchstart', handleTouchStart, { passive: false });
532
296
  svg.addEventListener('touchmove', handleTouchMove, { passive: false });
533
297
  svg.addEventListener('touchend', handleTouchEnd);
534
- // Global mouse events for pan
535
- document.addEventListener('mousemove', handlePan);
298
+ svg.addEventListener('touchcancel', handleTouchEnd);
299
+ svg.addEventListener('touchstart', handleTouchStartForTap, { passive: true });
300
+ svg.addEventListener('touchend', handleTouchEndForTap);
301
+ svg.addEventListener('mousedown', handleMouseDown);
302
+ document.addEventListener('mousemove', handleMouseMove);
536
303
  document.addEventListener('mouseup', handleMouseUp);
304
+ svg.addEventListener('mouseleave', handleMouseLeave);
305
+ svg.addEventListener('wheel', handleWheel, { passive: false });
306
+ // Hierarchical navigation: click on subgraph with sheet reference
307
+ svg.addEventListener('click', handleSubgraphClick);
537
308
  // Listen for scroll/resize to update highlight position
538
309
  window.addEventListener('scroll', handlePositionUpdate, true);
539
310
  window.addEventListener('resize', handlePositionUpdate);
540
311
  return {
541
312
  destroy: () => {
542
- svg.removeEventListener('mousemove', handleMouseMove);
543
- svg.removeEventListener('mouseleave', handleMouseLeave);
544
- svg.removeEventListener('mousedown', handleMouseDown);
545
- svg.removeEventListener('click', handleTap);
546
- svg.removeEventListener('wheel', handleWheel);
313
+ if (rafId !== null)
314
+ cancelAnimationFrame(rafId);
547
315
  svg.removeEventListener('touchstart', handleTouchStart);
548
316
  svg.removeEventListener('touchmove', handleTouchMove);
549
317
  svg.removeEventListener('touchend', handleTouchEnd);
550
- document.removeEventListener('mousemove', handlePan);
318
+ svg.removeEventListener('touchcancel', handleTouchEnd);
319
+ svg.removeEventListener('touchstart', handleTouchStartForTap);
320
+ svg.removeEventListener('touchend', handleTouchEndForTap);
321
+ svg.removeEventListener('mousedown', handleMouseDown);
322
+ document.removeEventListener('mousemove', handleMouseMove);
551
323
  document.removeEventListener('mouseup', handleMouseUp);
324
+ svg.removeEventListener('mouseleave', handleMouseLeave);
325
+ svg.removeEventListener('wheel', handleWheel);
326
+ svg.removeEventListener('click', handleSubgraphClick);
552
327
  window.removeEventListener('scroll', handlePositionUpdate, true);
553
328
  window.removeEventListener('resize', handlePositionUpdate);
554
- if (rafId !== null)
555
- cancelAnimationFrame(rafId);
556
- highlightElement(null);
557
- if (tooltip) {
558
- tooltip.remove();
559
- tooltip = null;
560
- }
561
- if (overlay) {
562
- overlay.remove();
563
- overlay = null;
564
- }
565
- if (highlightContainer) {
566
- highlightContainer.remove();
567
- highlightContainer = null;
568
- }
329
+ destroyTooltip();
330
+ destroySpotlight();
569
331
  },
570
332
  showDeviceModal: () => { },
571
333
  hideModal: () => { },
@@ -573,10 +335,11 @@ export function initInteractive(options) {
573
335
  hideTooltip: () => {
574
336
  hideTooltip();
575
337
  highlightElement(null);
576
- touchTooltipActive = false;
338
+ tooltipActive = false;
577
339
  },
578
340
  resetView,
579
341
  getScale,
342
+ navigateToSheet: dispatchNavigateEvent,
580
343
  };
581
344
  }
582
345
  //# sourceMappingURL=runtime.js.map