diff --git a/public/d2.html b/public/d2.html
index 423bb4e..159e564 100644
--- a/public/d2.html
+++ b/public/d2.html
@@ -320,6 +320,22 @@
filter: drop-shadow(0 0 20px currentColor);
}
+ /* Lane bands (top-down layers) */
+ .lane-band {
+ fill: rgba(2, 6, 23, 0.35);
+ stroke: #334155;
+ stroke-width: 1;
+ pointer-events: none;
+ }
+ .lane-label {
+ fill: #94a3b8;
+ font-size: 12px;
+ font-weight: 700;
+ text-anchor: start;
+ pointer-events: none;
+ opacity: 0.7;
+ }
+
/* Network Zones */
.network-zone {
stroke-width: 3;
@@ -361,6 +377,77 @@
pointer-events: none;
}
+ /* Type Group Zones (same-color environments) */
+ .type-zone {
+ stroke-width: 2;
+ stroke-dasharray: 6,4;
+ rx: 14;
+ ry: 14;
+ pointer-events: none;
+ opacity: 0.65;
+ }
+ .type-zone-label {
+ fill: #cbd5e1;
+ font-size: 11px;
+ font-weight: 700;
+ text-anchor: start;
+ pointer-events: none;
+ text-shadow: 0 2px 4px rgba(0,0,0,0.8);
+ }
+ .type-core { stroke: #f87171; fill: rgba(239, 68, 68, 0.06); }
+ .type-infra { stroke: #34d399; fill: rgba(16, 185, 129, 0.06); }
+ .type-worker { stroke: #a78bfa; fill: rgba(139, 92, 246, 0.06); }
+ .type-iot { stroke: #fb923c; fill: rgba(249, 115, 22, 0.06); }
+ .type-client { stroke: #9ca3af; fill: rgba(107, 114, 128, 0.06); }
+
+ /* Host/Environment grouped boxes */
+ .host-group {
+ stroke-width: 2.5;
+ rx: 18;
+ ry: 18;
+ pointer-events: none;
+ opacity: 0.9;
+ }
+ .host-athena {
+ fill: rgba(96, 165, 250, 0.08);
+ stroke: #60a5fa;
+ }
+ .host-infra {
+ fill: rgba(16, 185, 129, 0.08);
+ stroke: #34d399;
+ }
+ .host-title {
+ fill: #e2e8f0;
+ font-size: 13px;
+ font-weight: 800;
+ text-anchor: start;
+ pointer-events: none;
+ text-shadow: 0 2px 4px rgba(0,0,0,0.8);
+ }
+ .host-subtitle {
+ fill: #94a3b8;
+ font-size: 11px;
+ font-weight: 600;
+ text-anchor: start;
+ pointer-events: none;
+ }
+ /* Traefik subpanel inside Athena */
+ .host-panel {
+ fill: rgba(96, 165, 250, 0.12);
+ stroke: #60a5fa;
+ stroke-dasharray: 6,3;
+ rx: 10;
+ ry: 10;
+ pointer-events: none;
+ }
+ .host-panel-label {
+ fill: #cbd5e1;
+ font-size: 11px;
+ font-weight: 700;
+ text-anchor: start;
+ pointer-events: none;
+ }
+
/* Legend */
.legend {
position: fixed;
@@ -549,6 +636,9 @@
// Create main group for graph elements
g = svg.append('g');
+ // Lanes (swimlanes) to emphasize top-down flow
+ const laneConfig = initLanes(g, width, height);
+
// Add network zone backgrounds (will be positioned after simulation)
const zonesGroup = g.append('g').attr('class', 'zones');
@@ -592,6 +682,57 @@
.attr('class', 'zone-sublabel')
.text('192.168.137.0/24 (USB Bridge)');
+ // Type group zones (same-colored environments)
+ const typeGroups = [
+ { type: 'core', label: 'Core Services' },
+ { type: 'infra', label: 'Infrastructure' },
+ { type: 'worker', label: 'Workers' },
+ { type: 'iot', label: 'IoT Devices' },
+ { type: 'client', label: 'Clients' }
+ ];
+ const typeZones = typeGroups.map(grp => ({
+ ...grp,
+ rect: zonesGroup.append('rect')
+ .attr('class', `type-zone type-${grp.type}`)
+ .style('display', 'none'),
+ labelEl: zonesGroup.append('text')
+ .attr('class', 'type-zone-label')
+ .text(grp.label)
+ .style('display', 'none')
+ }));
+
+ // Host/environment groups (drawn behind links and nodes)
+ const hostGroups = g.append('g').attr('class', 'host-groups');
+
+ // Athena host box with Traefik subpanel
+ const athenaRect = hostGroups.append('rect')
+ .attr('class', 'host-group host-athena');
+ const athenaTitle = hostGroups.append('text')
+ .attr('class', 'host-title')
+ .text('Athena');
+ const athenaSubtitle = hostGroups.append('text')
+ .attr('class', 'host-subtitle')
+ .text('192.168.1.159');
+
+ const traefikPanel = hostGroups.append('rect')
+ .attr('class', 'host-panel');
+ const traefikPanelLabel = hostGroups.append('text')
+ .attr('class', 'host-panel-label')
+ .text('Traefik ingress');
+
+ // Infra DNS + AdGuard box
+ const infraRect = hostGroups.append('rect')
+ .attr('class', 'host-group host-infra');
+ const infraTitle = hostGroups.append('text')
+ .attr('class', 'host-title')
+ .text('Infra DNS + AdGuard');
+ const infraSubtitle = hostGroups.append('text')
+ .attr('class', 'host-subtitle')
+ .text('XU4 / C2 / AdGuard');
+
+ // Ensure host groups sit above lane bands but behind links/nodes
+ hostGroups.raise();
+
// Create arrow markers for directed edges
svg.append('defs').selectAll('marker')
.data(['arrow', 'arrow-dashed'])
@@ -686,18 +827,20 @@
.force('link', d3.forceLink(graphData.links)
.id(d => d.id)
.distance(d => d.distance || 150)
- .strength(0.5))
+ .strength(0.55))
.force('charge', d3.forceManyBody()
- .strength(-1000)
- .distanceMax(400))
+ .strength(-900)
+ .distanceMax(450))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
- .radius(d => (nodeTypes[d.type]?.radius || 30) + 15)
- .strength(0.7))
- .force('x', d3.forceX(width / 2).strength(0.05))
- .force('y', d3.forceY(height / 2).strength(0.05))
- .alphaDecay(0.02) // Slower decay = more settling time
- .velocityDecay(0.4) // More damping = smoother movement
+ .radius(d => (nodeTypes[d.type]?.radius || 30) + 16)
+ .strength(0.8))
+ // Keep a slight horizontal centering to avoid drift
+ .force('x', d3.forceX(width / 2).strength(0.04))
+ // Stronger vertical force to keep nodes in their lanes
+ .force('laneY', d3.forceY(d => laneConfig.centerFor(d)).strength(0.18))
+ .alphaDecay(0.02)
+ .velocityDecay(0.42)
.on('tick', ticked);
function ticked() {
@@ -715,6 +858,9 @@
});
node.attr('transform', d => `translate(${d.x},${d.y})`);
+
+ // Update host group boxes on every tick
+ updateHostGroups();
}
function dragstarted(event, d) {
@@ -723,6 +869,22 @@
d.fy = d.y;
}
+ // Helper: calculate bounding box for a group of nodes
+ function calculateBounds(nodes, padding = 20) {
+ const xs = nodes.map(n => n.x);
+ const ys = nodes.map(n => n.y);
+ const minX = Math.min(...xs) - padding;
+ const maxX = Math.max(...xs) + padding;
+ const minY = Math.min(...ys) - padding;
+ const maxY = Math.max(...ys) + padding;
+ return {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY
+ };
+ }
+
// Helper: compute polygon points (e.g., hexagon) centered at 0,0
function polygonPoints(sides, radius) {
const points = [];
@@ -735,6 +897,61 @@
return points.join(' ');
}
+ // Draw/update host environment boxes (Athena and Infra DNS)
+ function updateHostGroups() {
+ // Athena contains Traefik + core services
+ const athenaNodes = graphData.nodes.filter(n =>
+ ['Traefik','Dokku','Gitea','Auction','MI50'].includes(n.id)
+ );
+ if (athenaNodes.length > 0) {
+ const pad = 70;
+ const b = calculateBounds(athenaNodes, pad);
+ athenaRect
+ .attr('x', b.x)
+ .attr('y', b.y)
+ .attr('width', b.width)
+ .attr('height', b.height);
+ athenaTitle
+ .attr('x', b.x + 12)
+ .attr('y', b.y + 20);
+ athenaSubtitle
+ .attr('x', b.x + 12)
+ .attr('y', b.y + 36);
+
+ // Traefik subpanel at top of Athena
+ const panelPad = 10;
+ const panelHeight = 40;
+ traefikPanel
+ .attr('x', b.x + panelPad)
+ .attr('y', b.y + 46)
+ .attr('width', Math.max(0, b.width - panelPad * 2))
+ .attr('height', panelHeight);
+ traefikPanelLabel
+ .attr('x', b.x + panelPad + 10)
+ .attr('y', b.y + 46 + 24);
+ }
+
+ // Infra DNS + AdGuard grouping
+ const infraNodes = graphData.nodes.filter(n =>
+ ['XU4','C2','AdGuard'].includes(n.id)
+ );
+ if (infraNodes.length > 0) {
+ const pad = 60;
+ const b = calculateBounds(infraNodes, pad);
+ infraRect
+ .attr('x', b.x)
+ .attr('y', b.y)
+ .attr('width', b.width)
+ .attr('height', b.height);
+ infraTitle
+ .attr('x', b.x + 12)
+ .attr('y', b.y + 20);
+ infraSubtitle
+ .attr('x', b.x + 12)
+ .attr('y', b.y + 36);
+ }
+ }
+
// Draw a shape for a node based on d.shape
function drawNodeShape(group, d) {
const r = (nodeTypes[d.type]?.radius || 30);
@@ -868,6 +1085,51 @@
d.fy = null;
}
+ // Initialize lane bands and return mapping helpers
+ function initLanes(group, width, height) {
+ const lanes = [
+ { id: 'internet', label: '☁️ ISP / Internet' },
+ { id: 'gateway', label: '🛜 Gateway (Router/Traefik ingress)' },
+ { id: 'core', label: '⚡ Core Services (behind Traefik)' },
+ { id: 'infra', label: '🔧 Infrastructure DNS (XU4/C2/HA)' },
+ { id: 'clients', label: '👥 Clients & IoT' },
+ { id: 'tether', label: '📱 Tether Network' }
+ ];
+ const bandH = height / lanes.length;
+ const lanesGroup = group.append('g').attr('class', 'lanes');
+ lanes.forEach((lane, i) => {
+ lanesGroup.append('rect')
+ .attr('class', 'lane-band')
+ .attr('x', 0)
+ .attr('y', i * bandH)
+ .attr('width', width)
+ .attr('height', bandH);
+ lanesGroup.append('text')
+ .attr('class', 'lane-label')
+ .attr('x', 12)
+ .attr('y', i * bandH + 18)
+ .text(lane.label);
+ });
+ // keep lanes behind everything
+ lanesGroup.lower();
+
+ const centers = {};
+ lanes.forEach((lane, i) => { centers[lane.id] = i * bandH + bandH / 2; });
+
+ function laneOf(node) {
+ if (node.id === 'Internet') return 'internet';
+ if (node.id === 'Router' || node.id === 'Traefik') return 'gateway';
+ if (['Traefik', 'Dokku', 'Gitea', 'Auction', 'MI50'].includes(node.id)) return 'core';
+ if (['XU4', 'C2', 'HA'].includes(node.id)) return 'infra';
+ if (['Hermes', 'Plato'].includes(node.id)) return 'tether';
+ return 'clients';
+ }
+ function centerFor(node) {
+ return centers[laneOf(node)] || (height / 2);
+ }
+ return { centers, laneOf, centerFor };
+ }
+
// Hide loading
loadingEl.style.display = 'none';
@@ -921,9 +1183,8 @@
{ source: 'Internet', target: 'Router', label: 'DNS', distance: 200 },
{ source: 'Router', target: 'Traefik', distance: 120 },
{ source: 'Router', target: 'Dokku', distance: 120 },
- { source: 'Router', target: 'Gitea', distance: 120 },
- { source: 'Router', target: 'Auction', distance: 120 },
- { source: 'Router', target: 'MI50', distance: 120 },
+ // Ingress for core services flows via Traefik, not directly
+ // (Router direct links to core services removed)
{ source: 'Router', target: 'AdGuard', distance: 140 },
{ source: 'Router', target: 'XU4', distance: 140 },
{ source: 'Router', target: 'C2', distance: 140 },
@@ -935,20 +1196,25 @@
{ source: 'Router', target: 'IoT', distance: 160 },
{ source: 'Router', target: 'MIKE', distance: 140 },
{ source: 'Router', target: 'Lotte', distance: 160 },
- // Proxy connections
+ // Proxy connections (Core services are behind Traefik)
{ source: 'Traefik', target: 'Gitea', distance: 80 },
{ source: 'Traefik', target: 'Dokku', distance: 80 },
{ source: 'Traefik', target: 'Auction', distance: 80 },
+ { source: 'Traefik', target: 'MI50', distance: 100 },
+
// Tether connections
{ source: 'MIKE', target: 'Hermes', distance: 100 },
{ source: 'MIKE', target: 'Plato', distance: 100 },
- // DNS filtering (dashed)
- { source: 'XU4', target: 'AdGuard', dashed: true, distance: 100 },
- { source: 'C2', target: 'AdGuard', dashed: true, distance: 100 },
- { source: 'Atlas', target: 'AdGuard', dashed: true, distance: 120 },
- { source: 'HA', target: 'AdGuard', dashed: true, distance: 120 },
- { source: 'Hermes', target: 'AdGuard', dashed: true, distance: 150 },
- { source: 'Plato', target: 'AdGuard', dashed: true, distance: 150 }
+
+ // Core uses Infra DNS (dashed back-edges to DNS servers)
+ { source: 'Traefik', target: 'XU4', dashed: true, distance: 120 },
+ { source: 'Traefik', target: 'C2', dashed: true, distance: 120 },
+
+ // Clients and services use DNS (representative dashed edges)
+ { source: 'Atlas', target: 'XU4', dashed: true, distance: 120 },
+ { source: 'HA', target: 'C2', dashed: true, distance: 120 },
+ { source: 'Hermes', target: 'XU4', dashed: true, distance: 150 },
+ { source: 'Plato', target: 'C2', dashed: true, distance: 150 }
];
return { nodes, links };