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:
Claude (Chronicler #78)
2026-04-11 10:35:03 +00:00
parent 2e3d272e26
commit 0c7dad36ea

View File

@@ -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);