Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions jig-core/src/main/resources/templates/assets/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
Expand All @@ -815,6 +939,7 @@ if (typeof document !== 'undefined') {
setupAggregationDepthControl();
setupRelatedFilterControls();
setupDiagramDirectionControls();
setupTransitiveReductionControl();
const applied = applyDefaultPackageFilterIfPresent();
if (!applied) {
renderDiagramAndTable();
Expand All @@ -841,6 +966,9 @@ if (typeof module !== 'undefined' && module.exports) {
setDiagramDirection(value) {
diagramDirection = value;
},
setTransitiveReductionEnabled(value) {
transitiveReductionEnabled = value;
},
setDiagramElement(value) {
diagramElement = value;
},
Expand Down Expand Up @@ -884,5 +1012,8 @@ if (typeof module !== 'undefined' && module.exports) {
applyDefaultPackageFilterIfPresent,
setupRelatedFilterControls,
setupDiagramDirectionControls,
setupTransitiveReductionControl,
detectStronglyConnectedComponents,
transitiveReduction,
};
}
93 changes: 93 additions & 0 deletions jig-core/src/test/js/package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});