-
-
+
+
+
-
-
-
-
-
🌐
-
firefrostgaming.com
-
-
-
+
+
+
+
+
🌐
+
firefrostgaming.com
+
+
+
<% const positions = {
@@ -342,6 +399,16 @@
<% }); %>
+
+
+
+
+
━ 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);