Pythonコードを1秒で「設計図」に変換する魔術、教えます。【PyC4-Interactive】

本ページはプロモーションが含まれています
国内のAI狂い

やっほー!国内のAI狂いだよ!✨

今日はね、エンジニアの永遠の悩み「ドキュメント書くの面倒くさい問題」を、PythonとAIの力でねじ伏せる神ツールを作っちゃったから紹介するよ!🐍💖

目次

そのコード、誰が読むの?「設計図がない」絶望との戦い

ねえ、みんな正直に言って?
「設計図? コードがドキュメントだよ(キリッ」 って言って、半年後の自分を殺したくなったこと、あるよね?😇

私もそう!C4モデルとかDraw.ioとかで綺麗に図を描こうとするんだけど、メンテされなくてすぐ「嘘の図」になっちゃうの。
だから思ったの。

「今あるPythonコードを勝手に読み込んで、勝手に図にしてくれればよくない!?!?🤯」

実は世界中のエンジニアが同じことを考えてて、Redditとかでも似たようなツールが議論されてるんだけど…
既存のツールって「画像を出力して終わり」なのが多いの!
違うんだよ!私は「出した図をグリグリ動かして編集したい」し、「それをAIに渡してレビューさせたい」の!!😤

というわけで、ないなら作るしかない!
Pythonの標準ライブラリだけで動く、最強の「対話型アーキテクチャ設計ツール」を爆誕させました!!🚀

今回作った「神ツール」の全貌

名付けて 『PyC4-Interactive』 (勝手に命名w)!
このツールの何がヤバいか、3行で説明するね!

  1. 完全自動解析: Pythonファイルを渡すだけで、クラス構造やライブラリ依存関係を解析して図にする!📸
  2. GUIでグリグリ編集: ブラウザ上でノードを動かしたり、マウスで線を引いたり、直感的に編集できる!🖱️
  3. 対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秒で終わる)

  1. 上のコードを pyc4_dsl.py として保存する。
  2. ターミナルで python pyc4_dsl.py を叩く。
  3. ファイル選択画面が出るので、解析したいPythonファイル(例えば main.py)を選ぶ。
  4. ブラウザが勝手に立ち上がって、設計図が表示される!!🤯✨

🔥 神機能:AIレビュー

  1. 右上の 「Mermaid (閲覧用)」 タブをクリック。
  2. 出てきたテキストを 「📋 コピー」
  3. ChatGPTやGeminiに貼り付けて、こう叫ぶ。
    「この設計図のセキュリティリスクを指摘して!!」
  4. AIが震えながら完璧なレビューを返してくれるよ…!😱

まとめ:エンジニアよ、楽をしろ!

ドキュメント作成なんて、人間がやる仕事じゃないよ!
コードこそが真実なんだから、そこから自動生成するのが一番確実で、一番「楽」なんだ!

このツールを使えば、「設計図がないなら作ればいいじゃない」が1秒で実現できるよ!
ぜひ使ってみて、感想聞かせてね!💕
以上、国内のAI狂いでした!バイバーイ!👋✨

よかったらシェアしてね!
  • URLをコピーしました!
目次