posimai-atlas/index.html

1437 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-atlas">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
var t = localStorage.getItem('posimai-atlas-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="インフラ構成図・サービス依存マップ">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0C1221" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#EFF6FF" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Atlas">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>Atlas</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Accent + Background Override ─────────────────── */
:root,
[data-theme="dark"] {
--accent: #22D3EE;
--accent-dim: rgba(34, 211, 238, 0.15);
--bg: #0C1221;
}
[data-theme="light"] {
--accent: #0891B2;
--accent-dim: rgba(8, 145, 178, 0.1);
--bg: #EFF6FF;
}
/* ── Node type colors ──────────────────────────────── */
:root {
--c-device: #22D3EE;
--c-server: #FB923C;
--c-network: #818CF8;
--c-cloud: #C084FC;
--c-service: #4ADE80;
--c-app: #38BDF8;
}
/* ── Layout ────────────────────────────────────────── */
html, body {
height: 100%;
overflow: hidden;
}
#graph-wrap {
position: fixed;
inset: 52px 0 0 0;
overflow: hidden;
}
#graph-svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Aurora ────────────────────────────────────────── */
.aurora-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.aurora-blob {
position: absolute;
border-radius: 50%;
will-change: transform;
opacity: 0;
}
[data-theme="dark"] .aurora-blob { opacity: 1; }
.aurora-blob-1 {
width: 700px; height: 460px;
background: radial-gradient(ellipse, rgba(34,211,238,0.18) 0%, transparent 68%);
top: -100px; right: -80px;
filter: blur(90px);
animation: aurora-1 18s ease-in-out infinite alternate;
}
.aurora-blob-2 {
width: 560px; height: 400px;
background: radial-gradient(ellipse, rgba(129,140,248,0.14) 0%, transparent 68%);
bottom: -60px; left: -80px;
filter: blur(100px);
animation: aurora-2 22s ease-in-out infinite alternate;
}
.aurora-blob-3 {
width: 440px; height: 340px;
background: radial-gradient(ellipse, rgba(192,132,252,0.11) 0%, transparent 68%);
top: 40%; right: 20%;
filter: blur(110px);
animation: aurora-3 14s ease-in-out infinite alternate;
}
@keyframes aurora-1 {
0% { transform: translate(0,0) scale(1); }
100% { transform: translate(120px,150px) scale(1.2); }
}
@keyframes aurora-2 {
0% { transform: translate(0,0) scale(1.05); }
100% { transform: translate(190px,-120px) scale(0.88); }
}
@keyframes aurora-3 {
0% { transform: translate(0,0) scale(0.95); }
100% { transform: translate(-140px,100px) scale(1.25); }
}
/* ── Filter bar ────────────────────────────────────── */
#filter-bar {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 10;
flex-wrap: wrap;
justify-content: center;
padding: 0 16px;
pointer-events: none;
}
.filter-chip {
pointer-events: all;
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px 4px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: rgba(12, 18, 33, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text2);
transition: all 0.15s;
white-space: nowrap;
letter-spacing: 0;
}
[data-theme="light"] .filter-chip {
background: rgba(239, 246, 255, 0.85);
}
.filter-chip:hover { color: var(--text); }
.filter-chip.active {
border-color: var(--chip-color, var(--accent));
color: var(--text);
background: rgba(12, 18, 33, 0.85);
}
[data-theme="light"] .filter-chip.active {
background: rgba(239, 246, 255, 0.95);
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--chip-color, var(--accent));
flex-shrink: 0;
opacity: 0.5;
transition: opacity 0.15s;
}
.filter-chip.active .chip-dot { opacity: 1; }
.chip-count {
font-size: 10px;
opacity: 0.6;
margin-left: 1px;
}
/* ── SVG graph elements ────────────────────────────── */
.graph-edge {
stroke: rgba(255,255,255,0.07);
stroke-width: 1;
fill: none;
pointer-events: none;
transition: stroke 0.2s, stroke-width 0.2s;
}
[data-theme="light"] .graph-edge {
stroke: rgba(0,0,0,0.08);
}
.graph-edge.highlight {
stroke-width: 1.5;
}
.graph-node-circle {
stroke-width: 1.5;
transition: r 0.2s, stroke-width 0.2s;
cursor: pointer;
}
.graph-node-circle:hover {
stroke-width: 2.5;
}
.graph-node-label {
font-size: 11px;
font-family: 'Inter', sans-serif;
fill: var(--text2, #9CA3AF);
text-anchor: middle;
pointer-events: none;
font-weight: 400;
letter-spacing: -0.01em;
}
/* ── Detail panel ──────────────────────────────────── */
#detail-panel {
position: fixed;
z-index: 20;
background: rgba(12, 18, 33, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
transition: transform 0.25s cubic-bezier(0.2, 0.9, 0.2, 1);
overflow-y: auto;
}
[data-theme="light"] #detail-panel {
background: rgba(239, 246, 255, 0.92);
}
@media (min-width: 601px) {
#detail-panel {
top: 52px; right: 0; bottom: 0;
width: 300px;
border-top: none; border-right: none; border-bottom: none;
border-radius: 0;
transform: translateX(100%);
}
#detail-panel.open { transform: translateX(0); }
}
@media (max-width: 600px) {
#detail-panel {
bottom: 0; left: 0; right: 0;
max-height: 65vh;
border-bottom: none; border-left: none; border-right: none;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
#detail-panel.open { transform: translateY(0); }
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: inherit;
backdrop-filter: blur(20px);
}
.detail-type-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.detail-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
flex: 1;
line-height: 1.3;
}
.detail-type-label {
font-size: 10px;
color: var(--text3);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.detail-close {
flex-shrink: 0;
margin-top: -2px;
}
.detail-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-desc {
font-size: 13px;
color: var(--text2);
line-height: 1.6;
}
.detail-url a {
font-size: 12px;
color: var(--accent);
text-decoration: none;
word-break: break-all;
display: flex;
align-items: center;
gap: 5px;
}
.detail-url a:hover { text-decoration: underline; }
.detail-section-label {
font-size: 10px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.detail-connections {
display: flex;
flex-direction: column;
gap: 4px;
}
.conn-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text2);
padding: 5px 8px;
border-radius: 8px;
background: var(--surface);
}
.conn-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.conn-dir {
font-size: 10px;
color: var(--text3);
margin-left: auto;
}
.detail-actions {
display: flex;
gap: 8px;
padding: 0 16px 16px;
}
.detail-actions .btn {
flex: 1;
font-size: 12px;
padding: 7px 12px;
}
.detail-actions .btn-danger {
background: rgba(239,68,68,0.1);
color: #F87171;
border-color: rgba(239,68,68,0.25);
}
.detail-actions .btn-danger:hover {
background: rgba(239,68,68,0.2);
}
/* ── Status badge ──────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 10px;
}
.status-badge.active {
background: rgba(74,222,128,0.12);
color: #4ADE80;
}
.status-badge.inactive {
background: rgba(156,163,175,0.12);
color: #9CA3AF;
}
.status-badge.unknown {
background: rgba(251,146,60,0.12);
color: #FB923C;
}
.status-dot {
width: 5px; height: 5px;
border-radius: 50%;
background: currentColor;
}
/* ── Toolbar ───────────────────────────────────────── */
#toolbar {
position: fixed;
bottom: max(20px, env(safe-area-inset-bottom));
right: 16px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 20;
}
.tb-btn {
width: 40px; height: 40px;
border-radius: 12px;
background: rgba(12, 18, 33, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
color: var(--text2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
[data-theme="light"] .tb-btn {
background: rgba(239, 246, 255, 0.88);
}
.tb-btn:hover {
color: var(--text);
border-color: var(--accent);
}
.tb-btn.accent-btn {
background: var(--accent-dim);
border-color: rgba(34,211,238,0.35);
color: var(--accent);
}
.tb-btn.accent-btn:hover {
background: rgba(34,211,238,0.22);
}
.tb-divider {
height: 1px;
background: var(--border);
margin: 0 4px;
}
/* ── Modal ─────────────────────────────────────────── */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#modal-overlay.open {
opacity: 1;
pointer-events: all;
}
#modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.modal-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-label {
font-size: 11px;
font-weight: 500;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 13px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.form-select option {
background: var(--surface);
}
.modal-footer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.modal-footer .btn {
flex: 1;
font-size: 13px;
}
/* ── Edge modal (same base as modal-overlay) ──────── */
#edge-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#edge-modal-overlay.open {
opacity: 1;
pointer-events: all;
}
/* ── AI Context output ─────────────────────────────── */
#ai-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#ai-modal-overlay.open {
opacity: 1;
pointer-events: all;
}
#ai-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#ai-context-text {
font-size: 11px;
font-family: 'Geist Mono', 'Fira Code', monospace;
color: var(--text2);
line-height: 1.6;
padding: 14px 16px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
flex: 1;
background: var(--surface2);
border-radius: 0;
}
/* ── Edge label tooltip ────────────────────────────── */
#edge-tooltip {
position: fixed;
font-size: 10px;
color: var(--text3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
pointer-events: none;
z-index: 15;
opacity: 0;
transition: opacity 0.15s;
}
</style>
</head>
<body>
<a href="#graph-wrap" class="skip-link" tabindex="0" style="position:absolute;top:-100%;left:8px;background:var(--accent);color:#0D0D0D;padding:8px 16px;border-radius:8px;font-weight:600;font-size:13px;z-index:10000;text-decoration:none">コンテンツへスキップ</a>
<!-- Settings panel -->
<aside class="settings-panel" id="settingsPanel" role="complementary">
<div class="settings-panel-header">
<span class="settings-panel-title">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<div class="settings-item-label">テーマ</div>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark"><i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク</button>
<button class="theme-btn" data-theme-val="light"><i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト</button>
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
</div>
</div>
</div>
<div>
<div class="settings-group-label">データ</div>
<div class="settings-item" style="flex-direction:column;align-items:flex-start;gap:8px">
<button class="btn btn-secondary" id="btnExportJson" style="width:100%;font-size:12px">
<i data-lucide="download" style="width:13px;height:13px;stroke-width:1.75"></i>
atlas.json をエクスポート
</button>
<button class="btn btn-secondary" id="btnResetData" style="width:100%;font-size:12px;color:var(--text3)">
<i data-lucide="rotate-ccw" style="width:13px;height:13px;stroke-width:1.75"></i>
デフォルトに戻す
</button>
</div>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<!-- Aurora -->
<div class="aurora-bg" aria-hidden="true">
<div class="aurora-blob aurora-blob-1"></div>
<div class="aurora-blob aurora-blob-2"></div>
<div class="aurora-blob aurora-blob-3"></div>
</div>
<!-- Header -->
<header class="header">
<div class="header-brand">
<div class="header-dot" aria-hidden="true"></div>
<span class="header-title">Atlas</span>
</div>
<div style="display:flex;align-items:center;gap:4px">
<span id="node-count" style="font-size:11px;color:var(--text3);padding:0 8px"></span>
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
</button>
</div>
</header>
<!-- Filter bar -->
<div id="filter-bar" role="group" aria-label="ノードタイプフィルター"></div>
<!-- Graph -->
<div id="graph-wrap">
<svg id="graph-svg" role="img" aria-label="インフラ構成図"></svg>
</div>
<!-- Detail panel -->
<aside id="detail-panel" role="complementary" aria-label="ノード詳細">
<div class="detail-header">
<div class="detail-type-dot" id="dp-dot"></div>
<div style="flex:1;min-width:0">
<div class="detail-title" id="dp-title"></div>
<div class="detail-type-label" id="dp-type"></div>
</div>
<button class="icon-btn detail-close" id="detail-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="detail-body">
<div class="detail-desc" id="dp-desc"></div>
<div class="detail-url" id="dp-url"></div>
<div id="dp-status"></div>
<div id="dp-connections">
<div class="detail-section-label">接続</div>
<div class="detail-connections" id="dp-conn-list"></div>
</div>
</div>
<div class="detail-actions">
<button class="btn btn-secondary detail-close" id="dp-edit-btn" style="font-size:12px">
<i data-lucide="pencil" style="width:13px;height:13px;stroke-width:1.75"></i>編集
</button>
<button class="btn detail-close" id="dp-add-edge-btn" style="font-size:12px;background:var(--accent-dim);border-color:rgba(34,211,238,0.3);color:var(--accent)">
<i data-lucide="git-branch" style="width:13px;height:13px;stroke-width:1.75"></i>接続を追加
</button>
<button class="btn btn-secondary btn-danger" id="dp-delete-btn" style="flex:0 0 auto;width:36px;padding:7px">
<i data-lucide="trash-2" style="width:13px;height:13px;stroke-width:1.75"></i>
</button>
</div>
</aside>
<!-- Node edit/add modal -->
<div id="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div id="modal">
<div class="modal-header">
<span class="modal-title" id="modal-title">ノードを追加</span>
<button class="icon-btn" id="modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="f-node-id">
<div class="form-group">
<label class="form-label" for="f-label">名前</label>
<input class="form-input" id="f-label" type="text" placeholder="例: My Server">
</div>
<div class="form-group">
<label class="form-label" for="f-type">タイプ</label>
<select class="form-select" id="f-type">
<option value="device">デバイス</option>
<option value="server">サーバー</option>
<option value="network">ネットワーク</option>
<option value="cloud">クラウド</option>
<option value="service">サービス</option>
<option value="app">アプリ</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="f-desc">説明</label>
<textarea class="form-textarea" id="f-desc" placeholder="役割・用途を入力"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="f-url">URL任意</label>
<input class="form-input" id="f-url" type="url" placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label" for="f-status">ステータス</label>
<select class="form-select" id="f-status">
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="unknown">unknown</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-cancel-btn">キャンセル</button>
<button class="btn btn-primary" id="modal-save-btn">保存</button>
</div>
</div>
</div>
<!-- Edge add modal -->
<div id="edge-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="edge-modal-title">
<div id="edge-modal" style="background:var(--surface);border:1px solid var(--border);border-radius:16px;width:100%;max-width:380px;max-height:90vh;overflow-y:auto">
<div class="modal-header">
<span class="modal-title" id="edge-modal-title">接続を追加</span>
<button class="icon-btn" id="edge-modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="ef-from">接続元</label>
<select class="form-select" id="ef-from"></select>
</div>
<div class="form-group">
<label class="form-label" for="ef-to">接続先</label>
<select class="form-select" id="ef-to"></select>
</div>
<div class="form-group">
<label class="form-label" for="ef-type">タイプ</label>
<select class="form-select" id="ef-type">
<option value="connects">connects</option>
<option value="push">push</option>
<option value="trigger">trigger</option>
<option value="hosts">hosts</option>
<option value="runs-on">runs-on</option>
<option value="calls">calls</option>
<option value="dns">dns</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="ef-label">ラベル(任意)</label>
<input class="form-input" id="ef-label" type="text" placeholder="例: git push">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="edge-modal-cancel-btn">キャンセル</button>
<button class="btn btn-primary" id="edge-modal-save-btn">追加</button>
</div>
</div>
</div>
<!-- AI Context modal -->
<div id="ai-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="ai-modal-title">
<div id="ai-modal">
<div class="modal-header" style="border-bottom:1px solid var(--border)">
<span class="modal-title" id="ai-modal-title">AI Context</span>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn" id="ai-copy-btn" style="font-size:12px;padding:5px 12px;background:var(--accent-dim);border-color:rgba(34,211,238,0.3);color:var(--accent)">
<i data-lucide="clipboard-copy" style="width:13px;height:13px;stroke-width:1.75"></i>コピー
</button>
<button class="icon-btn" id="ai-modal-close-btn" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
</div>
<pre id="ai-context-text"></pre>
</div>
</div>
<!-- Toolbar -->
<div id="toolbar" role="toolbar" aria-label="グラフ操作">
<button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context">
<i data-lucide="brain-circuit" style="width:17px;height:17px;stroke-width:1.5"></i>
</button>
<div class="tb-divider"></div>
<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>
</button>
<button class="tb-btn" id="btn-fit" aria-label="画面に合わせる" title="フィット">
<i data-lucide="maximize-2" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
</div>
<!-- Edge label tooltip -->
<div id="edge-tooltip"></div>
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script>
// ── Constants ──────────────────────────────────────────────────
const STORAGE_KEY = 'posimai-atlas-data';
const TYPE_COLORS = {
device: '#22D3EE',
server: '#FB923C',
network: '#818CF8',
cloud: '#C084FC',
service: '#4ADE80',
app: '#38BDF8',
};
const TYPE_LABELS = {
device: 'デバイス',
server: 'サーバー',
network: 'ネットワーク',
cloud: 'クラウド',
service: 'サービス',
app: 'アプリ',
};
const EDGE_COLORS = {
push: 'rgba(34,211,238,0.28)',
trigger: 'rgba(192,132,252,0.28)',
hosts: 'rgba(74,222,128,0.22)',
calls: 'rgba(34,211,238,0.22)',
dns: 'rgba(129,140,248,0.25)',
'runs-on': 'rgba(251,146,60,0.22)',
};
// ── Data management ────────────────────────────────────────────
let atlasData = null;
let selectedNodeId = null;
let hiddenTypes = new Set();
let simulation = null;
let svgZoom = null;
let svgG = null;
async function loadData() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try { atlasData = JSON.parse(saved); return; } catch (e) {}
}
const res = await fetch('/atlas.json');
atlasData = await res.json();
saveData();
}
function saveData() {
atlasData.meta.updated = new Date().toISOString().slice(0, 10);
localStorage.setItem(STORAGE_KEY, JSON.stringify(atlasData));
}
// ── Graph rendering ────────────────────────────────────────────
let linkSel, nodeSel, labelSel;
function initGraph() {
const svg = d3.select('#graph-svg');
svg.selectAll('*').remove();
const W = svg.node().clientWidth;
const H = svg.node().clientHeight;
// Defs: arrowhead marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -4 8 8')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L8,0L0,4')
.attr('fill', 'rgba(255,255,255,0.2)');
// Zoom container
svgG = svg.append('g');
svgZoom = d3.zoom()
.scaleExtent([0.2, 5])
.on('zoom', event => svgG.attr('transform', event.transform));
svg.call(svgZoom)
.on('click', (event) => {
if (event.target === svg.node()) closeDetail();
});
// Filtered nodes/edges
const nodes = visibleNodes();
const edges = visibleEdges(nodes);
// Clone objects for simulation (D3 mutates them)
const simNodes = nodes.map(n => ({ ...n }));
const nodeMap = new Map(simNodes.map(n => [n.id, n]));
const simEdges = edges.map(e => ({
...e,
source: nodeMap.get(e.from) || e.from,
target: nodeMap.get(e.to) || e.to,
}));
// Simulation
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink(simEdges).id(d => d.id).distance(130).strength(0.6))
.force('charge', d3.forceManyBody().strength(-320))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide(42));
// Edges
linkSel = svgG.append('g')
.selectAll('line')
.data(simEdges)
.enter().append('line')
.attr('class', 'graph-edge')
.attr('stroke', d => EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)')
.attr('marker-end', 'url(#arrow)');
// Nodes
const nodeGroup = svgG.append('g')
.selectAll('g')
.data(simNodes)
.enter().append('g')
.attr('class', 'graph-node')
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', dragStart)
.on('drag', dragging)
.on('end', dragEnd)
)
.on('click', (event, d) => {
event.stopPropagation();
selectNode(d.id, simNodes, simEdges);
});
nodeSel = nodeGroup.append('circle')
.attr('class', 'graph-node-circle')
.attr('r', 20)
.attr('fill', d => TYPE_COLORS[d.type] + '18')
.attr('stroke', d => TYPE_COLORS[d.type])
.style('filter', d => `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`);
labelSel = nodeGroup.append('text')
.attr('class', 'graph-node-label')
.attr('dy', 36)
.text(d => d.label);
// Tick
simulation.on('tick', () => {
linkSel
.attr('x1', d => offsetPoint(d.source, d.target, 22).x)
.attr('y1', d => offsetPoint(d.source, d.target, 22).y)
.attr('x2', d => offsetPoint(d.target, d.source, 26).x)
.attr('y2', d => offsetPoint(d.target, d.source, 26).y);
nodeGroup.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
});
// Re-apply selection highlight if needed
if (selectedNodeId) {
updateHighlight(simNodes, simEdges);
}
updateNodeCount();
}
function offsetPoint(from, to, r) {
const dx = (to.x ?? 0) - (from.x ?? 0);
const dy = (to.y ?? 0) - (from.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return { x: (from.x ?? 0) + (dx / dist) * r, y: (from.y ?? 0) + (dy / dist) * r };
}
function dragStart(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}
function dragging(event, d) {
d.fx = event.x; d.fy = event.y;
}
function dragEnd(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}
function visibleNodes() {
return atlasData.nodes.filter(n => !hiddenTypes.has(n.type));
}
function visibleEdges(nodes) {
const ids = new Set(nodes.map(n => n.id));
return atlasData.edges.filter(e => ids.has(e.from) && ids.has(e.to));
}
// ── Fit to screen ──────────────────────────────────────────────
function fitGraph() {
const svg = d3.select('#graph-svg');
const W = svg.node().clientWidth;
const H = svg.node().clientHeight;
const bounds = svgG.node().getBBox();
if (!bounds.width || !bounds.height) return;
const pad = 60;
const scale = Math.min(
(W - pad * 2) / bounds.width,
(H - pad * 2) / bounds.height,
1.2
);
const tx = W / 2 - scale * (bounds.x + bounds.width / 2);
const ty = H / 2 - scale * (bounds.y + bounds.height / 2);
d3.select('#graph-svg')
.transition().duration(400)
.call(svgZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
// ── Node selection & detail panel ─────────────────────────────
function selectNode(id, simNodes, simEdges) {
selectedNodeId = id;
updateHighlight(simNodes, simEdges);
showDetail(id, simNodes, simEdges);
}
function updateHighlight(simNodes, simEdges) {
if (!nodeSel || !linkSel) return;
const connectedIds = new Set();
const connectedEdges = new Set();
if (selectedNodeId) {
simEdges.forEach((e, i) => {
const sid = typeof e.source === 'object' ? e.source.id : e.source;
const tid = typeof e.target === 'object' ? e.target.id : e.target;
if (sid === selectedNodeId || tid === selectedNodeId) {
connectedIds.add(sid);
connectedIds.add(tid);
connectedEdges.add(i);
}
});
}
nodeSel
.attr('r', d => d.id === selectedNodeId ? 24 : 20)
.attr('stroke-width', d => d.id === selectedNodeId ? 2.5 : connectedIds.has(d.id) ? 2 : 1.5)
.attr('fill', d => {
if (d.id === selectedNodeId) return TYPE_COLORS[d.type] + '30';
if (connectedIds.has(d.id)) return TYPE_COLORS[d.type] + '20';
return selectedNodeId ? TYPE_COLORS[d.type] + '0A' : TYPE_COLORS[d.type] + '18';
})
.style('filter', d => {
if (d.id === selectedNodeId) return `drop-shadow(0 0 16px ${TYPE_COLORS[d.type]}99)`;
if (connectedIds.has(d.id)) return `drop-shadow(0 0 10px ${TYPE_COLORS[d.type]}66)`;
return selectedNodeId ? 'none' : `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`;
});
linkSel
.attr('stroke', (d, i) => {
if (connectedEdges.has(i)) return EDGE_COLORS[d.type] || 'rgba(255,255,255,0.35)';
return selectedNodeId ? 'rgba(255,255,255,0.03)' : (EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)');
})
.attr('stroke-width', (d, i) => connectedEdges.has(i) ? 1.8 : 1);
labelSel.style('opacity', d => {
if (!selectedNodeId) return 1;
return d.id === selectedNodeId || connectedIds.has(d.id) ? 1 : 0.3;
});
}
function showDetail(id, simNodes, simEdges) {
const node = atlasData.nodes.find(n => n.id === id);
if (!node) return;
document.getElementById('dp-dot').style.background = TYPE_COLORS[node.type] || '#9CA3AF';
document.getElementById('dp-title').textContent = node.label;
document.getElementById('dp-type').textContent = TYPE_LABELS[node.type] || node.type;
document.getElementById('dp-desc').textContent = node.description || '';
const urlEl = document.getElementById('dp-url');
if (node.url) {
urlEl.innerHTML = `<a href="${node.url}" target="_blank" rel="noopener"><i data-lucide="external-link" style="width:12px;height:12px;stroke-width:1.75"></i>${node.url}</a>`;
} else {
urlEl.innerHTML = '';
}
const statusEl = document.getElementById('dp-status');
const s = node.status || 'unknown';
statusEl.innerHTML = `<span class="status-badge ${s}"><span class="status-dot"></span>${s}</span>`;
// Connections
const connList = document.getElementById('dp-conn-list');
const related = atlasData.edges.filter(e => e.from === id || e.to === id);
if (related.length === 0) {
connList.innerHTML = '<div style="font-size:12px;color:var(--text3)">接続なし</div>';
} else {
connList.innerHTML = related.map(e => {
const isOut = e.from === id;
const otherId = isOut ? e.to : e.from;
const other = atlasData.nodes.find(n => n.id === otherId);
const color = TYPE_COLORS[other?.type] || '#9CA3AF';
return `<div class="conn-item">
<span class="conn-dot" style="background:${color}"></span>
<span>${other?.label || otherId}</span>
${e.label ? `<span style="font-size:10px;color:var(--text3)">${e.label}</span>` : ''}
<span class="conn-dir">${isOut ? 'out' : 'in'}</span>
</div>`;
}).join('');
}
// Lucide icons in detail panel
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('detail-panel')] });
document.getElementById('detail-panel').classList.add('open');
}
function closeDetail() {
selectedNodeId = null;
document.getElementById('detail-panel').classList.remove('open');
if (nodeSel) {
nodeSel.attr('r', 20).attr('stroke-width', 1.5)
.attr('fill', d => TYPE_COLORS[d.type] + '18')
.style('filter', d => `drop-shadow(0 0 8px ${TYPE_COLORS[d.type]}55)`);
}
if (linkSel) {
linkSel.attr('stroke', d => EDGE_COLORS[d.type] || 'rgba(255,255,255,0.07)')
.attr('stroke-width', 1);
}
if (labelSel) labelSel.style('opacity', 1);
}
// ── Filter bar ─────────────────────────────────────────────────
function buildFilterBar() {
const bar = document.getElementById('filter-bar');
const types = [...new Set(atlasData.nodes.map(n => n.type))];
bar.innerHTML = types.map(type => {
const count = atlasData.nodes.filter(n => n.type === type).length;
const active = !hiddenTypes.has(type) ? 'active' : '';
const color = TYPE_COLORS[type] || '#9CA3AF';
return `<button class="filter-chip ${active}" data-type="${type}" style="--chip-color:${color}">
<span class="chip-dot"></span>
${TYPE_LABELS[type] || type}
<span class="chip-count">${count}</span>
</button>`;
}).join('');
bar.querySelectorAll('.filter-chip').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.type;
if (hiddenTypes.has(t)) hiddenTypes.delete(t);
else hiddenTypes.add(t);
btn.classList.toggle('active');
closeDetail();
initGraph();
setTimeout(fitGraph, 500);
});
});
}
function updateNodeCount() {
const visible = visibleNodes().length;
const total = atlasData.nodes.length;
document.getElementById('node-count').textContent = visible === total
? `${total} nodes`
: `${visible}/${total} nodes`;
}
// ── AI Context generator ───────────────────────────────────────
function generateAIContext() {
const { meta, nodes, edges } = atlasData;
const typeSummary = {};
nodes.forEach(n => {
typeSummary[n.type] = (typeSummary[n.type] || 0) + 1;
});
const lines = [
`# インフラ構成 — ${meta.description}`,
`更新日: ${meta.updated}`,
'',
'## ノード一覧',
'| 名前 | タイプ | 説明 | URL | ステータス |',
'|------|--------|------|-----|----------|',
...nodes.map(n =>
`| ${n.label} | ${TYPE_LABELS[n.type] || n.type} | ${n.description || '—'} | ${n.url || '—'} | ${n.status || '—'} |`
),
'',
'## 接続関係',
...edges.map(e => {
const from = nodes.find(n => n.id === e.from)?.label || e.from;
const to = nodes.find(n => n.id === e.to)?.label || e.to;
return `- ${from}${to}${e.label ? ` (${e.label})` : ''}${e.type !== 'connects' ? ` [${e.type}]` : ''}`;
}),
'',
'## 統計',
`- 総ノード数: ${nodes.length}`,
`- 総接続数: ${edges.length}`,
...Object.entries(typeSummary).map(([t, c]) => `- ${TYPE_LABELS[t] || t}: ${c}`),
];
return lines.join('\n');
}
// ── Node CRUD ──────────────────────────────────────────────────
function openAddModal() {
document.getElementById('modal-title').textContent = 'ノードを追加';
document.getElementById('f-node-id').value = '';
document.getElementById('f-label').value = '';
document.getElementById('f-type').value = 'device';
document.getElementById('f-desc').value = '';
document.getElementById('f-url').value = '';
document.getElementById('f-status').value = 'active';
openModal('modal-overlay');
}
function openEditModal(id) {
const node = atlasData.nodes.find(n => n.id === id);
if (!node) return;
document.getElementById('modal-title').textContent = 'ノードを編集';
document.getElementById('f-node-id').value = node.id;
document.getElementById('f-label').value = node.label;
document.getElementById('f-type').value = node.type;
document.getElementById('f-desc').value = node.description || '';
document.getElementById('f-url').value = node.url || '';
document.getElementById('f-status').value = node.status || 'active';
openModal('modal-overlay');
}
function saveNode() {
const id = document.getElementById('f-node-id').value;
const label = document.getElementById('f-label').value.trim();
if (!label) return;
const node = {
id: id || label.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''),
label,
type: document.getElementById('f-type').value,
description: document.getElementById('f-desc').value.trim(),
url: document.getElementById('f-url').value.trim() || undefined,
status: document.getElementById('f-status').value,
};
if (id) {
const idx = atlasData.nodes.findIndex(n => n.id === id);
if (idx >= 0) atlasData.nodes[idx] = node;
} else {
// Ensure unique ID
let base = node.id, suffix = 2;
while (atlasData.nodes.find(n => n.id === node.id)) {
node.id = `${base}-${suffix++}`;
}
atlasData.nodes.push(node);
}
saveData();
closeModal('modal-overlay');
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast(id ? '更新しました' : '追加しました');
}
function deleteNode(id) {
if (!confirm(`${atlasData.nodes.find(n => n.id === id)?.label}」を削除しますか?`)) return;
atlasData.nodes = atlasData.nodes.filter(n => n.id !== id);
atlasData.edges = atlasData.edges.filter(e => e.from !== id && e.to !== id);
saveData();
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast('削除しました');
}
// ── Edge CRUD ──────────────────────────────────────────────────
function openAddEdgeModal(fromId) {
const sel = (id, val) => {
const el = document.getElementById(id);
el.innerHTML = atlasData.nodes
.map(n => `<option value="${n.id}" ${n.id === val ? 'selected' : ''}>${n.label}</option>`)
.join('');
};
sel('ef-from', fromId);
sel('ef-to', '');
document.getElementById('ef-type').value = 'connects';
document.getElementById('ef-label').value = '';
openModal('edge-modal-overlay');
}
function saveEdge() {
const from = document.getElementById('ef-from').value;
const to = document.getElementById('ef-to').value;
if (!from || !to || from === to) { showToast('接続元と接続先を選択してください'); return; }
atlasData.edges.push({
from, to,
type: document.getElementById('ef-type').value,
label: document.getElementById('ef-label').value.trim() || undefined,
});
saveData();
closeModal('edge-modal-overlay');
initGraph();
showToast('接続を追加しました');
}
// ── Modal helpers ──────────────────────────────────────────────
function openModal(id) {
document.getElementById(id).classList.add('open');
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById(id)] });
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
// ── Toast ──────────────────────────────────────────────────────
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
// ── Export ─────────────────────────────────────────────────────
function exportJson() {
const blob = new Blob([JSON.stringify(atlasData, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'atlas.json';
a.click();
URL.revokeObjectURL(a.href);
}
// ── Event bindings ─────────────────────────────────────────────
function bindEvents() {
document.getElementById('btn-fit').addEventListener('click', fitGraph);
document.getElementById('btn-add-node').addEventListener('click', openAddModal);
document.getElementById('btn-ai').addEventListener('click', () => {
document.getElementById('ai-context-text').textContent = generateAIContext();
openModal('ai-modal-overlay');
});
document.getElementById('detail-close-btn').addEventListener('click', closeDetail);
document.getElementById('dp-edit-btn').addEventListener('click', () => openEditModal(selectedNodeId));
document.getElementById('dp-add-edge-btn').addEventListener('click', () => openAddEdgeModal(selectedNodeId));
document.getElementById('dp-delete-btn').addEventListener('click', () => deleteNode(selectedNodeId));
document.getElementById('modal-close-btn').addEventListener('click', () => closeModal('modal-overlay'));
document.getElementById('modal-cancel-btn').addEventListener('click', () => closeModal('modal-overlay'));
document.getElementById('modal-save-btn').addEventListener('click', saveNode);
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('modal-overlay');
});
document.getElementById('edge-modal-close-btn').addEventListener('click', () => closeModal('edge-modal-overlay'));
document.getElementById('edge-modal-cancel-btn').addEventListener('click', () => closeModal('edge-modal-overlay'));
document.getElementById('edge-modal-save-btn').addEventListener('click', saveEdge);
document.getElementById('edge-modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('edge-modal-overlay');
});
document.getElementById('ai-modal-close-btn').addEventListener('click', () => closeModal('ai-modal-overlay'));
document.getElementById('ai-modal-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal('ai-modal-overlay');
});
document.getElementById('ai-copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById('ai-context-text').textContent)
.then(() => showToast('クリップボードにコピーしました'));
});
document.getElementById('btnExportJson').addEventListener('click', () => {
exportJson();
showToast('エクスポートしました');
});
document.getElementById('btnResetData').addEventListener('click', () => {
if (!confirm('データをデフォルトに戻しますか?')) return;
localStorage.removeItem(STORAGE_KEY);
location.reload();
});
window.addEventListener('resize', () => {
if (simulation) {
const svg = d3.select('#graph-svg');
simulation.force('center', d3.forceCenter(
svg.node().clientWidth / 2,
svg.node().clientHeight / 2
));
simulation.alpha(0.1).restart();
}
});
}
// ── SW registration ────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// ── Init ───────────────────────────────────────────────────────
loadData().then(() => {
buildFilterBar();
initGraph();
bindEvents();
setTimeout(fitGraph, 800);
});
</script>
</body>
</html>