@shumoku/renderer 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/index.js +265 -157
- package/dist/html/index.js.map +1 -1
- package/dist/html/runtime.d.ts +2 -1
- package/dist/html/runtime.d.ts.map +1 -1
- package/dist/html/runtime.js +212 -486
- package/dist/html/runtime.js.map +1 -1
- package/dist/html/spotlight.d.ts +9 -0
- package/dist/html/spotlight.d.ts.map +1 -0
- package/dist/html/spotlight.js +119 -0
- package/dist/html/spotlight.js.map +1 -0
- package/dist/html/tooltip.d.ts +14 -0
- package/dist/html/tooltip.d.ts.map +1 -0
- package/dist/html/tooltip.js +105 -0
- package/dist/html/tooltip.js.map +1 -0
- package/dist/html/viewbox.d.ts +14 -0
- package/dist/html/viewbox.d.ts.map +1 -0
- package/dist/html/viewbox.js +21 -0
- package/dist/html/viewbox.js.map +1 -0
- package/dist/iife-string.js +1 -1
- package/dist/shumoku-interactive.iife.js +23 -20
- package/package.json +1 -1
- package/src/html/index.ts +334 -226
- package/src/html/runtime.ts +370 -654
- package/src/html/spotlight.ts +135 -0
- package/src/html/tooltip.ts +115 -0
- package/src/html/viewbox.ts +28 -0
package/dist/html/runtime.js
CHANGED
|
@@ -1,227 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interactive Runtime -
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
originalViewBox
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
// Calculate current scale
|
|
32
|
+
let pinchState = null;
|
|
252
33
|
const getScale = () => {
|
|
253
34
|
if (!originalViewBox)
|
|
254
35
|
return 1;
|
|
@@ -257,252 +38,211 @@ export function initInteractive(options) {
|
|
|
257
38
|
return 1;
|
|
258
39
|
return originalViewBox.width / current.width;
|
|
259
40
|
};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
302
|
-
if (
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
127
|
+
if (tooltipActive) {
|
|
128
|
+
hideTooltip();
|
|
129
|
+
highlightElement(null);
|
|
130
|
+
tooltipActive = false;
|
|
131
|
+
}
|
|
314
132
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
const
|
|
353
|
-
const
|
|
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 *
|
|
364
|
-
y: mouseY - newHeight *
|
|
190
|
+
x: mouseX - newWidth * xRatio,
|
|
191
|
+
y: mouseY - newHeight * yRatio,
|
|
365
192
|
width: newWidth,
|
|
366
193
|
height: newHeight,
|
|
367
|
-
});
|
|
194
|
+
}, updateHighlightPosition);
|
|
368
195
|
};
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
isPanning = true;
|
|
381
|
-
panStartX = touch.clientX;
|
|
382
|
-
panStartY = touch.clientY;
|
|
383
|
-
panStartViewBox = parseViewBox(svg);
|
|
384
|
-
}
|
|
196
|
+
// ============================================
|
|
197
|
+
// Tap for tooltip (touch devices)
|
|
198
|
+
// ============================================
|
|
199
|
+
let tapStart = null;
|
|
200
|
+
const handleTouchStartForTap = (e) => {
|
|
201
|
+
if (e.touches.length === 1) {
|
|
202
|
+
tapStart = {
|
|
203
|
+
x: e.touches[0].clientX,
|
|
204
|
+
y: e.touches[0].clientY,
|
|
205
|
+
time: Date.now(),
|
|
206
|
+
};
|
|
385
207
|
}
|
|
386
|
-
else
|
|
387
|
-
|
|
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
|
-
};
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
};
|
|
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
|
-
}
|
|
453
|
-
};
|
|
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;
|
|
462
|
-
}
|
|
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);
|
|
208
|
+
else {
|
|
209
|
+
tapStart = null;
|
|
472
210
|
}
|
|
473
211
|
};
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
212
|
+
const handleTouchEndForTap = (e) => {
|
|
213
|
+
if (!tapStart || e.touches.length > 0) {
|
|
214
|
+
tapStart = null;
|
|
477
215
|
return;
|
|
478
|
-
if (isPanning)
|
|
479
|
-
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
216
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
217
|
+
const touch = e.changedTouches[0];
|
|
218
|
+
const dx = touch.clientX - tapStart.x;
|
|
219
|
+
const dy = touch.clientY - tapStart.y;
|
|
220
|
+
const dt = Date.now() - tapStart.time;
|
|
221
|
+
if (Math.hypot(dx, dy) < 10 && dt < 300) {
|
|
222
|
+
const targetEl = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
223
|
+
if (targetEl) {
|
|
224
|
+
const info = getTooltipInfo(targetEl);
|
|
225
|
+
if (info) {
|
|
226
|
+
showTooltip(info.text, touch.clientX, touch.clientY);
|
|
227
|
+
highlightElement(info.element);
|
|
228
|
+
tooltipActive = true;
|
|
229
|
+
}
|
|
230
|
+
else if (tooltipActive) {
|
|
231
|
+
hideTooltip();
|
|
232
|
+
highlightElement(null);
|
|
233
|
+
tooltipActive = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
495
236
|
}
|
|
237
|
+
tapStart = null;
|
|
496
238
|
};
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
};
|
|
501
|
-
// Track viewBox changes for pan/zoom
|
|
239
|
+
// ============================================
|
|
240
|
+
// Track viewBox changes for smooth highlight during pan/zoom
|
|
241
|
+
// ============================================
|
|
502
242
|
let rafId = null;
|
|
503
243
|
let lastViewBox = '';
|
|
504
244
|
const trackViewBox = () => {
|
|
505
|
-
if (
|
|
245
|
+
if (getCurrentHighlight()) {
|
|
506
246
|
const viewBox = svg.getAttribute('viewBox') || '';
|
|
507
247
|
if (viewBox !== lastViewBox) {
|
|
508
248
|
lastViewBox = viewBox;
|
|
@@ -511,61 +251,47 @@ export function initInteractive(options) {
|
|
|
511
251
|
}
|
|
512
252
|
rafId = requestAnimationFrame(trackViewBox);
|
|
513
253
|
};
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const resetView = () => {
|
|
517
|
-
if (originalViewBox) {
|
|
518
|
-
setViewBox(svg, originalViewBox);
|
|
519
|
-
}
|
|
254
|
+
const handlePositionUpdate = () => {
|
|
255
|
+
updateHighlightPosition();
|
|
520
256
|
};
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
svg.addEventListener('
|
|
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 });
|
|
257
|
+
// Start tracking
|
|
258
|
+
rafId = requestAnimationFrame(trackViewBox);
|
|
259
|
+
// ============================================
|
|
260
|
+
// Setup Event Listeners
|
|
261
|
+
// ============================================
|
|
262
|
+
svg.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
532
263
|
svg.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
533
264
|
svg.addEventListener('touchend', handleTouchEnd);
|
|
534
|
-
|
|
535
|
-
|
|
265
|
+
svg.addEventListener('touchcancel', handleTouchEnd);
|
|
266
|
+
svg.addEventListener('touchstart', handleTouchStartForTap, { passive: true });
|
|
267
|
+
svg.addEventListener('touchend', handleTouchEndForTap);
|
|
268
|
+
svg.addEventListener('mousedown', handleMouseDown);
|
|
269
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
536
270
|
document.addEventListener('mouseup', handleMouseUp);
|
|
271
|
+
svg.addEventListener('mouseleave', handleMouseLeave);
|
|
272
|
+
svg.addEventListener('wheel', handleWheel, { passive: false });
|
|
537
273
|
// Listen for scroll/resize to update highlight position
|
|
538
274
|
window.addEventListener('scroll', handlePositionUpdate, true);
|
|
539
275
|
window.addEventListener('resize', handlePositionUpdate);
|
|
540
276
|
return {
|
|
541
277
|
destroy: () => {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
svg.removeEventListener('mousedown', handleMouseDown);
|
|
545
|
-
svg.removeEventListener('click', handleTap);
|
|
546
|
-
svg.removeEventListener('wheel', handleWheel);
|
|
278
|
+
if (rafId !== null)
|
|
279
|
+
cancelAnimationFrame(rafId);
|
|
547
280
|
svg.removeEventListener('touchstart', handleTouchStart);
|
|
548
281
|
svg.removeEventListener('touchmove', handleTouchMove);
|
|
549
282
|
svg.removeEventListener('touchend', handleTouchEnd);
|
|
550
|
-
|
|
283
|
+
svg.removeEventListener('touchcancel', handleTouchEnd);
|
|
284
|
+
svg.removeEventListener('touchstart', handleTouchStartForTap);
|
|
285
|
+
svg.removeEventListener('touchend', handleTouchEndForTap);
|
|
286
|
+
svg.removeEventListener('mousedown', handleMouseDown);
|
|
287
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
551
288
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
289
|
+
svg.removeEventListener('mouseleave', handleMouseLeave);
|
|
290
|
+
svg.removeEventListener('wheel', handleWheel);
|
|
552
291
|
window.removeEventListener('scroll', handlePositionUpdate, true);
|
|
553
292
|
window.removeEventListener('resize', handlePositionUpdate);
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
}
|
|
293
|
+
destroyTooltip();
|
|
294
|
+
destroySpotlight();
|
|
569
295
|
},
|
|
570
296
|
showDeviceModal: () => { },
|
|
571
297
|
hideModal: () => { },
|
|
@@ -573,7 +299,7 @@ export function initInteractive(options) {
|
|
|
573
299
|
hideTooltip: () => {
|
|
574
300
|
hideTooltip();
|
|
575
301
|
highlightElement(null);
|
|
576
|
-
|
|
302
|
+
tooltipActive = false;
|
|
577
303
|
},
|
|
578
304
|
resetView,
|
|
579
305
|
getScale,
|