diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js
index 4fd8fdc..6b0fe8d 100644
--- a/frontend/assets/js/app.js
+++ b/frontend/assets/js/app.js
@@ -3,41 +3,69 @@ let teams = [];
let events = [];
let nodes = [];
let links = [];
+let shapes = [];
let selectedNodeId = null;
+let selectedShapeId = null;
-// Network canvas state
+// Canvas state
let canvas, ctx;
let canvasNodes = [];
let canvasLinks = [];
-let isDragging = false;
-let dragNode = null;
-let offsetX, offsetY;
+let canvasShapes = [];
let panX = 0, panY = 0;
let isPanning = false;
let panStartX, panStartY;
+// Drag state
+let dragTarget = null;
+let dragType = null;
+let dragOffX, dragOffY;
+let dragHandle = null;
+let dragOrig = null;
+
+let nextShapeZ = 0;
+
document.addEventListener('DOMContentLoaded', () => {
canvas = document.getElementById('networkCanvas');
ctx = canvas.getContext('2d');
resizeCanvas();
- loadTeams().then(() => {
- loadEvents();
- });
+ loadTeams().then(() => loadEvents());
loadNetworkData();
document.getElementById('saveEvent').addEventListener('click', saveEvent);
document.getElementById('saveNode').addEventListener('click', saveNode);
document.getElementById('saveLink').addEventListener('click', saveLink);
+ document.getElementById('saveShape').addEventListener('click', saveShape);
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
+ document.getElementById('shapeOpacity').addEventListener('input', (e) => {
+ document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2);
+ });
- canvas.addEventListener('mousedown', onCanvasMouseDown);
- canvas.addEventListener('mousemove', onCanvasMouseMove);
- canvas.addEventListener('mouseup', onCanvasMouseUp);
- canvas.addEventListener('dblclick', onCanvasDblClick);
+ canvas.addEventListener('mousedown', onMouseDown);
+ canvas.addEventListener('mousemove', onMouseMove);
+ canvas.addEventListener('mouseup', onMouseUp);
+ canvas.addEventListener('dblclick', onDblClick);
+ canvas.addEventListener('contextmenu', onContextMenu);
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (document.activeElement === canvas || document.activeElement?.tagName !== 'INPUT') {
+ if (selectedNodeId) deleteSelectedNode();
+ else if (selectedShapeId) deleteSelectedShape();
+ }
+ }
+ if (e.key === 'Escape') {
+ selectedNodeId = null;
+ selectedShapeId = null;
+ renderNetwork();
+ renderNodeList();
+ }
+ });
+
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
tab.addEventListener('shown.bs.tab', () => {
if (tab.id === 'network-tab') {
@@ -47,7 +75,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
- // Bootstrap dark mode default
document.documentElement.setAttribute('data-bs-theme', 'dark');
});
@@ -57,7 +84,6 @@ function resizeCanvas() {
canvas.height = wrapper.clientHeight;
}
-// ==================== API HELPERS ====================
async function apiFetch(path, options = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
@@ -128,7 +154,7 @@ function renderTimeline() {
${renderComments(e)}
@@ -186,10 +212,18 @@ async function saveEvent() {
// ==================== NETWORK MAP ====================
async function loadNetworkData() {
- nodes = await apiFetch('nodes');
- links = await apiFetch('links');
+ const [n, l, s] = await Promise.all([
+ apiFetch('nodes'),
+ apiFetch('links'),
+ apiFetch('shapes')
+ ]);
+ nodes = n;
+ links = l;
+ shapes = s;
populateNodeSelects();
renderNodeList();
+ renderShapeList();
+ if (shapes.length) nextShapeZ = Math.max(...shapes.map(s => s.z_index)) + 1;
renderNetwork();
}
@@ -207,15 +241,31 @@ function renderNodeList() {
${esc(n.label)}
-
${n.ip_address || '—'} · ${n.node_type} · ${n.group_name}
+
${n.ip_address || '—'} · ${n.node_type}
`).join('');
}
+function renderShapeList() {
+ const list = document.getElementById('shapeList');
+ list.innerHTML = shapes.map(s => `
+
+
+
+
+ ${esc(s.label) || (s.shape_type === 'rectangle' ? 'Box' : 'Ellipse')}
+
+
${s.shape_type}
+
+
+ `).join('');
+}
+
function selectNode(id) {
selectedNodeId = id;
+ selectedShapeId = null;
const n = nodes.find(x => x.id == id);
if (n) {
document.getElementById('nodeDetails').innerHTML = `
@@ -225,13 +275,41 @@ function selectNode(id) {
Type: ${n.node_type}
Status: ${n.status}
Group: ${n.group_name}
+
`;
}
renderNodeList();
+ renderShapeList();
renderNetwork();
}
+function selectShape(id) {
+ selectedShapeId = id;
+ selectedNodeId = null;
+ renderNodeList();
+ renderShapeList();
+ renderNetwork();
+}
+
+async function deleteSelectedNode() {
+ if (!selectedNodeId) return;
+ if (!confirm('Delete this node and its connections?')) return;
+ const id = selectedNodeId;
+ selectedNodeId = null;
+ await apiFetch(`nodes/${id}`, { method: 'DELETE' });
+ loadNetworkData();
+}
+
+async function deleteSelectedShape() {
+ if (!selectedShapeId) return;
+ if (!confirm('Delete this shape?')) return;
+ const id = selectedShapeId;
+ selectedShapeId = null;
+ await apiFetch(`shapes/${id}`, { method: 'DELETE' });
+ loadNetworkData();
+}
+
async function saveNode() {
const data = {
label: document.getElementById('nodeLabel').value,
@@ -239,8 +317,8 @@ async function saveNode() {
node_type: document.getElementById('nodeType').value,
status: document.getElementById('nodeStatus').value,
group_name: document.getElementById('nodeGroup').value || 'default',
- pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2,
- pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2
+ pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2 - panX,
+ pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 - panY
};
if (!data.label) return alert('Label required');
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
@@ -263,19 +341,55 @@ async function saveLink() {
loadNetworkData();
}
+async function saveShape() {
+ const data = {
+ label: document.getElementById('shapeLabel').value,
+ shape_type: document.getElementById('shapeType').value,
+ pos_x: canvas.width / 2 - 100 - panX,
+ pos_y: canvas.height / 2 - 75 - panY,
+ width: 200,
+ height: 150,
+ color: document.getElementById('shapeColor').value,
+ border_color: document.getElementById('shapeBorderColor').value,
+ opacity: parseFloat(document.getElementById('shapeOpacity').value),
+ z_index: nextShapeZ++
+ };
+ await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) });
+ bootstrap.Modal.getInstance(document.getElementById('shapeModal')).hide();
+ document.getElementById('shapeForm').reset();
+ loadNetworkData();
+}
+
// ==================== CANVAS RENDERING ====================
+const NODE_ICONS = {
+ host: ['M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', '#3b82f6'],
+ server: ['M6,3 L18,3 L20,6 L20,20 L4,20 L4,6 Z M8,10 L16,10 M8,14 L16,14 M8,17 L12,17', '#8b5cf6'],
+ router: ['M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', '#f59e0b'],
+ firewall: ['M6,3 L18,3 L21,8 L21,16 L18,21 L6,21 L3,16 L3,8 Z M9,10 L15,10 L15,14 L9,14 Z', '#ef4444'],
+ switch: ['M4,8 L20,8 L20,16 L4,16 Z M7,11 L7,13 M10,11 L10,13 M13,11 L13,13 M16,11 L16,13', '#06b6d4'],
+ cloud: ['M10,4 C6,4 4,7 5,10 C3,11 2,14 4,16 L8,16 C10,18 14,18 16,16 L20,16 C22,14 21,10 19,9 C20,6 17,4 15,5 C14,4 12,4 10,4 Z', '#22c55e'],
+ endpoint: ['M8,4 L16,4 L18,10 L18,16 L16,20 L8,20 L6,16 L6,10 Z M10,12 L14,12 M12,10 L12,14', '#ec4899'],
+ other: ['M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', '#6b7280']
+};
+
function buildCanvasGraph() {
- canvasNodes = nodes.map(n => ({
- id: n.id,
- label: n.label,
- ip: n.ip_address,
- type: n.node_type,
- status: n.status,
- group: n.group_name,
- x: parseFloat(n.pos_x) || (Math.random() * canvas.width * 0.6 + canvas.width * 0.2),
- y: parseFloat(n.pos_y) || (Math.random() * canvas.height * 0.6 + canvas.height * 0.2),
- radius: n.node_type === 'router' || n.node_type === 'firewall' ? 22 : n.node_type === 'server' ? 28 : 18
- }));
+ canvasNodes = nodes.map(n => {
+ const icon = NODE_ICONS[n.node_type] || NODE_ICONS.other;
+ return {
+ id: n.id,
+ label: n.label,
+ ip: n.ip_address,
+ type: n.node_type,
+ status: n.status,
+ group: n.group_name,
+ x: parseFloat(n.pos_x) || 100,
+ y: parseFloat(n.pos_y) || 100,
+ iconPath: icon[0],
+ color: icon[1],
+ w: 36,
+ h: 36
+ };
+ });
canvasLinks = links.map(l => ({
source: canvasNodes.find(n => n.id == l.source_id),
@@ -283,15 +397,20 @@ function buildCanvasGraph() {
type: l.link_type,
label: l.label
})).filter(l => l.source && l.target);
-}
-function getNodeColor(node) {
- const colors = {
- host: '#3b82f6', server: '#8b5cf6', router: '#f59e0b',
- firewall: '#ef4444', switch: '#06b6d4', cloud: '#22c55e',
- endpoint: '#ec4899', other: '#6b7280'
- };
- return colors[node.type] || '#6b7280';
+ canvasShapes = shapes.map(s => ({
+ id: s.id,
+ label: s.label,
+ type: s.shape_type,
+ x: parseFloat(s.pos_x),
+ y: parseFloat(s.pos_y),
+ w: parseFloat(s.width),
+ h: parseFloat(s.height),
+ color: s.color,
+ borderColor: s.border_color,
+ opacity: parseFloat(s.opacity),
+ z: parseInt(s.z_index)
+ }));
}
function renderNetwork() {
@@ -300,6 +419,9 @@ function renderNetwork() {
ctx.save();
ctx.translate(panX, panY);
+ // Draw shapes (lowest layer)
+ canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape);
+
// Draw links
canvasLinks.forEach(l => {
ctx.beginPath();
@@ -308,120 +430,264 @@ function renderNetwork() {
const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' };
ctx.strokeStyle = colors[l.type] || '#334155';
- ctx.lineWidth = l.type === 'vpn' ? 2.5 : l.type === 'monitored' ? 1.5 : 1.5;
+ ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5;
+
+ if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]);
+ else ctx.setLineDash([]);
- if (l.type === 'vpn' || l.type === 'wireless') {
- ctx.setLineDash([6, 4]);
- } else {
- ctx.setLineDash([]);
- }
ctx.stroke();
ctx.setLineDash([]);
- // Link label
if (l.label) {
const mx = (l.source.x + l.target.x) / 2;
const my = (l.source.y + l.target.y) / 2;
ctx.fillStyle = '#94a3b8';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
- ctx.fillText(l.label, mx, my - 6);
+ ctx.fillText(l.label, mx, my - 8);
}
});
// Draw nodes
- canvasNodes.forEach(n => {
- const isSelected = selectedNodeId == n.id;
- const color = getNodeColor(n);
+ canvasNodes.forEach(drawCanvasNode);
+ ctx.restore();
+}
- // Glow for selected
- if (isSelected) {
- ctx.shadowColor = color;
- ctx.shadowBlur = 20;
- }
+function drawShape(s) {
+ ctx.save();
+ const isSelected = selectedShapeId == s.id;
- // Shape by type
+ ctx.globalAlpha = s.opacity;
+
+ if (s.type === 'ellipse') {
ctx.beginPath();
- if (n.type === 'router' || n.type === 'firewall') {
- // Diamond
- ctx.moveTo(n.x, n.y - n.radius);
- ctx.lineTo(n.x + n.radius, n.y);
- ctx.lineTo(n.x, n.y + n.radius);
- ctx.lineTo(n.x - n.radius, n.y);
- ctx.closePath();
- } else if (n.type === 'cloud') {
- // Cloud-like
- ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
- } else if (n.type === 'server') {
- // Square
- ctx.rect(n.x - n.radius * 0.8, n.y - n.radius * 0.8, n.radius * 1.6, n.radius * 1.6);
- } else {
- // Circle (host, endpoint, etc)
- ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
- }
-
- ctx.fillStyle = color + '30';
+ ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
+ ctx.fillStyle = s.color;
ctx.fill();
- ctx.strokeStyle = isSelected ? '#fff' : color;
- ctx.lineWidth = isSelected ? 3 : 2;
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor;
+ ctx.lineWidth = isSelected ? 2.5 : 1.5;
+ ctx.setLineDash([5, 3]);
ctx.stroke();
-
- // Status indicator
- ctx.shadowBlur = 0;
+ ctx.setLineDash([]);
+ } else {
ctx.beginPath();
- ctx.arc(n.x + n.radius * 0.6, n.y - n.radius * 0.6, 5, 0, Math.PI * 2);
- const statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
- ctx.fillStyle = statusColors[n.status] || '#9ca3af';
+ ctx.roundRect(s.x, s.y, s.w, s.h, 8);
+ ctx.fillStyle = s.color;
ctx.fill();
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor;
+ ctx.lineWidth = isSelected ? 2.5 : 1.5;
+ ctx.setLineDash([5, 3]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
- // Label
- ctx.fillStyle = '#e2e8f0';
- ctx.font = isSelected ? 'bold 11px sans-serif' : '10px sans-serif';
- ctx.textAlign = 'center';
- ctx.fillText(n.label, n.x, n.y + n.radius + 14);
- if (n.ip) {
- ctx.fillStyle = '#64748b';
- ctx.font = '9px sans-serif';
- ctx.fillText(n.ip, n.x, n.y + n.radius + 26);
- }
- });
+ // Resize handles
+ if (isSelected) {
+ const handles = getShapeHandles(s);
+ handles.forEach(h => {
+ ctx.beginPath();
+ ctx.arc(h.x, h.y, 5, 0, Math.PI * 2);
+ ctx.fillStyle = '#ffffff';
+ ctx.fill();
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ });
+ }
+
+ // Label
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#94a3b8';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(s.label, s.x + s.w / 2, s.y - 8);
ctx.restore();
}
+function drawCanvasNode(n) {
+ const isSelected = selectedNodeId == n.id;
+ const s = 1.8;
+
+ ctx.save();
+ if (isSelected) {
+ ctx.shadowColor = n.color;
+ ctx.shadowBlur = 18;
+ }
+
+ // Fill background
+ ctx.translate(n.x, n.y);
+ ctx.scale(1, 1);
+ ctx.beginPath();
+
+ // Icon background circle/box
+ if (n.type === 'switch') {
+ ctx.rect(-20, -20, 40, 40);
+ } else {
+ ctx.arc(0, 0, 22, 0, Math.PI * 2);
+ }
+ ctx.fillStyle = n.color + '18';
+ ctx.fill();
+
+ // Border
+ ctx.strokeStyle = isSelected ? '#ffffff' : n.color;
+ ctx.lineWidth = isSelected ? 2.5 : 1.5;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+
+ // Icon path
+ const scalePath = (path, cx, cy, sc) => {
+ return path.replace(/([\d.]+)/g, (m) => {
+ const v = parseFloat(m);
+ return ((v - 12) * sc + cx).toFixed(1);
+ });
+ };
+ const scaled = scalePath(n.iconPath, 0, 0, 0.85);
+ const path = new Path2D(scaled);
+ ctx.fillStyle = n.color;
+ ctx.fill(path);
+
+ // Status dot
+ ctx.beginPath();
+ ctx.arc(17, -17, 5, 0, Math.PI * 2);
+ const statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
+ ctx.fillStyle = statusColors[n.status] || '#9ca3af';
+ ctx.fill();
+ if (n.status === 'compromised') {
+ ctx.strokeStyle = '#ef4444';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }
+
+ ctx.translate(-n.x, -n.y);
+
+ // Label
+ ctx.fillStyle = '#e2e8f0';
+ ctx.font = isSelected ? 'bold 11px sans-serif' : '10px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(n.label, n.x, n.y + 34);
+ if (n.ip) {
+ ctx.fillStyle = '#64748b';
+ ctx.font = '9px sans-serif';
+ ctx.fillText(n.ip, n.x, n.y + 46);
+ }
+
+ ctx.restore();
+}
+
+function getShapeHandles(s) {
+ return [
+ { x: s.x, y: s.y, cx: 0, cy: 0 },
+ { x: s.x + s.w, y: s.y, cx: 1, cy: 0 },
+ { x: s.x + s.w, y: s.y + s.h, cx: 1, cy: 1 },
+ { x: s.x, y: s.y + s.h, cx: 0, cy: 1 }
+ ];
+}
+
// ==================== CANVAS EVENTS ====================
-function getCanvasNode(mx, my) {
+function getCanvasNodeRaw(mx, my) {
return canvasNodes.find(n => {
const dx = mx - n.x, dy = my - n.y;
- return Math.sqrt(dx * dx + dy * dy) < n.radius + 8;
+ return Math.sqrt(dx * dx + dy * dy) < 28;
});
}
-function onCanvasMouseDown(e) {
+function getShapeAt(mx, my) {
+ for (let i = canvasShapes.length - 1; i >= 0; i--) {
+ const s = canvasShapes[i];
+ if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) return s;
+ }
+ return null;
+}
+
+function getShapeResizeHandle(mx, my) {
+ for (const s of canvasShapes) {
+ if (selectedShapeId != s.id) continue;
+ const handles = getShapeHandles(s);
+ for (const h of handles) {
+ const dx = mx - h.x, dy = my - h.y;
+ if (Math.sqrt(dx * dx + dy * dy) < 8) return { shape: s, handle: h };
+ }
+ }
+ return null;
+}
+
+function onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY;
- const node = getCanvasNode(mx, my);
- if (node) {
- isDragging = true;
- dragNode = node;
- offsetX = mx - node.x;
- offsetY = my - node.y;
- canvas.style.cursor = 'grabbing';
- } else {
- isPanning = true;
- panStartX = e.clientX - panX;
- panStartY = e.clientY - panY;
- canvas.style.cursor = 'grabbing';
+ // Check shape resize handle first (only for selected shape)
+ const resize = getShapeResizeHandle(mx, my);
+ if (resize) {
+ dragType = 'resize';
+ dragTarget = resize.shape;
+ dragHandle = resize.handle;
+ dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h };
+ dragOffX = mx;
+ dragOffY = my;
+ return;
}
+
+ // Check node
+ const node = getCanvasNodeRaw(mx, my);
+ if (node) {
+ dragType = 'node';
+ dragTarget = node;
+ dragOffX = mx - node.x;
+ dragOffY = my - node.y;
+ selectNode(node.id);
+ return;
+ }
+
+ // Check shape body
+ const shape = getShapeAt(mx, my);
+ if (shape) {
+ selectedNodeId = null;
+ selectedShapeId = shape.id;
+ dragType = 'shape';
+ dragTarget = shape;
+ dragOffX = mx - shape.x;
+ dragOffY = my - shape.y;
+ renderShapeList();
+ renderNetwork();
+ return;
+ }
+
+ // Pan
+ selectedNodeId = null;
+ selectedShapeId = null;
+ renderNodeList();
+ renderShapeList();
+ isPanning = true;
+ panStartX = e.clientX - panX;
+ panStartY = e.clientY - panY;
+ canvas.style.cursor = 'grabbing';
}
-function onCanvasMouseMove(e) {
+function onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
- if (isDragging && dragNode) {
- dragNode.x = e.clientX - rect.left - panX - offsetX;
- dragNode.y = e.clientY - rect.top - panY - offsetY;
+
+ if (dragType === 'node' && dragTarget) {
+ dragTarget.x = e.clientX - rect.left - panX - dragOffX;
+ dragTarget.y = e.clientY - rect.top - panY - dragOffY;
+ renderNetwork();
+ } else if (dragType === 'shape' && dragTarget) {
+ dragTarget.x = e.clientX - rect.left - panX - dragOffX;
+ dragTarget.y = e.clientY - rect.top - panY - dragOffY;
+ renderNetwork();
+ } else if (dragType === 'resize' && dragTarget && dragHandle) {
+ const dx = e.clientX - rect.left - panX - dragOffX;
+ const dy = e.clientY - rect.top - panY - dragOffY;
+ const h = dragHandle;
+ if (h.cx === 0) { dragTarget.x = dragOrig.x + dx; dragTarget.w = dragOrig.w - dx; }
+ else { dragTarget.w = dragOrig.w + dx; }
+ if (h.cy === 0) { dragTarget.y = dragOrig.y + dy; dragTarget.h = dragOrig.h - dy; }
+ else { dragTarget.h = dragOrig.h + dy; }
+ if (dragTarget.w < 50) dragTarget.w = 50;
+ if (dragTarget.h < 50) dragTarget.h = 50;
renderNetwork();
} else if (isPanning) {
panX = e.clientX - panStartX;
@@ -430,32 +696,65 @@ function onCanvasMouseMove(e) {
} else {
const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY;
- canvas.style.cursor = getCanvasNode(mx, my) ? 'pointer' : 'grab';
+ const node = getCanvasNodeRaw(mx, my);
+ const shape = getShapeAt(mx, my);
+ const resize = getShapeResizeHandle(mx, my);
+ if (resize) canvas.style.cursor = 'nwse-resize';
+ else if (node || shape) canvas.style.cursor = 'pointer';
+ else canvas.style.cursor = 'grab';
}
}
-function onCanvasMouseUp(e) {
- if (isDragging && dragNode) {
- // Save position
- apiFetch(`nodes/${dragNode.id}`, {
- method: 'PUT',
- body: JSON.stringify({ pos_x: dragNode.x, pos_y: dragNode.y })
- });
+function onMouseUp(e) {
+ if (dragTarget) {
+ if (dragType === 'node') {
+ apiFetch(`nodes/${dragTarget.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y })
+ });
+ } else if (dragType === 'shape' || dragType === 'resize') {
+ apiFetch(`shapes/${dragTarget.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ pos_x: dragTarget.x,
+ pos_y: dragTarget.y,
+ width: dragTarget.w,
+ height: dragTarget.h
+ })
+ });
+ }
}
- isDragging = false;
- dragNode = null;
+
+ dragType = null;
+ dragTarget = null;
+ dragHandle = null;
+ dragOrig = null;
isPanning = false;
canvas.style.cursor = 'grab';
}
-function onCanvasDblClick(e) {
+function onDblClick(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY;
- const node = getCanvasNode(mx, my);
+
+ const node = getCanvasNodeRaw(mx, my);
if (node) {
selectNode(node.id);
+ return;
}
+
+ const shape = getShapeAt(mx, my);
+ if (shape) {
+ selectedNodeId = null;
+ selectedShapeId = shape.id;
+ renderShapeList();
+ renderNetwork();
+ }
+}
+
+function onContextMenu(e) {
+ e.preventDefault();
}
function esc(s) {
@@ -463,4 +762,22 @@ function esc(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
+}
+
+// roundRect polyfill for older browsers
+if (!CanvasRenderingContext2D.prototype.roundRect) {
+ CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
+ if (r > w / 2) r = w / 2;
+ if (r > h / 2) r = h / 2;
+ this.moveTo(x + r, y);
+ this.lineTo(x + w - r, y);
+ this.quadraticCurveTo(x + w, y, x + w, y + r);
+ this.lineTo(x + w, y + h - r);
+ this.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
+ this.lineTo(x + r, y + h);
+ this.quadraticCurveTo(x, y + h, x, y + h - r);
+ this.lineTo(x, y + r);
+ this.quadraticCurveTo(x, y, x + r, y);
+ return this;
+ };
}
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 3e82e7e..54ddc20 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,7 +5,7 @@
Neptune - Cybersecurity Incident Journal
-
+
@@ -13,7 +13,7 @@