posimai-atlas/index.html

1790 lines
68 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;
}
/* ── Wizard ────────────────────────────────────────── */
#wizard-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(12, 18, 33, 0.97);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
#wizard-overlay[hidden] { display: none; }
#wizard-card {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
}
.wizard-logo {
width: 72px; height: 72px;
border-radius: 18px;
overflow: hidden;
margin-bottom: 18px;
box-shadow: 0 0 32px rgba(34,211,238,0.3);
}
.wizard-logo img { width: 100%; height: 100%; object-fit: cover; }
.wizard-title {
font-size: 22px;
font-weight: 600;
color: var(--text);
margin: 0 0 6px;
letter-spacing: -0.03em;
}
.wizard-subtitle {
font-size: 13px;
color: var(--text3);
margin: 0 0 32px;
}
.wizard-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.wizard-option {
display: flex;
align-items: center;
gap: 14px;
text-align: left;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
width: 100%;
color: var(--text);
}
.wizard-option:hover {
background: rgba(34,211,238,0.08);
border-color: rgba(34,211,238,0.3);
}
.wizard-option-icon {
width: 38px; height: 38px;
border-radius: 10px;
background: rgba(34,211,238,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--accent);
}
.wizard-option-body { flex: 1; }
.wizard-option-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.wizard-option-desc {
font-size: 11px;
color: var(--text3);
}
.wizard-hint {
font-size: 11px;
color: var(--text3);
margin: 0;
}
/* ── Share banner ──────────────────────────────────── */
#share-banner {
position: fixed;
top: 52px;
left: 0; right: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 16px;
background: rgba(34,211,238,0.08);
border-bottom: 1px solid rgba(34,211,238,0.18);
font-size: 12px;
color: var(--accent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#share-banner[hidden] { display: none; }
.has-banner #graph-wrap { top: 88px; }
.has-banner #filter-bar { top: 96px; }
/* ── Edge delete button ─────────────────────────────── */
.conn-delete-btn {
flex-shrink: 0;
width: 20px; height: 20px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text3);
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
padding: 0;
margin-left: auto;
}
.conn-item:hover .conn-delete-btn { opacity: 1; }
.conn-delete-btn:hover {
background: rgba(239,68,68,0.15);
color: #F87171;
}
/* ── 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="btnImportJson" style="width:100%;font-size:12px">
<i data-lucide="upload" style="width:13px;height:13px;stroke-width:1.75"></i>
atlas.json をインポート
</button>
<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>
<!-- Share banner (read-only mode) -->
<div id="share-banner" hidden>
<i data-lucide="eye" style="width:13px;height:13px;stroke-width:1.75"></i>
<span>シェアビュー(読み取り専用)</span>
<button id="share-banner-own-btn" style="margin-left:12px;font-size:11px;padding:3px 10px;border-radius:8px;background:var(--accent-dim);border:1px solid rgba(34,211,238,0.3);color:var(--accent);cursor:pointer">自分のデータを開く</button>
</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>
<div style="padding:8px 16px;font-size:11px;color:var(--text3);border-bottom:1px solid var(--border);line-height:1.6">
Claude や Gemini のチャットに貼り付けると、AI があなたの環境を即座に把握します
</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>
<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>
</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>
<!-- Wizard (first-run) -->
<div id="wizard-overlay" hidden>
<div id="wizard-card">
<div class="wizard-logo">
<img src="/logo.png" alt="Atlas" width="72" height="72">
</div>
<h1 class="wizard-title">Atlas へようこそ</h1>
<p class="wizard-subtitle">インフラ構成図・サービス依存マップ</p>
<div class="wizard-options">
<button class="wizard-option" id="w-sample">
<div class="wizard-option-icon">
<i data-lucide="layout-dashboard" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">サンプルを使う</div>
<div class="wizard-option-desc">一般的な開発環境のデータで試してみる</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
<button class="wizard-option" id="w-empty">
<div class="wizard-option-icon">
<i data-lucide="plus-circle" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">空から始める</div>
<div class="wizard-option-desc">自分の環境をゼロから追加する</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
<button class="wizard-option" id="w-import">
<div class="wizard-option-icon">
<i data-lucide="upload" style="width:18px;height:18px;stroke-width:1.5"></i>
</div>
<div class="wizard-option-body">
<div class="wizard-option-title">ファイルから読み込む</div>
<div class="wizard-option-desc">既存の atlas.json をアップロード</div>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;stroke-width:1.5;color:var(--text3)"></i>
</button>
</div>
<p class="wizard-hint">ノードは後からいつでも追加・編集できます</p>
</div>
</div>
<!-- Hidden file input (shared by wizard + settings import) -->
<input type="file" id="fileInput" accept=".json" style="display:none">
<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;
let isReadOnly = false;
let currentSimNodes = [];
let currentSimEdges = [];
async function loadData() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try { atlasData = JSON.parse(saved); return; } catch (e) {}
}
// No saved data — wizard will handle initialization
}
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 }));
currentSimNodes = simNodes;
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,
}));
currentSimEdges = simEdges;
// 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';
const delBtn = isReadOnly ? '' :
`<button class="conn-delete-btn" onclick="deleteEdge('${e.from}','${e.to}')" aria-label="接続を削除" title="削除">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>`;
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" style="margin-left:auto;margin-right:4px">${isOut ? 'out' : 'in'}</span>
${delBtn}
</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-share').addEventListener('click', () => {
const url = generateShareURL();
navigator.clipboard.writeText(url)
.then(() => showToast('シェア URL をコピーしました'))
.catch(() => {
// Fallback: prompt
prompt('シェア URL をコピーしてください', url);
});
});
document.getElementById('share-banner-own-btn').addEventListener('click', () => {
history.replaceState(null, '', location.pathname);
location.reload();
});
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('btnImportJson').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
document.getElementById('btnExportJson').addEventListener('click', () => {
exportJson();
showToast('エクスポートしました');
});
document.getElementById('btnResetData').addEventListener('click', () => {
if (!confirm('サンプルデータに戻しますか?現在のデータは失われます。')) return;
localStorage.removeItem(STORAGE_KEY);
location.reload();
});
// File input (wizard + settings import)
document.getElementById('fileInput').addEventListener('change', e => {
const isWizard = !document.getElementById('wizard-overlay').hasAttribute('hidden');
handleImportFile(e.target.files[0], isWizard);
});
// Wizard buttons
document.getElementById('w-sample').addEventListener('click', async () => {
const res = await fetch('/atlas.json');
const data = await res.json();
finishWizard(data);
});
document.getElementById('w-empty').addEventListener('click', () => {
finishWizard({
meta: { owner: '', description: 'My infrastructure', updated: new Date().toISOString().slice(0, 10), version: '1' },
nodes: [],
edges: [],
});
});
document.getElementById('w-import').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
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');
}
// ── URL Sharing ────────────────────────────────────────────────
function generateShareURL() {
const json = JSON.stringify(atlasData);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `${location.origin}${location.pathname}#atlas=${b64}`;
}
function checkShareURL() {
const hash = location.hash;
if (!hash.startsWith('#atlas=')) return false;
try {
const b64 = hash.slice(7);
const json = decodeURIComponent(escape(atob(b64)));
const data = JSON.parse(json);
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) return false;
atlasData = data;
isReadOnly = true;
return true;
} catch (e) {
return false;
}
}
function applyReadOnly() {
const banner = document.getElementById('share-banner');
banner.removeAttribute('hidden');
document.body.classList.add('has-banner');
if (window.lucide) lucide.createIcons({ nodes: [banner] });
document.getElementById('btn-add-node').style.display = 'none';
}
// ── Edge delete ────────────────────────────────────────────────
function deleteEdge(from, to) {
if (isReadOnly) return;
atlasData.edges = atlasData.edges.filter(e => !(e.from === from && e.to === to));
saveData();
const keepId = selectedNodeId;
initGraph();
if (keepId) {
selectedNodeId = keepId;
updateHighlight(currentSimNodes, currentSimEdges);
showDetail(keepId, currentSimNodes, currentSimEdges);
}
showToast('接続を削除しました');
}
// ── Wizard ─────────────────────────────────────────────────────
function showWizard() {
const overlay = document.getElementById('wizard-overlay');
overlay.removeAttribute('hidden');
if (window.lucide) lucide.createIcons({ nodes: [overlay] });
}
function finishWizard(data) {
atlasData = data;
saveData();
document.getElementById('wizard-overlay').setAttribute('hidden', '');
buildFilterBar();
initGraph();
setTimeout(fitGraph, 800);
if (atlasData.nodes.length === 0) {
setTimeout(openAddModal, 700);
}
}
// ── File import (shared: wizard + settings) ────────────────────
function handleImportFile(file, isWizard) {
if (!file) return;
const reader = new FileReader();
reader.onload = evt => {
try {
const data = JSON.parse(evt.target.result);
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) throw new Error();
if (isWizard) {
finishWizard(data);
} else {
atlasData = data;
saveData();
closeDetail();
buildFilterBar();
initGraph();
setTimeout(fitGraph, 500);
showToast('インポートしました');
}
} catch (e) {
showToast('atlas.json の形式が正しくありません');
}
document.getElementById('fileInput').value = '';
};
reader.readAsText(file);
}
// ── Init ───────────────────────────────────────────────────────
loadData().then(() => {
bindEvents();
// 1. Check for shared atlas in URL hash (read-only mode)
if (checkShareURL()) {
buildFilterBar();
initGraph();
applyReadOnly();
setTimeout(fitGraph, 800);
return;
}
// 2. Normal flow
if (!atlasData) {
showWizard();
} else {
buildFilterBar();
initGraph();
setTimeout(fitGraph, 800);
}
});
</script>
</body>
</html>