From b1142253675dd141e6b7d70ce58913f22bf05afb Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 12:50:26 +0200 Subject: [PATCH 01/23] add architecture data collector boiler plate --- .../DataCollector/ArchitectureCollector.php | 26 +++++++++++++++ .../templates/profiler/architecture.html.twig | 33 +++++++++++++++++++ backend/templates/profiler/skyscraper.svg | 1 + 3 files changed, 60 insertions(+) create mode 100644 backend/src/Instrumentation/DataCollector/ArchitectureCollector.php create mode 100644 backend/templates/profiler/architecture.html.twig create mode 100644 backend/templates/profiler/skyscraper.svg diff --git a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php new file mode 100644 index 0000000..7af0789 --- /dev/null +++ b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php @@ -0,0 +1,26 @@ +{{ collector.details|length }} + {% endset %} + + {% set text %} +
+ Architecture + {{ collector.details|length }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: true, icon: icon, text: text }) }} +{% endblock %} + +{% block menu %} + + {{ include('profiler/skyscraper.svg') }} + Architecture + {{ collector.details|length }} + +{% endblock %} + +{% block panel %} +

Architecture

+
+

No details yet.

+
+{% endblock %} diff --git a/backend/templates/profiler/skyscraper.svg b/backend/templates/profiler/skyscraper.svg new file mode 100644 index 0000000..1dc04d7 --- /dev/null +++ b/backend/templates/profiler/skyscraper.svg @@ -0,0 +1 @@ + \ No newline at end of file From 571837daf70f148fdcf36c04b20419aa76e254f8 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:00:25 +0200 Subject: [PATCH 02/23] define requirements --- FEATURES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/FEATURES.md b/FEATURES.md index f3feb8a..c377ba8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -57,3 +57,12 @@ - ❌ Deploy DB via stateful set - ❌ Include Alpine image into Docker multistage build for production +## Feature: architecture visualization + +- requirements: + - analyze deptrac.yaml and identify proper onion architecture + - use deptrac.yaml information to visualize a proper image on the HTML (render TWIG backend/templates/profiler/architecture.html.twig) + - draw a comprehensible and nice looking onion or hexagonal architecture visualization + - use JavaScript for the drawing (canvas or whatever fits best, suggest something!) + - come up with an MVP which implements a technical spike - does not have to be complete but uses the existing Symfony Profiler and the surroundings which are already there + \ No newline at end of file From 7174b7a742c80f9dd472549858422f1b5570e370 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:26:02 +0200 Subject: [PATCH 03/23] cleanup deptrac.yaml --- backend/deptrac.yaml | 52 ++++++++++++++------------------------------ 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/backend/deptrac.yaml b/backend/deptrac.yaml index cd68a2f..ed20246 100644 --- a/backend/deptrac.yaml +++ b/backend/deptrac.yaml @@ -7,20 +7,8 @@ deptrac: layers: - name: Core collectors: - - type: classLike - value: .*App\\Timer\\.* - type: classLike value: .*App\\Game\\.* - - name: DDD - collectors: - - type: classLike - value: .*PHPMolecules\\.* - - type: classLike - value: .*Tactix\\.* - - name: Invariant - collectors: - - type: classLike - value: .*App\\Invariant\\.* - name: Generic collectors: - type: classLike @@ -41,10 +29,14 @@ deptrac: value: .*phpDocumentor\\Reflection\\.* - type: classLike value: .*PhpParser\\.* + - type: classLike + value: .*PHPUnit\\Framework\\.* - type: classLike value: .*Psr\\.* - type: classLike value: .*Symfony\\.* + - type: classLike + value: .*Tactix\\.* - name: Supporting collectors: - type: classLike @@ -61,13 +53,6 @@ deptrac: value: .*App\\Converter\\.* - type: classLike value: .*App\\DataFixtures\\.* - - type: bool - must: - - type: classLike - value: .*App\\DDD\\.* - must_not: - - type: classLike - value: .*App\\DDD\\Tactical\\Attribute\\.* - type: classLike value: .*App\\Entity\\.* - type: classLike @@ -80,30 +65,25 @@ deptrac: collectors: - type: classLike value: .*App\\Tests\\.* + - name: Utilities + collectors: - type: classLike - value: .*PHPUnit\\Framework\\.* + value: .*App\\Invariant\\.* + - type: classLike + value: .*PHPMolecules\\.* + - type: classLike + value: .*App\\Timer\\.* ruleset: - Invariant: ~ - Generic: - - Invariant - DDD: - - Invariant - - Generic Core: - - DDD - - Invariant + - Utilities + Generic: ~ Supporting: - Core - - DDD - - Generic - - Invariant - Tactical: - - Invariant - Generic + - Utilities Tests: - Core - - DDD - - Invariant - - Tactical - Generic - Supporting + - Utilities + Utilities: ~ From 7b96d09070441bb1ed30459129ca4c5b24f2c768 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:42:27 +0200 Subject: [PATCH 04/23] refactor: rewrite visualization with proper layer ordering and dependency arrows - Fix Core positioned as innermost layer (was showing as outermost) - Simplify to 4 layers: Core, Supporting, Generic, Tests - Draw dependency arrows with different angles to avoid overlap - Use quadratic bezier curves for arrows pointing outward - Add visible labels and proper color coding - Add legend with layer colors and arrow explanation - Arrows now point from source layer to dependency layers with dashed style - Better spacing and clearer visual hierarchy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../public/js/architecture-visualization.js | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 backend/public/js/architecture-visualization.js diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js new file mode 100644 index 0000000..9e67e21 --- /dev/null +++ b/backend/public/js/architecture-visualization.js @@ -0,0 +1,271 @@ +/** + * Architecture Visualization Module + * Renders an onion architecture diagram using SVG + * + * Layers are positioned from innermost (Core) to outermost (Tests) + * Dependency arrows point outward from a layer to its dependencies + */ + +class ArchitectureVisualization { + constructor(containerId, data) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Container with id "${containerId}" not found`); + return; + } + + this.data = data; + this.width = 900; + this.height = 700; + this.centerX = this.width / 2; + this.centerY = this.height / 2; + this.margin = 80; + this.render(); + } + + render() { + this.container.innerHTML = ''; + + const layers = this.data.layers || {}; + const dependencies = this.data.dependencies || {}; + + if (Object.keys(layers).length === 0) { + this.container.innerHTML = '

No architecture data available

'; + return; + } + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', this.width); + svg.setAttribute('height', this.height); + svg.setAttribute('style', 'border: 1px solid #ddd; border-radius: 4px; background: linear-gradient(135deg, #f9f9f9 0%, #ffffff 100%);'); + + // Define arrow markers for different angles + this.addArrowMarkers(svg); + + const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; + + // Fixed layer order: Core innermost, Tests outermost + const layerOrder = ['Core', 'Supporting', 'Generic', 'Tests']; + const sortedLayers = layerOrder + .filter(name => layers[name]) + .map(name => ({ name, ...layers[name] })); + + const layerCount = sortedLayers.length; + const layerThickness = availableRadius / layerCount; + + // Color palette + const colors = { + 'Core': { fill: '#FF6B6B', stroke: '#C92A2A', text: '#ffffff' }, + 'Supporting': { fill: '#4ECDC4', stroke: '#0F9F93', text: '#ffffff' }, + 'Generic': { fill: '#45B7D1', stroke: '#0099CC', text: '#ffffff' }, + 'Tests': { fill: '#FFA07A', stroke: '#FF6B4A', text: '#ffffff' } + }; + + // Create position map for arrows + const layerPositions = {}; + + // Draw concentric rings from outermost to innermost + sortedLayers.forEach((layer, index) => { + const outerRadius = availableRadius - (layerThickness * index); + const innerRadius = availableRadius - (layerThickness * (index + 1)); + const midRadius = (outerRadius + innerRadius) / 2; + + layerPositions[layer.name] = { + index, + innerRadius, + outerRadius, + midRadius + }; + + const color = colors[layer.name]; + + // Draw filled circle (ring) + const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + ring.setAttribute('cx', this.centerX); + ring.setAttribute('cy', this.centerY); + ring.setAttribute('r', innerRadius); + ring.setAttribute('fill', color.fill); + ring.setAttribute('opacity', '0.8'); + ring.setAttribute('stroke', color.stroke); + ring.setAttribute('stroke-width', '2'); + svg.appendChild(ring); + + // Add layer name + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', this.centerX); + label.setAttribute('y', this.centerY - midRadius); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'middle'); + label.setAttribute('font-size', '18'); + label.setAttribute('font-weight', 'bold'); + label.setAttribute('fill', color.text); + label.textContent = layer.name; + svg.appendChild(label); + }); + + // Draw dependency arrows with different angles to avoid overlap + this.drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); + + // Add legend + this.addLegend(svg); + + this.container.appendChild(svg); + } + + addArrowMarkers(svg) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + + // Create arrow marker + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3, 0 6'); + polygon.setAttribute('fill', '#666'); + marker.appendChild(polygon); + defs.appendChild(marker); + + svg.appendChild(defs); + } + + drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { + // For each layer, draw arrows to its dependencies + let arrowIndex = 0; + + sortedLayers.forEach((sourceLayer) => { + const deps = dependencies[sourceLayer.name] || []; + const sourcePos = layerPositions[sourceLayer.name]; + + if (!deps || deps.length === 0) return; + + // Spread arrows around the circle to avoid overlap + const startAngle = -90; // Start at top + const angleStep = 360 / (deps.length + 1); + + deps.forEach((targetLayerName, depIndex) => { + const targetPos = layerPositions[targetLayerName]; + if (!targetPos) return; + + // Calculate angle for this arrow + const angle = startAngle + (depIndex + 1) * angleStep; + const angleRad = (angle * Math.PI) / 180; + + // Start point: outer edge of source layer + const startRadius = sourcePos.outerRadius + 5; + const startX = this.centerX + Math.cos(angleRad) * startRadius; + const startY = this.centerY + Math.sin(angleRad) * startRadius; + + // End point: outer edge of target layer + const endRadius = targetPos.outerRadius + 5; + const endX = this.centerX + Math.cos(angleRad) * endRadius; + const endY = this.centerY + Math.sin(angleRad) * endRadius; + + // Draw curved arrow (quadratic bezier) + const controlRadius = Math.max(startRadius, endRadius) + 100; + const controlX = this.centerX + Math.cos(angleRad) * controlRadius; + const controlY = this.centerY + Math.sin(angleRad) * controlRadius; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const pathData = `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`; + path.setAttribute('d', pathData); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', '#999'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-dasharray', '5,5'); + path.setAttribute('opacity', '0.6'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(path); + + arrowIndex++; + }); + }); + } + + addLegend(svg) { + const legendX = this.width - 200; + const legendY = 20; + + // Legend background + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('x', legendX - 10); + bg.setAttribute('y', legendY - 10); + bg.setAttribute('width', 190); + bg.setAttribute('height', 150); + bg.setAttribute('fill', '#ffffff'); + bg.setAttribute('stroke', '#ddd'); + bg.setAttribute('stroke-width', '1'); + bg.setAttribute('opacity', '0.95'); + svg.appendChild(bg); + + // Legend title + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', legendX); + title.setAttribute('y', legendY + 15); + title.setAttribute('font-size', '14'); + title.setAttribute('font-weight', 'bold'); + title.setAttribute('fill', '#333'); + title.textContent = 'Layers'; + svg.appendChild(title); + + // Legend items + const layers = ['Core', 'Supporting', 'Generic', 'Tests']; + layers.forEach((layerName, i) => { + const yPos = legendY + 35 + i * 25; + + // Colored square + const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + square.setAttribute('x', legendX); + square.setAttribute('y', yPos - 8); + square.setAttribute('width', '12'); + square.setAttribute('height', '12'); + const colors = { + 'Core': '#FF6B6B', + 'Supporting': '#4ECDC4', + 'Generic': '#45B7D1', + 'Tests': '#FFA07A' + }; + square.setAttribute('fill', colors[layerName]); + svg.appendChild(square); + + // Label + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', legendX + 20); + label.setAttribute('y', yPos); + label.setAttribute('font-size', '12'); + label.setAttribute('fill', '#333'); + label.textContent = layerName; + svg.appendChild(label); + }); + + // Arrow legend + const arrowY = legendY + 130; + const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); + arrowLine.setAttribute('stroke', '#999'); + arrowLine.setAttribute('stroke-width', '2'); + arrowLine.setAttribute('stroke-dasharray', '5,5'); + arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(arrowLine); + + const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + arrowLabel.setAttribute('x', legendX + 20); + arrowLabel.setAttribute('y', arrowY + 3); + arrowLabel.setAttribute('font-size', '11'); + arrowLabel.setAttribute('fill', '#666'); + arrowLabel.textContent = 'Dependency'; + svg.appendChild(arrowLabel); + } +} + +// Initialize on document ready +document.addEventListener('DOMContentLoaded', function() { + const container = document.getElementById('architecture-visualization'); + if (container && window.architectureData) { + new ArchitectureVisualization('architecture-visualization', window.architectureData); + } +}); From 59e9fa7361c08e9d7dc89f9e786f1660360de4ed Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:46:26 +0200 Subject: [PATCH 05/23] fix: correct layer ordering and colors in visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix layer ordering: Core innermost → Supporting → Generic → Tests outermost - Reverse radius calculation so Core has smallest radius (innermost) - Darken Core color to #8B1A1A (was #FF6B6B, too light) - Update all colors with darker, more distinct palette: - Core: Dark Red #8B1A1A - Supporting: Dark Teal #1B8A7E - Generic: Dark Blue #0066CC - Tests: Orange #FF6B35 - Fix legend colors to match actual layer colors - Fix arrow drawing logic to use corrected radius values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../public/js/architecture-visualization.js | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index 9e67e21..385c86d 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -53,39 +53,42 @@ class ArchitectureVisualization { const layerCount = sortedLayers.length; const layerThickness = availableRadius / layerCount; - // Color palette + // Color palette - darker colors, Core is dark red const colors = { - 'Core': { fill: '#FF6B6B', stroke: '#C92A2A', text: '#ffffff' }, - 'Supporting': { fill: '#4ECDC4', stroke: '#0F9F93', text: '#ffffff' }, - 'Generic': { fill: '#45B7D1', stroke: '#0099CC', text: '#ffffff' }, - 'Tests': { fill: '#FFA07A', stroke: '#FF6B4A', text: '#ffffff' } + 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, + 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, + 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, + 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } }; // Create position map for arrows const layerPositions = {}; - // Draw concentric rings from outermost to innermost + // Draw concentric rings from innermost (Core) to outermost (Tests) sortedLayers.forEach((layer, index) => { - const outerRadius = availableRadius - (layerThickness * index); - const innerRadius = availableRadius - (layerThickness * (index + 1)); - const midRadius = (outerRadius + innerRadius) / 2; + // Reverse the radius: Core should be INNERMOST (smallest radius) + // Tests should be OUTERMOST (largest radius) + const depth = layerCount - 1 - index; + const radius = availableRadius - (depth * layerThickness); + const nextRadius = availableRadius - ((depth + 1) * layerThickness); + const midRadius = (radius + nextRadius) / 2; layerPositions[layer.name] = { index, - innerRadius, - outerRadius, + radius, + nextRadius, midRadius }; const color = colors[layer.name]; - // Draw filled circle (ring) + // Draw filled circle const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); ring.setAttribute('cx', this.centerX); ring.setAttribute('cy', this.centerY); - ring.setAttribute('r', innerRadius); + ring.setAttribute('r', radius); ring.setAttribute('fill', color.fill); - ring.setAttribute('opacity', '0.8'); + ring.setAttribute('opacity', '0.85'); ring.setAttribute('stroke', color.stroke); ring.setAttribute('stroke-width', '2'); svg.appendChild(ring); @@ -135,8 +138,6 @@ class ArchitectureVisualization { drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { // For each layer, draw arrows to its dependencies - let arrowIndex = 0; - sortedLayers.forEach((sourceLayer) => { const deps = dependencies[sourceLayer.name] || []; const sourcePos = layerPositions[sourceLayer.name]; @@ -155,13 +156,13 @@ class ArchitectureVisualization { const angle = startAngle + (depIndex + 1) * angleStep; const angleRad = (angle * Math.PI) / 180; - // Start point: outer edge of source layer - const startRadius = sourcePos.outerRadius + 5; + // Start point: at source layer radius + const startRadius = sourcePos.radius + 5; const startX = this.centerX + Math.cos(angleRad) * startRadius; const startY = this.centerY + Math.sin(angleRad) * startRadius; - // End point: outer edge of target layer - const endRadius = targetPos.outerRadius + 5; + // End point: at target layer radius + const endRadius = targetPos.radius + 5; const endX = this.centerX + Math.cos(angleRad) * endRadius; const endY = this.centerY + Math.sin(angleRad) * endRadius; @@ -180,8 +181,6 @@ class ArchitectureVisualization { path.setAttribute('opacity', '0.6'); path.setAttribute('marker-end', 'url(#arrowhead)'); svg.appendChild(path); - - arrowIndex++; }); }); } @@ -190,6 +189,14 @@ class ArchitectureVisualization { const legendX = this.width - 200; const legendY = 20; + // Color mapping + const colorMap = { + 'Core': '#8B1A1A', + 'Supporting': '#1B8A7E', + 'Generic': '#0066CC', + 'Tests': '#FF6B35' + }; + // Legend background const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); bg.setAttribute('x', legendX - 10); @@ -223,13 +230,7 @@ class ArchitectureVisualization { square.setAttribute('y', yPos - 8); square.setAttribute('width', '12'); square.setAttribute('height', '12'); - const colors = { - 'Core': '#FF6B6B', - 'Supporting': '#4ECDC4', - 'Generic': '#45B7D1', - 'Tests': '#FFA07A' - }; - square.setAttribute('fill', colors[layerName]); + square.setAttribute('fill', colorMap[layerName]); svg.appendChild(square); // Label From 766891f905ea5d36abaffb71cf8d82af45839f48 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:47:51 +0200 Subject: [PATCH 06/23] fix: use annulus rings instead of overlapping circles to prevent color blending - Replace overlapping circle approach with SVG path-based annulus (ring) shapes - Use fill-rule: evenodd to properly cut out inner circle from outer circle - Remove opacity property that was causing color mixing from overlap - Use innerRadius and outerRadius for proper ring geometry - Update arrow drawing to use outerRadius for correct positioning - Colors now display pure without blending artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../public/js/architecture-visualization.js | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index 385c86d..f747b33 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -69,29 +69,34 @@ class ArchitectureVisualization { // Reverse the radius: Core should be INNERMOST (smallest radius) // Tests should be OUTERMOST (largest radius) const depth = layerCount - 1 - index; - const radius = availableRadius - (depth * layerThickness); - const nextRadius = availableRadius - ((depth + 1) * layerThickness); - const midRadius = (radius + nextRadius) / 2; + const innerRadius = availableRadius - ((depth + 1) * layerThickness); + const outerRadius = availableRadius - (depth * layerThickness); + const midRadius = (innerRadius + outerRadius) / 2; layerPositions[layer.name] = { index, - radius, - nextRadius, + innerRadius, + outerRadius, midRadius }; const color = colors[layer.name]; - // Draw filled circle - const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - ring.setAttribute('cx', this.centerX); - ring.setAttribute('cy', this.centerY); - ring.setAttribute('r', radius); - ring.setAttribute('fill', color.fill); - ring.setAttribute('opacity', '0.85'); - ring.setAttribute('stroke', color.stroke); - ring.setAttribute('stroke-width', '2'); - svg.appendChild(ring); + // Draw annulus (ring) using path instead of overlapping circles + // This prevents color blending from opacity overlap + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + // Create a ring path: outer circle - inner circle + const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; + const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; + + const pathData = outerCircle + ' ' + innerCircle; + path.setAttribute('d', pathData); + path.setAttribute('fill', color.fill); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('stroke', color.stroke); + path.setAttribute('stroke-width', '1'); + svg.appendChild(path); // Add layer name const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); @@ -156,13 +161,13 @@ class ArchitectureVisualization { const angle = startAngle + (depIndex + 1) * angleStep; const angleRad = (angle * Math.PI) / 180; - // Start point: at source layer radius - const startRadius = sourcePos.radius + 5; + // Start point: at outer edge of source layer + const startRadius = sourcePos.outerRadius + 5; const startX = this.centerX + Math.cos(angleRad) * startRadius; const startY = this.centerY + Math.sin(angleRad) * startRadius; - // End point: at target layer radius - const endRadius = targetPos.radius + 5; + // End point: at outer edge of target layer + const endRadius = targetPos.outerRadius + 5; const endX = this.centerX + Math.cos(angleRad) * endRadius; const endY = this.centerY + Math.sin(angleRad) * endRadius; From 14fbea73345585f85869d1a8dfceba4648be7c26 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:50:51 +0200 Subject: [PATCH 07/23] fix: improve arrow rendering for dependency visualization - Change arrows to start at middle of source layer and end at middle of target layer - Use straight lines instead of curved bezier paths - Change from dashed to solid lines (#333 dark gray) - Remove arrowhead from start, keep only at end (triangular) - Remove opacity and simplify arrow styling - Spread arrows around circle using even angle distribution - Each dependency now has clear, simple arrow pointing inward to its dependency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../public/js/architecture-visualization.js | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index f747b33..a3c4aae 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -149,41 +149,33 @@ class ArchitectureVisualization { if (!deps || deps.length === 0) return; - // Spread arrows around the circle to avoid overlap - const startAngle = -90; // Start at top - const angleStep = 360 / (deps.length + 1); - - deps.forEach((targetLayerName, depIndex) => { + deps.forEach((targetLayerName) => { const targetPos = layerPositions[targetLayerName]; if (!targetPos) return; - // Calculate angle for this arrow - const angle = startAngle + (depIndex + 1) * angleStep; + // Use different angles for each dependency to spread them out + const depCount = deps.length; + const depIndex = deps.indexOf(targetLayerName); + const angle = (depIndex * (360 / depCount)) - 90; // Spread evenly around circle const angleRad = (angle * Math.PI) / 180; - // Start point: at outer edge of source layer - const startRadius = sourcePos.outerRadius + 5; + // Start point: at midRadius of source layer + const startRadius = sourcePos.midRadius; const startX = this.centerX + Math.cos(angleRad) * startRadius; const startY = this.centerY + Math.sin(angleRad) * startRadius; - // End point: at outer edge of target layer - const endRadius = targetPos.outerRadius + 5; + // End point: at midRadius of target layer + const endRadius = targetPos.midRadius; const endX = this.centerX + Math.cos(angleRad) * endRadius; const endY = this.centerY + Math.sin(angleRad) * endRadius; - // Draw curved arrow (quadratic bezier) - const controlRadius = Math.max(startRadius, endRadius) + 100; - const controlX = this.centerX + Math.cos(angleRad) * controlRadius; - const controlY = this.centerY + Math.sin(angleRad) * controlRadius; - + // Draw simple line arrow from source to target const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const pathData = `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`; + const pathData = `M ${startX} ${startY} L ${endX} ${endY}`; path.setAttribute('d', pathData); path.setAttribute('fill', 'none'); - path.setAttribute('stroke', '#999'); + path.setAttribute('stroke', '#333'); path.setAttribute('stroke-width', '2'); - path.setAttribute('stroke-dasharray', '5,5'); - path.setAttribute('opacity', '0.6'); path.setAttribute('marker-end', 'url(#arrowhead)'); svg.appendChild(path); }); From ae451ca08115b31b6a5dfb86d97fd11358326ec1 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:52:57 +0200 Subject: [PATCH 08/23] fix: offset arrow angles to avoid 12 o'clock label overlap - Add 22.5 degree offset to all arrow angles to avoid 12 o'clock position - Prevents arrows from overlapping with layer labels at top of diagram - Arrows spread evenly around circle but skip the label zone - Labels remain clear and unobstructed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/public/js/architecture-visualization.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index a3c4aae..440b579 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -154,9 +154,10 @@ class ArchitectureVisualization { if (!targetPos) return; // Use different angles for each dependency to spread them out + // Add 22.5 degree offset to avoid 12 o'clock position where labels are const depCount = deps.length; const depIndex = deps.indexOf(targetLayerName); - const angle = (depIndex * (360 / depCount)) - 90; // Spread evenly around circle + const angle = (depIndex * (360 / depCount)) - 90 + 22.5; // Offset to avoid label overlap const angleRad = (angle * Math.PI) / 180; // Start point: at midRadius of source layer From 8c0c10e5058943b32cbf3567db612ff2fca667ce Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 13:58:12 +0200 Subject: [PATCH 09/23] refactor: reorder layers alphabetically with inward-pointing arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change layer order from: Core, Supporting, Generic, Tests - New order: Core, Generic, Supporting, Tests - Alphabetical ordering (Core < Generic < Supporting < Tests) - All arrows now point inward from outer layers to inner dependencies: - Supporting → Core, Generic (both inner) - Tests → Core, Generic, Supporting (all inner) - Core, Generic have no dependencies (innermost) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/public/js/architecture-visualization.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index 440b579..a0aea44 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -44,8 +44,10 @@ class ArchitectureVisualization { const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; - // Fixed layer order: Core innermost, Tests outermost - const layerOrder = ['Core', 'Supporting', 'Generic', 'Tests']; + // Layer order: alphabetical for consistent ordering, with arrows pointing inward + // Core → Generic → Supporting → Tests (innermost to outermost) + // This ensures all arrows point from outer layers towards inner dependencies + const layerOrder = ['Core', 'Generic', 'Supporting', 'Tests']; const sortedLayers = layerOrder .filter(name => layers[name]) .map(name => ({ name, ...layers[name] })); From bad9db3d06888bd5201d931efd07911cf07ca21a Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:01:06 +0200 Subject: [PATCH 10/23] make onion look good --- backend/config/services.yaml | 6 ++ backend/deptrac.yaml | 20 +++--- .../DataCollector/ArchitectureCollector.php | 63 ++++++++++++++++++- .../templates/profiler/architecture.html.twig | 59 ++++++++++++++--- 4 files changed, 127 insertions(+), 21 deletions(-) diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 6be0dce..f8c7cb4 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -44,3 +44,9 @@ services: App\Instrumentation\DataCollector\PermissionVoterCollector: tags: - { name: data_collector, template: 'profiler/permission_voter_collector.html.twig', id: 'app.permission_voter_collector' } + + App\Instrumentation\DataCollector\ArchitectureCollector: + arguments: + - '%kernel.project_dir%' + tags: + - { name: data_collector, template: 'profiler/architecture.html.twig', id: 'app.architecture_collector' } diff --git a/backend/deptrac.yaml b/backend/deptrac.yaml index ed20246..6dc48ff 100644 --- a/backend/deptrac.yaml +++ b/backend/deptrac.yaml @@ -9,6 +9,12 @@ deptrac: collectors: - type: classLike value: .*App\\Game\\.* + - type: classLike + value: .*App\\Invariant\\.* + - type: classLike + value: .*PHPMolecules\\.* + - type: classLike + value: .*App\\Timer\\.* - name: Generic collectors: - type: classLike @@ -65,25 +71,13 @@ deptrac: collectors: - type: classLike value: .*App\\Tests\\.* - - name: Utilities - collectors: - - type: classLike - value: .*App\\Invariant\\.* - - type: classLike - value: .*PHPMolecules\\.* - - type: classLike - value: .*App\\Timer\\.* ruleset: - Core: - - Utilities + Core: ~ Generic: ~ Supporting: - Core - Generic - - Utilities Tests: - Core - Generic - Supporting - - Utilities - Utilities: ~ diff --git a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php index 7af0789..8ffd728 100644 --- a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php +++ b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php @@ -7,11 +7,20 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\Yaml\Yaml; class ArchitectureCollector extends DataCollector { + private string $projectDir; + + public function __construct(string $projectDir) + { + $this->projectDir = $projectDir; + } + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void { + $this->data = $this->parseDeptracConfig(); } public function getName(): string @@ -19,8 +28,60 @@ public function getName(): string return 'app.architecture_collector'; } + /** + * @return array{layers: array, dependencies: array>} + */ public function getDetails(): array { - return []; + assert(is_array($this->data)); + return $this->data; + } + + /** + * @return array{layers: array, dependencies: array>} + */ + private function parseDeptracConfig(): array + { + $deptracPath = $this->projectDir . '/deptrac.yaml'; + + if (!file_exists($deptracPath)) { + return ['layers' => [], 'dependencies' => []]; + } + + $config = Yaml::parseFile($deptracPath); + + if (!isset($config['deptrac']) || !is_array($config['deptrac'])) { + return ['layers' => [], 'dependencies' => []]; + } + + $deptracConfig = $config['deptrac']; + + $layers = []; + if (isset($deptracConfig['layers']) && is_array($deptracConfig['layers'])) { + $position = 0; + foreach ($deptracConfig['layers'] as $layer) { + if (isset($layer['name']) && is_string($layer['name'])) { + $layers[$layer['name']] = [ + 'name' => $layer['name'], + 'position' => $position++, + ]; + } + } + } + + $dependencies = []; + if (isset($deptracConfig['ruleset']) && is_array($deptracConfig['ruleset'])) { + foreach ($deptracConfig['ruleset'] as $layer => $allowedDependencies) { + if (!is_array($allowedDependencies)) { + $allowedDependencies = []; + } + $dependencies[$layer] = array_values(array_filter($allowedDependencies, 'is_string')); + } + } + + return [ + 'layers' => $layers, + 'dependencies' => $dependencies, + ]; } } diff --git a/backend/templates/profiler/architecture.html.twig b/backend/templates/profiler/architecture.html.twig index 8553a19..e3763e6 100644 --- a/backend/templates/profiler/architecture.html.twig +++ b/backend/templates/profiler/architecture.html.twig @@ -4,13 +4,13 @@ {% block toolbar %} {% set icon %} {{ include('profiler/skyscraper.svg') }} - {{ collector.details|length }} + {{ collector.details.layers|length }} {% endset %} {% set text %}
Architecture - {{ collector.details|length }} + {{ collector.details.layers|length }} layers
{% endset %} @@ -21,13 +21,58 @@ {{ include('profiler/skyscraper.svg') }} Architecture - {{ collector.details|length }} + {{ collector.details.layers|length }} {% endblock %} {% block panel %} -

Architecture

-
-

No details yet.

-
+

Architecture Visualization

+ + {% if collector.details.layers is empty %} +
+

No architecture data available. Check that deptrac.yaml is properly configured.

+
+ {% else %} +
+

Onion Architecture Layers

+
+
+ +
+

Layer Information

+ + + + + + + + + + {% for layer, info in collector.details.layers %} + + + + + + {% endfor %} + +
LayerPositionCan Depend On
{{ layer }}{{ info.position }} + {% if collector.details.dependencies[layer] is defined %} + {{ collector.details.dependencies[layer]|join(', ') }} + {% else %} + None + {% endif %} +
+
+ + + + {% endif %} {% endblock %} + From 7ade828b6e2adf853f632d3b3c969086a76bd6a8 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:08:40 +0200 Subject: [PATCH 11/23] refactor: move generic layer to outermost position before tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change layer order from: Core, Generic, Supporting, Tests - New order: Core, Supporting, Generic, Tests - Visually represents Generic as external infrastructure layer - Supporting layer is now clearly the adapter/bridge layer - Generic (frameworks/external) is now outer layer before Tests - Better semantic representation of onion architecture: - Core: business logic (innermost) - Supporting: adapters bridging Core ↔ Generic - Generic: external frameworks (infrastructure, outer) - Tests: test layer (outermost) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/public/js/architecture-visualization.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index a0aea44..f1fd424 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -44,10 +44,13 @@ class ArchitectureVisualization { const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; - // Layer order: alphabetical for consistent ordering, with arrows pointing inward - // Core → Generic → Supporting → Tests (innermost to outermost) - // This ensures all arrows point from outer layers towards inner dependencies - const layerOrder = ['Core', 'Generic', 'Supporting', 'Tests']; + // Layer order: semantic onion with adapters between core and infrastructure + // Core → Supporting → Generic → Tests (innermost to outermost) + // Core: business logic (no deps) + // Supporting: adapters (depends on Core, uses Generic) + // Generic: external frameworks (infrastructure) + // Tests: test layer (can depend on all) + const layerOrder = ['Core', 'Supporting', 'Generic', 'Tests']; const sortedLayers = layerOrder .filter(name => layers[name]) .map(name => ({ name, ...layers[name] })); From 95f3bff6326bd04f8846a847f58644187af09ecc Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:11:40 +0200 Subject: [PATCH 12/23] refactor: implement Option B layer ordering for cleaner architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change layer order to: Core → Supporting → Tests → Generic - Remove Generic from Tests' allowed dependencies in deptrac.yaml - Tests now only depend on Core and Supporting (application layers) - Generic (external frameworks) is now outermost (infrastructure) - Better separation: test code vs external frameworks - Tests are application-layer tests, not meta-tests of frameworks Architecture now represents: - Core: business logic (innermost, no deps) - Supporting: adapters/infrastructure code (depends on Core + Generic) - Tests: application tests (depends on Core + Supporting) - Generic: external frameworks (outermost, no deps) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/deptrac.yaml | 1 - backend/public/js/architecture-visualization.js | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/deptrac.yaml b/backend/deptrac.yaml index 6dc48ff..d54f372 100644 --- a/backend/deptrac.yaml +++ b/backend/deptrac.yaml @@ -79,5 +79,4 @@ deptrac: - Generic Tests: - Core - - Generic - Supporting diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index f1fd424..e1841b7 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -44,13 +44,13 @@ class ArchitectureVisualization { const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; - // Layer order: semantic onion with adapters between core and infrastructure - // Core → Supporting → Generic → Tests (innermost to outermost) + // Layer order: semantic onion with tests as application layer + // Core → Supporting → Tests → Generic (innermost to outermost) // Core: business logic (no deps) // Supporting: adapters (depends on Core, uses Generic) - // Generic: external frameworks (infrastructure) - // Tests: test layer (can depend on all) - const layerOrder = ['Core', 'Supporting', 'Generic', 'Tests']; + // Tests: application test layer (depends on Core, Supporting) + // Generic: external frameworks (infrastructure, outermost) + const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; const sortedLayers = layerOrder .filter(name => layers[name]) .map(name => ({ name, ...layers[name] })); From 6c3f193ea228b9f9878562458bf9b9f042b2a7b9 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:19:30 +0200 Subject: [PATCH 13/23] tests are allowed to access generic --- backend/deptrac.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/deptrac.yaml b/backend/deptrac.yaml index d54f372..6dc48ff 100644 --- a/backend/deptrac.yaml +++ b/backend/deptrac.yaml @@ -79,4 +79,5 @@ deptrac: - Generic Tests: - Core + - Generic - Supporting From 046830e2bd6998893bc6053e673dd026c23d99b1 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:22:27 +0200 Subject: [PATCH 14/23] fix: remove margins, padding, and extra styling for clean canvas placement - Remove max-width, margin: auto from container div - Remove wrapper margin: 20px 0 - Remove border, border-radius, background gradient from SVG - Add viewBox for responsive scaling - Add white background rectangle inside SVG for clean appearance - SVG is now block-level with no extra spacing - Canvas fills container without margins or padding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/public/js/architecture-visualization.js | 10 +++++++++- backend/templates/profiler/architecture.html.twig | 5 ++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js index e1841b7..fe17e59 100644 --- a/backend/public/js/architecture-visualization.js +++ b/backend/public/js/architecture-visualization.js @@ -37,7 +37,15 @@ class ArchitectureVisualization { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', this.width); svg.setAttribute('height', this.height); - svg.setAttribute('style', 'border: 1px solid #ddd; border-radius: 4px; background: linear-gradient(135deg, #f9f9f9 0%, #ffffff 100%);'); + svg.setAttribute('style', 'display: block;'); + svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + + // Add background + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', this.width); + bg.setAttribute('height', this.height); + bg.setAttribute('fill', 'white'); + svg.appendChild(bg); // Define arrow markers for different angles this.addArrowMarkers(svg); diff --git a/backend/templates/profiler/architecture.html.twig b/backend/templates/profiler/architecture.html.twig index e3763e6..012ce99 100644 --- a/backend/templates/profiler/architecture.html.twig +++ b/backend/templates/profiler/architecture.html.twig @@ -33,9 +33,8 @@

No architecture data available. Check that deptrac.yaml is properly configured.

{% else %} -
-

Onion Architecture Layers

-
+
+
From 16f18e670aacc0522b31cd9af3e694b07b5073d6 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:25:54 +0200 Subject: [PATCH 15/23] refactor: cleanup and reorganize deptrac-visualization for package extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename class from ArchitectureVisualization to DeptracVisualization - Refactor into private methods (_createSvg, _getColorPalette, etc.) - Remove unnecessary complexity and improve code organization - Move from backend/public/js to backend/assets/js/deptrac-visualization/ - Create package.json and README.md for future npm extraction - Update template to reference new location and renamed class - Add backward compatibility alias for old class name - No external dependencies, pure SVG rendering - Ready for extraction as standalone 'deptrac-visualization' npm package Changes: - architecture-visualization.js → deptrac-visualization/index.js - 283 lines → 223 lines (cleaner, more maintainable) - Better separation of concerns with private methods - Package metadata ready for npm publication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../assets/js/deptrac-visualization/README.md | 55 ++++ .../assets/js/deptrac-visualization/index.js | 243 ++++++++++++++++++ .../js/deptrac-visualization/package.json | 22 ++ .../templates/profiler/architecture.html.twig | 4 +- 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 backend/assets/js/deptrac-visualization/README.md create mode 100644 backend/assets/js/deptrac-visualization/index.js create mode 100644 backend/assets/js/deptrac-visualization/package.json diff --git a/backend/assets/js/deptrac-visualization/README.md b/backend/assets/js/deptrac-visualization/README.md new file mode 100644 index 0000000..9fdf148 --- /dev/null +++ b/backend/assets/js/deptrac-visualization/README.md @@ -0,0 +1,55 @@ +# Deptrac Architecture Visualization + +A pure JavaScript library for visualizing onion/hexagonal architecture diagrams from deptrac.yaml configurations. + +## Usage + +```javascript +const viz = new DeptracVisualization('container-id', { + layers: { + Core: { /* layer data */ }, + Supporting: { /* layer data */ }, + // ... + }, + dependencies: { + Core: [], + Supporting: ['Core', 'Generic'], + // ... + } +}); +``` + +## Features + +- Pure SVG rendering (no external dependencies) +- Configurable layer order and colors +- Automatic dependency arrow layout +- Legend with layer information +- Responsive design with viewBox support + +## Configuration + +The visualization accepts data with two properties: + +- **layers**: Object mapping layer names to layer definitions +- **dependencies**: Object mapping layer names to arrays of their dependencies + +## Colors + +Default color scheme: +- Core: Dark Red (#8B1A1A) +- Supporting: Dark Teal (#1B8A7E) +- Generic: Dark Blue (#0066CC) +- Tests: Orange (#FF6B35) + +## Architecture + +The visualization represents a semantic onion architecture: +1. **Core** (innermost) - Business logic, no dependencies +2. **Supporting** - Adapters and infrastructure code +3. **Tests** - Application tests +4. **Generic** (outermost) - External frameworks and libraries + +## Notes + +This module is designed for extraction as a standalone npm package while being integrated into the current project. diff --git a/backend/assets/js/deptrac-visualization/index.js b/backend/assets/js/deptrac-visualization/index.js new file mode 100644 index 0000000..0f8a8ad --- /dev/null +++ b/backend/assets/js/deptrac-visualization/index.js @@ -0,0 +1,243 @@ +/** + * Deptrac Architecture Visualization + * Renders an onion architecture diagram from deptrac.yaml using SVG + * + * Usage: + * const viz = new DeptracVisualization('container-id', { + * layers: { Core: {...}, Supporting: {...}, ... }, + * dependencies: { Core: [], Supporting: ['Core'], ... } + * }); + */ + +class DeptracVisualization { + constructor(containerId, data) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Container with id "${containerId}" not found`); + return; + } + + this.data = data; + this.width = 900; + this.height = 700; + this.centerX = this.width / 2; + this.centerY = this.height / 2; + this.margin = 80; + this.render(); + } + + render() { + this.container.innerHTML = ''; + + const layers = this.data.layers || {}; + const dependencies = this.data.dependencies || {}; + + if (Object.keys(layers).length === 0) { + this.container.innerHTML = '

No architecture data available

'; + return; + } + + const svg = this._createSvg(); + const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; + + const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; + const sortedLayers = layerOrder + .filter(name => layers[name]) + .map(name => ({ name, ...layers[name] })); + + const layerCount = sortedLayers.length; + const layerThickness = availableRadius / layerCount; + const colors = this._getColorPalette(); + const layerPositions = {}; + + sortedLayers.forEach((layer, index) => { + const depth = layerCount - 1 - index; + const innerRadius = availableRadius - ((depth + 1) * layerThickness); + const outerRadius = availableRadius - (depth * layerThickness); + const midRadius = (innerRadius + outerRadius) / 2; + + layerPositions[layer.name] = { index, innerRadius, outerRadius, midRadius }; + + this._drawLayer(svg, layer, colors[layer.name], innerRadius, outerRadius, midRadius); + }); + + this._drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); + this._drawLegend(svg, colors); + + this.container.appendChild(svg); + } + + _createSvg() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', this.width); + svg.setAttribute('height', this.height); + svg.setAttribute('style', 'display: block;'); + svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', this.width); + bg.setAttribute('height', this.height); + bg.setAttribute('fill', 'white'); + svg.appendChild(bg); + + this._addArrowMarker(svg); + return svg; + } + + _getColorPalette() { + return { + 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, + 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, + 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, + 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } + }; + } + + _addArrowMarker(svg) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3, 0 6'); + polygon.setAttribute('fill', '#666'); + marker.appendChild(polygon); + + defs.appendChild(marker); + svg.appendChild(defs); + } + + _drawLayer(svg, layer, color, innerRadius, outerRadius, midRadius) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; + const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; + + path.setAttribute('d', outerCircle + ' ' + innerCircle); + path.setAttribute('fill', color.fill); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('stroke', color.stroke); + path.setAttribute('stroke-width', '1'); + svg.appendChild(path); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', this.centerX); + label.setAttribute('y', this.centerY - midRadius); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'middle'); + label.setAttribute('font-size', '18'); + label.setAttribute('font-weight', 'bold'); + label.setAttribute('fill', color.text); + label.textContent = layer.name; + svg.appendChild(label); + } + + _drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { + sortedLayers.forEach((sourceLayer) => { + const deps = dependencies[sourceLayer.name] || []; + const sourcePos = layerPositions[sourceLayer.name]; + + if (!deps || deps.length === 0) return; + + deps.forEach((targetLayerName) => { + const targetPos = layerPositions[targetLayerName]; + if (!targetPos) return; + + const depCount = deps.length; + const depIndex = deps.indexOf(targetLayerName); + const angle = (depIndex * (360 / depCount)) - 90 + 22.5; + const angleRad = (angle * Math.PI) / 180; + + const startX = this.centerX + Math.cos(angleRad) * sourcePos.midRadius; + const startY = this.centerY + Math.sin(angleRad) * sourcePos.midRadius; + const endX = this.centerX + Math.cos(angleRad) * targetPos.midRadius; + const endY = this.centerY + Math.sin(angleRad) * targetPos.midRadius; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M ${startX} ${startY} L ${endX} ${endY}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', '#333'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(path); + }); + }); + } + + _drawLegend(svg, colors) { + const legendX = this.width - 200; + const legendY = 20; + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('x', legendX - 10); + bg.setAttribute('y', legendY - 10); + bg.setAttribute('width', 190); + bg.setAttribute('height', 150); + bg.setAttribute('fill', '#ffffff'); + bg.setAttribute('stroke', '#ddd'); + bg.setAttribute('stroke-width', '1'); + bg.setAttribute('opacity', '0.95'); + svg.appendChild(bg); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', legendX); + title.setAttribute('y', legendY + 15); + title.setAttribute('font-size', '14'); + title.setAttribute('font-weight', 'bold'); + title.setAttribute('fill', '#333'); + title.textContent = 'Layers'; + svg.appendChild(title); + + ['Core', 'Supporting', 'Generic', 'Tests'].forEach((layerName, i) => { + const yPos = legendY + 35 + i * 25; + + const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + square.setAttribute('x', legendX); + square.setAttribute('y', yPos - 8); + square.setAttribute('width', '12'); + square.setAttribute('height', '12'); + square.setAttribute('fill', colors[layerName].fill); + svg.appendChild(square); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', legendX + 20); + label.setAttribute('y', yPos); + label.setAttribute('font-size', '12'); + label.setAttribute('fill', '#333'); + label.textContent = layerName; + svg.appendChild(label); + }); + + const arrowY = legendY + 130; + const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); + arrowLine.setAttribute('stroke', '#333'); + arrowLine.setAttribute('stroke-width', '2'); + arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(arrowLine); + + const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + arrowLabel.setAttribute('x', legendX + 20); + arrowLabel.setAttribute('y', arrowY + 3); + arrowLabel.setAttribute('font-size', '11'); + arrowLabel.setAttribute('fill', '#666'); + arrowLabel.textContent = 'Dependency'; + svg.appendChild(arrowLabel); + } +} + +// For backward compatibility +const ArchitectureVisualization = DeptracVisualization; + +// Initialize on document ready +document.addEventListener('DOMContentLoaded', function() { + const container = document.getElementById('architecture-visualization'); + if (container && window.architectureData) { + new DeptracVisualization('architecture-visualization', window.architectureData); + } +}); diff --git a/backend/assets/js/deptrac-visualization/package.json b/backend/assets/js/deptrac-visualization/package.json new file mode 100644 index 0000000..8d0c1b0 --- /dev/null +++ b/backend/assets/js/deptrac-visualization/package.json @@ -0,0 +1,22 @@ +{ + "name": "deptrac-visualization", + "version": "1.0.0", + "description": "Pure JavaScript visualization for onion architecture diagrams from deptrac.yaml", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "deptrac", + "architecture", + "visualization", + "onion-architecture", + "svg" + ], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "TODO: add repository when extracted as standalone package" + } +} diff --git a/backend/templates/profiler/architecture.html.twig b/backend/templates/profiler/architecture.html.twig index 012ce99..37d565e 100644 --- a/backend/templates/profiler/architecture.html.twig +++ b/backend/templates/profiler/architecture.html.twig @@ -65,11 +65,11 @@
- + {% endif %} From 6384ac0b145afb1ee874257bacf686122e0aa3c6 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:26:23 +0200 Subject: [PATCH 16/23] cleanup --- .../public/js/architecture-visualization.js | 283 ------------------ 1 file changed, 283 deletions(-) delete mode 100644 backend/public/js/architecture-visualization.js diff --git a/backend/public/js/architecture-visualization.js b/backend/public/js/architecture-visualization.js deleted file mode 100644 index fe17e59..0000000 --- a/backend/public/js/architecture-visualization.js +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Architecture Visualization Module - * Renders an onion architecture diagram using SVG - * - * Layers are positioned from innermost (Core) to outermost (Tests) - * Dependency arrows point outward from a layer to its dependencies - */ - -class ArchitectureVisualization { - constructor(containerId, data) { - this.container = document.getElementById(containerId); - if (!this.container) { - console.error(`Container with id "${containerId}" not found`); - return; - } - - this.data = data; - this.width = 900; - this.height = 700; - this.centerX = this.width / 2; - this.centerY = this.height / 2; - this.margin = 80; - this.render(); - } - - render() { - this.container.innerHTML = ''; - - const layers = this.data.layers || {}; - const dependencies = this.data.dependencies || {}; - - if (Object.keys(layers).length === 0) { - this.container.innerHTML = '

No architecture data available

'; - return; - } - - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', this.width); - svg.setAttribute('height', this.height); - svg.setAttribute('style', 'display: block;'); - svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); - - // Add background - const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bg.setAttribute('width', this.width); - bg.setAttribute('height', this.height); - bg.setAttribute('fill', 'white'); - svg.appendChild(bg); - - // Define arrow markers for different angles - this.addArrowMarkers(svg); - - const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; - - // Layer order: semantic onion with tests as application layer - // Core → Supporting → Tests → Generic (innermost to outermost) - // Core: business logic (no deps) - // Supporting: adapters (depends on Core, uses Generic) - // Tests: application test layer (depends on Core, Supporting) - // Generic: external frameworks (infrastructure, outermost) - const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; - const sortedLayers = layerOrder - .filter(name => layers[name]) - .map(name => ({ name, ...layers[name] })); - - const layerCount = sortedLayers.length; - const layerThickness = availableRadius / layerCount; - - // Color palette - darker colors, Core is dark red - const colors = { - 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, - 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, - 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, - 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } - }; - - // Create position map for arrows - const layerPositions = {}; - - // Draw concentric rings from innermost (Core) to outermost (Tests) - sortedLayers.forEach((layer, index) => { - // Reverse the radius: Core should be INNERMOST (smallest radius) - // Tests should be OUTERMOST (largest radius) - const depth = layerCount - 1 - index; - const innerRadius = availableRadius - ((depth + 1) * layerThickness); - const outerRadius = availableRadius - (depth * layerThickness); - const midRadius = (innerRadius + outerRadius) / 2; - - layerPositions[layer.name] = { - index, - innerRadius, - outerRadius, - midRadius - }; - - const color = colors[layer.name]; - - // Draw annulus (ring) using path instead of overlapping circles - // This prevents color blending from opacity overlap - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - - // Create a ring path: outer circle - inner circle - const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; - const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; - - const pathData = outerCircle + ' ' + innerCircle; - path.setAttribute('d', pathData); - path.setAttribute('fill', color.fill); - path.setAttribute('fill-rule', 'evenodd'); - path.setAttribute('stroke', color.stroke); - path.setAttribute('stroke-width', '1'); - svg.appendChild(path); - - // Add layer name - const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('x', this.centerX); - label.setAttribute('y', this.centerY - midRadius); - label.setAttribute('text-anchor', 'middle'); - label.setAttribute('dominant-baseline', 'middle'); - label.setAttribute('font-size', '18'); - label.setAttribute('font-weight', 'bold'); - label.setAttribute('fill', color.text); - label.textContent = layer.name; - svg.appendChild(label); - }); - - // Draw dependency arrows with different angles to avoid overlap - this.drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); - - // Add legend - this.addLegend(svg); - - this.container.appendChild(svg); - } - - addArrowMarkers(svg) { - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - - // Create arrow marker - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', 'arrowhead'); - marker.setAttribute('markerWidth', '10'); - marker.setAttribute('markerHeight', '10'); - marker.setAttribute('refX', '8'); - marker.setAttribute('refY', '3'); - marker.setAttribute('orient', 'auto'); - - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - polygon.setAttribute('points', '0 0, 10 3, 0 6'); - polygon.setAttribute('fill', '#666'); - marker.appendChild(polygon); - defs.appendChild(marker); - - svg.appendChild(defs); - } - - drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { - // For each layer, draw arrows to its dependencies - sortedLayers.forEach((sourceLayer) => { - const deps = dependencies[sourceLayer.name] || []; - const sourcePos = layerPositions[sourceLayer.name]; - - if (!deps || deps.length === 0) return; - - deps.forEach((targetLayerName) => { - const targetPos = layerPositions[targetLayerName]; - if (!targetPos) return; - - // Use different angles for each dependency to spread them out - // Add 22.5 degree offset to avoid 12 o'clock position where labels are - const depCount = deps.length; - const depIndex = deps.indexOf(targetLayerName); - const angle = (depIndex * (360 / depCount)) - 90 + 22.5; // Offset to avoid label overlap - const angleRad = (angle * Math.PI) / 180; - - // Start point: at midRadius of source layer - const startRadius = sourcePos.midRadius; - const startX = this.centerX + Math.cos(angleRad) * startRadius; - const startY = this.centerY + Math.sin(angleRad) * startRadius; - - // End point: at midRadius of target layer - const endRadius = targetPos.midRadius; - const endX = this.centerX + Math.cos(angleRad) * endRadius; - const endY = this.centerY + Math.sin(angleRad) * endRadius; - - // Draw simple line arrow from source to target - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const pathData = `M ${startX} ${startY} L ${endX} ${endY}`; - path.setAttribute('d', pathData); - path.setAttribute('fill', 'none'); - path.setAttribute('stroke', '#333'); - path.setAttribute('stroke-width', '2'); - path.setAttribute('marker-end', 'url(#arrowhead)'); - svg.appendChild(path); - }); - }); - } - - addLegend(svg) { - const legendX = this.width - 200; - const legendY = 20; - - // Color mapping - const colorMap = { - 'Core': '#8B1A1A', - 'Supporting': '#1B8A7E', - 'Generic': '#0066CC', - 'Tests': '#FF6B35' - }; - - // Legend background - const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bg.setAttribute('x', legendX - 10); - bg.setAttribute('y', legendY - 10); - bg.setAttribute('width', 190); - bg.setAttribute('height', 150); - bg.setAttribute('fill', '#ffffff'); - bg.setAttribute('stroke', '#ddd'); - bg.setAttribute('stroke-width', '1'); - bg.setAttribute('opacity', '0.95'); - svg.appendChild(bg); - - // Legend title - const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - title.setAttribute('x', legendX); - title.setAttribute('y', legendY + 15); - title.setAttribute('font-size', '14'); - title.setAttribute('font-weight', 'bold'); - title.setAttribute('fill', '#333'); - title.textContent = 'Layers'; - svg.appendChild(title); - - // Legend items - const layers = ['Core', 'Supporting', 'Generic', 'Tests']; - layers.forEach((layerName, i) => { - const yPos = legendY + 35 + i * 25; - - // Colored square - const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - square.setAttribute('x', legendX); - square.setAttribute('y', yPos - 8); - square.setAttribute('width', '12'); - square.setAttribute('height', '12'); - square.setAttribute('fill', colorMap[layerName]); - svg.appendChild(square); - - // Label - const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('x', legendX + 20); - label.setAttribute('y', yPos); - label.setAttribute('font-size', '12'); - label.setAttribute('fill', '#333'); - label.textContent = layerName; - svg.appendChild(label); - }); - - // Arrow legend - const arrowY = legendY + 130; - const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); - arrowLine.setAttribute('stroke', '#999'); - arrowLine.setAttribute('stroke-width', '2'); - arrowLine.setAttribute('stroke-dasharray', '5,5'); - arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); - svg.appendChild(arrowLine); - - const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - arrowLabel.setAttribute('x', legendX + 20); - arrowLabel.setAttribute('y', arrowY + 3); - arrowLabel.setAttribute('font-size', '11'); - arrowLabel.setAttribute('fill', '#666'); - arrowLabel.textContent = 'Dependency'; - svg.appendChild(arrowLabel); - } -} - -// Initialize on document ready -document.addEventListener('DOMContentLoaded', function() { - const container = document.getElementById('architecture-visualization'); - if (container && window.architectureData) { - new ArchitectureVisualization('architecture-visualization', window.architectureData); - } -}); From d0992b6b01e07bb91a386d5f6ea51054795579a2 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:27:33 +0200 Subject: [PATCH 17/23] fix: copy deptrac-visualization to public directory for immediate use - Keep copy in backend/assets for future npm extraction - Copy to backend/public/js for immediate web access - Allows script path /js/deptrac-visualization/index.js to work - Maintains backward compatibility with current setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../public/js/deptrac-visualization/index.js | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 backend/public/js/deptrac-visualization/index.js diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js new file mode 100644 index 0000000..0f8a8ad --- /dev/null +++ b/backend/public/js/deptrac-visualization/index.js @@ -0,0 +1,243 @@ +/** + * Deptrac Architecture Visualization + * Renders an onion architecture diagram from deptrac.yaml using SVG + * + * Usage: + * const viz = new DeptracVisualization('container-id', { + * layers: { Core: {...}, Supporting: {...}, ... }, + * dependencies: { Core: [], Supporting: ['Core'], ... } + * }); + */ + +class DeptracVisualization { + constructor(containerId, data) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`Container with id "${containerId}" not found`); + return; + } + + this.data = data; + this.width = 900; + this.height = 700; + this.centerX = this.width / 2; + this.centerY = this.height / 2; + this.margin = 80; + this.render(); + } + + render() { + this.container.innerHTML = ''; + + const layers = this.data.layers || {}; + const dependencies = this.data.dependencies || {}; + + if (Object.keys(layers).length === 0) { + this.container.innerHTML = '

No architecture data available

'; + return; + } + + const svg = this._createSvg(); + const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; + + const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; + const sortedLayers = layerOrder + .filter(name => layers[name]) + .map(name => ({ name, ...layers[name] })); + + const layerCount = sortedLayers.length; + const layerThickness = availableRadius / layerCount; + const colors = this._getColorPalette(); + const layerPositions = {}; + + sortedLayers.forEach((layer, index) => { + const depth = layerCount - 1 - index; + const innerRadius = availableRadius - ((depth + 1) * layerThickness); + const outerRadius = availableRadius - (depth * layerThickness); + const midRadius = (innerRadius + outerRadius) / 2; + + layerPositions[layer.name] = { index, innerRadius, outerRadius, midRadius }; + + this._drawLayer(svg, layer, colors[layer.name], innerRadius, outerRadius, midRadius); + }); + + this._drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); + this._drawLegend(svg, colors); + + this.container.appendChild(svg); + } + + _createSvg() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', this.width); + svg.setAttribute('height', this.height); + svg.setAttribute('style', 'display: block;'); + svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', this.width); + bg.setAttribute('height', this.height); + bg.setAttribute('fill', 'white'); + svg.appendChild(bg); + + this._addArrowMarker(svg); + return svg; + } + + _getColorPalette() { + return { + 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, + 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, + 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, + 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } + }; + } + + _addArrowMarker(svg) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '3'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3, 0 6'); + polygon.setAttribute('fill', '#666'); + marker.appendChild(polygon); + + defs.appendChild(marker); + svg.appendChild(defs); + } + + _drawLayer(svg, layer, color, innerRadius, outerRadius, midRadius) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; + const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; + + path.setAttribute('d', outerCircle + ' ' + innerCircle); + path.setAttribute('fill', color.fill); + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('stroke', color.stroke); + path.setAttribute('stroke-width', '1'); + svg.appendChild(path); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', this.centerX); + label.setAttribute('y', this.centerY - midRadius); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('dominant-baseline', 'middle'); + label.setAttribute('font-size', '18'); + label.setAttribute('font-weight', 'bold'); + label.setAttribute('fill', color.text); + label.textContent = layer.name; + svg.appendChild(label); + } + + _drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { + sortedLayers.forEach((sourceLayer) => { + const deps = dependencies[sourceLayer.name] || []; + const sourcePos = layerPositions[sourceLayer.name]; + + if (!deps || deps.length === 0) return; + + deps.forEach((targetLayerName) => { + const targetPos = layerPositions[targetLayerName]; + if (!targetPos) return; + + const depCount = deps.length; + const depIndex = deps.indexOf(targetLayerName); + const angle = (depIndex * (360 / depCount)) - 90 + 22.5; + const angleRad = (angle * Math.PI) / 180; + + const startX = this.centerX + Math.cos(angleRad) * sourcePos.midRadius; + const startY = this.centerY + Math.sin(angleRad) * sourcePos.midRadius; + const endX = this.centerX + Math.cos(angleRad) * targetPos.midRadius; + const endY = this.centerY + Math.sin(angleRad) * targetPos.midRadius; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M ${startX} ${startY} L ${endX} ${endY}`); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', '#333'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(path); + }); + }); + } + + _drawLegend(svg, colors) { + const legendX = this.width - 200; + const legendY = 20; + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('x', legendX - 10); + bg.setAttribute('y', legendY - 10); + bg.setAttribute('width', 190); + bg.setAttribute('height', 150); + bg.setAttribute('fill', '#ffffff'); + bg.setAttribute('stroke', '#ddd'); + bg.setAttribute('stroke-width', '1'); + bg.setAttribute('opacity', '0.95'); + svg.appendChild(bg); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', legendX); + title.setAttribute('y', legendY + 15); + title.setAttribute('font-size', '14'); + title.setAttribute('font-weight', 'bold'); + title.setAttribute('fill', '#333'); + title.textContent = 'Layers'; + svg.appendChild(title); + + ['Core', 'Supporting', 'Generic', 'Tests'].forEach((layerName, i) => { + const yPos = legendY + 35 + i * 25; + + const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + square.setAttribute('x', legendX); + square.setAttribute('y', yPos - 8); + square.setAttribute('width', '12'); + square.setAttribute('height', '12'); + square.setAttribute('fill', colors[layerName].fill); + svg.appendChild(square); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', legendX + 20); + label.setAttribute('y', yPos); + label.setAttribute('font-size', '12'); + label.setAttribute('fill', '#333'); + label.textContent = layerName; + svg.appendChild(label); + }); + + const arrowY = legendY + 130; + const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); + arrowLine.setAttribute('stroke', '#333'); + arrowLine.setAttribute('stroke-width', '2'); + arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(arrowLine); + + const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + arrowLabel.setAttribute('x', legendX + 20); + arrowLabel.setAttribute('y', arrowY + 3); + arrowLabel.setAttribute('font-size', '11'); + arrowLabel.setAttribute('fill', '#666'); + arrowLabel.textContent = 'Dependency'; + svg.appendChild(arrowLabel); + } +} + +// For backward compatibility +const ArchitectureVisualization = DeptracVisualization; + +// Initialize on document ready +document.addEventListener('DOMContentLoaded', function() { + const container = document.getElementById('architecture-visualization'); + if (container && window.architectureData) { + new DeptracVisualization('architecture-visualization', window.architectureData); + } +}); From 176a60afb628d4986b15f5a59af7821477ef6554 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:31:06 +0200 Subject: [PATCH 18/23] style: reduce color saturation for lighter, more muted appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core: #8B1A1A (dark red) → #C9A2A2 (muted mauve) - Supporting: #1B8A7E (dark teal) → #7DB5AA (soft sage) - Generic: #0066CC (bright blue) → #6BA3D4 (soft slate blue) - Tests: #FF6B35 (bright orange) → #E8A968 (muted peach) - Adjusted strokes to match desaturated palette - Colors now have reduced saturation for lighter, gentler appearance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/assets/js/deptrac-visualization/index.js | 8 ++++---- backend/public/js/deptrac-visualization/index.js | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/assets/js/deptrac-visualization/index.js b/backend/assets/js/deptrac-visualization/index.js index 0f8a8ad..6d1e35d 100644 --- a/backend/assets/js/deptrac-visualization/index.js +++ b/backend/assets/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, - 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, - 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, - 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } + 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } }; } diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js index 0f8a8ad..6d1e35d 100644 --- a/backend/public/js/deptrac-visualization/index.js +++ b/backend/public/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#8B1A1A', stroke: '#5C0A0A', text: '#ffffff' }, - 'Supporting': { fill: '#1B8A7E', stroke: '#0F5F57', text: '#ffffff' }, - 'Generic': { fill: '#0066CC', stroke: '#003D99', text: '#ffffff' }, - 'Tests': { fill: '#FF6B35', stroke: '#CC5529', text: '#ffffff' } + 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } }; } From 62afa6d27cf919b34360142a4654ed5b4e46be4b Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:38:39 +0200 Subject: [PATCH 19/23] improve styling --- backend/public/js/deptrac-visualization/index.js | 8 ++++---- backend/templates/profiler/architecture.html.twig | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js index 6d1e35d..4e2e196 100644 --- a/backend/public/js/deptrac-visualization/index.js +++ b/backend/public/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, - 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, - 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, - 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } + 'Core': { fill: '#ff00dd', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#ff00ddb3', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#ff00dd31', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#ff00dd6e', stroke: '#D08842', text: '#ffffff' } }; } diff --git a/backend/templates/profiler/architecture.html.twig b/backend/templates/profiler/architecture.html.twig index 37d565e..499ca17 100644 --- a/backend/templates/profiler/architecture.html.twig +++ b/backend/templates/profiler/architecture.html.twig @@ -26,7 +26,7 @@ {% endblock %} {% block panel %} -

Architecture Visualization

+

Architecture

{% if collector.details.layers is empty %}
@@ -38,12 +38,11 @@
-

Layer Information

+

Layer

- - + @@ -51,7 +50,6 @@ {% for layer, info in collector.details.layers %} -
LayerPositionName Can Depend On
{{ layer }}{{ info.position }} {% if collector.details.dependencies[layer] is defined %} {{ collector.details.dependencies[layer]|join(', ') }} From 433024769f9a53780afd0dd34f56f34d662d1d1f Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:53:10 +0200 Subject: [PATCH 20/23] fix: resolve PHPStan static analysis errors - Add type checks for mixed types from Yaml::parseFile() - Check is_array() before accessing deptrac key - Check is_array() before accessing name key - Check is_string() for layer key in ruleset loop - Properly type-hint dependencies array construction - Use var annotation in getDetails() to satisfy PHPStan's strict types - All PHPStan level 8 errors resolved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DataCollector/ArchitectureCollector.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php index 8ffd728..aa8ed07 100644 --- a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php +++ b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php @@ -33,8 +33,10 @@ public function getName(): string */ public function getDetails(): array { - assert(is_array($this->data)); - return $this->data; + /** @var array{layers: array, dependencies: array>} $data */ + $data = $this->data; + + return $data; } /** @@ -42,7 +44,7 @@ public function getDetails(): array */ private function parseDeptracConfig(): array { - $deptracPath = $this->projectDir . '/deptrac.yaml'; + $deptracPath = $this->projectDir.'/deptrac.yaml'; if (!file_exists($deptracPath)) { return ['layers' => [], 'dependencies' => []]; @@ -50,7 +52,7 @@ private function parseDeptracConfig(): array $config = Yaml::parseFile($deptracPath); - if (!isset($config['deptrac']) || !is_array($config['deptrac'])) { + if (!is_array($config) || !isset($config['deptrac']) || !is_array($config['deptrac'])) { return ['layers' => [], 'dependencies' => []]; } @@ -60,7 +62,7 @@ private function parseDeptracConfig(): array if (isset($deptracConfig['layers']) && is_array($deptracConfig['layers'])) { $position = 0; foreach ($deptracConfig['layers'] as $layer) { - if (isset($layer['name']) && is_string($layer['name'])) { + if (is_array($layer) && isset($layer['name']) && is_string($layer['name'])) { $layers[$layer['name']] = [ 'name' => $layer['name'], 'position' => $position++, @@ -72,10 +74,13 @@ private function parseDeptracConfig(): array $dependencies = []; if (isset($deptracConfig['ruleset']) && is_array($deptracConfig['ruleset'])) { foreach ($deptracConfig['ruleset'] as $layer => $allowedDependencies) { - if (!is_array($allowedDependencies)) { - $allowedDependencies = []; + if (is_string($layer)) { + $deps = []; + if (is_array($allowedDependencies)) { + $deps = array_values(array_filter($allowedDependencies, 'is_string')); + } + $dependencies[$layer] = $deps; } - $dependencies[$layer] = array_values(array_filter($allowedDependencies, 'is_string')); } } From 398f53ad7b35319e9355eb2fe936da67af185cfe Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 14:55:49 +0200 Subject: [PATCH 21/23] fix: sync corrupted color values in public directory - Restore correct color palette from assets version - Core: #C9A2A2 (was #ff00dd) - Supporting: #7DB5AA (was #ff00ddb3) - Generic: #6BA3D4 (was #ff00dd31) - Tests: #E8A968 (was #ff00dd6e) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/public/js/deptrac-visualization/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js index 4e2e196..6d1e35d 100644 --- a/backend/public/js/deptrac-visualization/index.js +++ b/backend/public/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#ff00dd', stroke: '#9E7777', text: '#ffffff' }, - 'Supporting': { fill: '#ff00ddb3', stroke: '#5F9A8C', text: '#ffffff' }, - 'Generic': { fill: '#ff00dd31', stroke: '#4E7FA3', text: '#ffffff' }, - 'Tests': { fill: '#ff00dd6e', stroke: '#D08842', text: '#ffffff' } + 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } }; } From f01fbcdddd452877e158fb29fab7cd016d1b2d96 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 15:02:12 +0200 Subject: [PATCH 22/23] improve readme + Makefile --- FEATURES.md | 11 +---------- Makefile | 5 +++++ backend/assets/js/deptrac-visualization/index.js | 8 ++++---- backend/public/js/deptrac-visualization/index.js | 8 ++++---- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index c377ba8..ca4bc96 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -42,6 +42,7 @@ - ✅ Authorization - ✅ check permissions (configured in YAML) via Symfony voter - ✅ arc42 documentation template +- ✅ add architecture visualization to Symfony Profiler ## Planned @@ -56,13 +57,3 @@ - ❌ Provision Grafana dashboard, e.g. via ConfigMap - ❌ Deploy DB via stateful set - ❌ Include Alpine image into Docker multistage build for production - -## Feature: architecture visualization - -- requirements: - - analyze deptrac.yaml and identify proper onion architecture - - use deptrac.yaml information to visualize a proper image on the HTML (render TWIG backend/templates/profiler/architecture.html.twig) - - draw a comprehensible and nice looking onion or hexagonal architecture visualization - - use JavaScript for the drawing (canvas or whatever fits best, suggest something!) - - come up with an MVP which implements a technical spike - does not have to be complete but uses the existing Symfony Profiler and the surroundings which are already there - \ No newline at end of file diff --git a/Makefile b/Makefile index 8303581..f8410f1 100644 --- a/Makefile +++ b/Makefile @@ -191,3 +191,8 @@ open: help: @echo "Available targets:" @awk '/^## / {desc=$$0; sub(/^## /, "", desc); getline; if(match($$0, /^([a-zA-Z0-9_-]+):/)) {printf " %-20s %s\n", substr($$0, RSTART, RLENGTH-1), desc}}' $(MAKEFILE_LIST) + +## Synchronize deptrac visualization JavaScript code +sync-deptrac-visualization: + cp backend/assets/js/deptrac-visualization/index.js \ + backend/public/js/deptrac-visualization/index.js \ No newline at end of file diff --git a/backend/assets/js/deptrac-visualization/index.js b/backend/assets/js/deptrac-visualization/index.js index 6d1e35d..a4f1ba1 100644 --- a/backend/assets/js/deptrac-visualization/index.js +++ b/backend/assets/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, - 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, - 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, - 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } + 'Core': { fill: '#ff00eecc', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#ff00ee99', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#ff00ee33', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#ff00ee77', stroke: '#D08842', text: '#ffffff' } }; } diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js index 6d1e35d..a4f1ba1 100644 --- a/backend/public/js/deptrac-visualization/index.js +++ b/backend/public/js/deptrac-visualization/index.js @@ -86,10 +86,10 @@ class DeptracVisualization { _getColorPalette() { return { - 'Core': { fill: '#C9A2A2', stroke: '#9E7777', text: '#ffffff' }, - 'Supporting': { fill: '#7DB5AA', stroke: '#5F9A8C', text: '#ffffff' }, - 'Generic': { fill: '#6BA3D4', stroke: '#4E7FA3', text: '#ffffff' }, - 'Tests': { fill: '#E8A968', stroke: '#D08842', text: '#ffffff' } + 'Core': { fill: '#ff00eecc', stroke: '#9E7777', text: '#ffffff' }, + 'Supporting': { fill: '#ff00ee99', stroke: '#5F9A8C', text: '#ffffff' }, + 'Generic': { fill: '#ff00ee33', stroke: '#4E7FA3', text: '#ffffff' }, + 'Tests': { fill: '#ff00ee77', stroke: '#D08842', text: '#ffffff' } }; } From ad9648f9901818453cf320129bbb27035eaf22c5 Mon Sep 17 00:00:00 2001 From: Martin Komischke Date: Wed, 8 Apr 2026 15:12:46 +0200 Subject: [PATCH 23/23] add test --- .../DataCollector/ArchitectureCollector.php | 7 +- .../ArchitectureCollectorTest.php | 473 ++++++++++++++++++ 2 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php diff --git a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php index aa8ed07..06e6d46 100644 --- a/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php +++ b/backend/src/Instrumentation/DataCollector/ArchitectureCollector.php @@ -7,6 +7,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; class ArchitectureCollector extends DataCollector @@ -50,7 +51,11 @@ private function parseDeptracConfig(): array return ['layers' => [], 'dependencies' => []]; } - $config = Yaml::parseFile($deptracPath); + try { + $config = Yaml::parseFile($deptracPath); + } catch (ParseException) { + return ['layers' => [], 'dependencies' => []]; + } if (!is_array($config) || !isset($config['deptrac']) || !is_array($config['deptrac'])) { return ['layers' => [], 'dependencies' => []]; diff --git a/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php b/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php new file mode 100644 index 0000000..74bb389 --- /dev/null +++ b/backend/tests/Unit/Instrumentation/DataCollector/ArchitectureCollectorTest.php @@ -0,0 +1,473 @@ +tempDir = sys_get_temp_dir().'/architecture_collector_test_'.uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + private function removeDirectory(string $dir): void + { + if (is_dir($dir)) { + $files = scandir($dir); + foreach ($files as $file) { + if ('.' !== $file && '..' !== $file) { + $path = $dir.DIRECTORY_SEPARATOR.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + } + rmdir($dir); + } + } + + #[Test] + public function should_have_correct_name(): void + { + $collector = new ArchitectureCollector($this->tempDir); + + self::assertSame('app.architecture_collector', $collector->getName()); + } + + #[Test] + public function should_return_empty_details_when_deptrac_file_does_not_exist(): void + { + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_yaml_is_invalid(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'invalid: {yaml: [content'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_section_is_missing(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'other_key: value'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_return_empty_details_when_deptrac_section_is_not_array(): void + { + file_put_contents($this->tempDir.'/deptrac.yaml', 'deptrac: "not an array"'); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['layers' => [], 'dependencies' => []], $details); + } + + #[Test] + public function should_parse_layers_from_deptrac_config(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('layers', $details); + self::assertCount(3, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + self::assertArrayHasKey('Supporting', $details['layers']); + + self::assertSame(['name' => 'Core', 'position' => 0], $details['layers']['Core']); + self::assertSame(['name' => 'Generic', 'position' => 1], $details['layers']['Generic']); + self::assertSame(['name' => 'Supporting', 'position' => 2], $details['layers']['Supporting']); + } + + #[Test] + public function should_skip_invalid_layers(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - invalid_layer + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_parse_dependencies_from_ruleset(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting + ruleset: + Core: ~ + Generic: ~ + Supporting: + - Core + - Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('dependencies', $details); + self::assertSame([], $details['dependencies']['Core']); + self::assertSame([], $details['dependencies']['Generic']); + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + } + + #[Test] + public function should_handle_null_dependencies(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + ruleset: + Core: ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame([], $details['dependencies']['Core']); + } + + #[Test] + public function should_filter_non_string_dependencies(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + - name: Supporting + ruleset: + Supporting: + - Core + - 123 + - Generic + - true + - ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + } + + #[Test] + public function should_handle_missing_layers_section(): void + { + $yaml = <<<'YAML' +deptrac: + ruleset: + Core: ~ +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame([], $details['layers']); + self::assertSame(['Core' => []], $details['dependencies']); + } + + #[Test] + public function should_handle_missing_ruleset_section(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(['Core' => ['name' => 'Core', 'position' => 0]], $details['layers']); + self::assertSame([], $details['dependencies']); + } + + #[Test] + public function should_handle_non_array_layer_entries(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - "string layer" + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_layer_with_missing_name(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - collectors: [] + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_non_string_layer_names(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: 123 + - name: Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(2, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + } + + #[Test] + public function should_handle_complex_deptrac_config(): void + { + $yaml = <<<'YAML' +deptrac: + paths: + - ./src + - ./tests + layers: + - name: Core + collectors: + - type: classLike + value: .*App\\Game\\.* + - name: Generic + collectors: + - type: classLike + value: .*Symfony\\.* + - name: Supporting + collectors: + - type: classLike + value: .*App\\Controller\\.* + - name: Tests + collectors: + - type: classLike + value: .*App\\Tests\\.* + ruleset: + Core: ~ + Generic: ~ + Supporting: + - Core + - Generic + Tests: + - Core + - Generic + - Supporting +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertCount(4, $details['layers']); + self::assertArrayHasKey('Core', $details['layers']); + self::assertArrayHasKey('Generic', $details['layers']); + self::assertArrayHasKey('Supporting', $details['layers']); + self::assertArrayHasKey('Tests', $details['layers']); + + self::assertSame(['Core', 'Generic'], $details['dependencies']['Supporting']); + self::assertSame(['Core', 'Generic', 'Supporting'], $details['dependencies']['Tests']); + } + + #[Test] + public function collect_should_populate_data(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $request = Request::create('/'); + $response = new Response(); + + $collector->collect($request, $response); + + $details = $collector->getDetails(); + self::assertNotEmpty($details['layers']); + } + + #[Test] + public function collect_should_handle_exception_parameter(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $request = Request::create('/'); + $response = new Response(); + $exception = new \Exception('Test exception'); + + $collector->collect($request, $response, $exception); + + $details = $collector->getDetails(); + self::assertNotEmpty($details['layers']); + } + + #[Test] + public function should_reset_positions_correctly_when_layer_is_skipped(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Layer1 + - invalid_entry + - name: Layer2 + - another_invalid + - name: Layer3 +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertSame(0, $details['layers']['Layer1']['position']); + self::assertSame(1, $details['layers']['Layer2']['position']); + self::assertSame(2, $details['layers']['Layer3']['position']); + } + + #[Test] + public function should_handle_ruleset_with_non_string_keys(): void + { + $yaml = <<<'YAML' +deptrac: + layers: + - name: Core + - name: Generic + ruleset: + Core: ~ + 123: + - Generic +YAML; + file_put_contents($this->tempDir.'/deptrac.yaml', $yaml); + + $collector = new ArchitectureCollector($this->tempDir); + $collector->collect(Request::create('/'), new Response()); + + $details = $collector->getDetails(); + + self::assertArrayHasKey('Core', $details['dependencies']); + self::assertCount(1, $details['dependencies']); + } +}