やっほー!国内のAI狂いだよ!✨
今日はね、エンジニアの永遠の悩み「ドキュメント書くの面倒くさい問題」を、PythonとAIの力でねじ伏せる神ツールを作っちゃったから紹介するよ!🐍💖
そのコード、誰が読むの?「設計図がない」絶望との戦い
ねえ、みんな正直に言って?
「設計図? コードがドキュメントだよ(キリッ」 って言って、半年後の自分を殺したくなったこと、あるよね?😇
私もそう!C4モデルとかDraw.ioとかで綺麗に図を描こうとするんだけど、メンテされなくてすぐ「嘘の図」になっちゃうの。
だから思ったの。
「今あるPythonコードを勝手に読み込んで、勝手に図にしてくれればよくない!?!?🤯」
実は世界中のエンジニアが同じことを考えてて、Redditとかでも似たようなツールが議論されてるんだけど…
既存のツールって「画像を出力して終わり」なのが多いの!
違うんだよ!私は「出した図をグリグリ動かして編集したい」し、「それをAIに渡してレビューさせたい」の!!😤
というわけで、ないなら作るしかない!
Pythonの標準ライブラリだけで動く、最強の「対話型アーキテクチャ設計ツール」を爆誕させました!!🚀
今回作った「神ツール」の全貌
名付けて 『PyC4-Interactive』 (勝手に命名w)!
このツールの何がヤバいか、3行で説明するね!
- 完全自動解析: Pythonファイルを渡すだけで、クラス構造やライブラリ依存関係を解析して図にする!📸
- GUIでグリグリ編集: ブラウザ上でノードを動かしたり、マウスで線を引いたり、直感的に編集できる!🖱️
- 対AI特化: 図の内容をボタン一発で「Mermaid記法」や「JSON」に変換してコピーできるから、ChatGPTやGeminiに「これレビューして!」って即投げられる!🤖
技術的な「踏み抜いた地雷」たち💣
これを作るにあたって、数々の地雷を踏み抜いてきたから、その屍を越えていってね…💀
1. Mermaid.jsの限界とCytoscape.jsへの転生
最初は流行りのMermaid.jsで出力してたんだけど、「図を微調整できない」のがストレスすぎて発狂しそうになったの!😡
だから、バイオインフォマティクス(遺伝子解析)とかでも使われるガチのグラフ描画ライブラリ『Cytoscape.js』に乗り換えたよ!
これが大正解!マウスでのズーム、パン、ノード移動がヌルヌル動く!最高!✨
2. 「赤い丸が出ない!」CDNの罠
マウスで線を引くための拡張機能 cytoscape-edgehandles を入れたんだけど、CDN(ライブラリの読み込み先)によって動いたり動かなかったりして…😭
最終的に、もしプラグインが読み込めなくても自前のJavaScriptロジックで強制的に線を引く「手動モード」を実装して解決したよ!
これぞエンジニアの執念!!💪
ソースコード全公開!(コピペOK)
お待たせしました!これがその魔法のスクリプトだよ!🐍
pip install すら不要!Pythonさえ入っていれば動く、奇跡のシングルファイル構成!
これを pyc4_dsl.py って名前で保存してね!
pyc4_dsl.py (クリックして閉じる)
import argparse
import sys
import os
import importlib.util
import ast
import json
import webbrowser
from typing import List, Optional, Set, Dict, Any
# GUI用ライブラリ
import tkinter as tk
from tkinter import filedialog
# ==========================================
# 🏗️ Core DSL Classes (DSLエンジン)
# ==========================================
class C4Element:
def __init__(self, alias: str, label: str, description: str = "", technology: str = ""):
self.alias = alias
self.label = label
self.description = description
self.technology = technology
self.parent: Optional['C4Element'] = None
self.children: List['C4Element'] = []
def add_child(self, child: 'C4Element'):
child.parent = self
self.children.append(child)
class Person(C4Element): pass
class System(C4Element): pass
class Container(C4Element): pass
class Component(C4Element): pass
class Relationship:
def __init__(self, source: C4Element, target: C4Element, label: str, technology: str = ""):
self.source = source
self.target = target
self.label = label
self.technology = technology
# ==========================================
# 🎨 Context Manager (DSLビルダー)
# ==========================================
class C4ModelBuilder:
def __init__(self, title: str):
self.title = title
self.elements: List[C4Element] = []
self.relationships: List[Relationship] = []
self._stack: List[C4Element] = []
# ID管理用
self._alias_map: Dict[str, str] = {}
self._id_counter = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def _register(self, element: C4Element):
if self._stack:
parent = self._stack[-1]
parent.add_child(element)
else:
self.elements.append(element)
return element
def person(self, alias, label, desc="") -> Person:
return self._register(Person(alias, label, desc))
def system(self, alias, label, desc=""):
element = System(alias, label, desc)
self._register(element)
return self._Scope(self, element)
def container(self, alias, label, tech="", desc=""):
element = Container(alias, label, desc, tech)
self._register(element)
return self._Scope(self, element)
def component(self, alias, label, tech="", desc=""):
element = Component(alias, label, desc, tech)
self._register(element)
return self._Scope(self, element)
def relate(self, source, target, label, tech=""):
self.relationships.append(Relationship(source, target, label, tech))
def add_element(self, element: C4Element):
self.elements.append(element)
return element
class _Scope:
def __init__(self, builder, element):
self.builder = builder
self.element = element
def __enter__(self):
self.builder._stack.append(self.element)
return self.element
def __exit__(self, exc_type, exc_val, exc_tb):
self.builder._stack.pop()
def __getattr__(self, name):
return getattr(self.element, name)
# ==========================================
# 📤 Exporters (Cytoscape JSON生成ロジック)
# ==========================================
def _get_safe_id(self, alias: str) -> str:
"""エイリアスを一意で安全なIDに変換"""
if alias not in self._alias_map:
self._id_counter += 1
# Cytoscape用にIDはシンプルに
self._alias_map[alias] = f"n{self._id_counter}"
return self._alias_map[alias]
def to_cytoscape_json(self) -> str:
"""Cytoscape.js用のエレメント配列(JSON)を生成"""
cy_elements = []
# ノードの変換(再帰的)
def _process_element(e: C4Element):
node_id = self._get_safe_id(e.alias)
# ノードの種類判定
c4_type = "Element"
if isinstance(e, Person): c4_type = "Person"
elif isinstance(e, System): c4_type = "System"
elif isinstance(e, Container): c4_type = "Container"
elif isinstance(e, Component): c4_type = "Component"
data = {
"id": node_id,
"label": e.label,
"type": c4_type,
"description": e.description,
"technology": e.technology
}
# 親がいる場合(Compound Node)
if e.parent:
data["parent"] = self._get_safe_id(e.parent.alias)
cy_elements.append({"data": data, "group": "nodes"})
# 子要素も処理
for child in e.children:
_process_element(child)
# トップレベル要素から開始
for el in self.elements:
_process_element(el)
# エッジ(関係線)の変換
for rel in self.relationships:
source_id = self._get_safe_id(rel.source.alias)
target_id = self._get_safe_id(rel.target.alias)
edge_id = f"{source_id}_{target_id}"
cy_elements.append({
"data": {
"id": edge_id,
"source": source_id,
"target": target_id,
"label": rel.label,
"technology": rel.technology
},
"group": "edges"
})
return json.dumps(cy_elements, ensure_ascii=False, indent=2)
def to_html(self) -> str:
"""Cytoscape.jsを使った最強のインタラクティブViewer"""
json_data = self.to_cytoscape_json()
# HTMLテンプレート(JS込み)
html_template = """
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C4モデル 編集ツール</title>
<!-- Cytoscape Core -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<!-- Extensions -->
<script src="https://unpkg.com/layout-base/layout-base.js"></script>
<script src="https://unpkg.com/cose-base/cose-base.js"></script>
<script src="https://unpkg.com/cytoscape-fcose/cytoscape-fcose.js"></script>
<script src="https://unpkg.com/dagre/dist/dagre.min.js"></script>
<script src="https://unpkg.com/cytoscape-dagre/cytoscape-dagre.js"></script>
<!-- EdgeHandles -->
<script src="https://unpkg.com/cytoscape-edgehandles/dist/cytoscape-edgehandles.min.js"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: 'Noto Sans JP', 'Segoe UI', sans-serif; height: 100vh; display: flex; flex-direction: column; background: #1e1e1e; color: #e0e0e0; overflow: hidden; }
header { padding: 0 20px; background: #252526; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; height: 50px; flex-shrink: 0; }
h1 { margin: 0; font-size: 1.2rem; color: #4fc1ff; display: flex; align-items: center; gap: 10px; }
.main-container { display: flex; flex: 1; overflow: hidden; position: relative; }
/* Left Panel: Editor */
.editor-pane { width: 380px; display: flex; flex-direction: column; border-right: 1px solid #333; background: #1e1e1e; transition: all 0.3s ease; flex-shrink: 0; }
.editor-pane.hidden { width: 0; border: none; overflow: hidden; }
/* Tabs */
.editor-tabs { display: flex; background: #252526; border-bottom: 1px solid #333; }
.tab { flex: 1; padding: 10px; text-align: center; cursor: pointer; font-size: 0.85rem; color: #888; border-bottom: 2px solid transparent; transition: all 0.2s; }
.tab:hover { background: #2d2d2d; color: #ccc; }
.tab.active { color: #fff; border-bottom: 2px solid #0078d4; background: #1e1e1e; font-weight: bold; }
.editor-toolbar { padding: 8px 10px; background: #2d2d2d; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; }
textarea { flex: 1; background: #1e1e1e; color: #ce9178; border: none; padding: 15px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; resize: none; outline: none; line-height: 1.4; white-space: pre; }
/* Preview Panel */
.preview-pane { flex: 1; position: relative; background: #222; overflow: hidden; }
#cy { width: 100%; height: 100%; cursor: default; }
/* Buttons */
.btn { background: #3c3c3c; color: #eee; border: 1px solid #555; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8rem; transition: all 0.2s; font-family: 'Noto Sans JP', sans-serif; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
.btn:hover { background: #505050; border-color: #777; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #0078d4; border-color: #0078d4; color: white; }
.btn-primary:hover { background: #106ebe; border-color: #106ebe; }
.btn-sync { background: #d83b01; border-color: #d83b01; color: white; }
.btn-sync:hover { background: #ea4a1f; border-color: #ea4a1f; }
.btn-green { background: #107c10; border-color: #107c10; color: white; }
.btn-green:hover { background: #0b5a0b; }
.btn-danger { background: #a80000; border-color: #a80000; color: white; }
.btn-danger:hover { background: #c50f1f; border-color: #c50f1f; }
.btn-toggle { background: #607d8b; color: white; border-color: #455a64; }
.btn-draw-active { background-color: #b71c1c !important; border-color: #f44336 !important; color: white !important; animation: pulse 2s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(244, 67, 54, 0); } 100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); } }
/* Controls */
.controls { position: absolute; top: 15px; left: 15px; display: flex; flex-direction: column; gap: 8px; background: rgba(37, 37, 38, 0.95); padding: 10px; border-radius: 6px; z-index: 99; border: 1px solid #454545; box-shadow: 0 4px 10px rgba(0,0,0,0.3); transition: opacity 0.3s; }
.control-row { display: flex; gap: 8px; align-items: center; }
select { background: #333; color: #fff; border: 1px solid #555; padding: 5px; border-radius: 4px; font-family: 'Noto Sans JP', sans-serif; width: 100%; }
/* History & Help */
.history-controls { display: flex; gap: 2px; margin-right: 10px; }
.help-wrapper { position: relative; display: inline-block; }
.help-tooltip {
visibility: hidden; position: absolute; top: 100%; left: 0; background: #333; color: #fff; text-align: left; padding: 10px; border-radius: 6px; width: 280px; border: 1px solid #555; box-shadow: 0 5px 15px rgba(0,0,0,0.5); z-index: 1000; font-size: 0.85rem; line-height: 1.5; opacity: 0; transition: opacity 0.3s;
margin-top: 10px; pointer-events: none;
}
.help-wrapper:hover .help-tooltip { visibility: visible; opacity: 1; }
/* Context Menu */
#ctx-menu {
position: fixed;
background: #2d2d2d; border: 1px solid #454545;
box-shadow: 4px 4px 15px rgba(0,0,0,0.6);
border-radius: 4px; padding: 5px 0; z-index: 9999;
display: none; min-width: 180px;
}
.ctx-item { padding: 8px 15px; cursor: pointer; font-size: 0.9rem; color: #eee; display: flex; align-items: center; gap: 8px; }
.ctx-item:hover { background: #0078d4; color: #fff; }
.ctx-separator { height: 1px; background: #454545; margin: 4px 0; }
/* Logger & Notification */
#logger { position: fixed; bottom: 0; left: 0; right: 0; height: 120px; background: #111; border-top: 2px solid #333; color: #0f0; font-family: 'Consolas', monospace; font-size: 12px; padding: 10px; overflow-y: auto; z-index: 2000; display: none; opacity: 0.95; }
.log-entry { margin-bottom: 4px; border-bottom: 1px solid #222; padding-bottom: 2px; }
.log-info { color: #88ccff; }
.log-success { color: #88ff88; }
.log-error { color: #ff8888; }
#toggle-log { position: fixed; bottom: 10px; right: 10px; z-index: 2001; font-size: 0.8rem; opacity: 0.7; }
.notification { position: fixed; top: 70px; right: 20px; background: #107c10; color: white; padding: 10px 20px; border-radius: 4px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; box-shadow: 0 4px 6px rgba(0,0,0,0.3); font-weight: bold; }
.source-node { border-width: 4px !important; border-color: #ff3333 !important; box-shadow: 0 0 15px rgba(255, 50, 50, 0.5); }
</style>
</head>
<body>
<header>
<h1>💎 C4モデル 編集ツール</h1>
<div>
<div class="history-controls">
<button class="btn" id="btn-undo" onclick="undo()" disabled title="やり直し (Ctrl+Z)">⬅️ 戻る</button>
<button class="btn" id="btn-redo" onclick="redo()" disabled title="進む (Ctrl+Y)">進む ➡️</button>
</div>
<button class="btn btn-toggle" onclick="toggleEditor()">👁️ エディタ切替</button>
<button class="btn" id="btn-draw" onclick="toggleDrawMode()">🔗 接続モード</button>
<div class="help-wrapper">
<button class="btn">❓ ガイド</button>
<div class="help-tooltip">
<strong>基本操作:</strong><br>
🖱️ <strong>ドラッグ:</strong> ノードを移動<br>
🖱️ <strong>ダブルクリック:</strong> 名前を変更<br>
🖱️ <strong>右クリック:</strong> メニュー表示<br>
<hr style="border:0; border-top:1px solid #555; margin: 8px 0;">
<strong>線の引き方 (接続モード):</strong><br>
1. 「🔗 接続モード」を押す<br>
2. 始点ノードをクリック (赤くなる)<br>
3. 終点ノードをクリック → 線が引ける
</div>
</div>
<button class="btn" onclick="applyLayout('dagre')">階層</button>
<button class="btn" onclick="applyLayout('fcose')">整列</button>
<button class="btn btn-green" onclick="copyCurrentContent()">📋 コピー</button>
</div>
</header>
<div class="main-container">
<!-- Editor Panel -->
<div class="editor-pane">
<div class="editor-tabs">
<div class="tab active" onclick="switchTab('json')" id="tab-json">JSON (編集可)</div>
<div class="tab" onclick="switchTab('mermaid')" id="tab-mermaid">Mermaid (閲覧用)</div>
</div>
<div class="editor-header" id="json-tools">
<span>構成データ</span>
<div>
<button class="btn btn-sync" onclick="syncGraphToJson()" title="現在の配置をJSONに保存">⬅️ 配置保存</button>
<button class="btn btn-primary" onclick="applyJsonFromEditor()" title="JSONの内容を図に反映">反映 ➡️</button>
</div>
</div>
<div class="editor-header" id="mermaid-tools" style="display:none;">
<span>Mermaid形式 (AI共有用)</span>
<span style="font-size:0.8rem; color:#aaa;">※ここは編集できません</span>
</div>
<textarea id="code-input" spellcheck="false"></textarea>
</div>
<!-- Preview Panel -->
<div class="preview-pane">
<div id="cy"></div>
<!-- Controls -->
<div class="controls">
<div style="font-weight:bold; margin-bottom:5px; color:#fff;">ツールボックス</div>
<div class="control-row">
<select id="node-type">
<option value="Component">部品 (Component)</option>
<option value="Container">コンテナ (Container)</option>
<option value="System">システム (System)</option>
<option value="Person">人物 (Person)</option>
</select>
</div>
<div class="control-row">
<button class="btn btn-primary" style="width:100%" onclick="addNode()">➕ 追加</button>
</div>
<div class="control-row" style="margin-top:5px;">
<button class="btn btn-danger" style="width:100%" onclick="deleteSelected()">🗑️ 選択削除</button>
</div>
</div>
</div>
</div>
<!-- Context Menu -->
<div id="ctx-menu">
<div class="ctx-item" onclick="toggleDrawMode()">🔗 接続モード切替</div>
<div class="ctx-separator"></div>
<div class="ctx-item" onclick="triggerCtxAction('rename')">✏️ 名前を変更</div>
<div class="ctx-item" onclick="triggerCtxAction('delete')">🗑️ 削除</div>
</div>
<div id="notification">コピーしました!</div>
<div id="logger"></div>
<button id="toggle-log" class="btn" onclick="toggleLog()">📜 ログ</button>
<script>
const initialElements = """ + json_data + """;
const codeInput = document.getElementById('code-input');
const nodeTypeSelect = document.getElementById('node-type');
const ctxMenu = document.getElementById('ctx-menu');
const logger = document.getElementById('logger');
const editorPane = document.querySelector('.editor-pane');
let currentMode = 'json'; // 'json' or 'mermaid'
let selectedElement = null;
function log(msg, type='info') {
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `log-entry log-${type}`;
div.textContent = `[${time}] ${msg}`;
logger.appendChild(div);
logger.scrollTop = logger.scrollHeight;
}
function toggleLog() {
logger.style.display = logger.style.display === 'none' ? 'block' : 'none';
}
// Init Editor
codeInput.value = JSON.stringify(initialElements, null, 2);
// Init Cytoscape
let cy = cytoscape({
container: document.getElementById('cy'),
elements: initialElements,
wheelSensitivity: 0.2, minZoom: 0.1, maxZoom: 3.0,
style: [
{ selector: 'node', style: { 'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'color': '#fff', 'font-size': '12px', 'font-weight': 'bold', 'width': 'label', 'height': 'label', 'padding': '12px', 'shape': 'round-rectangle', 'text-wrap': 'wrap', 'text-max-width': '120px', 'font-family': '"Noto Sans JP", sans-serif' } },
{ selector: 'node[type="Person"]', style: { 'background-color': '#08427b', 'shape': 'ellipse', 'width': 80, 'height': 80 } },
{ selector: 'node[type="System"]', style: { 'background-color': '#1168bd' } },
{ selector: 'node[type="Container"]', style: { 'background-color': '#438dd5' } },
{ selector: 'node[type="Component"]', style: { 'background-color': '#85bbf0', 'color': '#222' } },
{ selector: 'node[type="External Lib"]', style: { 'background-color': '#444', 'shape': 'hexagon', 'color': '#ccc', 'font-size': '10px' } },
{ selector: ':parent', style: { 'text-valign': 'top', 'background-opacity': 0.05, 'border-width': 1, 'border-style': 'dashed', 'font-weight': 'normal' } },
{ selector: 'edge', style: { 'width': 2, 'line-color': '#666', 'target-arrow-color': '#666', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'label': 'data(label)', 'font-size': '10px', 'color': '#aaa', 'text-background-color': '#222', 'text-background-opacity': 0.8, 'arrow-scale': 0.8, 'font-family': '"Noto Sans JP", sans-serif' } },
{ selector: '.eh-handle', style: { 'background-color': '#ff3333', 'width': 12, 'height': 12, 'shape': 'ellipse', 'overlay-opacity': 0, 'border-width': 12, 'border-opacity': 0 } },
{ selector: '.eh-hover', style: { 'background-color': '#ff3333' } },
{ selector: '.eh-source', style: { 'border-width': 2, 'border-color': '#ff3333' } },
{ selector: '.eh-target', style: { 'border-width': 2, 'border-color': '#ff3333' } },
{ selector: '.eh-preview, .eh-ghost-edge', style: { 'background-color': '#ff3333', 'line-color': '#ff3333', 'target-arrow-color': '#ff3333', 'source-arrow-color': '#ff3333' } },
{ selector: ':selected', style: { 'border-width': 3, 'border-color': '#ffeb3b', 'line-color': '#ffeb3b', 'target-arrow-color': '#ffeb3b' } }
],
layout: { name: 'fcose', animate: false }
});
// --- Tab Switching Logic ---
window.switchTab = (mode) => {
currentMode = mode;
// UI update
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + mode).classList.add('active');
if (mode === 'json') {
document.getElementById('json-tools').style.display = 'flex';
document.getElementById('mermaid-tools').style.display = 'none';
codeInput.readOnly = false;
codeInput.style.color = '#ce9178';
// Sync current graph state to JSON
const json = cy.elements().jsons();
codeInput.value = JSON.stringify(json, null, 2);
} else {
document.getElementById('json-tools').style.display = 'none';
document.getElementById('mermaid-tools').style.display = 'flex';
codeInput.readOnly = true;
codeInput.style.color = '#9cdcfe'; // Blueish for contrast
// Convert graph to Mermaid
codeInput.value = generateMermaidFromGraph();
}
};
// --- Mermaid Generator ---
function generateMermaidFromGraph() {
let lines = ["C4Context", "title System Architecture"];
const nodes = cy.nodes();
const edges = cy.edges();
// Helper to sanitize text for Mermaid
const sanitize = (txt) => (txt || "").replace(/"/g, "'").replace(/\\n/g, "<br>");
// 1. Process Nodes (Simple flat list for now, hierarchy is complex in mermaid reverse)
// Ideally we should handle compounds, but let's list them flat or by parent group
// Find root nodes (no parent) and parents
nodes.forEach(node => {
const data = node.data();
if (data.parent) return; // Skip children for now, handle in groups?
// If it's a parent (boundary)
if (node.isParent()) {
lines.push(`Boundary(${data.id}, "${sanitize(data.label)}") {`);
// Find children
node.children().forEach(child => {
const cData = child.data();
const type = cData.type || "Component";
lines.push(` ${type}(${cData.id}, "${sanitize(cData.label)}", "${sanitize(cData.description)}", "${sanitize(cData.technology)}")`);
});
lines.push("}");
} else {
// Standalone node
const type = data.type || "Component";
// External Libs might be System
let mType = type;
if(type === 'External Lib') mType = 'System';
lines.push(`${mType}(${data.id}, "${sanitize(data.label)}", "${sanitize(data.description)}", "${sanitize(data.technology)}")`);
}
});
// 2. Process Edges
edges.forEach(edge => {
const data = edge.data();
lines.push(`Rel(${data.source}, ${data.target}, "${sanitize(data.label)}")`);
});
return lines.join("\\n");
}
// --- History System ---
let history = [];
let historyPtr = -1;
function saveHistory() {
let currentJson;
try { currentJson = JSON.stringify(cy.elements().jsons()); } catch(e) { return; }
if (historyPtr < history.length - 1) { history = history.slice(0, historyPtr + 1); }
if (history.length > 0 && history[historyPtr] === currentJson) { return; }
history.push(currentJson);
historyPtr++;
updateUndoRedoButtons();
// If in Mermaid mode, update preview
if (currentMode === 'mermaid') {
codeInput.value = generateMermaidFromGraph();
}
}
function undo() {
if (historyPtr > 0) {
historyPtr--;
loadFromHistory();
}
}
function redo() {
if (historyPtr < history.length - 1) {
historyPtr++;
loadFromHistory();
}
}
function loadFromHistory() {
const jsonStr = history[historyPtr];
if (currentMode === 'json') {
codeInput.value = JSON.stringify(JSON.parse(jsonStr), null, 2);
}
applyJsonToGraph(jsonStr, false);
updateUndoRedoButtons();
}
function updateUndoRedoButtons() {
document.getElementById('btn-undo').disabled = (historyPtr <= 0);
document.getElementById('btn-redo').disabled = (historyPtr >= history.length - 1);
}
saveHistory();
// --- Edge Handles Init ---
let retries = 0;
function initEdgeHandles() {
try {
if (cy.edgehandles) {
cy.edgehandles({
snap: true, handleNodes: 'node', handlePosition: () => 'middle top',
handleInDrawMode: false, edgeType: () => 'flat', loopAllowed: () => false,
complete: function( sourceNode, targetNode, addedEles ) {
log("線を追加しました", "success");
syncGraphToJson(); saveHistory();
}
});
} else {
if (retries < 10) { retries++; setTimeout(initEdgeHandles, 500); }
}
} catch(e) { console.error(e); }
}
setTimeout(initEdgeHandles, 500);
// --- Manual Draw Mode ---
let drawMode = false;
let sourceNode = null;
window.toggleDrawMode = () => {
drawMode = !drawMode;
const btn = document.getElementById('btn-draw');
if (drawMode) {
btn.classList.add('btn-draw-active');
btn.textContent = '🔗 始点を選択中...';
cy.autolock(true);
log("【接続モードON】始点→終点の順にクリックしてください", "warn");
} else {
btn.classList.remove('btn-draw-active');
btn.textContent = '🔗 接続モード';
if (sourceNode) {
sourceNode.removeStyle('border-width');
sourceNode.removeStyle('border-color');
sourceNode.removeClass('source-node');
}
sourceNode = null;
cy.autolock(false);
log("接続モード: OFF", "info");
}
ctxMenu.style.display = 'none';
};
cy.on('tap', 'node', function(e){
if (!drawMode) return;
const target = e.target;
if (!sourceNode) {
sourceNode = target;
sourceNode.addClass('source-node');
sourceNode.style({ 'border-width': 4, 'border-color': '#ff3333' });
document.getElementById('btn-draw').textContent = '🔗 終点を選択中...';
} else {
if (sourceNode.id() === target.id()) return;
const edgeId = sourceNode.id() + '_' + target.id();
if (cy.getElementById(edgeId).length === 0) {
cy.add({ group: 'edges', data: { source: sourceNode.id(), target: target.id(), id: edgeId, label: 'relates to' } });
log("接続しました", "success");
syncGraphToJson();
saveHistory();
}
sourceNode.removeStyle('border-width');
sourceNode.removeStyle('border-color');
sourceNode.removeClass('source-node');
sourceNode = null;
document.getElementById('btn-draw').textContent = '🔗 始点を選択中...';
}
});
cy.on('tap', function(e){
if (drawMode && e.target === cy && sourceNode) {
sourceNode.removeStyle('border-width');
sourceNode.removeStyle('border-color');
sourceNode.removeClass('source-node');
sourceNode = null;
document.getElementById('btn-draw').textContent = '🔗 始点を選択中...';
}
if (e.target === cy) ctxMenu.style.display = 'none';
});
// --- Interaction ---
cy.on('doubleTap', 'node, edge', function(e){
const el = e.target;
const newLabel = prompt("名前を変更:", el.data('label') || "");
if (newLabel !== null) {
el.data('label', newLabel);
syncGraphToJson(); saveHistory();
}
});
cy.on('cxttap', function(e){
e.preventDefault();
const target = e.target;
ctxMenu.style.top = e.originalEvent.clientY + 'px';
ctxMenu.style.left = e.originalEvent.clientX + 'px';
ctxMenu.style.display = 'block';
if (target === cy) {
// Background clicked - show global options
selectedElement = null;
} else {
selectedElement = target;
}
});
window.addEventListener('click', () => ctxMenu.style.display = 'none');
document.addEventListener('keydown', function(e){
if (document.activeElement === codeInput) return;
if (e.key === 'Delete' || e.key === 'Backspace') deleteSelected();
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'y') { e.preventDefault(); redo(); }
});
cy.on('dragfree', 'node', function(){ syncGraphToJson(); saveHistory(); });
window.addNode = () => {
const type = nodeTypeSelect.value;
const id = 'n_' + Math.random().toString(36).substr(2, 9);
const x = cy.width() / 2;
const y = cy.height() / 2;
cy.add({ group: 'nodes', data: { id: id, label: '新規 ' + type, type: type }, position: { x: x, y: y } });
syncGraphToJson(); saveHistory();
};
window.deleteSelected = () => {
cy.$(':selected').remove();
syncGraphToJson(); saveHistory();
};
window.triggerCtxAction = (action) => {
if (action === 'rename' && selectedElement) {
const newLabel = prompt("名前を変更:", selectedElement.data('label') || "");
if (newLabel !== null) { selectedElement.data('label', newLabel); syncGraphToJson(); saveHistory(); }
} else if (action === 'delete' && selectedElement) {
selectedElement.remove(); syncGraphToJson(); saveHistory();
}
ctxMenu.style.display = 'none';
};
window.syncGraphToJson = () => {
if (currentMode === 'json') {
const json = cy.elements().jsons();
codeInput.value = JSON.stringify(json, null, 2);
}
};
function applyJsonToGraph(jsonStr, doSaveHistory=true) {
try {
const newElements = JSON.parse(jsonStr);
const hasPositions = newElements.some(el => el.position && el.position.x);
cy.elements().remove();
cy.add(newElements);
if (!hasPositions) applyLayout('fcose'); else cy.fit();
if (doSaveHistory) saveHistory();
} catch (e) { log("JSONエラー", "error"); }
}
window.applyJsonFromEditor = () => {
if (currentMode === 'json') {
applyJsonToGraph(codeInput.value, true);
}
};
window.applyLayout = (name) => {
let config = { name: name, animate: true, padding: 50, nodeDimensionsIncludeLabels: true };
if (name === 'dagre') config.rankDir = 'TB';
cy.layout(config).run();
setTimeout(saveHistory, 1000);
};
window.copyCurrentContent = () => {
navigator.clipboard.writeText(codeInput.value).then(() => {
const notif = document.getElementById('notification');
notif.style.opacity = '1';
setTimeout(() => notif.style.opacity = '0', 2000);
});
};
window.toggleEditor = () => {
editorPane.classList.toggle('hidden');
setTimeout(() => cy.resize(), 320);
};
window.undo = undo; window.redo = redo;
if (!initialElements.some(el => el.position)) applyLayout('fcose');
</script>
</body>
</html>
"""
return html_template
# ==========================================
# 🕵️ Auto-Analyzer
# ==========================================
STANDARD_LIBS = { "os", "sys", "re", "json", "time", "datetime", "math", "random", "typing", "collections", "itertools", "functools", "pathlib", "logging", "argparse", "subprocess", "threading", "multiprocessing", "asyncio", "socket", "io", "shutil", "glob", "uuid", "base64", "hashlib", "csv", "pickle", "traceback", "queue", "copy", "contextlib", "abc", "enum", "inspect", "importlib", "signal", "tempfile", "platform", "ctypes", "html", "codecs", "urllib", "http", "xml", "email" }
LIB_DESCRIPTIONS = { "openai": "OpenAI API", "pandas": "Pandas Data Analysis", "numpy": "NumPy Math", "requests": "Requests HTTP", "flask": "Flask Web Framework", "django": "Django Web Framework", "fastapi": "FastAPI", "streamlit": "Streamlit App", "playwright": "Playwright Automation", "selenium": "Selenium Automation", "scikit-learn": "ML Library", "matplotlib": "Plotting Library" }
def analyze_source_code(file_path: str) -> C4ModelBuilder:
print(f"🕵️ Analyzing: {file_path} ...")
file_name = os.path.basename(file_path)
module_name = os.path.splitext(file_name)[0]
builder = C4ModelBuilder(f"Analysis: {file_name}")
main_container = Container(module_name, file_name, "Python File", "Analyzed Code")
builder.add_element(main_container)
try:
with open(file_path, "r", encoding="utf-8") as f: source = f.read()
tree = ast.parse(source)
except Exception as e:
print(f"❌ Error: {e}")
return builder
imports: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for n in node.names: imports.add(n.name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
if node.module: imports.add(node.module.split('.')[0])
elif isinstance(node, ast.ClassDef):
class_comp = Component(node.name, node.name, "Class", "Python Class")
class_comp.description = ast.get_docstring(node) or ""
main_container.add_child(class_comp)
lib_boundary = System("external_libs_group", "External Libraries", "Dependencies")
has_libs = False
for lib in imports:
if lib in STANDARD_LIBS: continue
has_libs = True
desc = LIB_DESCRIPTIONS.get(lib, "External Library")
lib_system = System(lib, f"{lib}", desc)
lib_boundary.add_child(lib_system)
builder.relate(main_container, lib_system, "imports")
if has_libs: builder.add_element(lib_boundary)
return builder
# ==========================================
# 🔧 Main Execution
# ==========================================
def load_user_module(path: str) -> Optional[C4ModelBuilder]:
if not os.path.exists(path): return None
try:
module_name = os.path.splitext(os.path.basename(path))[0]
spec = importlib.util.spec_from_file_location(module_name, path)
if spec and spec.loader:
user_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(user_module)
if hasattr(user_module, "define_architecture"):
print("✨ DSLモード")
return user_module.define_architecture()
except Exception: pass
print("🤖 解析モード")
return analyze_source_code(path)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate C4 Model diagrams.")
parser.add_argument("-i", "--input", type=str, help="Path to python file")
parser.add_argument("-f", "--format", choices=["html"], default="html", help="Output format")
parser.add_argument("-o", "--output", type=str, help="Output file path")
args = parser.parse_args()
input_file = args.input
print("🚀 起動中...")
if not input_file:
try:
root = tk.Tk(); root.withdraw()
print("🎨 ファイル選択...")
input_file = filedialog.askopenfilename(title="Pythonファイルを選択", filetypes=[("Python Files", "*.py"), ("All Files", "*.*")])
if not input_file: sys.exit(0)
root.destroy()
except Exception: sys.exit(1)
model = load_user_module(input_file)
if model is None: sys.exit(1)
result = model.to_html()
output_path = args.output or f"{os.path.splitext(os.path.basename(input_file))[0]}_architecture.html"
try:
with open(output_path, "w", encoding="utf-8") as f: f.write(result)
print(f"✅ 保存完了: {output_path}")
if os.name == 'nt': os.startfile(output_path)
except Exception as e: print(f"❌ Error: {e}")
解説:ここがエンジニアのこだわりポイント!😤
ただコードをポンと渡されても「なんじゃこりゃ」ってなるよね?
私が「ここだけは見て!!」って思う、技術的なこだわりポイントを3つだけ解説させて!🔥
1. PythonがPythonを読む!?禁断の「AST解析」
このツールの肝は、「書かれたコードを文字として読むんじゃなくて、構造として理解する」ところにあるの。
Pythonには ast (Abstract Syntax Tree: 抽象構文木) っていう、コードを解剖するための標準ライブラリがあるんだよ!
# コードの一部抜粋:analyze_source_code関数
import ast
# 1. ファイルを読み込んで「木」にする
tree = ast.parse(source_code)
# 2. 木を探索して「クラス」と「インポート」を探す
for node in ast.walk(tree):
# import文を見つけたら依存関係リストに入れる!
if isinstance(node, ast.Import):
imports.add(node.names[0].name)
# クラス定義を見つけたら「コンポーネント」として図に追加!
elif isinstance(node, ast.ClassDef):
class_comp = Component(node.name, node.name)
解説:
正規表現(Regex)で `import` とかを探すのは素人!
ASTを使えば、Pythonインタプリタが理解するのと同じレベルで「これはクラス定義だ」「これはインポートだ」って正確に把握できるの。
だから、コメントアウトされたコードを間違って図にしちゃうこともないし、インデントが深くてもちゃんと解析できるんだよ!🧠✨
2. 黒い画面よサヨウナラ!HTML直埋め込み術
Pythonのスクリプトなのに、実行するとリッチなWebアプリが立ち上がる。
これ、どうやってると思う?実は…Pythonコードの中にHTMLを全部埋め込んでるの!🤣
# コードの一部抜粋:to_htmlメソッド
def to_html(self) -> str:
# Pythonで解析したデータをJSONにする
json_data = self.to_cytoscape_json()
# HTMLテンプレートの中にJSONを「置換」で埋め込む!
html_template = """
... (大量のHTML/JS/CSS) ...
const initialElements = __JSON_DATA__; // ここが置き換わる!
...
"""
return html_template.replace("__JSON_DATA__", json_data)
解説:
ふつうは `index.html` とか別ファイルにするけど、それだと配布が面倒でしょ?
だから、あえてPythonファイル1本の中にHTMLもJavaScriptもCSSも全部詰め込んだの!
これを to_html() で書き出してブラウザで開けば、サーバー不要で動くWebアプリの完成!これぞ 「シングルファイル・アーキテクチャ」 だよ!🏰
3. AIに「理解させる」ためのJSON設計
図を描くだけなら画像でいい。でも、AIにレビューさせたいなら「構造データ」が必要なの。
だから、このツールはMermaid記法だけじゃなくて、Cytoscape用の生JSONも扱えるようにしたんだ。
// AIに渡すデータのイメージ(Mermaidより分かりやすい!)
[
{ "data": { "id": "user", "label": "User", "type": "Person" } },
{ "data": { "id": "app", "label": "Web App", "type": "Container" } },
{ "data": { "source": "user", "target": "app", "label": "Uses" } }
]
解説:
AI(LLM)にとって、画像認識はコストが高いし精度もムラがある。
でも、こういうシンプルなJSONなら100%正確に構造を理解できるんだよ!
ツール上の「📋 コピー」ボタンは、人間用(Mermaid)とAI用(JSON)を切り替えられるようにしてあるから、用途に合わせて使い分けてね!🤖🤝
使い方(3秒で終わる)
- 上のコードを
pyc4_dsl.pyとして保存する。 - ターミナルで
python pyc4_dsl.pyを叩く。 - ファイル選択画面が出るので、解析したいPythonファイル(例えば
main.py)を選ぶ。 - ブラウザが勝手に立ち上がって、設計図が表示される!!🤯✨
🔥 神機能:AIレビュー
- 右上の 「Mermaid (閲覧用)」 タブをクリック。
- 出てきたテキストを 「📋 コピー」。
- ChatGPTやGeminiに貼り付けて、こう叫ぶ。
「この設計図のセキュリティリスクを指摘して!!」 - AIが震えながら完璧なレビューを返してくれるよ…!😱
まとめ:エンジニアよ、楽をしろ!
ドキュメント作成なんて、人間がやる仕事じゃないよ!
コードこそが真実なんだから、そこから自動生成するのが一番確実で、一番「楽」なんだ!
このツールを使えば、「設計図がないなら作ればいいじゃない」が1秒で実現できるよ!
ぜひ使ってみて、感想聞かせてね!💕
以上、国内のAI狂いでした!バイバーイ!👋✨





