手順を導き出す「Logic Editor 3D」
【更新履歴】
・2026/2/12 バージョン1.0公開。
・2026/2/12 バージョン1.1公開。
・2026/2/12 バージョン1.2公開。
・2026/8/2 バージョン1.3公開。(OrderMade方式に変更)
・ダウンロードされる方はこちら。↓
概要
・本ツールは、生成AIを活用したタスク定義機能や、
直感的な3Dグラフビューを備え、
複雑な計画立案を強力にサポートします。
・完全ローカル環境で動作し、
JSON形式でのプロジェクト保存・読み込みにも対応しているため、
セキュアかつシームレスな運用が可能です。
取扱説明書
アプリケーションの起動と初期設定
(1)本アプリケーションを起動するには、
HTMLファイルをローカルサーバー環境
(Live ServerやPythonのhttp.serverなど)で開いてください。
(2)起動すると、初期設定として
サンプルのリソースとタスクが読み込まれます。
(3)画面上部のメニューバーから「新規」を選択することで、
まっさらな状態からプロジェクトを開始できます。
(4)作成したプロジェクトは「保存」ボタンから
JSONファイルとしてダウンロードでき、
「開く」ボタンからいつでも再開可能です。
リソースとタスクの管理
(1)画面左側の「リソース」タブを開き、
計画に登場する要素(例:りんごの金額、合計金額など)を
登録します。
(2)次に「タスク」タブを開き、
各リソースを処理する手順を定義します。
(3)タスクには、入力となるリソース、出力となるリソース、
所要時間、コスト、そして必要に応じて計算式を設定します。
(4)タスクの計算式にはJavaScriptの書式を使用し、
入力値は選んだ順に「args[0]」「args[1]」として扱われます。
AIを用いたタスク設計
(1)タスクの定義が難しい場合は、
「AI設計」タブをご利用ください。
(2)ユーザーが実現したい要望を文章で入力し、
「プロンプトを作る」ボタンを押します。
(3)生成されたテキストをコピーし、
外部の生成AI(ChatGPTなど)に入力します。
(4)生成AIから返ってきたJSON形式の回答を
「STEP 3」のテキストエリアに貼り付け、
「タスク一覧に取り込む」ボタンを押すことで、
自動的にタスクが追加されます。
計画の作成と実行
(1)「実行」タブを開き、
「求めたいもの」のドロップダウンから
最終的な目標リソースを選択します。
(2)あるいは、「知りたいことを文章で」の欄に
直接要望を入力して目標を設定することも可能です。
(3)「時間短縮」「コスト削減」「バランス」の中から
優先モードを選び、
「計画を作る」ボタンを押します。
(4)画面右側の「計画ツリー」タブに、
目標を達成するために必要な入力項目と
タスクの階層が表示されます。
(5)ツリー上で「入力が必要」と表示されている項目に
具体的な数値を入力します。
(6)すべての入力が完了したら、
「実行して答えを出す」ボタンを押し、
最終的な計算結果を確認します。
画面の表示とログの確認
(1)画面中央のキャンバスには、
作成された計画のタスクとリソースのつながりが
3Dグラフとして視覚的に表示されます。
(2)3Dグラフはマウスのドラッグで回転、
スクロールでズームが可能です。
(3)画面右側の「工程表」タブでは、
タスクの実行順序や合計時間、
推定予算を確認できます。
(4)「ログ」タブでは、
アプリケーション内での操作履歴や
計算プロセスの詳細をテキストで確認でき、
クリップボードへのコピーや
ファイルへの出力が可能です。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Logic Editor 3D v1.3</title>
<style>
/* --- テーマ設定 --- */
:root {
--bg-dark: #121212;
--bg-panel: #1e1e1e;
--border: #333;
--accent: #00e676;
--accent-sec: #2979ff;
--warn: #ffa726;
--danger: #ff5252;
--text-main: #e0e0e0;
--text-dim: #888;
}
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Meiryo', sans-serif; }
/* レイアウト: 高さ100vhを厳守し、はみ出しを防ぐ */
#app { display: grid; grid-template-rows: 45px 1fr 30px; height: 100vh; width: 100vw; }
/* 1. メニューバー */
#menubar { background: var(--bg-panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 15px; user-select: none; }
.brand { font-weight: 800; color: var(--accent); margin-right: 25px; font-size: 1.1em; letter-spacing: 1px; display:flex; align-items:center; gap:10px; }
.menu-divider { width: 1px; height: 20px; background: var(--border); margin: 0 10px; }
.btn { background: #2c2c2c; border: 1px solid #444; color: #ccc; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-size: 0.85em; transition: 0.2s; white-space: nowrap; }
.btn:hover { background: #444; color: #fff; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-primary { background: var(--accent); color: #000; border:none; font-weight:bold; }
.btn-primary:hover { background: #00c853; }
.btn-sec { background: var(--accent-sec); color:#fff; border:none; }
.btn-sec:hover { background: #1565c0; }
.btn-sm { padding: 4px 8px; font-size: 0.75em; }
/* 2. メインワークスペース (min-height:0 が重要) */
#workspace { display: grid; grid-template-columns: 360px 1fr 380px; overflow: hidden; min-height: 0; }
/* パネル共通設定 */
.panel { background: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); z-index: 10; min-height: 0; }
.panel-right { border-right: none; border-left: 1px solid var(--border); }
.panel-header { padding: 0; display: flex; background: #252525; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.tab { flex: 1; padding: 12px 5px; text-align: center; cursor: pointer; color: var(--text-dim); font-size: 0.8em; font-weight: 600; border-bottom: 2px solid transparent; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tab:hover { color: #fff; background: #2a2a2a; }
.tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); background: #1e1e1e; }
/* スクロールエリア */
.scroll-area { flex: 1; overflow-y: auto; padding: 15px; min-height: 0; }
.scroll-area::-webkit-scrollbar { width: 8px; }
.scroll-area::-webkit-scrollbar-track { background: #1a1a1a; }
.scroll-area::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; }
.scroll-area::-webkit-scrollbar-thumb:hover { background: #555; }
/* フォーム要素 */
.form-group { margin-bottom: 20px; background: #252525; padding: 15px; border-radius: 6px; border: 1px solid var(--border); }
.label { display: block; font-size: 0.7em; color: var(--text-dim); margin-bottom: 8px; text-transform: uppercase; font-weight:bold; }
.input-row { display: flex; gap: 10px; }
input, select, textarea { width: 100%; background: #121212; border: 1px solid #333; color: #fff; padding: 8px; border-radius: 4px; font-size: 0.9em; box-sizing: border-box; font-family: inherit; }
textarea { resize: vertical; min-height: 60px; line-height: 1.4; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
.hint { font-size: 0.75em; color: var(--text-dim); line-height: 1.5; margin-top: 6px; }
/* リストアイテム */
.list-item { background: #252525; border: 1px solid var(--border); padding: 10px; margin-bottom: 5px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; }
.tag { background: #333; color: #aaa; padding: 2px 6px; border-radius: 3px; font-size: 0.7em; margin-right: 5px; }
.del-icon { color: var(--danger); cursor: pointer; padding: 4px; font-weight:bold; }
/* アコーディオン (AIステップ用) */
details { background: #252525; border: 1px solid var(--border); border-radius: 4px; margin-bottom: 10px; overflow: hidden; }
summary { padding: 10px 15px; cursor: pointer; font-weight: bold; background: #2a2a2a; color: var(--accent-sec); user-select: none; outline: none; font-size: 0.9em; }
summary:hover { background: #333; }
.details-content { padding: 15px; border-top: 1px solid var(--border); }
details[open] summary { border-bottom: 1px solid var(--border); }
/* キャンバス */
#canvas-wrapper { position: relative; background: radial-gradient(circle at center, #1a1a1a 0%, #000 100%); overflow: hidden; }
#canvas-overlay { position: absolute; top: 15px; left: 15px; pointer-events: none; }
.badge { background: rgba(0,0,0,0.7); color: var(--accent); padding: 5px 10px; border-radius: 20px; font-size: 0.8em; border: 1px solid var(--accent); backdrop-filter: blur(4px); }
/* 指標パネル */
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; }
.metric-card { background: #252525; padding: 10px; border-radius: 4px; text-align: center; border: 1px solid var(--border); }
.metric-val { font-size: 1.4em; font-weight: bold; color: #fff; display:block; }
.metric-label { font-size: 0.7em; color: var(--text-dim); text-transform: uppercase; }
.schedule-step { border-left: 2px solid var(--border); padding-left: 15px; margin-bottom: 20px; position: relative; }
.schedule-step::before { content: ''; position: absolute; left: -6px; top: 0; width: 10px; height: 10px; background: #333; border-radius: 50%; }
.step-header { font-weight: bold; color: #fff; margin-bottom: 4px; }
.step-desc { font-size: 0.8em; color: #ccc; margin-bottom: 4px; font-style: italic; }
.step-meta { font-size: 0.8em; color: #888; display: flex; gap: 10px; }
/* --- 計画ツリー (項目名 + 値入力欄) --- */
.tree-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid #262626; }
.tree-label { flex: 1; font-size: 0.85em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-label.func { color: var(--accent); }
.tree-label.input { color: var(--danger); }
.tree-label.alt { color: var(--warn); }
.tree-value { width: 110px; flex-shrink: 0; padding: 5px; font-size: 0.85em; }
.tree-result { font-size: 0.8em; color: var(--text-dim); flex-shrink: 0; }
.tree-meta { font-size: 0.7em; color: var(--text-dim); }
#result-box { background: #0d2818; border: 1px solid var(--accent); border-radius: 6px; padding: 15px; margin-bottom: 15px; }
#result-value { font-size: 1.6em; font-weight: bold; color: var(--accent); word-break: break-all; }
/* --- ログ --- */
#tab-log { display: flex; flex-direction: column; min-height: 0; padding: 0; }
#log-text { flex: 1; background: #000; color: #9acd32; font-family: 'Consolas', monospace; font-size: 0.85em; border: none; border-radius: 0; resize: none; padding: 10px; line-height: 1.5; }
#log-buttons { flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 8px; background: #252525; border-top: 1px solid var(--border); }
/* ステータスバー */
#statusbar { background: var(--bg-panel); border-top: 1px solid var(--border); display: flex; align-items: center; padding: 0 15px; font-size: 0.8em; color: var(--text-dim); justify-content: space-between; }
/* トースト通知 */
#toast-container { position: fixed; bottom: 40px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
.toast { background: #333; color: #fff; padding: 10px 20px; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border-left: 4px solid var(--accent); font-size: 0.9em; transform: translateX(100%); transition: transform 0.3s; pointer-events: auto; max-width: 400px; white-space: pre-line; }
.toast.show { transform: translateX(0); }
.toast.error { border-left-color: var(--danger); }
.hidden { display: none !important; }
.copy-box { position: relative; }
.copy-btn { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.5); border: 1px solid #444; color: #fff; padding: 2px 8px; font-size: 0.7em; border-radius: 3px; cursor: pointer; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<div id="app">
<div id="menubar">
<div class="brand">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h20M2 12l4-4m-4 4l4 4M22 4v16"/></svg>
LOGIC EDITOR 3D <span style="font-weight:normal; font-size:0.8em; color:#666;">v5.0</span>
</div>
<div class="menu-divider"></div>
<button class="btn" onclick="app.newProject()">新規</button>
<button class="btn" onclick="app.openProject()">開く</button>
<button class="btn" onclick="app.saveProject()">保存</button>
<div class="menu-divider"></div>
<div style="font-size:0.85em; color:#888;">ファイル: <span id="filename-display" style="color:#fff;">未保存</span></div>
</div>
<div id="workspace">
<div class="panel">
<div class="panel-header">
<div class="tab active" onclick="UIManager.switchTab('run')">実行</div>
<div class="tab" onclick="UIManager.switchTab('ai')">AI設計</div>
<div class="tab" onclick="UIManager.switchTab('types')">リソース</div>
<div class="tab" onclick="UIManager.switchTab('funcs')">タスク</div>
</div>
<div id="tab-run" class="scroll-area">
<div class="form-group">
<div class="label">求めたいもの</div>
<select id="run-goal"></select>
<div id="prompt-wrap" class="hidden" style="margin-top:10px;">
<div class="label">知りたいことを文章で</div>
<textarea id="run-prompt" placeholder="例: りんごとみかんの合計金額が知りたい"></textarea>
<div class="hint">文章から求めたい項目名を切り出します。切り出した項目を返すタスクがまだ無ければ、AI設計タブでタスクを作れます。</div>
</div>
<div class="label" style="margin-top:12px;">優先モード</div>
<select id="run-mode">
<option value="time">時間短縮 (最速)</option>
<option value="cost">コスト削減 (最安)</option>
<option value="balanced">バランス (スコア)</option>
</select>
<button class="btn btn-primary" style="width:100%; margin-top:15px;" onclick="app.solve()">計画を作る</button>
<div class="hint">計画を作ったら、右の「計画ツリー」で足りない値を入力して実行します。</div>
</div>
<div class="form-group">
<div class="label">計画の見つけ方</div>
<div class="hint">
求めたい項目を出力するタスクをまずタスク一覧から探します。<br>
見つかればそのタスクを計画に組み込み、その入力についても同じように探します。<br>
どこにも見つからない項目は、あなたが入力する値として計画の末端に置かれます。
</div>
</div>
</div>
<div id="tab-ai" class="scroll-area hidden">
<div class="hint" style="margin-bottom:12px;">
タスクが足りないときは、ここで生成AIに作らせて取り込みます。作ったタスクはタスク一覧に蓄積され、次回からは検索で見つかるようになります。
</div>
<details open>
<summary>STEP 1: 要望の入力</summary>
<div class="details-content">
<textarea id="ai-user-prompt" placeholder="例: りんごとみかんの合計金額が知りたい"></textarea>
<button class="btn btn-primary" style="width:100%; margin-top:10px;" onclick="app.generateAIPrompt()">プロンプトを作る</button>
</div>
</details>
<details>
<summary>STEP 2: AIへ送信</summary>
<div class="details-content">
<div class="hint" style="margin-bottom:5px;">このテキストをコピーして、ChatGPT等に送信してください。</div>
<div class="copy-box">
<textarea id="ai-system-prompt" readonly style="height:160px; font-family:monospace; font-size:0.75em; color:#88ff88;"></textarea>
<button class="copy-btn" onclick="app.copyToClipboard()">コピー</button>
</div>
</div>
</details>
<details>
<summary>STEP 3: 結果の反映</summary>
<div class="details-content">
<div class="hint" style="margin-bottom:5px;">AIからの返信をそのまま貼り付けてください。前後に文章が付いていてもJSON部分だけ取り出します。</div>
<textarea id="ai-response-json" placeholder='{ "types": [...], "functions": [...] }'></textarea>
<button class="btn btn-sec" style="width:100%; margin-top:10px;" onclick="app.applyAIResponse()">タスク一覧に取り込む</button>
</div>
</details>
</div>
<div id="tab-types" class="scroll-area hidden">
<div class="form-group">
<div class="label">リソースを追加</div>
<div class="input-row">
<input type="text" id="type-name" placeholder="名称 (例: りんごの金額)">
<button class="btn" onclick="app.addType()">追加</button>
</div>
<div class="hint">計画に登場した項目は自動で登録されます。ここでは手動で追加・削除できます。</div>
</div>
<div id="type-list"></div>
</div>
<div id="tab-funcs" class="scroll-area hidden">
<div class="form-group">
<div class="label">タスクを定義</div>
<input type="text" id="func-name" placeholder="タスク名" style="margin-bottom:5px;">
<textarea id="func-desc" placeholder="作業内容の説明(任意)" style="margin-bottom:10px; min-height:40px;"></textarea>
<div class="label">入力リソース (Ctrl+Click で複数選択)</div>
<select id="func-inputs" multiple style="height:60px; margin-bottom:10px;"></select>
<div class="label">出力リソース</div>
<select id="func-output" style="margin-bottom:10px;"></select>
<div class="label">計算式 (任意)</div>
<input type="text" id="func-formula" placeholder="例: args[0] + args[1]" style="margin-bottom:5px;">
<div class="hint" style="margin-bottom:10px;">入力リソースの値が、選んだ順に args[0], args[1] ... に入ります。式を書くとこのタスクを実行して値を計算できます。空欄なら工程表にだけ現れます。</div>
<div class="input-row">
<div style="flex:1">
<div class="label">時間(h)</div>
<input type="number" id="func-time" value="1" min="0" step="0.5">
</div>
<div style="flex:1">
<div class="label">コスト(円)</div>
<input type="number" id="func-cost" value="0" min="0" step="100">
</div>
</div>
<button class="btn" style="width:100%; margin-top:10px;" onclick="app.addFunction()">登録</button>
</div>
<div id="func-list"></div>
</div>
</div>
<div id="canvas-wrapper">
<div id="canvas-overlay">
<span class="badge" id="view-mode-badge">3D グラフビュー</span>
</div>
</div>
<div class="panel panel-right">
<div class="panel-header">
<div class="tab active" onclick="UIManager.switchRightTab('tree')">計画ツリー</div>
<div class="tab" onclick="UIManager.switchRightTab('metrics')">工程表</div>
<div class="tab" onclick="UIManager.switchRightTab('log')">ログ</div>
</div>
<div id="tab-tree" class="scroll-area">
<div id="result-box" class="hidden">
<div class="label" style="margin-bottom:4px;">答え</div>
<div id="result-value">-</div>
</div>
<div id="tree-list">
<div style="text-align:center; color:#555; padding:20px;">
まだ計画がありません。<br>左の「計画を作る」から始めてください。
</div>
</div>
<button id="btn-execute" class="btn btn-primary hidden" style="width:100%; margin-top:15px;" onclick="app.execute()">実行して答えを出す</button>
</div>
<div id="tab-metrics" class="scroll-area hidden">
<div class="metric-grid">
<div class="metric-card">
<span class="metric-val" id="metric-time">0h</span>
<span class="metric-label">合計時間</span>
</div>
<div class="metric-card">
<span class="metric-val" id="metric-cost">¥0</span>
<span class="metric-label">推定予算</span>
</div>
</div>
<div class="label" style="border-bottom:1px solid #333; padding-bottom:5px; margin-bottom:15px;">工程表</div>
<div id="schedule-list">
<div style="text-align:center; color:#555; padding:20px;">スケジュールなし</div>
</div>
</div>
<div id="tab-log" class="hidden">
<textarea id="log-text" readonly spellcheck="false"></textarea>
<div id="log-buttons">
<button class="btn btn-sm" onclick="Logger.copy()">コピー</button>
<button class="btn btn-sm" onclick="Logger.reset()">リセット</button>
<button class="btn btn-sm" onclick="Logger.save()">ファイルに出力</button>
</div>
</div>
</div>
</div>
<div id="statusbar">
<span id="status-msg">Ready</span>
<span style="font-family:monospace; color:#555;">v5.0.0</span>
</div>
</div>
<input type="file" id="file-uploader" style="display:none" accept=".json">
<div id="toast-container"></div>
<script>
// 文章から目標を切り出すときの、選択肢の先頭に出す特別な項目
const PROMPT_SENTINEL = "(文章で入力して決める)";
// 計画を掘り下げる深さの上限。
// これが無いと「AにはBが必要」「BにはCが必要」…と際限なく分解が続き、
// ブラウザが固まる。上限を超えた項目は、ユーザーが入力する値として扱う。
const MAX_DEPTH = 6;
// --- Toast通知 ---
const Toast = {
show(msg, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerText = msg;
container.appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 300);
}, 4000);
}
};
// --- ログ ---
const Logger = {
lines: [],
add(msg) {
const t = new Date().toTimeString().slice(0, 8);
this.lines.push(`[${t}] ${msg}`);
this.render();
},
render() {
const el = document.getElementById('log-text');
if (!el) return;
el.value = this.lines.join('\n');
el.scrollTop = el.scrollHeight;
},
reset() { this.lines = []; this.render(); },
copy() {
const el = document.getElementById('log-text');
el.select();
document.execCommand('copy');
Toast.show("ログをコピーしました");
},
save() {
const blob = new Blob([this.lines.join('\r\n')], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = "log.txt"; a.click();
URL.revokeObjectURL(url);
}
};
// --- メインアプリ ---
class App {
constructor() {
this.types = [];
this.functions = [];
this.currentProjectName = "無題のプロジェクト";
this.tree = null; // 現在の計画ツリー
this.values = {}; // 項目名 → ユーザーが入力した値
this.overrides = {}; // 項目名 → 明示的に選んだタスクID
this.solver = new LogicSolver(this);
this.viz = new Visualizer('canvas-wrapper');
this.setupDragDrop();
this.loadLocalStorage();
this.viz.init();
}
initUI() {
UIManager.refreshAll();
document.getElementById('run-goal').onchange = () => UIManager.updatePromptVisibility();
UIManager.updatePromptVisibility();
Logger.add("起動しました。");
}
// --- プロジェクト操作 ---
newProject() {
if (confirm("新規作成しますか?")) {
this.types = []; this.functions = [];
this.tree = null; this.values = {}; this.overrides = {};
this.currentProjectName = "無題のプロジェクト";
this.saveLocalStorage();
UIManager.refreshAll();
UIManager.clearTree();
this.viz.clearScene();
Logger.add("新規プロジェクトを作成しました。");
}
}
saveProject() {
const data = JSON.stringify({ version: "5.0", name: this.currentProjectName, types: this.types, functions: this.functions }, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = this.currentProjectName + ".json"; a.click();
URL.revokeObjectURL(url);
Logger.add(`保存しました: ${this.currentProjectName}.json`);
}
openProject() { document.getElementById('file-uploader').click(); }
loadFromFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
this.types = data.types || [];
this.functions = (data.functions || []).map(f => ({ formula: "", ...f }));
this.currentProjectName = data.name || "LoadProject";
this.tree = null; this.values = {}; this.overrides = {};
this.saveLocalStorage();
UIManager.refreshAll();
UIManager.clearTree();
this.viz.clearScene();
Logger.add(`読み込みました: ${file.name}`);
} catch (err) {
Toast.show("ファイルを読み込めませんでした。JSON形式か確認してください。", "error");
Logger.add(`読み込みに失敗: ${err.message}`);
}
};
reader.readAsText(file);
}
setupDragDrop() {
const d = document.body;
d.ondragover = (e) => { e.preventDefault(); };
d.ondrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files.length > 0) this.loadFromFile(e.dataTransfer.files[0]);
};
document.getElementById('file-uploader').onchange = (e) => {
if (e.target.files.length > 0) this.loadFromFile(e.target.files[0]);
};
}
// --- データ管理 ---
loadLocalStorage() {
const saved = localStorage.getItem('logic_architect_v5');
if (saved) {
try {
const data = JSON.parse(saved);
this.types = data.types || [];
this.functions = (data.functions || []).map(f => ({ formula: "", ...f }));
} catch (e) { this.loadSampleData(); }
} else {
this.loadSampleData();
}
}
saveLocalStorage() {
localStorage.setItem('logic_architect_v5', JSON.stringify({ types: this.types, functions: this.functions }));
UIManager.updateFilename(this.currentProjectName);
}
loadSampleData() {
this.types = ["りんごの金額", "みかんの金額", "合計金額", "消費税額", "税込合計"];
this.functions = [
{ id: "f1", name: "合計する", desc: "りんごとみかんの金額を足す", inputs: ["りんごの金額", "みかんの金額"], output: "合計金額", formula: "args[0] + args[1]", time: 0, cost: 0 },
{ id: "f2", name: "消費税を計算する", desc: "合計金額に10%を掛ける", inputs: ["合計金額"], output: "消費税額", formula: "args[0] * 0.1", time: 0, cost: 0 },
{ id: "f3", name: "税込にする", desc: "合計金額と消費税額を足す", inputs: ["合計金額", "消費税額"], output: "税込合計", formula: "args[0] + args[1]", time: 0, cost: 0 }
];
this.saveLocalStorage();
}
// 項目をリソース一覧に登録する(計画に登場したものを覚えておく)
registerType(name) {
if (name && !this.types.includes(name)) {
this.types.push(name);
this.saveLocalStorage();
}
}
// --- リソース操作 ---
addType() {
const name = document.getElementById('type-name').value.trim();
if (!name) return;
if (this.types.includes(name)) return Toast.show("同じ名称が既にあります", "error");
this.types.push(name);
document.getElementById('type-name').value = '';
this.saveLocalStorage();
UIManager.renderTypeList();
UIManager.updateSelects();
Logger.add(`リソースを追加: ${name}`);
}
deleteType(name) {
if (this.functions.some(f => f.output === name || f.inputs.includes(name))) {
return Toast.show(`「${name}」はタスクで使われているため削除できません`, "error");
}
this.types = this.types.filter(t => t !== name);
this.saveLocalStorage();
UIManager.refreshAll();
Logger.add(`リソースを削除: ${name}`);
}
// --- タスク操作 ---
addFunction() {
const name = document.getElementById('func-name').value.trim();
const desc = document.getElementById('func-desc').value.trim();
const inputs = Array.from(document.getElementById('func-inputs').selectedOptions).map(o => o.value);
const output = document.getElementById('func-output').value;
const formula = document.getElementById('func-formula').value.trim();
const time = parseFloat(document.getElementById('func-time').value) || 0;
const cost = parseFloat(document.getElementById('func-cost').value) || 0;
if (!name || !output) return Toast.show("タスク名と出力リソースを入力してください", "error");
this.functions.push({ id: 'func_' + Date.now(), name, desc, inputs, output, formula, time, cost });
this.saveLocalStorage();
UIManager.renderFuncList();
Logger.add(`タスクを登録: ${name} (${inputs.join('+') || '入力なし'} → ${output})`);
Toast.show("タスクを登録しました");
}
deleteFunction(id) {
const f = this.functions.find(x => x.id === id);
this.functions = this.functions.filter(x => x.id !== id);
this.saveLocalStorage();
UIManager.renderFuncList();
if (f) Logger.add(`タスクを削除: ${f.name}`);
}
// --- AI機能 ---
generateAIPrompt() {
const req = document.getElementById('ai-user-prompt').value.trim();
if (!req) return Toast.show("要望を入力してください", "error");
// C#版の関数生成プロンプトと同じ考え方:
// ・元の要望文(文脈)を必ず伝える → 「値1」「値2」のような曖昧な入力名を防ぐ
// ・それ以上分解できない項目は入力を空にするよう明示 → 際限ない分解を防ぐ
// ・自分自身を入力に含めないよう明示 → 循環参照を防ぐ
// ・「できません」と断らないよう明示 → 必要な情報は入力として挙げさせる
const prompt = `あなたはプロセス設計の専門家です。ユーザーの要望を実現するために必要なリソース(types)とタスク(functions)を定義してください。
【ユーザーの要望】
${req}
【必ず守るルール】
1. 入力リソース名は、この要望の文脈に沿った具体的な名前にしてください。
良い例: 「りんごの金額」「みかんの金額」
悪い例: 「値1」「値2」「入力A」(何を指すか分からない名前は禁止)
2. あるタスクの出力リソースを、そのタスク自身の入力に含めないでください(循環します)。
3. それ以上分解できない項目(ユーザーが直接入力するしかない金額・個数・長さなど)は、
タスクを作らず、他のタスクの入力リソースとして名前を挙げるだけにしてください。
4. 「情報が不足している」「できません」とは答えないでください。
足りない情報は、ユーザーが入力する入力リソースとして挙げてください。
5. formula には、入力リソースの値を使ったJavaScriptの式を1行で書いてください。
値は選んだ順に args[0], args[1] ... に入ります。例: "args[0] + args[1]"
計算ではないタスク(作業工程など)の場合は formula を "" にしてください。
【出力形式 (JSONのみ。説明文やMarkdownは不要)】
{
"types": ["リソースA", "リソースB"],
"functions": [
{
"name": "タスク名",
"description": "具体的な作業内容の説明",
"inputs": ["入力A", "入力B"],
"output": "出力C",
"formula": "args[0] + args[1]",
"time": 0,
"cost": 0
}
]
}`;
document.getElementById('ai-system-prompt').value = prompt;
document.querySelectorAll('details')[1].open = true;
Toast.show("STEP 2 を開いてコピーしてください");
}
copyToClipboard() {
const el = document.getElementById('ai-system-prompt');
el.select();
document.execCommand('copy');
Toast.show("コピーしました");
}
applyAIResponse() {
let raw = document.getElementById('ai-response-json').value;
const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (jsonMatch) raw = jsonMatch[0];
raw = raw.replace(/```json/g, '').replace(/```/g, '').trim();
try {
const data = JSON.parse(raw);
if (!data.functions) throw new Error("functions が見つかりません");
let added = 0, updated = 0, skipped = 0;
(data.types || []).forEach(t => { if (!this.types.includes(t)) this.types.push(t); });
data.functions.forEach(newF => {
// 自己参照するタスクは取り込まない(循環して計画が作れなくなるため)
if ((newF.inputs || []).includes(newF.output)) {
Logger.add(`タスク「${newF.name}」は出力を自分の入力に含んでいるため取り込みませんでした。`);
skipped++;
return;
}
const idx = this.functions.findIndex(f => f.name === newF.name && f.output === newF.output);
if (idx !== -1) {
const ex = this.functions[idx];
ex.inputs = newF.inputs || [];
ex.time = newF.time || 0;
ex.cost = newF.cost || 0;
ex.formula = newF.formula || ex.formula || "";
ex.desc = newF.description || newF.desc || ex.desc;
updated++;
} else {
this.functions.push({
id: 'ai_' + Math.random().toString(36).substr(2, 9),
name: newF.name,
desc: newF.description || newF.desc || "",
inputs: newF.inputs || [],
output: newF.output,
formula: newF.formula || "",
time: newF.time || 0,
cost: newF.cost || 0
});
added++;
}
// 登場した項目をリソース一覧にも登録しておく
this.registerType(newF.output);
(newF.inputs || []).forEach(i => this.registerType(i));
});
this.saveLocalStorage();
UIManager.refreshAll();
Logger.add(`タスクを取り込みました: 追加${added}件 / 更新${updated}件${skipped ? ` / 除外${skipped}件` : ''}`);
Toast.show(`追加${added}件 / 更新${updated}件`);
if (data.functions.length > 0) {
const last = data.functions[data.functions.length - 1];
const sel = document.getElementById('run-goal');
if ([...sel.options].some(o => o.value === last.output)) sel.value = last.output;
UIManager.updatePromptVisibility();
}
} catch (e) {
Toast.show("JSONを読み取れませんでした。AIの回答に { } で囲まれた部分が含まれているか確認してください。", "error");
Logger.add(`取り込みに失敗: ${e.message}`);
}
}
// --- 計画作成 ---
solve() {
let goal = document.getElementById('run-goal').value;
if (goal === PROMPT_SENTINEL || !goal) {
const prompt = document.getElementById('run-prompt').value.trim();
if (!prompt) {
Toast.show("知りたいことを文章で入力してください", "error");
return;
}
goal = this.extractGoalFromPrompt(prompt);
if (!goal) {
Toast.show("文章から項目名を切り出せませんでした。「〜の合計金額が知りたい」のような書き方を試してください。", "error");
return;
}
Logger.add(`要望文「${prompt}」から、求めたい項目を「${goal}」と判断しました。`);
this.registerType(goal);
}
const mode = document.getElementById('run-mode').value;
this.values = {};
this.overrides = {};
const res = this.solver.solve(goal, mode);
this.tree = res.tree;
const schedule = this.solver.flatten(res.tree);
let t = 0, c = 0;
schedule.forEach(n => { t += n.time; c += n.cost; });
UIManager.renderMetrics(t, c);
UIManager.renderSchedule(schedule);
UIManager.renderTree(res.tree);
this.viz.renderGraph(schedule);
UIManager.switchRightTab('tree');
Logger.add(`計画を作りました。目的: ${goal} (タスク${schedule.length}件)`);
const inputs = this.solver.collectInputs(res.tree);
if (inputs.length > 0) {
Logger.add(`入力が必要な項目: ${inputs.join(', ')}`);
}
}
// 文章から、求めたい項目名を切り出す。
// ブラウザ版はAPIを呼べないため、登録済みリソース名との照合と助詞による切り出しで代替する。
extractGoalFromPrompt(prompt) {
// 1) 登録済みリソース名が文中にあれば、最も長く一致したものを採用する
const hit = this.types
.filter(t => t !== PROMPT_SENTINEL && prompt.includes(t))
.sort((a, b) => b.length - a.length)[0];
if (hit) return hit;
// 2) 「〜が知りたい」「〜を求めたい」等の直前の名詞句を切り出す
const m = prompt.match(/([^\s、。「」]+?)(?:が知りたい|を知りたい|を求めたい|が欲しい|を計算|を出したい)/);
if (m && m[1]) {
// 「りんごとみかんの合計金額」→「合計金額」のように、最後の「の」以降を採る
const parts = m[1].split('の');
return parts[parts.length - 1] || m[1];
}
return "";
}
// 値の入力を受け取る
setValue(name, value) {
this.values[name] = value;
}
// タスクを選び直す
changeFunction(typeName, funcId) {
this.overrides[typeName] = funcId;
const goal = this.tree ? this.tree.name : null;
if (!goal) return;
const mode = document.getElementById('run-mode').value;
const res = this.solver.solve(goal, mode);
this.tree = res.tree;
const schedule = this.solver.flatten(res.tree);
let t = 0, c = 0;
schedule.forEach(n => { t += n.time; c += n.cost; });
UIManager.renderMetrics(t, c);
UIManager.renderSchedule(schedule);
UIManager.renderTree(res.tree);
this.viz.renderGraph(schedule);
const f = this.functions.find(x => x.id === funcId);
Logger.add(`「${typeName}」のタスクを「${f ? f.name : funcId}」に変更し、計画を作り直しました。`);
}
// --- 実行 ---
execute() {
if (!this.tree) return Toast.show("先に計画を作ってください", "error");
const missing = this.solver.collectInputs(this.tree)
.filter(n => this.values[n] === undefined || String(this.values[n]).trim() === "");
if (missing.length > 0) {
Toast.show("次の値が未入力です:\n" + missing.join(', '), "error");
Logger.add(`実行できません。未入力: ${missing.join(', ')}`);
return;
}
try {
const result = this.solver.evaluate(this.tree);
document.getElementById('result-box').classList.remove('hidden');
document.getElementById('result-value').innerText = String(result);
UIManager.renderTree(this.tree);
Logger.add(`実行しました。${this.tree.name} = ${result}`);
Toast.show("実行しました");
} catch (e) {
Toast.show(e.message, "error");
Logger.add(`実行に失敗: ${e.message}`);
}
}
}
class LogicSolver {
constructor(app) { this.app = app; }
solve(target, mode) {
const tree = this.findPath(target, mode, new Set(), 0);
return { success: true, tree };
}
// 求めたい項目を出力するタスクを探し、見つかればその入力についても同じことを繰り返す。
// どこにも見つからない項目は、ユーザーが入力する末端ノードにする(失敗にはしない)。
findPath(target, mode, visited, depth) {
const makeInputNode = (reason) => ({
type: 'INPUT', name: target, func: null, inputs: [],
totalScore: 0, time: 0, cost: 0, candidateCount: 0, reason
});
if (depth >= MAX_DEPTH) return makeInputNode("階層の上限に達しました");
let cands = this.app.functions.filter(f => f.output === target);
if (cands.length === 0) return makeInputNode("これを出力するタスクがありません");
// 明示的に選び直されたタスクがあれば、それを優先する
const overrideId = this.app.overrides[target];
const ordered = overrideId ? cands.filter(f => f.id === overrideId).concat(cands.filter(f => f.id !== overrideId)) : cands;
let best = null, bestScore = Infinity;
for (const f of ordered) {
if (visited.has(f.id)) continue; // 同じ経路での循環を防ぐ
if (f.inputs.includes(f.output)) continue; // 自己参照タスクは使わない
const nextVis = new Set(visited).add(f.id);
const inputs = [];
let score = (mode === 'time' ? f.time : mode === 'cost' ? f.cost : f.time * 10 + f.cost);
for (const inp of f.inputs) {
const sub = this.findPath(inp, mode, nextVis, depth + 1);
inputs.push(sub);
score += sub.totalScore;
}
if (score < bestScore) {
bestScore = score;
best = {
type: 'FUNC', name: target, func: f, inputs,
totalScore: score,
time: f.time + inputs.reduce((s, n) => s + n.time, 0),
cost: f.cost + inputs.reduce((s, n) => s + n.cost, 0),
candidateCount: cands.length
};
}
if (overrideId && f.id === overrideId) break; // 選び直したものが使えたら確定
}
return best || makeInputNode("使えるタスクが見つかりませんでした");
}
// ユーザー入力が必要な項目名を集める
collectInputs(node, out = []) {
if (!node) return out;
if (node.type === 'INPUT') {
if (!out.includes(node.name)) out.push(node.name);
return out;
}
node.inputs.forEach(n => this.collectInputs(n, out));
return out;
}
// 計画を実行して値を求める
evaluate(node) {
if (node.type === 'INPUT') {
const raw = this.app.values[node.name];
if (raw === undefined || String(raw).trim() === "") {
throw new Error(`「${node.name}」が未入力です`);
}
const num = parseFloat(raw);
node.result = isNaN(num) ? raw : num;
return node.result;
}
const args = node.inputs.map(n => this.evaluate(n));
const f = node.func;
if (!f.formula) {
throw new Error(`タスク「${f.name}」に計算式がありません。タスク一覧で計算式を設定してください。`);
}
let value;
try {
// 計算式はこのプロジェクト内で定義したものだけを想定している。
// 外部から受け取った式をそのまま実行しないよう注意すること。
value = new Function('args', `"use strict"; return (${f.formula});`)(args);
} catch (e) {
throw new Error(`タスク「${f.name}」の計算式でエラー: ${e.message}`);
}
node.result = value;
Logger.add(`${f.name}(${args.join(', ')}) = ${value}`);
return value;
}
flatten(node, list = []) {
if (!node || node.type === 'INPUT') return list;
node.inputs.forEach(n => this.flatten(n, list));
if (!list.find(i => i.id === node.func.id)) list.push(node.func);
return list;
}
}
class UIManager {
static switchTab(id) {
['run', 'ai', 'types', 'funcs'].forEach(t => document.getElementById('tab-' + t).classList.add('hidden'));
const tabs = document.querySelectorAll('.panel:not(.panel-right) .tab');
tabs.forEach(e => e.classList.remove('active'));
document.getElementById('tab-' + id).classList.remove('hidden');
tabs[['run', 'ai', 'types', 'funcs'].indexOf(id)].classList.add('active');
}
static switchRightTab(id) {
['tree', 'metrics', 'log'].forEach(t => document.getElementById('tab-' + t).classList.add('hidden'));
const tabs = document.querySelectorAll('.panel-right .tab');
tabs.forEach(e => e.classList.remove('active'));
const el = document.getElementById('tab-' + id);
el.classList.remove('hidden');
if (id === 'log') el.style.display = 'flex';
tabs[['tree', 'metrics', 'log'].indexOf(id)].classList.add('active');
}
static updateFilename(name) { document.getElementById('filename-display').innerText = name; }
static updatePromptVisibility() {
const isPrompt = document.getElementById('run-goal').value === PROMPT_SENTINEL;
document.getElementById('prompt-wrap').classList.toggle('hidden', !isPrompt);
}
static refreshAll() { this.renderTypeList(); this.renderFuncList(); this.updateSelects(); }
static renderTypeList() {
const c = document.getElementById('type-list'); c.innerHTML = '';
if (app.types.length === 0) {
c.innerHTML = '<div style="text-align:center;color:#555;padding:20px;">リソースがありません</div>';
return;
}
app.types.forEach(t => {
const used = app.functions.filter(f => f.output === t).length;
c.innerHTML += `<div class="list-item">
<div style="flex:1"><span>${t}</span>
<span class="tag" style="margin-left:8px;">${used ? `${used}件のタスクが出力` : '入力値'}</span></div>
<span class="del-icon" onclick="app.deleteType('${t.replace(/'/g, "\\'")}')">×</span>
</div>`;
});
}
static renderFuncList() {
const c = document.getElementById('func-list'); c.innerHTML = '';
if (app.functions.length === 0) {
c.innerHTML = '<div style="text-align:center;color:#555;padding:20px;">タスクがありません</div>';
return;
}
app.functions.forEach(f => {
c.innerHTML += `<div class="list-item">
<div style="flex:1; min-width:0;">
<div style="font-weight:bold">${f.name}</div>
<div style="font-size:0.7em; color:#aaa; margin-bottom:2px;">${f.desc || ''}</div>
<div style="font-size:0.75em; color:#888;">${f.inputs.join(' + ') || '入力なし'} ➜ ${f.output}</div>
${f.formula ? `<div style="font-size:0.7em;color:#6a9;font-family:monospace;">${f.formula}</div>` : '<div style="font-size:0.7em;color:#a66;">計算式なし</div>'}
<div style="font-size:0.7em; margin-top:2px;"><span class="tag">${f.time}h</span><span class="tag">¥${f.cost}</span></div>
</div>
<span class="del-icon" onclick="app.deleteFunction('${f.id}')">×</span>
</div>`;
});
}
static updateSelects() {
const goalEl = document.getElementById('run-goal');
const outEl = document.getElementById('func-output');
const inEl = document.getElementById('func-inputs');
const prevGoal = goalEl.value;
goalEl.innerHTML = ''; outEl.innerHTML = ''; inEl.innerHTML = '';
goalEl.appendChild(new Option(PROMPT_SENTINEL, PROMPT_SENTINEL));
app.types.forEach(t => {
goalEl.appendChild(new Option(t, t));
outEl.appendChild(new Option(t, t));
inEl.appendChild(new Option(t, t));
});
if (prevGoal && [...goalEl.options].some(o => o.value === prevGoal)) goalEl.value = prevGoal;
this.updatePromptVisibility();
}
// --- 計画ツリー (項目名 + 値入力欄) ---
static renderTree(root) {
const c = document.getElementById('tree-list');
c.innerHTML = '';
document.getElementById('btn-execute').classList.remove('hidden');
this.renderTreeNode(root, 0, c);
}
static renderTreeNode(node, depth, container) {
const row = document.createElement('div');
row.className = 'tree-row';
row.style.paddingLeft = (depth * 16) + 'px';
const label = document.createElement('div');
label.className = 'tree-label ' + (node.type === 'INPUT' ? 'input' : (node.candidateCount > 1 ? 'alt' : 'func'));
if (node.type === 'INPUT') {
label.innerText = `${node.name}(入力が必要)`;
label.title = node.reason || '';
} else {
label.innerText = `${node.name} ← ${node.func.name}`;
label.title = node.func.desc || '';
}
row.appendChild(label);
if (node.type === 'INPUT') {
const input = document.createElement('input');
input.className = 'tree-value';
input.type = 'text';
input.placeholder = '値';
input.value = app.values[node.name] !== undefined ? app.values[node.name] : '';
// 値の反映だけを行い、ツリーは組み直さない
// (組み直すと入力中のフォーカスが飛び、入力済みの値が失われる)
input.oninput = (e) => app.setValue(node.name, e.target.value);
row.appendChild(input);
} else {
if (node.result !== undefined) {
const res = document.createElement('span');
res.className = 'tree-result';
res.innerText = '= ' + node.result;
row.appendChild(res);
}
if (node.candidateCount > 1) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm';
btn.innerText = `他${node.candidateCount - 1}件`;
btn.title = 'このリソースを出力する別のタスクに切り替えます';
btn.onclick = () => this.showCandidates(node);
row.appendChild(btn);
}
}
container.appendChild(row);
node.inputs.forEach(child => this.renderTreeNode(child, depth + 1, container));
}
static showCandidates(node) {
const cands = app.functions.filter(f => f.output === node.name);
const names = cands.map((f, i) => `${i + 1}. ${f.name} (${f.inputs.join('+') || '入力なし'})`).join('\n');
const ans = prompt(`「${node.name}」を出力するタスクを選んでください:\n\n${names}\n\n番号を入力:`, '1');
const idx = parseInt(ans, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= cands.length) return;
app.changeFunction(node.name, cands[idx].id);
}
static clearTree() {
document.getElementById('tree-list').innerHTML = '<div style="text-align:center; color:#555; padding:20px;">まだ計画がありません。<br>左の「計画を作る」から始めてください。</div>';
document.getElementById('btn-execute').classList.add('hidden');
document.getElementById('result-box').classList.add('hidden');
}
static renderMetrics(t, c) {
document.getElementById('metric-time').innerText = t.toFixed(1) + 'h';
document.getElementById('metric-cost').innerText = '¥' + c.toLocaleString();
}
static renderSchedule(list) {
const c = document.getElementById('schedule-list'); c.innerHTML = '';
if (list.length === 0) {
c.innerHTML = '<div style="text-align:center;color:#555;padding:20px;">実行するタスクはありません<br>(すべて入力値です)</div>';
return;
}
list.forEach((item, i) => {
c.innerHTML += `<div class="schedule-step">
<div class="step-header">${i + 1}. ${item.name}</div>
${item.desc ? `<div class="step-desc">${item.desc}</div>` : ''}
<div class="step-meta">出力: ${item.output} | ${item.time}h / ¥${item.cost}</div>
</div>`;
});
}
}
class Visualizer {
constructor(id) { this.container = document.getElementById(id); this.meshMap = new Map(); }
init() {
this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x0e0e0e);
const w = this.container.clientWidth, h = this.container.clientHeight;
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 1000); this.camera.position.set(0, 20, 40);
this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(w, h);
this.container.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const d = new THREE.DirectionalLight(0xffffff, 1); d.position.set(10, 30, 10); this.scene.add(d);
this.scene.add(new THREE.GridHelper(100, 20, 0x222222, 0x111111));
this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('click', e => this.onClick(e));
window.addEventListener('resize', () => {
const nw = this.container.clientWidth, nh = this.container.clientHeight;
this.camera.aspect = nw / nh; this.camera.updateProjectionMatrix(); this.renderer.setSize(nw, nh);
});
this.animate();
}
renderGraph(schedule) {
this.clearScene();
if (!schedule || !schedule.length) return;
schedule.forEach((node, i) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(4, 1, 2), new THREE.MeshLambertMaterial({ color: 0x00e676 }));
mesh.position.set((i * 8) - (schedule.length * 4), 1, (i % 2 === 0 ? 3 : -3));
mesh.userData = { info: node };
this.scene.add(mesh); this.meshMap.set(node.id, mesh);
this.addLabel(node.name, mesh.position);
node.inputs.forEach(inp => {
const prov = schedule.find(p => p.output === inp);
if (prov && this.meshMap.get(prov.id)) this.drawCurve(this.meshMap.get(prov.id).position, mesh.position);
else {
const start = mesh.position.clone().add(new THREE.Vector3(-3, -3, 0));
this.drawCurve(start, mesh.position, 0xff5252); this.addLabel(inp, start, 0.6);
}
});
});
this.controls.reset();
}
drawCurve(p1, p2, col = 0x2979ff) {
const mid = p1.clone().add(p2).multiplyScalar(0.5); mid.y += 2;
const pts = new THREE.QuadraticBezierCurve3(p1, mid, p2).getPoints(20);
this.scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: col })));
}
addLabel(txt, pos, s = 1) {
const cvs = document.createElement('canvas'); const ctx = cvs.getContext('2d');
cvs.width = 256; cvs.height = 64; ctx.font = "Bold 30px sans-serif"; ctx.fillStyle = "white"; ctx.textAlign = "center";
ctx.fillText(txt, 128, 42);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), transparent: true }));
sp.position.copy(pos); sp.position.y += 2; sp.scale.set(5 * s, 1.25 * s, 1);
this.scene.add(sp);
}
clearScene() { this.meshMap.clear(); this.scene.children = this.scene.children.filter(c => ["GridHelper", "AmbientLight", "DirectionalLight"].includes(c.type)); }
onClick(e) {
const r = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; this.mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const isects = this.raycaster.intersectObjects(this.scene.children);
if (isects.length > 0 && isects[0].object.userData.info) {
const i = isects[0].object.userData.info;
Toast.show(`${i.name}\n${i.desc || ''}\n(${i.time}h / ¥${i.cost})`);
}
}
animate() { requestAnimationFrame(() => this.animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); }
}
var app; window.onload = () => { app = new App(); app.initUI(); };
</script>
</body>
</html>



コメント