diff --git a/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java b/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java index ee82990cc..84ae12c47 100644 --- a/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java +++ b/jig-core/src/main/java/org/dddjava/jig/adapter/JigDocumentGenerator.java @@ -155,6 +155,9 @@ private void generateAssets() { copyAsset("style.css", assetsPath); copyAsset("jig.js", assetsPath); copyAsset("favicon.ico", assetsPath); + // ページごとのスクリプトを追加する + // 増えるごとにここに追加しなきゃいけないのはいかがなものか + copyAsset("package.js", assetsPath); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/jig-core/src/main/resources/templates/assets/jig.js b/jig-core/src/main/resources/templates/assets/jig.js index ce5e05e2f..3f72e3f45 100644 --- a/jig-core/src/main/resources/templates/assets/jig.js +++ b/jig-core/src/main/resources/templates/assets/jig.js @@ -180,16 +180,6 @@ function updateLetterNavigationVisibility() { }); } -function toggleDescription() { - // クラス名に一致する要素を全部取得 - const elements = document.getElementsByClassName("description"); - - // 各要素に対して「hidden」クラスをトグル(付けたり外したり)する - Array.from(elements).forEach(el => { - console.log(el); - el.classList.toggle("hidden"); - }); -} function setupSortableTables() { document.querySelectorAll("table.sortable").forEach(table => { @@ -198,6 +188,9 @@ function setupSortableTables() { if (header.hasAttribute("onclick")) { return; } + if (header.classList.contains("no-sort")) { + return; + } header.addEventListener("click", sortTable); header.style.cursor = "pointer"; @@ -285,58 +278,6 @@ function zoomFamilyTables(baseTable, baseRow) { }) } -function writePackageTable() { - const jsonText = document.getElementById('package-data').textContent; - /** @type {{packages?: Array<{fqn: string, name: string, classCount: number, description: string}>, relations?: Array<{from: string, to: string}>} | Array<{fqn: string, name: string, classCount: number, description: string}>} */ - const packageData = JSON.parse(jsonText); - const packages = Array.isArray(packageData) ? packageData : (packageData.packages ?? []); - const relations = Array.isArray(packageData) ? [] : (packageData.relations ?? []); - const incomingCounts = new Map(); - const outgoingCounts = new Map(); - relations.forEach(relation => { - outgoingCounts.set(relation.from, (outgoingCounts.get(relation.from) ?? 0) + 1); - incomingCounts.set(relation.to, (incomingCounts.get(relation.to) ?? 0) + 1); - }); - - const tbody = document.querySelector('#package-table tbody'); - //tbody.innerHTML = ''; - - packages.forEach(item => { - const tr = document.createElement('tr'); - - const fqnTd = document.createElement('td'); - fqnTd.textContent = item.fqn; - fqnTd.className = 'fqn'; - tr.appendChild(fqnTd); - - const nameTd = document.createElement('td'); - nameTd.textContent = item.name; - tr.appendChild(nameTd); - - const classCountTd = document.createElement('td'); - classCountTd.textContent = String(item.classCount); - classCountTd.className = 'number'; - tr.appendChild(classCountTd); - - const incomingCountTd = document.createElement('td'); - incomingCountTd.textContent = String(incomingCounts.get(item.fqn) ?? 0); - incomingCountTd.className = 'number'; - tr.appendChild(incomingCountTd); - - const outgoingCountTd = document.createElement('td'); - outgoingCountTd.textContent = String(outgoingCounts.get(item.fqn) ?? 0); - outgoingCountTd.className = 'number'; - tr.appendChild(outgoingCountTd); - - const descTd = document.createElement('td'); - descTd.textContent = item.description; - descTd.className = 'description hidden markdown'; - tr.appendChild(descTd); - - tbody.appendChild(tr); - }); -} - // ページ読み込み時のイベント // リスナーの登録はそのページだけでやる document.addEventListener("DOMContentLoaded", function () { @@ -350,10 +291,6 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById("show-letter-navigation").addEventListener("change", updateLetterNavigationVisibility); updateLetterNavigationVisibility(); - } else if (document.body.classList.contains("package-list")) { - document.getElementById("toggle-description-btn").addEventListener("click", toggleDescription); - setupSortableTables(); - writePackageTable(); } else if (document.body.classList.contains("insight")) { setupSortableTables(); setupZoomIcons(); diff --git a/jig-core/src/main/resources/templates/assets/package.js b/jig-core/src/main/resources/templates/assets/package.js new file mode 100644 index 000000000..b43b7b55c --- /dev/null +++ b/jig-core/src/main/resources/templates/assets/package.js @@ -0,0 +1,772 @@ +let packageSummaryCache = null; +let diagramNodeIdToFqn = new Map(); +let aggregationDepth = 0; +let activeFilterType = 'package'; +let diagramElement = null; +let pendingDiagramRender = null; +let lastDiagramSource = ''; +let lastDiagramEdgeCount = 0; +const DEFAULT_MAX_EDGES = 500; +let packageFilterFqn = null; +let relatedFilterMode = 'direct'; +let relatedFilterFqn = null; +let diagramDirection = 'TD'; + +function getOrCreateDiagramErrorBox(diagram) { + let errorBox = document.getElementById('package-diagram-error'); + if (errorBox) return errorBox; + errorBox = document.createElement('div'); + errorBox.id = 'package-diagram-error'; + errorBox.setAttribute('role', 'alert'); + errorBox.style.display = 'none'; + errorBox.style.whiteSpace = 'pre-wrap'; + errorBox.style.border = '1px solid #cc3333'; + errorBox.style.background = '#fff5f5'; + errorBox.style.color = '#222222'; + errorBox.style.padding = '8px 12px'; + errorBox.style.margin = '12px 0'; + + const message = document.createElement('pre'); + message.id = 'package-diagram-error-message'; + message.style.whiteSpace = 'pre-wrap'; + message.style.margin = '0 0 8px 0'; + + const action = document.createElement('button'); + action.id = 'package-diagram-error-action'; + action.type = 'button'; + action.style.display = 'none'; + action.textContent = '描画する'; + + errorBox.appendChild(message); + errorBox.appendChild(action); + diagram.parentNode.insertBefore(errorBox, diagram); + return errorBox; +} + +function showDiagramErrorMessage(message, withAction) { + const diagram = diagramElement; + if (!diagram) return; + const errorBox = getOrCreateDiagramErrorBox(diagram); + const messageNode = document.getElementById('package-diagram-error-message'); + const actionNode = document.getElementById('package-diagram-error-action'); + if (messageNode) messageNode.textContent = message; + if (actionNode) { + actionNode.style.display = withAction ? '' : 'none'; + if (withAction) { + actionNode.onclick = function () { + if (!pendingDiagramRender) return; + renderDiagramSvg(pendingDiagramRender.text, pendingDiagramRender.maxEdges); + pendingDiagramRender = null; + }; + } else { + actionNode.onclick = null; + } + } + errorBox.style.display = ''; + diagram.style.display = 'none'; +} + +function hideDiagramErrorMessage(diagram) { + const errorBox = getOrCreateDiagramErrorBox(diagram); + const messageNode = document.getElementById('package-diagram-error-message'); + const actionNode = document.getElementById('package-diagram-error-action'); + if (messageNode) messageNode.textContent = ''; + if (actionNode) { + actionNode.style.display = 'none'; + actionNode.onclick = null; + } + errorBox.style.display = 'none'; + diagram.style.display = ''; +} + +function renderDiagramSvg(text, maxEdges) { + const diagram = diagramElement; + if (!diagram || !window.mermaid) return; + hideDiagramErrorMessage(diagram); + diagram.removeAttribute('data-processed'); + diagram.textContent = text; + mermaid.initialize({startOnLoad: false, securityLevel: 'loose', maxEdges: maxEdges}); + mermaid.run({nodes: [diagram]}); +} + +function getPackageSummaryData() { + if (packageSummaryCache) return packageSummaryCache; + const jsonText = document.getElementById('package-data').textContent; + /** @type {{packages?: Array<{fqn: string, name: string, classCount: number, description: string}>, relations?: Array<{from: string, to: string}>} | Array<{fqn: string, name: string, classCount: number, description: string}>} */ + const packageData = JSON.parse(jsonText); + packageSummaryCache = { + packages: Array.isArray(packageData) ? packageData : (packageData.packages ?? []), + relations: Array.isArray(packageData) ? [] : (packageData.relations ?? []), + }; + return packageSummaryCache; +} + +function getPackageDepth(fqn) { + if (!fqn || fqn === '(default)') return 0; + return fqn.split('.').length; +} + +function getMaxPackageDepth() { + const {packages} = getPackageSummaryData(); + return packages.reduce((max, item) => Math.max(max, getPackageDepth(item.fqn)), 0); +} + +function getAggregatedFqn(fqn, depth) { + if (!depth || depth <= 0) return fqn; + if (!fqn || fqn === '(default)') return fqn; + const parts = fqn.split('.'); + if (parts.length <= depth) return fqn; + return parts.slice(0, depth).join('.'); +} + +function getCommonPrefixDepth(fqns) { + if (!fqns || fqns.length === 0) return 0; + const firstParts = fqns[0].split('.'); + let depth = firstParts.length; + for (let i = 1; i < fqns.length; i += 1) { + const parts = fqns[i].split('.'); + depth = Math.min(depth, parts.length); + for (let j = 0; j < depth; j += 1) { + if (parts[j] !== firstParts[j]) { + depth = j; + break; + } + } + } + return depth; +} + +function buildAggregationStats(packages, relations, maxDepth) { + const stats = new Map(); + for (let depth = 0; depth <= maxDepth; depth += 1) { + const aggregatedPackages = new Set(packages.map(item => getAggregatedFqn(item.fqn, depth))); + const relationKeys = new Set(); + relations.forEach(relation => { + const from = getAggregatedFqn(relation.from, depth); + const to = getAggregatedFqn(relation.to, depth); + if (from === to) return; + relationKeys.add(`${from}::${to}`); + }); + stats.set(depth, { + packageCount: aggregatedPackages.size, + relationCount: relationKeys.size, + }); + } + return stats; +} + +function buildAggregationStatsForPackageFilter(packages, relations, packageFilterFqn, maxDepth) { + const filterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; + const withinFilter = fqn => !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(filterPrefix); + const filteredPackages = packages.filter(item => withinFilter(item.fqn)); + const filteredRelations = relations.filter(relation => withinFilter(relation.from) && withinFilter(relation.to)); + return buildAggregationStats(filteredPackages, filteredRelations, maxDepth); +} + +function buildAggregationStatsForRelated(packages, relations, rootFqn, maxDepth) { + if (!rootFqn) { + return buildAggregationStats(packages, relations, maxDepth); + } + const aggregatedRoot = getAggregatedFqn(rootFqn, aggregationDepth); + const relatedSet = collectRelatedSet(aggregatedRoot, relations); + const relatedPackages = packages.filter(item => relatedSet.has(getAggregatedFqn(item.fqn, aggregationDepth))); + const relatedRelations = relations.filter(relation => { + const from = getAggregatedFqn(relation.from, aggregationDepth); + const to = getAggregatedFqn(relation.to, aggregationDepth); + return relatedSet.has(from) && relatedSet.has(to); + }); + return buildAggregationStats(relatedPackages, relatedRelations, maxDepth); +} + +function renderPackageTable() { + const {packages, relations} = getPackageSummaryData(); + const incomingCounts = new Map(); + const outgoingCounts = new Map(); + relations.forEach(relation => { + outgoingCounts.set(relation.from, (outgoingCounts.get(relation.from) ?? 0) + 1); + incomingCounts.set(relation.to, (incomingCounts.get(relation.to) ?? 0) + 1); + }); + + const tbody = document.querySelector('#package-table tbody'); + //tbody.innerHTML = ''; + + const input = document.getElementById('package-filter-input'); + const applyFilter = fqn => { + if (input) { + input.value = fqn; + } + packageFilterFqn = fqn; + relatedFilterFqn = null; + activeFilterType = 'package'; + renderDiagramAndTable(); + renderRelatedFilterTarget(); + }; + const applyRelatedFilterForRow = fqn => { + applyRelatedFilter(fqn); + }; + + packages.forEach(item => { + const tr = document.createElement('tr'); + + const actionTd = document.createElement('td'); + const actionButton = document.createElement('button'); + actionButton.type = 'button'; + actionButton.className = 'package-filter-icon'; + actionButton.setAttribute('aria-label', 'このパッケージで絞り込み'); + const actionText = document.createElement('span'); + actionText.className = 'screen-reader-only'; + actionText.textContent = '絞り込み'; + actionButton.appendChild(actionText); + actionButton.addEventListener('click', () => applyFilter(item.fqn)); + actionTd.appendChild(actionButton); + tr.appendChild(actionTd); + + const relatedTd = document.createElement('td'); + const relatedButton = document.createElement('button'); + relatedButton.type = 'button'; + relatedButton.className = 'related-icon'; + relatedButton.setAttribute('aria-label', '関連のみ表示'); + const relatedText = document.createElement('span'); + relatedText.className = 'screen-reader-only'; + relatedText.textContent = '関連のみ表示'; + relatedButton.appendChild(relatedText); + relatedButton.addEventListener('click', () => applyRelatedFilterForRow(item.fqn)); + relatedTd.appendChild(relatedButton); + tr.appendChild(relatedTd); + + const fqnTd = document.createElement('td'); + fqnTd.textContent = item.fqn; + fqnTd.className = 'fqn'; + tr.appendChild(fqnTd); + + const nameTd = document.createElement('td'); + nameTd.textContent = item.name; + tr.appendChild(nameTd); + + const classCountTd = document.createElement('td'); + classCountTd.textContent = String(item.classCount); + classCountTd.className = 'number'; + tr.appendChild(classCountTd); + + const incomingCountTd = document.createElement('td'); + incomingCountTd.textContent = String(incomingCounts.get(item.fqn) ?? 0); + incomingCountTd.className = 'number'; + tr.appendChild(incomingCountTd); + + const outgoingCountTd = document.createElement('td'); + outgoingCountTd.textContent = String(outgoingCounts.get(item.fqn) ?? 0); + outgoingCountTd.className = 'number'; + tr.appendChild(outgoingCountTd); + + tbody.appendChild(tr); + }); +} + +function applyPackageFilterToTable(packageFilterFqn) { + const rows = document.querySelectorAll('#package-table tbody tr'); + const filterPrefix = packageFilterFqn ? `${packageFilterFqn}.` : null; + rows.forEach(row => { + const fqnCell = row.querySelector('td.fqn'); + const fqn = fqnCell ? fqnCell.textContent : ''; + const visible = !packageFilterFqn || fqn === packageFilterFqn || fqn.startsWith(filterPrefix); + row.classList.toggle('hidden', !visible); + }); +} + +function applyRelatedFilterToTable(fqn) { + if (!fqn) { + applyPackageFilterToTable(null); + return; + } + const {relations} = getPackageSummaryData(); + const aggregatedRoot = getAggregatedFqn(fqn, aggregationDepth); + const relatedSet = collectRelatedSet(aggregatedRoot, relations); + const rows = document.querySelectorAll('#package-table tbody tr'); + rows.forEach(row => { + const fqnCell = row.querySelector('td.fqn'); + const rowFqn = fqnCell ? fqnCell.textContent : ''; + const aggregatedRow = getAggregatedFqn(rowFqn, aggregationDepth); + row.classList.toggle('hidden', !relatedSet.has(aggregatedRow)); + }); +} + +function renderRelatedFilterTarget() { + const target = document.getElementById('related-filter-target'); + if (!target) return; + target.textContent = relatedFilterFqn ? relatedFilterFqn : '未選択'; +} + +function collectRelatedSet(root, relations) { + if (!root) return new Set(); + if (relatedFilterMode === 'direct') { + const relatedSet = new Set([root]); + relations.forEach(relation => { + const from = getAggregatedFqn(relation.from, aggregationDepth); + const to = getAggregatedFqn(relation.to, aggregationDepth); + if (from === root) relatedSet.add(to); + if (to === root) relatedSet.add(from); + }); + return relatedSet; + } + + const adjacency = new Map(); + const addEdge = (from, to) => { + if (!adjacency.has(from)) adjacency.set(from, new Set()); + adjacency.get(from).add(to); + }; + relations.forEach(relation => { + const from = getAggregatedFqn(relation.from, aggregationDepth); + const to = getAggregatedFqn(relation.to, aggregationDepth); + addEdge(from, to); + if (relatedFilterMode === 'all') { + addEdge(to, from); + } + }); + + const relatedSet = new Set([root]); + const queue = [root]; + while (queue.length) { + const current = queue.shift(); + const nextSet = adjacency.get(current); + if (!nextSet) continue; + nextSet.forEach(next => { + if (relatedSet.has(next)) return; + relatedSet.add(next); + queue.push(next); + }); + } + return relatedSet; +} + +function renderDiagramAndTable() { + if (activeFilterType === 'related') { + renderPackageDiagram(relatedFilterFqn, activeFilterType); + applyRelatedFilterToTable(relatedFilterFqn); + updateAggregationDepthOptions(getMaxPackageDepth()); + return; + } + renderPackageDiagram(packageFilterFqn, activeFilterType); + applyPackageFilterToTable(packageFilterFqn); + updateAggregationDepthOptions(getMaxPackageDepth()); +} + +function renderMutualDependencyList(mutualPairs, filteredRelations) { + const container = document.getElementById('mutual-dependency-list'); + if (!container) return; + if (!mutualPairs || mutualPairs.size === 0) { + container.style.display = 'none'; + container.innerHTML = ''; + return; + } + const relationMap = new Map(); + filteredRelations.forEach(relation => { + const from = getAggregatedFqn(relation.from, aggregationDepth); + const to = getAggregatedFqn(relation.to, aggregationDepth); + if (from === to) return; + const key = from < to ? `${from}::${to}` : `${to}::${from}`; + if (!relationMap.has(key)) { + relationMap.set(key, new Set()); + } + relationMap.get(key).add(`${relation.from} -> ${relation.to}`); + }); + + container.style.display = ''; + const title = document.createElement('h2'); + title.textContent = '相互依存と原因'; + const list = document.createElement('ul'); + Array.from(mutualPairs).sort().forEach(key => { + const parts = key.split('::'); + const pairLabel = `${parts[0]} <-> ${parts[1]}`; + const item = document.createElement('li'); + const pair = document.createElement('div'); + pair.className = 'pair'; + pair.textContent = pairLabel; + item.appendChild(pair); + const causes = relationMap.get(key); + if (causes && causes.size > 0) { + const details = document.createElement('pre'); + details.textContent = Array.from(causes).sort().join('\n'); + item.appendChild(details); + } + list.appendChild(item); + }); + container.innerHTML = ''; + container.appendChild(title); + container.appendChild(list); +} + +function renderPackageDiagram(filterFqn, mode) { + const diagram = document.getElementById('package-relation-diagram'); + if (!diagram) return; + diagramElement = diagram; + + const {packages, relations} = getPackageSummaryData(); + const escapeMermaidText = text => text.replace(/"/g, '\\"'); + const nameByFqn = new Map(packages.map(item => [item.fqn, item.name || item.fqn])); + const lines = [`graph ${diagramDirection}`]; + const aggregatedRoot = filterFqn ? getAggregatedFqn(filterFqn, aggregationDepth) : null; + const packageFilterPrefix = filterFqn ? `${filterFqn}.` : null; + const withinPackageFilter = fqn => !filterFqn || fqn === filterFqn || fqn.startsWith(packageFilterPrefix); + const visiblePackages = mode === 'package' + ? packages.filter(item => withinPackageFilter(item.fqn)) + : packages; + const visibleSet = new Set(visiblePackages.map(item => getAggregatedFqn(item.fqn, aggregationDepth))); + const filteredRelations = relations.filter(relation => { + if (mode !== 'package') return true; + return withinPackageFilter(relation.from) && withinPackageFilter(relation.to); + }); + const visibleRelations = filteredRelations + .map(relation => ({ + from: getAggregatedFqn(relation.from, aggregationDepth), + to: getAggregatedFqn(relation.to, aggregationDepth), + })) + .filter(relation => relation.from !== relation.to); + const uniqueRelationMap = new Map(); + visibleRelations.forEach(relation => { + uniqueRelationMap.set(`${relation.from}::${relation.to}`, relation); + }); + let uniqueRelations = Array.from(uniqueRelationMap.values()); + + if (aggregatedRoot && mode === 'related') { + const relatedSet = collectRelatedSet(aggregatedRoot, uniqueRelations); + if (relatedFilterMode === 'direct') { + uniqueRelations = uniqueRelations.filter(relation => + relation.from === aggregatedRoot || relation.to === aggregatedRoot + ); + } else { + uniqueRelations = uniqueRelations.filter(relation => + relatedSet.has(relation.from) && relatedSet.has(relation.to) + ); + } + visibleSet.clear(); + relatedSet.forEach(value => visibleSet.add(value)); + } else if (aggregatedRoot && mode === 'package') { + const filteredSet = new Set(); + visibleSet.forEach(value => { + if (value === aggregatedRoot || value.startsWith(`${aggregatedRoot}.`)) { + filteredSet.add(value); + } + }); + visibleSet.clear(); + filteredSet.forEach(value => visibleSet.add(value)); + } + uniqueRelations.forEach(relation => { + visibleSet.add(relation.from); + visibleSet.add(relation.to); + }); + + const nodeIdByFqn = new Map(); + diagramNodeIdToFqn = new Map(); + const nodeLabelById = new Map(); + let nodeIndex = 0; + const ensureNodeId = fqn => { + if (nodeIdByFqn.has(fqn)) return nodeIdByFqn.get(fqn); + const nodeId = `P${nodeIndex++}`; + nodeIdByFqn.set(fqn, nodeId); + diagramNodeIdToFqn.set(nodeId, fqn); + const label = nameByFqn.get(fqn) || fqn; + nodeLabelById.set(nodeId, label); + return nodeId; + }; + + Array.from(visibleSet).sort().forEach(ensureNodeId); + const relationKey = (from, to) => `${from}::${to}`; + const canonicalPairKey = (from, to) => (from < to ? `${from}::${to}` : `${to}::${from}`); + const relationSet = new Set(uniqueRelations.map(relation => relationKey(relation.from, relation.to))); + const mutualPairs = new Set(); + uniqueRelations.forEach(relation => { + if (relationSet.has(relationKey(relation.to, relation.from))) { + mutualPairs.add(canonicalPairKey(relation.from, relation.to)); + } + }); + + const linkStyles = []; + let linkIndex = 0; + const edgeLines = []; + uniqueRelations.forEach(relation => { + const fromId = ensureNodeId(relation.from); + const toId = ensureNodeId(relation.to); + const pairKey = canonicalPairKey(relation.from, relation.to); + if (mutualPairs.has(pairKey)) { + if (relation.from > relation.to) { + return; + } + edgeLines.push(`${fromId} <--> ${toId}`); + linkStyles.push(`linkStyle ${linkIndex} stroke:red,stroke-width:2px`); + linkIndex += 1; + return; + } + edgeLines.push(`${fromId} --> ${toId}`); + linkIndex += 1; + }); + + const visibleFqns = Array.from(visibleSet).sort(); + const parentFqns = new Set(); + visibleFqns.forEach(fqn => { + const parts = fqn.split('.'); + for (let i = 1; i < parts.length; i += 1) { + const prefix = parts.slice(0, i).join('.'); + if (visibleSet.has(prefix)) parentFqns.add(prefix); + } + }); + + const addNodeLines = nodeId => { + const label = nodeLabelById.get(nodeId); + lines.push(`${nodeId}["${escapeMermaidText(label)}"]`); + lines.push(`click ${nodeId} filterPackageDiagram`); + const fqn = diagramNodeIdToFqn.get(nodeId); + if (fqn && parentFqns.has(fqn)) { + lines.push(`class ${nodeId} parentPackage`); + } + }; + + const prefixDepth = getCommonPrefixDepth(visibleFqns); + const baseDepth = Math.max(prefixDepth - 1, 0); + let groupIndex = 0; + const createGroupNode = key => ({key, children: new Map(), nodes: []}); + const rootGroup = createGroupNode(''); + visibleFqns.forEach(fqn => { + const parts = fqn.split('.'); + const maxDepth = parts.length; + let current = rootGroup; + for (let depth = baseDepth + 1; depth <= maxDepth; depth += 1) { + const key = parts.slice(0, depth).join('.'); + if (!current.children.has(key)) { + current.children.set(key, createGroupNode(key)); + } + current = current.children.get(key); + } + current.nodes.push(nodeIdByFqn.get(fqn)); + }); + const renderGroup = (group, isRoot) => { + group.nodes.forEach(addNodeLines); + const childKeys = Array.from(group.children.keys()).sort(); + if (isRoot && group.nodes.length === 0 && childKeys.length === 1) { + renderGroup(group.children.get(childKeys[0]), false); + return; + } + childKeys.forEach(key => { + const child = group.children.get(key); + const childNodeCount = child.nodes.length + child.children.size; + if (childNodeCount <= 1) { + renderGroup(child, false); + return; + } + const groupId = `G${groupIndex++}`; + lines.push(`subgraph ${groupId}["${escapeMermaidText(child.key)}"]`); + renderGroup(child, false); + lines.push('end'); + }); + }; + renderGroup(rootGroup, true); + if (parentFqns.size > 0) { + lines.push('classDef parentPackage fill:#ffffde,stroke:#aaaa00,stroke-width:2px'); + } + + edgeLines.forEach(line => lines.push(line)); + linkStyles.forEach(styleLine => lines.push(styleLine)); + renderMutualDependencyList(mutualPairs, filteredRelations); + + lastDiagramSource = lines.join('\n'); + lastDiagramEdgeCount = uniqueRelations.length; + if (lastDiagramEdgeCount > DEFAULT_MAX_EDGES) { + pendingDiagramRender = {text: lastDiagramSource, maxEdges: lastDiagramEdgeCount}; + const message = [ + '関連数が多すぎるため描画を省略しました。', + `エッジ数: ${lastDiagramEdgeCount}(上限: ${DEFAULT_MAX_EDGES})`, + '描画する場合はボタンを押してください。', + ].join('\n'); + showDiagramErrorMessage(message, true); + return; + } + diagram.removeAttribute('data-processed'); + diagram.textContent = lastDiagramSource; + if (window.mermaid) { + if (!mermaid.parseError) { + mermaid.parseError = function (err, hash) { + const message = err && err.message ? err.message : String(err); + const location = hash ? `\nLine: ${hash.line} Column: ${hash.loc}` : ''; + const isEdgeLimit = message.includes('Edge limit exceeded'); + if (isEdgeLimit) { + pendingDiagramRender = {text: lastDiagramSource, maxEdges: lastDiagramEdgeCount}; + } + showDiagramErrorMessage(`Mermaid parse error: ${message}${location}`, isEdgeLimit); + console.error('Mermaid parse error:', err); + if (hash) { + console.error('Mermaid error location:', hash.line, hash.loc); + } + }; + } + renderDiagramSvg(lastDiagramSource, DEFAULT_MAX_EDGES); + } +} + +function applyRelatedFilter(fqn) { + relatedFilterFqn = fqn; + activeFilterType = 'related'; + renderDiagramAndTable(); + renderRelatedFilterTarget(); +} + +window.filterPackageDiagram = function (nodeId) { + const fqn = diagramNodeIdToFqn.get(nodeId); + if (!fqn) return; + applyRelatedFilter(fqn); +}; + +function setupPackageFilterControls() { + const input = document.getElementById('package-filter-input'); + const applyButton = document.getElementById('apply-package-filter'); + const clearPackageButton = document.getElementById('clear-package-filter'); + const depthSelect = document.getElementById('package-depth-select'); + if (!input || !applyButton || !clearPackageButton) return; + + const applyFilter = () => { + const value = input.value.trim(); + packageFilterFqn = value || null; + relatedFilterFqn = null; + activeFilterType = 'package'; + renderDiagramAndTable(); + renderRelatedFilterTarget(); + }; + const clearPackageFilter = () => { + input.value = ''; + packageFilterFqn = null; + activeFilterType = 'package'; + renderDiagramAndTable(); + renderRelatedFilterTarget(); + }; + + applyButton.addEventListener('click', applyFilter); + clearPackageButton.addEventListener('click', clearPackageFilter); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + applyFilter(); + } + }); +} + +function setupAggregationDepthControl() { + const select = document.getElementById('package-depth-select'); + if (!select) return; + const {packages} = getPackageSummaryData(); + const maxDepth = packages.reduce((max, item) => Math.max(max, getPackageDepth(item.fqn)), 0); + updateAggregationDepthOptions(maxDepth); + select.value = String(aggregationDepth); + select.addEventListener('change', () => { + aggregationDepth = Number(select.value); + renderDiagramAndTable(); + renderRelatedFilterTarget(); + updateAggregationDepthOptions(maxDepth); + }); +} + +function updateAggregationDepthOptions(maxDepth) { + const select = document.getElementById('package-depth-select'); + if (!select) return; + const {packages, relations} = getPackageSummaryData(); + let aggregationStats; + if (activeFilterType === 'related') { + aggregationStats = buildAggregationStatsForRelated(packages, relations, relatedFilterFqn, maxDepth); + } else { + aggregationStats = buildAggregationStatsForPackageFilter(packages, relations, packageFilterFqn, maxDepth); + } + select.innerHTML = ''; + const noAggregationOption = document.createElement('option'); + noAggregationOption.value = '0'; + const noAggregationStats = aggregationStats.get(0); + noAggregationOption.textContent = `集約なし(P${noAggregationStats.packageCount} / R${noAggregationStats.relationCount})`; + select.appendChild(noAggregationOption); + for (let depth = 1; depth <= maxDepth; depth += 1) { + const option = document.createElement('option'); + option.value = String(depth); + const stats = aggregationStats.get(depth); + if (!stats || stats.relationCount === 0) { + continue; + } + option.textContent = `深さ${depth}(P${stats.packageCount} / R${stats.relationCount})`; + select.appendChild(option); + } + const value = Math.min(aggregationDepth, maxDepth); + select.value = String(value); +} + +function applyDefaultPackageFilterIfPresent() { + const input = document.getElementById('package-filter-input'); + if (!input || input.value.trim()) return false; + const {packages} = getPackageSummaryData(); + const domainRoots = packages + .map(item => item.fqn) + .map(fqn => { + const parts = fqn.split('.'); + const domainIndex = parts.indexOf('domain'); + if (domainIndex === -1) return null; + return parts.slice(0, domainIndex + 1).join('.'); + }) + .filter(Boolean); + if (domainRoots.length === 0) return false; + const candidate = domainRoots.reduce((best, current) => { + const bestDepth = best.split('.').length; + const currentDepth = current.split('.').length; + return currentDepth < bestDepth ? current : best; + }); + if (!candidate) return false; + input.value = candidate; + packageFilterFqn = candidate; + activeFilterType = 'package'; + renderDiagramAndTable(); + return true; +} + +function setupRelatedFilterControls() { + const select = document.getElementById('related-mode-select'); + const clearButton = document.getElementById('clear-related-filter'); + if (!select) return; + select.value = relatedFilterMode; + select.addEventListener('change', () => { + relatedFilterMode = select.value; + if (activeFilterType === 'related') { + renderDiagramAndTable(); + } + }); + if (clearButton) { + clearButton.addEventListener('click', () => { + relatedFilterFqn = null; + activeFilterType = 'package'; + packageFilterFqn = document.getElementById('package-filter-input')?.value.trim() || null; + renderDiagramAndTable(); + renderRelatedFilterTarget(); + }); + } +} + +function setupDiagramDirectionControls() { + const radios = document.querySelectorAll('input[name="diagram-direction"]'); + radios.forEach(radio => { + if (radio.value === diagramDirection) { + radio.checked = true; + } + radio.addEventListener('change', () => { + if (!radio.checked) return; + diagramDirection = radio.value; + renderDiagramAndTable(); + }); + }); +} + +document.addEventListener("DOMContentLoaded", function () { + if (!document.body.classList.contains("package-list")) return; + setupSortableTables(); + renderPackageTable(); + setupPackageFilterControls(); + setupAggregationDepthControl(); + setupRelatedFilterControls(); + setupDiagramDirectionControls(); + activeFilterType = 'package'; + const applied = applyDefaultPackageFilterIfPresent(); + if (!applied) { + renderDiagramAndTable(); + } + renderRelatedFilterTarget(); +}); diff --git a/jig-core/src/main/resources/templates/assets/style.css b/jig-core/src/main/resources/templates/assets/style.css index 726872d90..d42e7dc6f 100644 --- a/jig-core/src/main/resources/templates/assets/style.css +++ b/jig-core/src/main/resources/templates/assets/style.css @@ -38,6 +38,19 @@ aside.notice { .hidden { display: none; } +col.hidden { + visibility: collapse; +} +.screen-reader-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} .weak { font-size: 0.9em; color: gray; @@ -220,6 +233,163 @@ label { margin-left: 8px; } +/* パッケージ概要の表示設定 */ +.package-list details.controls { + background: linear-gradient(180deg, #ffffff 0%, #f9f9f9 100%); + border: 1px solid #ccc; + border-radius: 8px; + padding: 12px 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + margin: 1em auto; + width: 95%; + max-width: 980px; +} +.package-list details.controls summary { + list-style: none; + cursor: pointer; + padding: 6px 0; + font-weight: 600; +} +.package-list details.controls > :not(summary) { + display: block; + margin: 8px 0; +} +.package-list details.controls .control-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.package-list details.controls .control-label { + font-weight: 600; +} +.package-list details.controls .radio-label { + display: inline-flex; + align-items: center; + gap: 6px; +} +.package-list details.controls input[type="text"], +.package-list details.controls select { + padding: 6px 8px; + border: 1px solid #bbb; + border-radius: 6px; + background-color: #fff; +} +.package-list details.controls input[type="text"] { + min-width: 50ch; +} +.package-list details.controls button { + padding: 6px 10px; + border: 1px solid #888; + border-radius: 6px; + background-color: #f2f2f2; + cursor: pointer; +} +.package-list details.controls button:hover { + background-color: #e6e6e6; +} +.package-list details.controls .note { + font-size: 0.9em; + color: #555; +} +.package-list button.package-filter-icon { + width: 1.8em; + height: 1.8em; + border-radius: 999px; + border: 1px solid #999; + background: #fff no-repeat center; + cursor: pointer; + padding: 0; + background-image: url("data:image/svg+xml;utf8,"); +} +.package-list button.package-filter-icon:hover { + background-color: #f5f5f5; +} +.package-list button.icon-only { + cursor: default; + pointer-events: none; + background-color: transparent; +} +.package-list button.related-icon { + width: 1.8em; + height: 1.8em; + border-radius: 999px; + border: 1px solid #999; + background: #fff no-repeat center; + cursor: pointer; + padding: 0; + background-image: url("data:image/svg+xml;utf8,"); +} +.package-list button.related-icon:hover { + background-color: #f5f5f5; +} +.package-list .mutual-dependencies { + margin: 12px 0 16px 0; + padding: 8px 12px; + border: 1px solid #ccc; + background-color: #f9f9f9; +} +.package-list .mutual-dependencies h2 { + margin: 0 0 6px 0; + font-size: 1em; +} +.package-list .mutual-dependencies ul { + margin: 0 0 0 1.2em; + padding: 0; +} +.package-list .mutual-dependencies li { + margin: 6px 0; +} +.package-list .mutual-dependencies .pair { + font-weight: bold; +} +.package-list .mutual-dependencies pre { + margin: 4px 0 0 0; + white-space: pre-wrap; +} +.package-list table .col-action { + width: 2.5em; +} +.package-list table th.no-sort { + cursor: default; +} +.package-list table { + border-collapse: collapse; + width: 100%; + margin-top: 10px; + font-size: 16px; + table-layout: fixed; +} +.package-list td { + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} +.package-list td.fqn { + white-space: nowrap; + overflow: scroll; + max-width: 20em; + overflow-wrap: normal; + word-break: normal; +} +.package-list tr:nth-child(even) { + background-color: #f9f9f9; +} +@media (max-width: 720px) { + .package-list details.controls .control-row { + align-items: flex-start; + } + .package-list details.controls .control-label { + width: 100%; + } + .package-list details.controls input[type="text"], + .package-list details.controls select { + width: 100%; + } +} + /* 用語集 / 目次 */ .glossary .letter-navigation { text-align: center; @@ -284,32 +454,6 @@ label { margin: 0; } -/* テーブル全体のスタイル */ -.package-list table { - border-collapse: collapse; - width: 100%; - margin-top: 10px; - font-size: 16px; - table-layout: fixed; /* 列幅を固定して可読性を確保 */ -} -.package-list td { - text-align: left; - vertical-align: top; - overflow-wrap: anywhere; - word-break: break-word; - white-space: normal; -} -.package-list td.fqn { - white-space: nowrap; - overflow: scroll; - max-width: 20em; - overflow-wrap: normal; - word-break: normal; -} -.package-list tr:nth-child(even) { - background-color: #f9f9f9; -} - .insight table { border-collapse: collapse; width: 100%; diff --git a/jig-core/src/main/resources/templates/fragment-base.html b/jig-core/src/main/resources/templates/fragment-base.html index b8552f25c..0a48bd15e 100644 --- a/jig-core/src/main/resources/templates/fragment-base.html +++ b/jig-core/src/main/resources/templates/fragment-base.html @@ -25,7 +25,7 @@ - + diff --git a/jig-core/src/main/resources/templates/package.html b/jig-core/src/main/resources/templates/package.html index 7cca2f4a6..421943504 100644 --- a/jig-core/src/main/resources/templates/package.html +++ b/jig-core/src/main/resources/templates/package.html @@ -16,43 +16,102 @@

パッケージ概要

⚙️ 表示設定 - +
+ + + 凡例: P=パッケージ数 / R=関連数 +
+
+ + + + + +
+
+ + + + 未選択 + +
+
+ 図の向き: + + +
+

+    
+ + + + + + + + + + + + - - - - - - - - -
絞り込み関連のみ表示 完全修飾名 名称 クラス数 関連数(依存元) 関連数(依存先)
com.exampleえぐざんぷる1025
- + +