diff --git a/jig-core/src/main/resources/templates/assets/package.js b/jig-core/src/main/resources/templates/assets/package.js index 6b9c5a994..85ec8cdf2 100644 --- a/jig-core/src/main/resources/templates/assets/package.js +++ b/jig-core/src/main/resources/templates/assets/package.js @@ -10,6 +10,7 @@ let packageFilterFqn = null; let relatedFilterMode = 'direct'; let relatedFilterFqn = null; let diagramDirection = 'TD'; +let transitiveReductionEnabled = true; function getOrCreateDiagramErrorBox(diagram) { let errorBox = document.getElementById('package-diagram-error'); @@ -449,6 +450,101 @@ function renderMutualDependencyList(mutualPairs, causeRelationEvidence) { container.appendChild(details); } +function detectStronglyConnectedComponents(graph) { + const indices = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const result = []; + const index = {value: 0}; + + function strongConnect(node) { + indices.set(node, index.value); + lowLink.set(node, index.value); + index.value++; + stack.push(node); + onStack.add(node); + + (graph.get(node) || []).forEach(neighbor => { + if (!indices.has(neighbor)) { + strongConnect(neighbor); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(neighbor))); + } else if (onStack.has(neighbor)) { + lowLink.set(node, Math.min(lowLink.get(node), indices.get(neighbor))); + } + }); + + if (lowLink.get(node) === indices.get(node)) { + const scc = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + scc.push(current); + } while (current !== node); + result.push(scc); + } + } + + for (const node of graph.keys()) { + if (!indices.has(node)) { + strongConnect(node); + } + } + return result; +} + +function transitiveReduction(relations) { + const graph = new Map(); + relations.forEach(relation => { + if (!graph.has(relation.from)) graph.set(relation.from, []); + graph.get(relation.from).push(relation.to); + }); + + const sccs = detectStronglyConnectedComponents(graph); + const cyclicNodes = new Set(sccs.filter(scc => scc.length > 1).flat()); + const cyclicEdges = new Set( + relations + .filter(edge => cyclicNodes.has(edge.from) && cyclicNodes.has(edge.to)) + .map(edge => `${edge.from}::${edge.to}`) + ); + + const acyclicGraph = new Map(); + relations.forEach(edge => { + if (cyclicEdges.has(`${edge.from}::${edge.to}`)) return; + if (!acyclicGraph.has(edge.from)) acyclicGraph.set(edge.from, []); + acyclicGraph.get(edge.from).push(edge.to); + }); + + function isReachableWithoutDirect(start, end) { + const visited = new Set(); + + function dfs(current, target, skipDirect) { + if (current === target) return true; + visited.add(current); + const neighbors = acyclicGraph.get(current) || []; + for (const neighbor of neighbors) { + if (skipDirect && neighbor === target) continue; + if (visited.has(neighbor)) continue; + if (dfs(neighbor, target, false)) return true; + } + return false; + } + + return dfs(start, end, true); + } + + const toRemove = new Set(); + relations.forEach(edge => { + if (cyclicEdges.has(`${edge.from}::${edge.to}`)) return; + if (isReachableWithoutDirect(edge.from, edge.to)) { + toRemove.add(`${edge.from}::${edge.to}`); + } + }); + + return relations.filter(edge => !toRemove.has(`${edge.from}::${edge.to}`)); +} + function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { const diagram = document.getElementById('package-relation-diagram'); if (!diagram) return; @@ -488,6 +584,10 @@ function renderPackageDiagram(packageFilterFqn, relatedFilterFqn) { }); let uniqueRelations = Array.from(uniqueRelationMap.values()); + if (transitiveReductionEnabled) { + uniqueRelations = transitiveReduction(uniqueRelations); + } + if (aggregatedRoot) { const relatedSet = collectRelatedSet(aggregatedRoot, uniqueRelations); if (relatedFilterMode === 'direct') { @@ -806,6 +906,30 @@ function setupDiagramDirectionControls() { }); } +function setupTransitiveReductionControl() { + const container = document.querySelector('input[name="diagram-direction"]')?.parentNode?.parentNode; + if (!container) return; + + const controlContainer = document.createElement('div'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = 'transitive-reduction-toggle'; + checkbox.checked = transitiveReductionEnabled; + checkbox.addEventListener('change', () => { + transitiveReductionEnabled = checkbox.checked; + renderDiagramAndTable(); + }); + + const label = document.createElement('label'); + label.htmlFor = checkbox.id; + label.textContent = '推移簡約'; + label.style.marginLeft = '4px'; + + controlContainer.appendChild(checkbox); + controlContainer.appendChild(label); + container.appendChild(controlContainer); +} + if (typeof document !== 'undefined') { document.addEventListener("DOMContentLoaded", function () { if (!document.body.classList.contains("package-list")) return; @@ -815,6 +939,7 @@ if (typeof document !== 'undefined') { setupAggregationDepthControl(); setupRelatedFilterControls(); setupDiagramDirectionControls(); + setupTransitiveReductionControl(); const applied = applyDefaultPackageFilterIfPresent(); if (!applied) { renderDiagramAndTable(); @@ -841,6 +966,9 @@ if (typeof module !== 'undefined' && module.exports) { setDiagramDirection(value) { diagramDirection = value; }, + setTransitiveReductionEnabled(value) { + transitiveReductionEnabled = value; + }, setDiagramElement(value) { diagramElement = value; }, @@ -884,5 +1012,8 @@ if (typeof module !== 'undefined' && module.exports) { applyDefaultPackageFilterIfPresent, setupRelatedFilterControls, setupDiagramDirectionControls, + setupTransitiveReductionControl, + detectStronglyConnectedComponents, + transitiveReduction, }; } diff --git a/jig-core/src/test/js/package.test.js b/jig-core/src/test/js/package.test.js index 3d708eb0c..18b479d4c 100644 --- a/jig-core/src/test/js/package.test.js +++ b/jig-core/src/test/js/package.test.js @@ -921,4 +921,97 @@ test.describe('package.js', () => { assert.equal(pkg.getDiagramDirection(), 'LR'); }); }); + + test.describe('推移簡約', () => { + test('detectStronglyConnectedComponents: 循環を検出する', () => { + const graph = new Map([ + ['a', ['b']], + ['b', ['c']], + ['c', ['a', 'd']], + ['d', ['e']], + ['e', ['f']], + ['f', ['d']], + ]); + const sccs = pkg.detectStronglyConnectedComponents(graph); + const sortedSccs = sccs.map(scc => scc.sort()).sort((a, b) => a[0].localeCompare(b[0])); + assert.deepEqual(sortedSccs, [['a', 'b', 'c'], ['d', 'e', 'f']]); + }); + + test('transitiveReduction: 単純な推移関係を簡約する', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'c'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'b>c']); + }); + + test('transitiveReduction: 循環参照は対象外とする', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'a'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a']); + }); + + test('transitiveReduction: 循環ではないが簡約対象でもない', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'c', to: 'd'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'c>d']); + }); + + test('transitiveReduction: 循環からの関係は簡約対象にしない', () => { + const relations = [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'a'}, // cycle + {from: 'b', to: 'c'}, + {from: 'a', to: 'c'}, + ]; + const result = pkg.transitiveReduction(relations); + assert.deepEqual(result.map(r => `${r.from}>${r.to}`).sort(), ['a>b', 'a>c', 'b>a', 'b>c']); + }); + + test('setupTransitiveReductionControl: UIをセットアップする', () => { + const doc = setupDocument(); + const container = doc.createElement('div'); + const pp = doc.createElement('div'); + const input = doc.createElement('input'); + input.name = 'diagram-direction'; + pp.appendChild(input); + container.appendChild(pp); + doc.selectors.set('input[name="diagram-direction"]', input); + input.parentNode = pp; + pp.parentNode = container; + + // renderDiagramAndTableの副作用をチェックするための準備 + setupDiagramEnvironment(doc); + setPackageData(doc, {packages: [{fqn: 'a'}], relations: []}); + const depthSelect = createDepthSelect(doc); + const dummyOption = doc.createElement('option'); + dummyOption.id = 'dummy-option-for-test'; + depthSelect.appendChild(dummyOption); + + pkg.setupTransitiveReductionControl(); + + const checkbox = doc.getElementById('transitive-reduction-toggle'); + assert.ok(checkbox, 'checkbox should be created'); + assert.equal(checkbox.checked, true); + + // 事前確認:ダミー要素が存在する + assert.strictEqual(depthSelect.children.some(c => c.id === 'dummy-option-for-test'), true); + + // changeイベントを発火させる + checkbox.checked = false; + checkbox.eventListeners.get('change')(); + + // 事後確認:`renderDiagramAndTable`が呼ばれ、selectの中身が再構築され、dummy-optionが消えているはず + assert.strictEqual(depthSelect.children.some(c => c.id === 'dummy-option-for-test'), false); + }); + }); });