| document.addEventListener('DOMContentLoaded', () => { |
| const canvas = document.getElementById('simCanvas'); |
| const ctx = canvas.getContext('2d'); |
| const startBtn = document.getElementById('startBtn'); |
| const stopBtn = document.getElementById('stopBtn'); |
| const nodeCountInput = document.getElementById('nodeCount'); |
| const simTimeEl = document.getElementById('simTime'); |
| const activeNodesEl = document.getElementById('activeNodes'); |
|
|
| |
| const deadCountEl = document.getElementById('deadCount'); |
| const avgDowntimeEl = document.getElementById('avgDowntime'); |
| const deadNodesListEl = document.getElementById('deadNodesList'); |
|
|
| let isRunning = false; |
| let animationId = null; |
| const AREA_SIZE = 1000.0; |
|
|
| function resizeCanvas() { |
| const wrapper = document.getElementById('canvas-wrapper'); |
| canvas.width = wrapper.clientWidth; |
| canvas.height = wrapper.clientHeight; |
| } |
|
|
| window.addEventListener('resize', resizeCanvas); |
| resizeCanvas(); |
|
|
| startBtn.addEventListener('click', async () => { |
| if (isRunning) return; |
|
|
| const n_nodes = parseInt(nodeCountInput.value); |
| if (!n_nodes || n_nodes < 1) { |
| alert("Please enter a valid number of nodes."); |
| return; |
| } |
|
|
| try { |
| const res = await fetch('/start', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ n_nodes: n_nodes }) |
| }); |
| const data = await res.json(); |
|
|
| if (data.status === 'ok') { |
| isRunning = true; |
| startBtn.disabled = true; |
| stopBtn.disabled = false; |
| startBtn.classList.add('disabled'); |
|
|
| |
| if (deadNodesListEl) deadNodesListEl.innerHTML = ''; |
| if (deadCountEl) deadCountEl.textContent = '0'; |
| if (avgDowntimeEl) avgDowntimeEl.textContent = '0'; |
|
|
| loop(); |
| } |
| } catch (e) { |
| console.error("Error starting simulation:", e); |
| } |
| }); |
|
|
| stopBtn.addEventListener('click', () => { |
| isRunning = false; |
| if (animationId) clearTimeout(animationId); |
| startBtn.disabled = false; |
| stopBtn.disabled = true; |
| startBtn.classList.remove('disabled'); |
| }); |
|
|
| async function loop() { |
| if (!isRunning) return; |
|
|
| try { |
| const res = await fetch('/step'); |
| const state = await res.json(); |
| draw(state); |
| updateDashboard(state); |
|
|
| simTimeEl.textContent = state.sim_time; |
| const liveNodes = state.nodes.filter(n => !n.dead).length; |
| activeNodesEl.textContent = liveNodes; |
|
|
| if (liveNodes === 0 && isRunning) { |
| isRunning = false; |
| startBtn.disabled = false; |
| stopBtn.disabled = true; |
| startBtn.classList.remove('disabled'); |
| return; |
| } |
|
|
| |
| animationId = setTimeout(loop, 200); |
| } catch (e) { |
| console.error("Error fetching step:", e); |
| isRunning = false; |
| } |
| } |
|
|
| function updateDashboard(state) { |
| if (!state.dead_stats) return; |
|
|
| const deadCount = state.dead_stats.length; |
| if (deadCountEl) deadCountEl.textContent = deadCount; |
|
|
| |
| let totalDowntime = 0; |
| state.dead_stats.forEach(s => totalDowntime += s.downtime); |
| const avg = deadCount > 0 ? (totalDowntime / deadCount).toFixed(1) : 0; |
| if (avgDowntimeEl) avgDowntimeEl.textContent = avg; |
|
|
| |
| if (deadNodesListEl) { |
| |
| deadNodesListEl.innerHTML = ''; |
|
|
| |
| |
| |
| const sorted = [...state.dead_stats].sort((a, b) => b.dead_since - a.dead_since); |
|
|
| sorted.slice(0, 10).forEach(stat => { |
| const li = document.createElement('li'); |
| li.className = 'dead-node-item'; |
| li.innerHTML = ` |
| <span class="dead-node-id">Node ${stat.id}</span> |
| <span class="dead-node-time">Down for ${stat.downtime}t (Since: ${stat.dead_since})</span> |
| `; |
| deadNodesListEl.appendChild(li); |
| }); |
| } |
| } |
|
|
| function draw(state) { |
| |
| ctx.fillStyle = '#0a0a12'; |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
| |
| const scaleX = canvas.width / AREA_SIZE; |
| const scaleY = canvas.height / AREA_SIZE; |
| const scale = Math.min(scaleX, scaleY) * 0.9; |
| const offsetX = (canvas.width - AREA_SIZE * scale) / 2; |
| const offsetY = (canvas.height - AREA_SIZE * scale) / 2; |
|
|
| const transform = (x, y) => ({ |
| x: offsetX + x * scale, |
| y: offsetY + y * scale |
| |
| |
| |
| |
| |
| |
| }); |
|
|
| |
| const t = (x, y) => { |
| return { |
| x: offsetX + x * scale, |
| y: offsetY + (AREA_SIZE - y) * scale |
| }; |
| }; |
|
|
| |
| if (state.links) { |
| state.links.forEach(link => { |
| const start = t(link.start[0], link.start[1]); |
| const end = t(link.end[0], link.end[1]); |
|
|
| ctx.beginPath(); |
| ctx.moveTo(start.x, start.y); |
| ctx.lineTo(end.x, end.y); |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| }); |
| } |
|
|
| |
| const gw = t(state.gateway[0], state.gateway[1]); |
| ctx.fillStyle = '#00f3ff'; |
| ctx.shadowColor = '#00f3ff'; |
| ctx.shadowBlur = 20; |
| ctx.beginPath(); |
| ctx.arc(gw.x, gw.y, 8, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.shadowBlur = 0; |
|
|
| |
| state.nodes.forEach(node => { |
| const p = t(node.x, node.y); |
|
|
| |
| if (node.is_head) { |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, 12, 0, Math.PI * 2); |
| ctx.fillStyle = hexToRgba(node.color, 0.2); |
| ctx.fill(); |
|
|
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); |
| ctx.strokeStyle = node.color; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| } |
|
|
| |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, node.is_head ? 6 : 4, 0, Math.PI * 2); |
| ctx.fillStyle = node.color; |
| ctx.fill(); |
|
|
| |
| if (node.dead) { |
| ctx.strokeStyle = '#fff'; |
| ctx.lineWidth = 1; |
| ctx.beginPath(); |
| ctx.moveTo(p.x - 3, p.y - 3); |
| ctx.lineTo(p.x + 3, p.y + 3); |
| ctx.moveTo(p.x + 3, p.y - 3); |
| ctx.lineTo(p.x - 3, p.y + 3); |
| ctx.stroke(); |
| } |
| }); |
| } |
|
|
| function hexToRgba(hex, alpha) { |
| |
| let c; |
| if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { |
| c = hex.substring(1).split(''); |
| if (c.length === 3) { |
| c = [c[0], c[0], c[1], c[1], c[2], c[2]]; |
| } |
| c = '0x' + c.join(''); |
| return 'rgba(' + [(c >> 16) & 255, (c >> 8) & 255, c & 255].join(',') + ',' + alpha + ')'; |
| } |
| return hex; |
| } |
|
|
| |
| const dropZone = document.getElementById('dropZone'); |
| const videoInput = document.getElementById('videoInput'); |
| const uploadStatus = document.getElementById('uploadStatus'); |
| const videoPlaceholder = document.getElementById('videoPlaceholder'); |
| const feedWrapper = document.querySelector('.video-feed-wrapper'); |
|
|
| if (dropZone) { |
| dropZone.addEventListener('click', () => videoInput.click()); |
|
|
| dropZone.addEventListener('dragover', (e) => { |
| e.preventDefault(); |
| dropZone.style.borderColor = '#00f3ff'; |
| }); |
|
|
| dropZone.addEventListener('dragleave', (e) => { |
| e.preventDefault(); |
| dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)'; |
| }); |
|
|
| dropZone.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)'; |
| if (e.dataTransfer.files.length) { |
| handleUpload(e.dataTransfer.files[0]); |
| } |
| }); |
|
|
| videoInput.addEventListener('change', () => { |
| if (videoInput.files.length) { |
| handleUpload(videoInput.files[0]); |
| } |
| }); |
| } |
|
|
| async function handleUpload(file) { |
| if (!file.type.startsWith('video/')) { |
| uploadStatus.textContent = "Error: Please upload a video file."; |
| uploadStatus.style.color = '#ff4444'; |
| return; |
| } |
|
|
| uploadStatus.textContent = `Uploading ${file.name}... (This may take a moment)`; |
| uploadStatus.style.color = '#8892b0'; |
|
|
| const formData = new FormData(); |
| formData.append('video', file); |
|
|
| try { |
| const res = await fetch('/upload_video', { |
| method: 'POST', |
| body: formData |
| }); |
|
|
| const data = await res.json(); |
|
|
| if (data.status === 'ok') { |
| uploadStatus.textContent = "Processing Started! Stream below."; |
| uploadStatus.style.color = '#00ff00'; |
| startVideoFeed(); |
| } else { |
| uploadStatus.textContent = "Upload Failed: " + (data.error || 'Unknown error'); |
| uploadStatus.style.color = '#ff4444'; |
| } |
| } catch (e) { |
| console.error(e); |
| uploadStatus.textContent = "Network Error."; |
| uploadStatus.style.color = '#ff4444'; |
| } |
| } |
|
|
| function startVideoFeed() { |
| |
| if (videoPlaceholder) videoPlaceholder.style.display = 'none'; |
|
|
| |
| const existing = document.getElementById('detectionFeed'); |
| if (existing) existing.remove(); |
|
|
| |
| const img = document.createElement('img'); |
| img.id = 'detectionFeed'; |
| img.src = '/video_feed?' + new Date().getTime(); |
| feedWrapper.appendChild(img); |
| } |
| }); |
|
|