Files
proxmox_manager/templates/cluster_topology.html
2026-02-17 12:43:27 +01:00

1095 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Topologia Cluster - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.topology-container {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.topology-canvas-wrapper {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 16px;
position: relative;
min-height: 700px;
height: 700px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
#topologyCanvas {
display: block;
border-radius: var(--radius-md);
background: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.03) 0px,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 20px
),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0px,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 20px
);
cursor: grab;
max-width: 100%;
max-height: 100%;
user-select: none;
-webkit-user-select: none;
}
#topologyCanvas:active {
cursor: grabbing;
}
.topology-sidebar {
width: 300px;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.topology-stats {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.stat-item {
display: flex;
justify-content: space-between;
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--border-default);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stat-value {
color: var(--text-primary);
font-weight: 600;
}
.topology-legend {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.legend-title {
font-weight: 600;
margin-bottom: var(--space-md);
color: var(--text-primary);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
font-size: 0.9rem;
}
.legend-color {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border: 2px solid var(--border-default);
}
.legend-color.cluster {
background: var(--accent-blue);
}
.legend-color.node {
background: var(--accent-purple);
}
.legend-color.vm {
background: var(--accent-green);
}
.legend-color.lxc {
background: var(--accent-yellow);
}
.legend-color.storage {
background: var(--accent-orange);
}
.topology-controls {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.control-btn {
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: 0.9rem;
}
.control-btn:hover {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
.control-btn.active {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
.node-details {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-lg);
display: none;
}
.node-details.visible {
display: block;
}
.node-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.node-details-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
}
.close-details {
cursor: pointer;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.close-details:hover {
color: var(--accent-red);
}
.detail-section {
margin-bottom: var(--space-md);
}
.detail-section-title {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
margin-bottom: var(--space-sm);
}
.detail-item {
display: flex;
justify-content: space-between;
padding: var(--space-xs) 0;
font-size: 0.9rem;
}
.resource-bar {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: var(--space-xs);
}
.resource-bar-fill {
height: 100%;
transition: width var(--transition-fast);
border-radius: var(--radius-sm);
}
.resource-bar-fill.cpu {
background: var(--accent-blue);
}
.resource-bar-fill.memory {
background: var(--accent-purple);
}
.resource-bar-fill.disk {
background: var(--accent-orange);
}
.zoom-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.zoom-btn {
width: 40px;
height: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: all var(--transition-fast);
}
.zoom-btn:hover {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
.zoom-level {
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
}
@media (max-width: 1024px) {
.topology-container {
flex-direction: column;
}
.topology-sidebar {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Topologia del Cluster</h1>
<p class="subtitle">↔️ Trascina NODI per riposizionare | 🖱️ Trascina sfondo per muovere | 🔍 Rotella per zoom | Click per dettagli</p>
</div>
</div>
<div class="topology-controls">
<button class="control-btn active" onclick="setViewMode('all')" id="btnAll">Vista Completa</button>
<button class="control-btn" onclick="setViewMode('nodes')" id="btnNodes">Solo Nodi</button>
<button class="control-btn" onclick="setViewMode('vms')" id="btnVms">Solo VM/LXC</button>
<button class="control-btn" onclick="setViewMode('storage')" id="btnStorage">Storage</button>
<button class="control-btn" onclick="refreshTopology()">🔄 Aggiorna</button>
<button class="control-btn" onclick="saveLayout()">💾 Salva Layout</button>
<button class="control-btn" onclick="loadLayout()">📂 Carica Layout</button>
<button class="control-btn" onclick="resetLayout()">🔄 Reset Layout</button>
<button class="control-btn" onclick="exportTopology()">📤 Esporta JSON</button>
</div>
<div class="topology-container">
<div class="topology-canvas-wrapper">
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
<div class="zoom-level" id="zoomLevel">100%</div>
<button class="zoom-btn" onclick="zoomOut()" title="Zoom Out"></button>
<button class="zoom-btn" onclick="resetZoom()" title="Reset"></button>
</div>
<canvas id="topologyCanvas"></canvas>
</div>
<div class="topology-sidebar">
<div class="topology-stats card">
<h3 style="margin-bottom: var(--space-md);">Statistiche Cluster</h3>
<div class="stat-item">
<span class="stat-label">Nodi</span>
<span class="stat-value" id="statNodes">-</span>
</div>
<div class="stat-item">
<span class="stat-label">VM (QEMU)</span>
<span class="stat-value" id="statVMs">-</span>
</div>
<div class="stat-item">
<span class="stat-label">Container (LXC)</span>
<span class="stat-value" id="statLXC">-</span>
</div>
<div class="stat-item">
<span class="stat-label">Storage</span>
<span class="stat-value" id="statStorage">-</span>
</div>
<div class="stat-item">
<span class="stat-label">Totale VM Attive</span>
<span class="stat-value" id="statActive">-</span>
</div>
</div>
<div class="topology-legend card">
<div class="legend-title">Legenda</div>
<div class="legend-item">
<div class="legend-color cluster"></div>
<span>Cluster</span>
</div>
<div class="legend-item">
<div class="legend-color node"></div>
<span>Nodo Proxmox</span>
</div>
<div class="legend-item">
<div class="legend-color vm"></div>
<span>VM (QEMU)</span>
</div>
<div class="legend-item">
<div class="legend-color lxc"></div>
<span>Container (LXC)</span>
</div>
<div class="legend-item">
<div class="legend-color storage"></div>
<span>Storage</span>
</div>
</div>
</div>
</div>
<div class="node-details card" id="nodeDetails">
<div class="node-details-header">
<div class="node-details-title" id="detailsTitle">Dettagli Nodo</div>
<span class="close-details" onclick="closeDetails()"></span>
</div>
<div id="detailsContent"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let topologyData = null;
let currentViewMode = 'all';
let canvas, ctx;
// Zoom e Pan
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let hasMoved = false;
// Drag dei nodi
let draggedNode = null;
let nodePositions = {}; // Salva posizioni custom dei nodi
document.addEventListener('DOMContentLoaded', function() {
canvas = document.getElementById('topologyCanvas');
ctx = canvas.getContext('2d');
// Set canvas size to match wrapper
resizeCanvas();
// Carica layout salvato se disponibile
const saved = localStorage.getItem('topology_layout');
if (saved) {
try {
nodePositions = JSON.parse(saved);
console.log('Layout salvato caricato:', Object.keys(nodePositions).length, 'nodi');
} catch (e) {
console.error('Errore caricamento layout:', e);
nodePositions = {};
}
}
loadTopology();
// Inizializza tutti gli event listener
initEventListeners();
// Resize on window resize
window.addEventListener('resize', function() {
resizeCanvas();
if (topologyData) {
drawTopology();
}
});
});
function resizeCanvas() {
const wrapper = canvas.parentElement;
// Get the actual available space
const wrapperWidth = wrapper.clientWidth;
const wrapperHeight = wrapper.clientHeight;
// Account for padding (16px on each side = 32px total)
canvas.width = wrapperWidth - 32;
canvas.height = Math.max(650, wrapperHeight - 32);
// Set CSS size to match
canvas.style.width = canvas.width + 'px';
canvas.style.height = canvas.height + 'px';
console.log('Canvas resized to:', canvas.width, 'x', canvas.height);
console.log('Wrapper size:', wrapperWidth, 'x', wrapperHeight);
}
async function loadTopology() {
const result = await apiCall('/api/admin/cluster/topology');
if (result.status === 'success') {
topologyData = result.data;
updateStats();
drawTopology();
} else {
showAlert('Errore nel caricamento della topologia: ' + result.message, 'error');
}
}
function updateStats() {
if (!topologyData) return;
const nodes = topologyData.nodes || [];
const resources = topologyData.resources || [];
const vms = resources.filter(r => r.type === 'qemu');
const lxc = resources.filter(r => r.type === 'lxc');
const storage = resources.filter(r => r.type === 'storage');
const active = resources.filter(r => (r.type === 'qemu' || r.type === 'lxc') && r.status === 'running');
document.getElementById('statNodes').textContent = nodes.length;
document.getElementById('statVMs').textContent = vms.length;
document.getElementById('statLXC').textContent = lxc.length;
document.getElementById('statStorage').textContent = storage.length;
document.getElementById('statActive').textContent = active.length;
}
function drawTopology() {
if (!topologyData) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Salva stato e applica trasformazioni
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
// Reset click areas
canvas.clickAreas = [];
const nodes = topologyData.nodes || [];
const resources = topologyData.resources || [];
// Draw cluster center - più centrato verticalmente
const centerX = canvas.width / 2;
const centerY = Math.min(150, canvas.height / 3);
// Usa posizione salvata se disponibile
const clusterKey = 'cluster_proxmox';
const clusterPos = nodePositions[clusterKey] || { x: centerX, y: centerY };
drawNode(clusterPos.x, clusterPos.y, 'Proxmox Cluster', 'cluster', 60);
// Draw nodes in a circle around cluster
const nodeRadius = 200;
nodes.forEach((node, idx) => {
const angle = (Math.PI * 2 * idx) / nodes.length - Math.PI / 2;
const defaultX = centerX + Math.cos(angle) * nodeRadius;
const defaultY = centerY + Math.sin(angle) * nodeRadius;
// Usa posizione salvata se disponibile
const nodeKey = `node_${node.node}`;
const nodePos = nodePositions[nodeKey] || { x: defaultX, y: defaultY };
// Draw connection to cluster
drawConnection(clusterPos.x, clusterPos.y, nodePos.x, nodePos.y, '#58a6ff');
// Draw node
drawNode(nodePos.x, nodePos.y, node.node, 'node', 50, node);
if (currentViewMode === 'all' || currentViewMode === 'vms') {
// Draw VMs/LXC for this node
const nodeResources = resources.filter(r =>
(r.type === 'qemu' || r.type === 'lxc') && r.node === node.node
);
nodeResources.forEach((res, resIdx) => {
const resAngle = angle + (resIdx - nodeResources.length / 2) * 0.3;
const defaultResX = nodePos.x + Math.cos(resAngle) * 120;
const defaultResY = nodePos.y + Math.sin(resAngle) * 120;
// Usa posizione salvata se disponibile
const resKey = `${res.type}_${res.vmid}`;
const resPos = nodePositions[resKey] || { x: defaultResX, y: defaultResY };
drawConnection(nodePos.x, nodePos.y, resPos.x, resPos.y, res.type === 'qemu' ? '#3fb950' : '#d2991e');
drawNode(resPos.x, resPos.y, res.name || `${res.type}-${res.vmid}`, res.type, 30, res);
});
}
});
if (currentViewMode === 'all' || currentViewMode === 'storage') {
// Draw storage
const storage = resources.filter(r => r.type === 'storage');
const storageY = canvas.height - 80;
storage.forEach((stor, idx) => {
const x = 100 + idx * 150;
drawNode(x, storageY, stor.storage, 'storage', 40, stor);
});
}
// Ripristina stato canvas
ctx.restore();
}
function drawNode(x, y, label, type, size, data = null) {
const colors = {
'cluster': '#58a6ff',
'node': '#a371f7',
'qemu': '#3fb950',
'lxc': '#d2991e',
'storage': '#ff8800'
};
const color = colors[type] || '#58a6ff';
// Draw circle
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = color + '33';
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.stroke();
// Draw status indicator for VMs/LXC
if (data && (type === 'qemu' || type === 'lxc')) {
const statusColor = data.status === 'running' ? '#3fb950' : '#ff4e50';
ctx.beginPath();
ctx.arc(x + size - 8, y - size + 8, 6, 0, Math.PI * 2);
ctx.fillStyle = statusColor;
ctx.fill();
}
// Draw icon
ctx.font = `${Math.max(size / 2, 20)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
const icons = {
'cluster': '⚡',
'node': '🖥️',
'qemu': '💻',
'lxc': '📦',
'storage': '💾'
};
ctx.fillText(icons[type] || '•', x, y);
// Draw label
ctx.font = '12px Arial';
ctx.fillStyle = '#c9d1d9';
ctx.fillText(label.length > 15 ? label.substring(0, 15) + '...' : label, x, y + size + 15);
// Genera chiave univoca per questo nodo
let nodeKey;
if (type === 'cluster') {
nodeKey = 'cluster_proxmox';
} else if (type === 'node') {
nodeKey = `node_${label}`;
} else if (type === 'storage') {
nodeKey = `storage_${label}`;
} else if (data && data.vmid) {
nodeKey = `${type}_${data.vmid}`;
} else {
nodeKey = `${type}_${label}`;
}
// Store click area con chiave univoca (initialized in drawTopology)
canvas.clickAreas.push({ x, y, size, type, label, data, nodeKey });
}
function drawConnection(x1, y1, x2, y2, color) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color + '66';
ctx.lineWidth = 2;
ctx.stroke();
}
function handleCanvasClick(event) {
// Se il mouse si è mosso durante il drag, non è un click
if (hasMoved) {
hasMoved = false;
return;
}
const rect = canvas.getBoundingClientRect();
// Calculate click position considering canvas scale
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
// Coordinate nel canvas
let x = (event.clientX - rect.left) * scaleX;
let y = (event.clientY - rect.top) * scaleY;
// Applica trasformazione inversa per zoom e pan
x = (x - offsetX) / scale;
y = (y - offsetY) / scale;
console.log('Click at:', x, y, 'scale:', scale, 'offset:', offsetX, offsetY);
console.log('Canvas areas:', canvas.clickAreas ? canvas.clickAreas.length : 0);
if (!canvas.clickAreas || canvas.clickAreas.length === 0) {
console.warn('No click areas defined!');
return;
}
for (const area of canvas.clickAreas) {
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
console.log(`Distance to ${area.label}:`, distance, 'threshold:', area.size);
if (distance <= area.size) {
console.log('Click detected on:', area.label);
showDetails(area);
break;
}
}
}
function showDetails(area) {
const details = document.getElementById('nodeDetails');
const title = document.getElementById('detailsTitle');
const content = document.getElementById('detailsContent');
title.textContent = `${area.label} (${area.type.toUpperCase()})`;
let html = '';
if (area.data) {
const data = area.data;
if (area.type === 'node') {
html = `
<div class="detail-section">
<div class="detail-section-title">Informazioni Nodo</div>
<div class="detail-item">
<span>Stato:</span>
<span><strong>${data.status || 'N/A'}</strong></span>
</div>
<div class="detail-item">
<span>CPU:</span>
<span>${data.cpu ? (data.cpu * 100).toFixed(1) + '%' : 'N/A'}</span>
</div>
${data.cpu ? `<div class="resource-bar"><div class="resource-bar-fill cpu" style="width: ${data.cpu * 100}%"></div></div>` : ''}
<div class="detail-item">
<span>Memoria:</span>
<span>${data.mem && data.maxmem ? ((data.mem / data.maxmem) * 100).toFixed(1) + '%' : 'N/A'}</span>
</div>
${data.mem && data.maxmem ? `<div class="resource-bar"><div class="resource-bar-fill memory" style="width: ${(data.mem / data.maxmem) * 100}%"></div></div>` : ''}
<div class="detail-item">
<span>Uptime:</span>
<span>${data.uptime ? formatUptime(data.uptime) : 'N/A'}</span>
</div>
</div>
`;
} else if (area.type === 'qemu' || area.type === 'lxc') {
html = `
<div class="detail-section">
<div class="detail-section-title">Informazioni ${area.type === 'qemu' ? 'VM' : 'Container'}</div>
<div class="detail-item">
<span>ID:</span>
<span><strong>${data.vmid || 'N/A'}</strong></span>
</div>
<div class="detail-item">
<span>Nodo:</span>
<span>${data.node || 'N/A'}</span>
</div>
<div class="detail-item">
<span>Stato:</span>
<span><strong style="color: ${data.status === 'running' ? 'var(--accent-green)' : 'var(--accent-red)'}">${data.status || 'N/A'}</strong></span>
</div>
<div class="detail-item">
<span>CPU:</span>
<span>${data.cpu ? (data.cpu * 100).toFixed(1) + '%' : 'N/A'}</span>
</div>
<div class="detail-item">
<span>Memoria:</span>
<span>${data.mem && data.maxmem ? `${(data.mem / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxmem / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
</div>
<div class="detail-item">
<span>Disco:</span>
<span>${data.disk && data.maxdisk ? `${(data.disk / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxdisk / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
</div>
<div class="detail-item">
<span>Uptime:</span>
<span>${data.uptime ? formatUptime(data.uptime) : 'N/A'}</span>
</div>
</div>
`;
} else if (area.type === 'storage') {
html = `
<div class="detail-section">
<div class="detail-section-title">Informazioni Storage</div>
<div class="detail-item">
<span>Nome:</span>
<span><strong>${data.storage || 'N/A'}</strong></span>
</div>
<div class="detail-item">
<span>Tipo:</span>
<span>${data.plugintype || 'N/A'}</span>
</div>
<div class="detail-item">
<span>Utilizzo:</span>
<span>${data.disk && data.maxdisk ? ((data.disk / data.maxdisk) * 100).toFixed(1) + '%' : 'N/A'}</span>
</div>
${data.disk && data.maxdisk ? `<div class="resource-bar"><div class="resource-bar-fill disk" style="width: ${(data.disk / data.maxdisk) * 100}%"></div></div>` : ''}
<div class="detail-item">
<span>Spazio:</span>
<span>${data.disk && data.maxdisk ? `${(data.disk / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxdisk / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
</div>
</div>
`;
}
} else {
html = '<p>Nessun dettaglio disponibile</p>';
}
content.innerHTML = html;
details.classList.add('visible');
}
function closeDetails() {
document.getElementById('nodeDetails').classList.remove('visible');
}
function setViewMode(mode) {
currentViewMode = mode;
document.querySelectorAll('.control-btn').forEach(btn => btn.classList.remove('active'));
// Fix per ID corretto dei bottoni
const btnMap = {
'all': 'btnAll',
'nodes': 'btnNodes',
'vms': 'btnVms',
'storage': 'btnStorage'
};
if (btnMap[mode]) {
document.getElementById(btnMap[mode]).classList.add('active');
}
drawTopology();
}
function refreshTopology() {
showAlert('Aggiornamento topologia...', 'info');
loadTopology();
}
function saveLayout() {
localStorage.setItem('topology_layout', JSON.stringify(nodePositions));
showAlert('Layout salvato! Le posizioni dei nodi sono state memorizzate.', 'success');
}
function loadLayout() {
const saved = localStorage.getItem('topology_layout');
if (saved) {
nodePositions = JSON.parse(saved);
drawTopology();
showAlert('Layout caricato!', 'success');
} else {
showAlert('Nessun layout salvato trovato', 'warning');
}
}
function resetLayout() {
if (confirm('Vuoi ripristinare il layout originale? Tutte le posizioni personalizzate verranno perse.')) {
nodePositions = {};
localStorage.removeItem('topology_layout');
drawTopology();
showAlert('Layout ripristinato!', 'success');
}
}
function exportTopology() {
if (!topologyData) {
showAlert('Nessun dato da esportare', 'warning');
return;
}
const dataStr = JSON.stringify(topologyData, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `cluster_topology_${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(url);
showAlert('Topologia esportata con successo', 'success');
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}g ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
// ==================== ZOOM E PAN ====================
function zoomIn() {
scale = Math.min(scale * 1.2, 3); // Max 3x
updateZoomLevel();
drawTopology();
}
function zoomOut() {
scale = Math.max(scale / 1.2, 0.3); // Min 0.3x
updateZoomLevel();
drawTopology();
}
function resetZoom() {
scale = 1;
offsetX = 0;
offsetY = 0;
updateZoomLevel();
drawTopology();
}
function updateZoomLevel() {
document.getElementById('zoomLevel').textContent = Math.round(scale * 100) + '%';
}
// Inizializza tutti gli event listener
function initEventListeners() {
let mouseDownX = 0;
let mouseDownY = 0;
let touchStartDistance = 0;
let touchStartScale = 1;
// Click handler
canvas.addEventListener('click', handleCanvasClick);
// Mouse wheel zoom
canvas.addEventListener('wheel', function(event) {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
// Calcola punto nel mondo prima dello zoom
const worldX = (mouseX - offsetX) / scale;
const worldY = (mouseY - offsetY) / scale;
// Applica zoom
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.min(Math.max(scale * zoomFactor, 0.3), 3);
// Calcola nuovo offset per mantenere punto sotto mouse
offsetX = mouseX - worldX * newScale;
offsetY = mouseY - worldY * newScale;
scale = newScale;
updateZoomLevel();
drawTopology();
});
// Pan con mouse drag / Drag nodi
canvas.addEventListener('mousedown', function(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
let x = (event.clientX - rect.left) * scaleX;
let y = (event.clientY - rect.top) * scaleY;
// Applica trasformazione inversa
x = (x - offsetX) / scale;
y = (y - offsetY) / scale;
// Controlla se si è cliccato su un nodo
draggedNode = null;
if (canvas.clickAreas) {
for (const area of canvas.clickAreas) {
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
if (distance <= area.size) {
draggedNode = area;
canvas.style.cursor = 'move';
break;
}
}
}
// Se non è su un nodo, è pan del canvas
if (!draggedNode) {
isDragging = true;
canvas.style.cursor = 'grabbing';
}
hasMoved = false;
mouseDownX = event.clientX;
mouseDownY = event.clientY;
dragStartX = event.clientX - offsetX;
dragStartY = event.clientY - offsetY;
});
canvas.addEventListener('mousemove', function(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
if (draggedNode) {
// Drag del nodo
let x = (event.clientX - rect.left) * scaleX;
let y = (event.clientY - rect.top) * scaleY;
// Applica trasformazione inversa
x = (x - offsetX) / scale;
y = (y - offsetY) / scale;
// Salva nuova posizione
nodePositions[draggedNode.nodeKey] = { x, y };
hasMoved = true;
drawTopology();
} else if (isDragging) {
// Pan del canvas
const newOffsetX = event.clientX - dragStartX;
const newOffsetY = event.clientY - dragStartY;
// Se il mouse si è mosso di più di 3 pixel, considera come drag
const movedDistance = Math.sqrt(
Math.pow(event.clientX - mouseDownX, 2) +
Math.pow(event.clientY - mouseDownY, 2)
);
if (movedDistance > 3) {
hasMoved = true;
offsetX = newOffsetX;
offsetY = newOffsetY;
drawTopology();
}
} else {
// Hover detection
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
let x = (event.clientX - rect.left) * scaleX;
let y = (event.clientY - rect.top) * scaleY;
// Applica trasformazione inversa
x = (x - offsetX) / scale;
y = (y - offsetY) / scale;
let isOverNode = false;
if (canvas.clickAreas) {
for (const area of canvas.clickAreas) {
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
if (distance <= area.size) {
isOverNode = true;
break;
}
}
}
canvas.style.cursor = isOverNode ? 'move' : 'grab';
}
});
canvas.addEventListener('mouseup', function() {
isDragging = false;
draggedNode = null;
canvas.style.cursor = 'grab';
});
canvas.addEventListener('mouseleave', function() {
isDragging = false;
draggedNode = null;
hasMoved = false;
canvas.style.cursor = 'grab';
});
// Touch support per mobile
canvas.addEventListener('touchstart', function(event) {
if (event.touches.length === 2) {
// Pinch zoom
const touch1 = event.touches[0];
const touch2 = event.touches[1];
touchStartDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
touchStartScale = scale;
} else if (event.touches.length === 1) {
// Pan
isDragging = true;
dragStartX = event.touches[0].clientX - offsetX;
dragStartY = event.touches[0].clientY - offsetY;
}
});
canvas.addEventListener('touchmove', function(event) {
event.preventDefault();
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
scale = Math.min(Math.max(touchStartScale * (currentDistance / touchStartDistance), 0.3), 3);
updateZoomLevel();
drawTopology();
} else if (event.touches.length === 1 && isDragging) {
offsetX = event.touches[0].clientX - dragStartX;
offsetY = event.touches[0].clientY - dragStartY;
drawTopology();
}
});
canvas.addEventListener('touchend', function() {
isDragging = false;
});
}
</script>
{% endblock %}