feat: Add zoom/pan/pinch to Infrastructure topology
- Mouse wheel zoom (centered on cursor position) - Click-drag to pan - Touch pinch-zoom for mobile - Touch drag to pan on mobile - Zoom controls: +, −, percentage display, reset (⌂) - Zoom range: 50% to 300% - Drag guard prevents accidental clicks after panning - Canvas connections redraw correctly at all zoom levels - Smooth CSS transitions on zoom, disabled during drag Chronicler #78 | firefrost-services
This commit is contained in:
@@ -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; }
|
||||
</style>
|
||||
|
||||
<div id="infra-module">
|
||||
@@ -280,30 +336,31 @@
|
||||
<!-- Topology View -->
|
||||
<div id="topology-view">
|
||||
<div class="topo-canvas-wrap" id="topo-wrap">
|
||||
<div class="topo-grid"></div>
|
||||
<canvas id="topo-canvas" class="topo-canvas"></canvas>
|
||||
<div class="topo-zoomable" id="topo-zoomable">
|
||||
<div class="topo-grid"></div>
|
||||
<canvas id="topo-canvas" class="topo-canvas"></canvas>
|
||||
|
||||
<!-- External service nodes -->
|
||||
<div class="topo-ext" data-node="cloudflare" style="left:calc(50% - 40px); top:14px;" onclick="showExternal('cloudflare')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #f4812055);">☁️</div>
|
||||
<div class="topo-ext-label" style="color:#f48120;">Cloudflare</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="claude" style="left:calc(12.5% - 40px); top:14px;" onclick="showExternal('claude')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #d4a57455);">🧠</div>
|
||||
<div class="topo-ext-label" style="color:#d4a574;">Claude.ai</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="website" style="left:calc(73% - 40px); top:14px;" onclick="showExternal('website')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #4ECDC455);">🌐</div>
|
||||
<div class="topo-ext-label" style="color:#4ECDC4;">firefrostgaming.com</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="stripe" style="left:calc(31.25% - 40px); top:14px;" onclick="showExternal('stripe')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #635bff55);">💳</div>
|
||||
<div class="topo-ext-label" style="color:#635bff;">Stripe</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="discord" style="left:calc(87.5% - 40px); top:14px;" onclick="showExternal('discord')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #5865f255);">💬</div>
|
||||
<div class="topo-ext-label" style="color:#5865f2;">Discord</div>
|
||||
</div>
|
||||
<!-- External service nodes -->
|
||||
<div class="topo-ext" data-node="cloudflare" style="left:calc(50% - 40px); top:14px;" onclick="showExternal('cloudflare')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #f4812055);">☁️</div>
|
||||
<div class="topo-ext-label" style="color:#f48120;">Cloudflare</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="claude" style="left:calc(12.5% - 40px); top:14px;" onclick="showExternal('claude')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #d4a57455);">🧠</div>
|
||||
<div class="topo-ext-label" style="color:#d4a574;">Claude.ai</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="website" style="left:calc(73% - 40px); top:14px;" onclick="showExternal('website')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #4ECDC455);">🌐</div>
|
||||
<div class="topo-ext-label" style="color:#4ECDC4;">firefrostgaming.com</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="stripe" style="left:calc(31.25% - 40px); top:14px;" onclick="showExternal('stripe')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #635bff55);">💳</div>
|
||||
<div class="topo-ext-label" style="color:#635bff;">Stripe</div>
|
||||
</div>
|
||||
<div class="topo-ext" data-node="discord" style="left:calc(87.5% - 40px); top:14px;" onclick="showExternal('discord')">
|
||||
<div class="topo-ext-icon" style="filter:drop-shadow(0 0 8px #5865f255);">💬</div>
|
||||
<div class="topo-ext-label" style="color:#5865f2;">Discord</div>
|
||||
</div>
|
||||
|
||||
<!-- Server nodes -->
|
||||
<% const positions = {
|
||||
@@ -342,6 +399,16 @@
|
||||
</div>
|
||||
<% }); %>
|
||||
|
||||
</div> <!-- end topo-zoomable -->
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
|
||||
<button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
|
||||
<div class="zoom-level" id="zoom-level">100%</div>
|
||||
<button class="zoom-btn" onclick="zoomReset()" title="Reset View" style="font-size:12px;">⌂</button>
|
||||
</div>
|
||||
|
||||
<div class="topo-legend">
|
||||
<span><span style="color:#f48120">━</span> External</span>
|
||||
<span><span style="color:#4ECDC4">━</span> Internal</span>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user