feat(atlas): list view toggle + Mermaid export button

List view groups nodes by type with colored left border, status dot,
description, connection count, URL link. Click card → switches back
to graph view and opens detail panel. Mermaid export copies flowchart
syntax to clipboard for pasting in Notion/GitHub/docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-31 10:10:53 +09:00
parent 0b9902b495
commit 2bad7274a6
1 changed files with 250 additions and 0 deletions

View File

@ -430,6 +430,121 @@
background: var(--border); background: var(--border);
margin: 0 4px; margin: 0 4px;
} }
.tb-btn.active {
background: var(--accent-dim);
border-color: rgba(34,211,238,0.35);
color: var(--accent);
}
/* ── List view ─────────────────────────────────────────── */
#list-view {
display: none;
position: fixed;
inset: 52px 0 0 0;
overflow-y: auto;
z-index: 5;
padding: 20px 24px;
}
#list-view.visible { display: block; }
.list-group {
margin-bottom: 24px;
}
.list-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.list-group-dot {
width: 8px; height: 8px;
border-radius: 50%;
}
.list-group-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
}
.list-group-count {
font-size: 11px;
color: var(--text3);
margin-left: auto;
}
.list-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.list-card {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.list-card:hover {
background: var(--surface2);
}
.list-card-top {
display: flex;
align-items: center;
gap: 8px;
}
.list-card-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
}
.list-card-status {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.list-card-status.active { background: var(--ok); box-shadow: 0 0 5px var(--ok); }
.list-card-status.inactive { background: var(--crit); }
.list-card-status.unknown { background: var(--text3); }
.list-card-desc {
font-size: 11px;
color: var(--text3);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.list-card-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.list-card-url {
font-size: 10px;
color: var(--accent);
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
flex: 1;
}
.list-card-url:hover { opacity: 1; }
.list-card-conns {
font-size: 10px;
color: var(--text3);
white-space: nowrap;
}
/* ── Modal ─────────────────────────────────────────── */ /* ── Modal ─────────────────────────────────────────── */
#modal-overlay { #modal-overlay {
@ -1246,6 +1361,9 @@
</div> </div>
</div> </div>
<!-- List view container -->
<div id="list-view" role="main" aria-label="ノード一覧"></div>
<!-- Toolbar --> <!-- Toolbar -->
<div id="toolbar" role="toolbar" aria-label="グラフ操作"> <div id="toolbar" role="toolbar" aria-label="グラフ操作">
<button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context"> <button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context">
@ -1254,6 +1372,16 @@
<button class="tb-btn" id="btn-share" aria-label="URLでシェア" title="URLでシェア"> <button class="tb-btn" id="btn-share" aria-label="URLでシェア" title="URLでシェア">
<i data-lucide="share-2" style="width:16px;height:16px;stroke-width:1.75"></i> <i data-lucide="share-2" style="width:16px;height:16px;stroke-width:1.75"></i>
</button> </button>
<button class="tb-btn" id="btn-mermaid" aria-label="Mermaid としてコピー" title="Mermaid export">
<i data-lucide="workflow" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<div class="tb-divider"></div>
<button class="tb-btn" id="btn-view-graph" aria-label="グラフビュー" title="グラフビュー">
<i data-lucide="network" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<button class="tb-btn" id="btn-view-list" aria-label="リストビュー" title="リストビュー">
<i data-lucide="layout-list" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
<div class="tb-divider"></div> <div class="tb-divider"></div>
<button class="tb-btn" id="btn-add-node" aria-label="ノードを追加" title="ノードを追加"> <button class="tb-btn" id="btn-add-node" aria-label="ノードを追加" title="ノードを追加">
<i data-lucide="plus" style="width:17px;height:17px;stroke-width:1.75"></i> <i data-lucide="plus" style="width:17px;height:17px;stroke-width:1.75"></i>
@ -2295,10 +2423,131 @@ async function runVercelScan() {
} }
} }
// ── List view ──────────────────────────────────────────────────
let currentView = 'graph'; // 'graph' | 'list'
function renderListView() {
const el = document.getElementById('list-view');
const groups = {};
for (const node of atlasData.nodes) {
if (!groups[node.type]) groups[node.type] = [];
groups[node.type].push(node);
}
const typeOrder = ['device','server','service','app','cloud','network'];
const html = typeOrder
.filter(t => groups[t]?.length)
.map(type => {
const nodes = groups[type];
const color = TYPE_COLORS[type] || '#9CA3AF';
const label = TYPE_LABELS[type] || type;
const cards = nodes.map(node => {
const connCount = atlasData.edges.filter(
e => e.from === node.id || e.to === node.id
).length;
const statusCls = node.status === 'active' ? 'active'
: node.status === 'inactive' ? 'inactive' : 'unknown';
const urlDisplay = node.url
? node.url.replace(/^https?:\/\//, '').replace(/\/$/, '')
: '';
return `
<div class="list-card" style="border-left-color:${color}"
onclick="switchToGraph('${node.id}')">
<div class="list-card-top">
<div class="list-card-status ${statusCls}"></div>
<span class="list-card-name">${node.label}</span>
</div>
${node.description
? `<div class="list-card-desc">${node.description}</div>` : ''}
<div class="list-card-footer">
${urlDisplay
? `<a class="list-card-url" href="${node.url}" target="_blank"
rel="noopener" onclick="event.stopPropagation()">${urlDisplay}</a>`
: '<span></span>'}
<span class="list-card-conns">${connCount} 接続</span>
</div>
</div>`;
}).join('');
return `
<div class="list-group">
<div class="list-group-header">
<div class="list-group-dot" style="background:${color}"></div>
<span class="list-group-label">${label}</span>
<span class="list-group-count">${nodes.length}</span>
</div>
<div class="list-cards">${cards}</div>
</div>`;
}).join('');
el.innerHTML = html;
}
function switchToGraph(nodeId) {
setView('graph');
if (nodeId) {
setTimeout(() => {
if (currentSimNodes.length) {
showDetail(nodeId, currentSimNodes, currentSimEdges);
}
}, 100);
}
}
function setView(view) {
currentView = view;
const graphWrap = document.getElementById('graph-wrap');
const listEl = document.getElementById('list-view');
const btnGraph = document.getElementById('btn-view-graph');
const btnList = document.getElementById('btn-view-list');
if (view === 'list') {
renderListView();
listEl.classList.add('visible');
graphWrap.style.display = 'none';
btnList.classList.add('active');
btnGraph.classList.remove('active');
} else {
listEl.classList.remove('visible');
graphWrap.style.display = '';
btnGraph.classList.add('active');
btnList.classList.remove('active');
}
}
// ── Mermaid export ─────────────────────────────────────────────
function generateMermaid() {
const lines = ['flowchart LR'];
// Node definitions
for (const node of atlasData.nodes) {
const safe = node.id.replace(/[^a-zA-Z0-9_]/g, '_');
const label = node.label.replace(/"/g, "'");
lines.push(` ${safe}["${label}"]`);
}
lines.push('');
// Edges
for (const edge of atlasData.edges) {
const from = edge.from.replace(/[^a-zA-Z0-9_]/g, '_');
const to = edge.to.replace(/[^a-zA-Z0-9_]/g, '_');
const lbl = edge.label ? ` |${edge.label}|` : '';
lines.push(` ${from} -->${lbl} ${to}`);
}
return lines.join('\n');
}
// ── Event bindings ───────────────────────────────────────────── // ── Event bindings ─────────────────────────────────────────────
function bindEvents() { function bindEvents() {
document.getElementById('btn-fit').addEventListener('click', fitGraph); document.getElementById('btn-fit').addEventListener('click', fitGraph);
document.getElementById('btn-add-node').addEventListener('click', openAddModal); document.getElementById('btn-add-node').addEventListener('click', openAddModal);
document.getElementById('btn-view-graph').addEventListener('click', () => setView('graph'));
document.getElementById('btn-view-list').addEventListener('click', () => setView('list'));
document.getElementById('btn-mermaid').addEventListener('click', () => {
const text = generateMermaid();
navigator.clipboard.writeText(text)
.then(() => showToast('Mermaid をクリップボードにコピーしました'))
.catch(() => prompt('Mermaid をコピーしてください', text));
});
document.getElementById('btn-share').addEventListener('click', () => { document.getElementById('btn-share').addEventListener('click', () => {
const url = generateShareURL(); const url = generateShareURL();
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
@ -2648,6 +2897,7 @@ function handleImportFile(file, isWizard) {
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
loadData().then(() => { loadData().then(() => {
bindEvents(); bindEvents();
document.getElementById('btn-view-graph').classList.add('active');
// 1. Check for shared atlas in URL hash (read-only mode) // 1. Check for shared atlas in URL hash (read-only mode)
if (checkShareURL()) { if (checkShareURL()) {
buildFilterBar(); buildFilterBar();