diff --git a/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs b/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs index 1589b2d..0dbc941 100644 --- a/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs +++ b/services/arbiter-3.0/src/views/admin/infrastructure/index.ejs @@ -216,6 +216,62 @@ .detail-metrics { grid-template-columns: repeat(2, 1fr) !important; } .detail-panels { grid-template-columns: 1fr !important; } } + + /* Zoom controls */ + .zoom-controls { + position: absolute; + bottom: 12px; + left: 12px; + display: flex; + gap: 4px; + z-index: 20; + } + .zoom-btn { + width: 32px; + height: 32px; + background: #1a1a1acc; + border: 1px solid #404040; + border-radius: 6px; + color: #ccc; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; + transition: all 0.15s; + user-select: none; + -webkit-user-select: none; + } + .zoom-btn:hover { background: #2d2d2d; border-color: #4ECDC4; color: #4ECDC4; } + .zoom-btn:active { transform: scale(0.92); } + .zoom-level { + height: 32px; + padding: 0 8px; + background: #1a1a1acc; + border: 1px solid #404040; + border-radius: 6px; + color: #888; + font-size: 10px; + display: flex; + align-items: center; + font-family: inherit; + min-width: 44px; + justify-content: center; + } + .topo-zoomable { + position: absolute; + inset: 0; + transform-origin: 0 0; + transition: transform 0.15s ease; + will-change: transform; + } + .topo-zoomable.dragging { + transition: none; + cursor: grabbing; + } + .topo-canvas-wrap { cursor: grab; } + .topo-canvas-wrap.dragging { cursor: grabbing; }
@@ -280,30 +336,31 @@
-
- +
+
+ - -
-
☁️
-
Cloudflare
-
-
-
🧠
-
Claude.ai
-
-
-
🌐
-
firefrostgaming.com
-
-
-
💳
-
Stripe
-
-
-
💬
-
Discord
-
+ +
+
☁️
+
Cloudflare
+
+
+
🧠
+
Claude.ai
+
+
+
🌐
+
firefrostgaming.com
+
+
+
💳
+
Stripe
+
+
+
💬
+
Discord
+
<% const positions = { @@ -342,6 +399,16 @@
<% }); %> +
+ + +
+ + +
100%
+ +
+
External Internal @@ -445,22 +512,32 @@ const connections = [ let hoveredNode = null; function getNodePositions() { - const wrap = document.getElementById('topo-wrap'); - if (!wrap) return {}; - const w = wrap.offsetWidth; - const h = wrap.offsetHeight; + const zoomable = document.getElementById('topo-zoomable'); + if (!zoomable) return {}; const positions = {}; + // Temporarily remove transform to get unscaled positions + const savedTransform = zoomable.style.transform; + const savedTransition = zoomable.style.transition; + zoomable.style.transition = 'none'; + zoomable.style.transform = 'none'; + + const zoomRect = zoomable.getBoundingClientRect(); + document.querySelectorAll('.topo-node, .topo-ext').forEach(el => { const id = el.dataset.node; if (!id) return; const rect = el.getBoundingClientRect(); - const wrapRect = wrap.getBoundingClientRect(); positions[id] = { - x: (rect.left - wrapRect.left) + rect.width / 2, - y: (rect.top - wrapRect.top) + rect.height / 2 + x: (rect.left - zoomRect.left) + rect.width / 2, + y: (rect.top - zoomRect.top) + rect.height / 2 }; }); + + // Restore transform + zoomable.style.transform = savedTransform; + zoomable.style.transition = savedTransition; + return positions; } @@ -524,6 +601,7 @@ function showTopology() { } function showServer(id) { + if (dragMoved) return; if (!fleet) return; const server = fleet.servers[id]; if (!server) return; @@ -633,6 +711,7 @@ function showServer(id) { } function showExternal(id) { + if (dragMoved) return; const ext = externalInfo[id]; if (!ext) return; @@ -697,6 +776,171 @@ async function refreshAudit() { } } +// ─── Zoom / Pan / Pinch System ─── + +let zoomScale = 1; +let panX = 0, panY = 0; +let isDragging = false; +let dragStartX = 0, dragStartY = 0; +let dragStartPanX = 0, dragStartPanY = 0; +let dragMoved = false; + +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 3.0; +const ZOOM_STEP = 0.15; + +function applyTransform() { + const el = document.getElementById('topo-zoomable'); + if (!el) return; + el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomScale})`; + document.getElementById('zoom-level').textContent = Math.round(zoomScale * 100) + '%'; + // Redraw canvas connections at new scale + setTimeout(drawConnections, 20); +} + +function zoomIn() { + zoomScale = Math.min(MAX_ZOOM, zoomScale + ZOOM_STEP); + applyTransform(); +} + +function zoomOut() { + zoomScale = Math.max(MIN_ZOOM, zoomScale - ZOOM_STEP); + applyTransform(); +} + +function zoomReset() { + zoomScale = 1; + panX = 0; + panY = 0; + applyTransform(); +} + +function zoomAtPoint(delta, clientX, clientY) { + const wrap = document.getElementById('topo-wrap'); + if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + + // Mouse position relative to wrap + const mx = clientX - rect.left; + const my = clientY - rect.top; + + // Point in content space before zoom + const contentX = (mx - panX) / zoomScale; + const contentY = (my - panY) / zoomScale; + + // Apply zoom + const oldScale = zoomScale; + zoomScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomScale + delta)); + + // Adjust pan so the point under the mouse stays put + panX = mx - contentX * zoomScale; + panY = my - contentY * zoomScale; + + applyTransform(); +} + +// Mouse wheel zoom +document.addEventListener('DOMContentLoaded', () => { + const wrap = document.getElementById('topo-wrap'); + if (!wrap) return; + + wrap.addEventListener('wheel', (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + zoomAtPoint(delta, e.clientX, e.clientY); + }, { passive: false }); + + // Mouse drag to pan + wrap.addEventListener('mousedown', (e) => { + if (e.target.closest('.topo-node, .topo-ext, .zoom-btn, .zoom-level')) return; + isDragging = true; + dragMoved = false; + dragStartX = e.clientX; + dragStartY = e.clientY; + dragStartPanX = panX; + dragStartPanY = panY; + wrap.classList.add('dragging'); + document.getElementById('topo-zoomable')?.classList.add('dragging'); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true; + panX = dragStartPanX + dx; + panY = dragStartPanY + dy; + applyTransform(); + }); + + document.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + wrap.classList.remove('dragging'); + document.getElementById('topo-zoomable')?.classList.remove('dragging'); + } + }); + + // Touch: pinch zoom + drag pan + let lastTouchDist = 0; + let lastTouchCenter = { x: 0, y: 0 }; + + wrap.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastTouchDist = Math.sqrt(dx * dx + dy * dy); + lastTouchCenter = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 + }; + } else if (e.touches.length === 1) { + if (e.target.closest('.topo-node, .topo-ext, .zoom-btn')) return; + isDragging = true; + dragMoved = false; + dragStartX = e.touches[0].clientX; + dragStartY = e.touches[0].clientY; + dragStartPanX = panX; + dragStartPanY = panY; + } + }, { passive: false }); + + wrap.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const dist = Math.sqrt(dx * dx + dy * dy); + const center = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 + }; + + if (lastTouchDist > 0) { + const scaleDelta = (dist - lastTouchDist) * 0.005; + zoomAtPoint(scaleDelta, center.x, center.y); + } + + lastTouchDist = dist; + lastTouchCenter = center; + } else if (e.touches.length === 1 && isDragging) { + const dx = e.touches[0].clientX - dragStartX; + const dy = e.touches[0].clientY - dragStartY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true; + panX = dragStartPanX + dx; + panY = dragStartPanY + dy; + applyTransform(); + } + }, { passive: false }); + + wrap.addEventListener('touchend', () => { + isDragging = false; + lastTouchDist = 0; + }); +}); + // Draw connections on load and resize window.addEventListener('load', () => setTimeout(drawConnections, 100)); window.addEventListener('resize', drawConnections);