2026-03-29 03:21:18 +00:00
|
|
|
|
<!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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:36:42 +00:00
|
|
|
|
/* ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 03:21:18 +00:00
|
|
|
|
/* ── 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">
|
2026-03-29 03:36:42 +00:00
|
|
|
|
<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">
|
2026-03-29 03:21:18 +00:00
|
|
|
|
<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>
|