Files
nex/public/struz.html
2025-12-08 21:19:34 +01:00

1295 lines
46 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.
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Structurizr Network Topology</title>
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
overflow: hidden;
}
.container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(10px);
padding: 20px 30px;
border-bottom: 1px solid #334155;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.8em;
background: linear-gradient(90deg, #60a5fa 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.controls {
display: flex;
gap: 10px;
}
.control-btn {
background: rgba(30, 41, 59, 0.7);
border: 1px solid #475569;
color: #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
font-size: 14px;
}
.control-btn:hover {
background: rgba(56, 70, 100, 0.8);
border-color: #60a5fa;
}
.main-svg {
flex: 1;
width: 100%;
cursor: grab;
}
.main-svg:active {
cursor: grabbing;
}
/* Node Styles */
.node {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
}
.node:hover {
transform: scale(1.08);
filter: drop-shadow(0 0 15px currentColor);
}
.node.selected {
transform: scale(1.12);
filter: drop-shadow(0 0 20px currentColor);
}
.node-circle {
stroke-width: 2;
transition: stroke-width 0.3s ease, stroke 0.3s ease;
}
.node:hover .node-circle {
stroke-width: 3;
}
.node-text {
font-size: 12px;
font-weight: 600;
text-anchor: middle;
pointer-events: none;
user-select: none;
fill: #f1f5f9;
}
.node-subtext {
font-size: 10px;
fill: #94a3b8;
text-anchor: middle;
pointer-events: none;
}
/* Connection Styles */
.connection {
fill: none;
stroke: #475569;
stroke-width: 2;
cursor: pointer;
transition: all 0.3s ease;
}
.connection:hover {
stroke: #60a5fa;
stroke-width: 3;
}
.connection.highlighted {
stroke: #818cf8;
stroke-width: 3;
filter: drop-shadow(0 0 5px #818cf8);
}
/* Subnet Containers */
.subnet-container {
fill: none;
stroke: #334155;
stroke-width: 2;
stroke-dasharray: 5,5;
rx: 15;
ry: 15;
transition: stroke 0.3s ease;
}
.subnet-container:hover {
stroke: #475569;
}
.subnet-label {
font-size: 14px;
font-weight: bold;
fill: #94a3b8;
text-anchor: middle;
pointer-events: none;
}
/* Legend */
.legend {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(15, 23, 42, 0.8);
padding: 15px;
border-radius: 12px;
backdrop-filter: blur(10px);
border: 1px solid #334155;
font-size: 11px;
max-height: 200px;
overflow-y: auto;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: opacity 0.3s ease;
padding: 4px 8px;
border-radius: 6px;
}
.legend-item:hover {
opacity: 0.9;
background: rgba(71, 85, 105, 0.3);
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid;
flex-shrink: 0;
}
/* Tooltip */
.tooltip {
position: fixed;
background: rgba(15, 23, 42, 0.95);
color: #e2e8f0;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
border: 1px solid #475569;
backdrop-filter: blur(10px);
max-width: 300px;
line-height: 1.5;
}
.tooltip.show {
opacity: 1;
}
/* Detail Panel */
.detail-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: rgba(15, 23, 42, 0.98);
border: 1px solid #475569;
border-radius: 16px;
padding: 30px;
min-width: 450px;
max-width: 650px;
max-height: 80vh;
z-index: 2000;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(20px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
overflow-y: auto;
}
.detail-panel.show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #334155;
}
.detail-title {
font-size: 1.5em;
color: #60a5fa;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #94a3b8;
font-size: 24px;
cursor: pointer;
transition: color 0.3s ease;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #e2e8f0;
}
.detail-content {
line-height: 1.6;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section h3 {
color: #818cf8;
margin-bottom: 12px;
font-size: 1.1em;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.tag {
display: inline-block;
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
margin: 2px;
border: 1px solid rgba(96, 165, 250, 0.2);
font-family: 'Courier New', monospace;
}
.tag.warning {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
background: rgba(248, 113, 113, 0.1);
}
.tag.success {
color: #34d399;
border-color: rgba(52, 211, 153, 0.3);
background: rgba(52, 211, 153, 0.1);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.detail-item {
background: rgba(30, 41, 59, 0.5);
padding: 10px;
border-radius: 8px;
border: 1px solid #334155;
}
.detail-item strong {
color: #94a3b8;
font-size: 0.9em;
}
.detail-item code {
font-family: 'Courier New', monospace;
color: #e2e8f0;
}
/* Overlay */
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1500;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.overlay.show {
opacity: 1;
pointer-events: all;
}
/* Animation for active nodes */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.node.active .node-circle {
animation: pulse 2s infinite;
}
/* Scrollbar styling */
.detail-panel::-webkit-scrollbar {
width: 8px;
}
.detail-panel::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
border-radius: 4px;
}
.detail-panel::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
.detail-panel::-webkit-scrollbar-thumb:hover {
background: #60a5fa;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 Structurizr Network Topology</h1>
<div class="controls">
<button class="control-btn" onclick="resetView()">🎯 Reset</button>
<button class="control-btn" onclick="toggleAnimation()">✨ Animate</button>
<button class="control-btn" onclick="exportSVG()">💾 Export</button>
</div>
</div>
<svg class="main-svg" id="network-svg" viewBox="0 0 1600 900">
<!-- Definitions -->
<defs>
<!-- Gradients -->
<linearGradient id="gradient-router" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-core" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ef4444;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f87171;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-infra" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#10b981;stop-opacity:1" />
<stop offset="100%" style="stop-color:#34d399;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-worker" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a78bfa;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-iot" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fb923c;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-client" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6b7280;stop-opacity:1" />
<stop offset="100%" style="stop-color:#9ca3af;stop-opacity:1" />
</linearGradient>
<linearGradient id="gradient-dns" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0ea5e9;stop-opacity:1" />
<stop offset="100%" style="stop-color:#38bdf8;stop-opacity:1" />
</linearGradient>
<!-- Glow filter -->
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Arrow marker -->
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
</marker>
</defs>
<!-- Subnet containers -->
<rect class="subnet-container" x="50" y="200" width="1100" height="650" />
<text class="subnet-label" x="600" y="230">LAN 192.168.1.0/24</text>
<rect class="subnet-container" x="1200" y="400" width="350" height="350" />
<text class="subnet-label" x="1375" y="430">Tether_LAN_192.168.137</text>
<!-- Node groups will be generated by JavaScript -->
<g id="nodes-group"></g>
<g id="connections-group"></g>
</svg>
<div class="legend">
<div class="legend-item" data-type="internet">
<div class="legend-color" style="border-color: #0ea5e9; background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);"></div>
<span>Internet/DNS</span>
</div>
<div class="legend-item" data-type="router">
<div class="legend-color" style="border-color: #f59e0b; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);"></div>
<span>Router</span>
</div>
<div class="legend-item" data-type="core">
<div class="legend-color" style="border-color: #ef4444; background: linear-gradient(135deg, #ef4444 0%, #f87171 100%);"></div>
<span>Core Server</span>
</div>
<div class="legend-item" data-type="infra">
<div class="legend-color" style="border-color: #10b981; background: linear-gradient(135deg, #10b981 0%, #34d399 100%);"></div>
<span>Infra/DNS</span>
</div>
<div class="legend-item" data-type="worker">
<div class="legend-color" style="border-color: #8b5cf6; background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);"></div>
<span>Worker/Edge</span>
</div>
<div class="legend-item" data-type="iot">
<div class="legend-color" style="border-color: #f97316; background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);"></div>
<span>IoT Devices</span>
</div>
<div class="legend-item" data-type="client">
<div class="legend-color" style="border-color: #6b7280; background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);"></div>
<span>Clients</span>
</div>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="overlay" id="overlay" onclick="closePanel()"></div>
<div class="detail-panel" id="detail-panel">
<div class="detail-header">
<div class="detail-title" id="detail-title">Node Details</div>
<button class="close-btn" onclick="closePanel()">×</button>
</div>
<div class="detail-content" id="detail-content"></div>
</div>
<!-- Structurizr DSL Model -->
<script type="text/x-structurizr" id="structurizr-dsl">
workspace "Home Network" {
model {
person mike "Mike" "Network Administrator" {
tags client
}
person lotte "Lotte" "User" {
tags client
}
softwareSystem internet "Internet" "Cloudflare DNS<br/>5.132.33.195" {
tags internet
}
softwareSystem router "Zyxel VMG8825-T50" "Main gateway router<br/>hub.lan<br/>192.168.1.1" {
tags router
}
deploymentNode lan "LAN 192.168.1.0/24" {
deploymentNode coreGroup "Core_Server_TOUR" {
container traefik "Traefik" "Reverse Proxy" {
tags core
}
container dokku "Dokku PaaS" "Platform as a Service" {
tags core
}
container gitea "Gitea" "Git service" {
tags core
url "git.appmodel.nl"
}
container auction "Auction Stack" "Auction application" {
tags core
url "auction.appmodel.nl"
}
container mi50 "MI50 / Ollama" "AI/ML inference" {
tags core
url "ollama.lan"
}
}
deploymentNode infraGroup "Infra_DNS" {
container xu4 "XU4 DNS" "Primary DNS (Odroid XU4)<br/>192.168.1.163" {
tags infra dns
}
container cu2 "C2 DNS" "Secondary DNS (Odroid C2)<br/>192.168.1.227" {
tags infra dns
}
container adguard "AdGuard DNS" "Network-wide ad blocking" {
tags infra dns
}
}
container atlas "Atlas" "Worker node<br/>192.168.1.100" {
tags worker
services "Docker, Portainer"
}
container ha "Home Assistant" "Home automation<br/>192.168.1.193" {
tags infra
}
container iot_tv "Kamer-TV" "Smart TV<br/>192.168.1.240" {
tags iot
}
container iot_hue "Philips Hue" "Smart lighting<br/>192.168.1.49" {
tags iot
}
container iot_eufy "Eufy S380HB" "Security camera<br/>192.168.1.59" {
tags iot
}
container iot_misc "IoT Devices" "Nest Mini, Roborock, ESP, Printer" {
tags iot
}
container clientMike "MIKE PC" "Workstation<br/>192.168.1.100<br/>LAN + Tether gateway" {
tags client
}
container clientLotte "Lotte" "Mobile device<br/>192.168.1.133" {
tags client
}
}
deploymentNode tether "Tether_LAN_192.168.137" {
container hermes "Hermes" "Tether node<br/>192.168.137.239" {
tags worker
}
container plato "Plato" "LLM server<br/>192.168.137.239<br/>llm.plato.lan" {
tags worker
}
}
# Relationships
internet -> router "provides DNS"
router -> traefik "routes traffic"
router -> xu4 "routes traffic"
router -> atlas "routes traffic"
router -> ha "routes traffic"
router -> iot_tv "routes traffic"
router -> iot_hue "routes traffic"
router -> iot_eufy "routes traffic"
router -> iot_misc "routes traffic"
router -> clientMike "routes traffic"
router -> clientLotte "routes traffic"
clientMike -> hermes "tether bridge"
clientMike -> plato "tether bridge"
traefik -> gitea "proxies"
traefik -> dokku "proxies"
traefik -> auction "proxies"
# DNS flows (dashed)
xu4 -.-> adguard "filters"
cu2 -.-> adguard "filters"
atlas -.-> adguard "uses"
ha -.-> adguard "uses"
hermes -.-> adguard "uses"
plato -.-> adguard "uses"
}
views {
systemLandscape networkDiagram {
include *
autolayout lr
}
}
styles {
element router {
color #f59e0b
shape rectangle
}
element internet {
color #0ea5e9
shape ellipse
}
element core {
color #ef4444
shape hexagon
}
element infra {
color #10b981
shape rectangle
}
element worker {
color #8b5cf6
shape hexagon
}
element iot {
color #f97316
shape roundedbox
}
element client {
color #6b7280
shape person
}
element dns {
icon cloud
}
}
}
</script>
<script>
// Model parser and diagram generator
class StructurizrParser {
constructor(dsl) {
this.dsl = dsl;
this.model = { nodes: [], relationships: [] };
this.styles = {};
this.parse();
}
parse() {
// Extract model block
const modelMatch = this.dsl.match(/model\s*{([\s\S]*?)^\s*}/m);
if (!modelMatch) return;
const modelContent = modelMatch[1];
// Parse styles first
this.parseStyles();
// Parse people
this.parsePeople(modelContent);
// Parse software systems
this.parseSoftwareSystems(modelContent);
// Parse deployment nodes
this.parseDeploymentNodes(modelContent);
// Parse relationships
this.parseRelationships(modelContent);
}
parseStyles() {
const stylesMatch = this.dsl.match(/styles\s*{([\s\S]*?)^\s*}/m);
if (!stylesMatch) return;
const stylesContent = stylesMatch[1];
const elementRegex = /element\s+(\w+)\s*{([^}]+)}/g;
let match;
while ((match = elementRegex.exec(stylesContent)) !== null) {
const [, type, props] = match;
this.styles[type] = {};
const colorMatch = props.match(/color\s+#([0-9a-fA-F]{6})/);
if (colorMatch) this.styles[type].color = `#${colorMatch[1]}`;
const shapeMatch = props.match(/shape\s+(\w+)/);
if (shapeMatch) this.styles[type].shape = shapeMatch[1];
}
}
parsePeople(content) {
const personRegex = /person\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
let match;
while ((match = personRegex.exec(content)) !== null) {
const [, id, name, desc, block] = match;
const tagsMatch = block.match(/tags\s+(.+)/);
this.model.nodes.push({
id,
name,
description: desc,
type: tagsMatch ? tagsMatch[1].split(/\s+/)[0] : 'person',
category: 'person'
});
}
}
parseSoftwareSystems(content) {
const systemRegex = /softwareSystem\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
let match;
while ((match = systemRegex.exec(content)) !== null) {
const [, id, name, desc, block] = match;
const tagsMatch = block.match(/tags\s+(.+)/);
this.model.nodes.push({
id,
name,
description: desc,
type: tagsMatch ? tagsMatch[1] : 'system',
category: 'softwareSystem'
});
}
}
parseDeploymentNodes(content) {
const nodeRegex = /deploymentNode\s+(\w+)\s+"([^"]+)"\s*{([\s\S]*?)^\s*}/gm;
let nodeMatch;
while ((nodeMatch = nodeRegex.exec(content)) !== null) {
const [, id, name, nodeContent] = nodeMatch;
// Parse child containers
this.parseContainers(id, nodeContent);
}
}
parseContainers(parentId, content) {
const containerRegex = /container\s+(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*{([^}]*)}/g;
let match;
while ((match = containerRegex.exec(content)) !== null) {
const [, id, name, desc, block] = match;
const tagsMatch = block.match(/tags\s+(.+)/);
const servicesMatch = block.match(/services\s+"([^"]+)"/);
const ipMatch = desc.match(/(\d+\.\d+\.\d+\.\d+)/);
const urlMatch = block.match(/url\s+"([^"]+)"/);
this.model.nodes.push({
id,
name,
description: desc,
type: tagsMatch ? tagsMatch[1].split(/\s+/)[0] : 'container',
category: 'container',
parent: parentId,
ip: ipMatch ? ipMatch[1] : null,
url: urlMatch ? urlMatch[1] : null,
services: servicesMatch ? servicesMatch[1].split(', ') : []
});
}
}
parseRelationships(content) {
const relRegex = /(\w+)\s*(-\.?>)?\s*(\w+)\s*"([^"]+)"/g;
let match;
while ((match = relRegex.exec(content)) !== null) {
const [, from, dashed, to, label] = match;
this.model.relationships.push({
from,
to,
label,
style: dashed ? 'dashed' : 'solid'
});
}
}
getNodeStyle(type) {
const style = this.styles[type] || {};
const colors = {
router: '#f59e0b',
internet: '#0ea5e9',
core: '#ef4444',
infra: '#10b981',
worker: '#8b5cf6',
iot: '#f97316',
client: '#6b7280',
dns: '#0ea5e9'
};
return {
color: style.color || colors[type] || '#6b7280',
shape: style.shape || 'circle'
};
}
}
// Diagram generator
class TopologyGenerator {
constructor(svg, model, styles) {
this.svg = svg;
this.model = model;
this.styles = styles;
this.nodePositions = new Map();
this.panZoom = null;
this.selectedNode = null;
this.init();
}
init() {
this.generateLayout();
this.render();
this.setupPanZoom();
this.setupEventListeners();
}
generateLayout() {
// Predefined layout based on the DSL structure
const layout = {
internet: { x: 800, y: 50, group: null },
router: { x: 800, y: 150, group: null },
// Core group
traefik: { x: 200, y: 300, group: 'core' },
dokku: { x: 350, y: 300, group: 'core' },
gitea: { x: 275, y: 380, group: 'core' },
auction: { x: 150, y: 380, group: 'core' },
mi50: { x: 400, y: 380, group: 'core' },
// Infra group
xu4: { x: 600, y: 320, group: 'infra' },
cu2: { x: 750, y: 320, group: 'infra' },
adguard: { x: 675, y: 400, group: 'infra' },
// Workers and clients
atlas: { x: 900, y: 320, group: null },
ha: { x: 1050, y: 320, group: null },
// IoT
iot_tv: { x: 200, y: 550, group: null },
iot_hue: { x: 350, y: 550, group: null },
iot_eufy: { x: 500, y: 550, group: null },
iot_misc: { x: 650, y: 550, group: null },
// Clients
clientMike: { x: 900, y: 550, group: null },
clientLotte: { x: 1100, y: 550, group: null },
// Tether
hermes: { x: 1300, y: 500, group: null },
plato: { x: 1450, y: 500, group: null }
};
this.model.nodes.forEach(node => {
const pos = layout[node.id];
if (pos) {
this.nodePositions.set(node.id, pos);
} else {
// Fallback positioning
this.nodePositions.set(node.id, {
x: 800 + Math.random() * 200,
y: 400 + Math.random() * 200,
group: null
});
}
});
}
render() {
this.renderNodes();
this.renderRelationships();
}
renderNodes() {
const nodesGroup = document.getElementById('nodes-group');
nodesGroup.innerHTML = '';
this.model.nodes.forEach(node => {
const pos = this.nodePositions.get(node.id);
if (!pos) return;
const style = this.styles[node.type] || {};
const colors = {
router: '#f59e0b',
internet: '#0ea5e9',
core: '#ef4444',
infra: '#10b981',
worker: '#8b5cf6',
iot: '#f97316',
client: '#6b7280',
dns: '#0ea5e9'
};
const color = colors[node.type] || '#6b7280';
const gradientId = `gradient-${node.type}`;
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.classList.add('node');
g.setAttribute('data-id', node.id);
g.setAttribute('data-type', node.type);
g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
// Create shape based on type
if (node.type === 'router' || node.id === 'clientMike') {
// Rectangle for router and MIKE PC
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.classList.add('node-circle');
rect.setAttribute('x', '-35');
rect.setAttribute('y', '-20');
rect.setAttribute('width', '70');
rect.setAttribute('height', '40');
rect.setAttribute('rx', '5');
rect.setAttribute('fill', `url(#${gradientId})`);
rect.setAttribute('stroke', color);
g.appendChild(rect);
} else {
// Circle for others
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.classList.add('node-circle');
circle.setAttribute('r', node.type === 'core' || node.type === 'infra' ? '20' : '20');
circle.setAttribute('fill', `url(#${gradientId})`);
circle.setAttribute('stroke', color);
g.appendChild(circle);
}
// Add text
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.classList.add('node-text');
text.setAttribute('y', '5');
const icon = node.category === 'person' ? '👤 ' :
node.type === 'router' ? '🛜 ' :
node.type === 'internet' ? '☁️ ' :
node.type === 'iot' ? '📱 ' :
node.type === 'worker' ? '💻 ' :
node.type === 'infra' ? '🔧 ' :
node.type === 'core' ? '⚡ ' : '🖥️ ';
text.textContent = icon + node.name;
g.appendChild(text);
// Add subtext for IP/hostname
if (node.ip || node.url) {
const subtext = document.createElementNS('http://www.w3.org/2000/svg', 'text');
subtext.classList.add('node-subtext');
subtext.setAttribute('y', node.type === 'router' || node.id === 'clientMike' ? '50' : '30');
subtext.textContent = node.ip || node.url;
g.appendChild(subtext);
}
// Add description subtext if exists
if (node.description && node.description.length > 20) {
const descText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
descText.classList.add('node-subtext');
descText.setAttribute('y', node.type === 'router' || node.id === 'clientMike' ? '65' : '45');
descText.textContent = node.description.substring(0, 30) + '...';
g.appendChild(descText);
}
nodesGroup.appendChild(g);
});
}
renderRelationships() {
const connectionsGroup = document.getElementById('connections-group');
connectionsGroup.innerHTML = '';
this.model.relationships.forEach(rel => {
const fromPos = this.nodePositions.get(rel.from);
const toPos = this.nodePositions.get(rel.to);
if (!fromPos || !toPos) return;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.classList.add('connection');
if (rel.style === 'dashed') {
path.setAttribute('stroke-dasharray', '5,5');
}
// Simple straight line for now
path.setAttribute('d', `M ${fromPos.x} ${fromPos.y} L ${toPos.x} ${toPos.y}`);
path.setAttribute('marker-end', 'url(#arrowhead)');
connectionsGroup.appendChild(path);
});
}
setupPanZoom() {
this.panZoom = svgPanZoom('#network-svg', {
zoomEnabled: true,
controlIconsEnabled: false,
fit: false,
center: false,
minZoom: 0.5,
maxZoom: 3,
dblClickZoomEnabled: true,
mouseWheelZoomEnabled: true,
preventMouseEventsDefault: false
});
// Custom click/drag handling
let isDragging = false;
let dragThreshold = 5;
let startPos = { x: 0, y: 0 };
let currentPos = { x: 0, y: 0 };
this.svg.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('node-circle')) {
isDragging = false;
startPos = { x: e.clientX, y: e.clientY };
}
});
this.svg.addEventListener('mousemove', (e) => {
if (e.buttons === 1 && !isDragging) {
currentPos = { x: e.clientX, y: e.clientY };
const distance = Math.sqrt(
Math.pow(currentPos.x - startPos.x, 2) +
Math.pow(currentPos.y - startPos.y, 2)
);
if (distance > dragThreshold) {
isDragging = true;
}
}
});
this.svg.addEventListener('mouseup', (e) => {
if (!isDragging && e.target.classList.contains('node-circle')) {
const nodeEl = e.target.closest('.node');
const nodeId = nodeEl.dataset.id;
this.selectNode(nodeId);
}
isDragging = false;
});
}
setupEventListeners() {
// Node hover and click
this.svg.addEventListener('mouseover', (e) => {
if (e.target.classList.contains('node-circle')) {
const nodeEl = e.target.closest('.node');
const nodeId = nodeEl.dataset.id;
const data = this.model.nodes.find(n => n.id === nodeId);
if (data) {
this.showTooltip(e, data);
this.highlightConnections(nodeId);
}
}
});
this.svg.addEventListener('mouseout', (e) => {
if (e.target.classList.contains('node-circle')) {
this.hideTooltip();
this.unhighlightConnections();
}
});
// Legend toggle
document.querySelectorAll('.legend-item').forEach(item => {
item.addEventListener('click', () => {
const type = item.dataset.type;
this.toggleNodeType(type);
});
});
}
selectNode(nodeId) {
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
const nodeEl = document.querySelector(`[data-id="${nodeId}"]`);
if (nodeEl) {
nodeEl.classList.add('selected');
this.selectedNode = nodeId;
this.showDetailPanel(nodeId);
}
}
showTooltip(event, data) {
const tooltip = document.getElementById('tooltip');
const services = data.services ? data.services.join(', ') : '';
const devices = data.devices ? data.devices.join(', ') : '';
tooltip.innerHTML = `
<strong>${data.name}</strong><br>
${data.ip ? `IP: ${data.ip}<br>` : ''}
${data.url ? `URL: ${data.url}<br>` : ''}
${data.description ? `${data.description}<br>` : ''}
${services ? `<small>Services: ${services}</small><br>` : ''}
${devices ? `<small>Devices: ${devices}</small>` : ''}
`;
tooltip.style.left = event.clientX + 15 + 'px';
tooltip.style.top = event.clientY - 10 + 'px';
tooltip.classList.add('show');
}
hideTooltip() {
document.getElementById('tooltip').classList.remove('show');
}
highlightConnections(nodeId) {
// For simplicity, highlight all connections
document.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = '#60a5fa';
conn.style.strokeWidth = '3';
});
}
unhighlightConnections() {
document.querySelectorAll('.connection').forEach(conn => {
conn.style.stroke = '';
conn.style.strokeWidth = '';
});
}
showDetailPanel(nodeId) {
const data = this.model.nodes.find(n => n.id === nodeId);
if (!data) return;
const panel = document.getElementById('detail-panel');
const overlay = document.getElementById('overlay');
const title = document.getElementById('detail-title');
const content = document.getElementById('detail-content');
title.textContent = data.name;
let html = `
<div class="detail-section">
<p>${data.description || 'No description available'}</p>
</div>
`;
if (data.ip || data.url) {
html += `
<div class="detail-section">
<h3>📡 Network</h3>
<div class="detail-grid">
${data.ip ? `<div class="detail-item"><strong>IP:</strong><br><code class="tag">${data.ip}</code></div>` : ''}
${data.url ? `<div class="detail-item"><strong>URL:</strong><br><code class="tag">${data.url}</code></div>` : ''}
</div>
</div>
`;
}
if (data.services && data.services.length > 0) {
html += `
<div class="detail-section">
<h3>🚀 Services</h3>
${data.services.map(s => `<span class="tag">${s}</span>`).join('')}
</div>
`;
}
// Show relationships
const related = this.model.relationships.filter(r => r.from === nodeId || r.to === nodeId);
if (related.length > 0) {
html += `
<div class="detail-section">
<h3>🔗 Connections</h3>
<div class="detail-grid">
`;
related.forEach(rel => {
const otherId = rel.from === nodeId ? rel.to : rel.from;
const otherNode = this.model.nodes.find(n => n.id === otherId);
if (otherNode) {
const direction = rel.from === nodeId ? '→' : '←';
html += `
<div class="detail-item">
<strong>${direction} ${otherNode.name}</strong><br>
<small>${rel.label}</small>
</div>
`;
}
});
html += `
</div>
</div>
`;
}
content.innerHTML = html;
overlay.classList.add('show');
panel.classList.add('show');
}
toggleNodeType(type) {
document.querySelectorAll(`.node[data-type="${type}"]`).forEach(node => {
const isVisible = node.style.display !== 'none';
node.style.display = isVisible ? 'none' : 'block';
node.style.opacity = isVisible ? '0' : '1';
});
}
reset() {
if (this.panZoom) {
this.panZoom.reset();
}
closePanel();
}
toggleAnimation() {
document.querySelectorAll('.node[data-type="core"], .node[data-type="infra"]').forEach(node => {
node.classList.toggle('active');
});
}
export() {
const svgData = new XMLSerializer().serializeToString(this.svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'network-topology.svg';
a.click();
URL.revokeObjectURL(url);
}
}
// Global functions
let generator;
function resetView() {
generator.reset();
}
function toggleAnimation() {
generator.toggleAnimation();
}
function exportSVG() {
generator.export();
}
function closePanel() {
document.getElementById('overlay').classList.remove('show');
document.getElementById('detail-panel').classList.remove('show');
document.querySelectorAll('.node').forEach(n => n.classList.remove('selected'));
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
const dslScript = document.getElementById('structurizr-dsl');
const parser = new StructurizrParser(dslScript.textContent);
const svg = document.getElementById('network-svg');
generator = new TopologyGenerator(svg, parser.model, parser.styles);
});
</script>
</body>
</html>