プロジェクト名: JSVisualizer
バージョン: 2.16
作成日: 2026-05-25
最終更新: 2026-09-29 (v2.16)
作成者: Tetsuo Tanaka
| バージョン | 日付 | 変更内容 |
|---|---|---|
| 0.1 | 2026-05-25 | 初版 |
| 0.2 | 2026-05-25 | 実装済みモジュール(step-controller, trace-builder, code-view, state-view, animated-trace, trace-table, scope-view, callstack-view, settings-panel)の設計を実態に合わせて更新。CSS テーマシステム、ファイル構成を更新 |
| 0.3 | 2026-05-26 | Phase 3 実装反映: LineTrace(行×変数トレース表), BarChart(棒グラフ), ColorBox(色付き箱), Timeline(時系列 SVG), Heatmap(実行頻度マップ)。TraceBuilder に buildHeatmap() 追加 |
| 0.4 | 2026-05-26 | Phase 4 実装反映: RecursionTree(再帰ツリー SVG), Lifetime(変数ライフタイム SVG Gantt), ControlFlow(制御フロー SVG)。TraceBuilder に buildRecursionTree / buildLifetime / buildControlFlow を追加 |
| 0.5 | 2026-05-26 | Phase 5 実装反映: MemoryView(スタック/ヒープ + SVG 矢印), ObjectGraph(力学レイアウト SVG グラフ)。SVG 設計パターンの統合、全ディレクトリ ✅ |
| 0.6 | 2026-05-26 | Phase 6 仕上げ反映: ViewSwitcher のキーボードタブ切り替え(1〜9)・localStorage 永続化。DebuggerAdapter のエラー種別判定(parse/runtime)。code-editor.js の showError(msg, errorType) とエラーバッジ。RecursionTree 色覚多様性対応(状態アイコン)。サンプルコード 17 種。Jest テスト 37 件。GitHub Actions CI/CD ワークフロー |
| 0.7 | 2026-05-26 | 修正 1〜8 反映: JSInterpreter assignTo 拡張(分割代入)。PaneResizer 追加。CodeMirror 6 エディタ化(Compartment・MutationObserver)。プログラム名表示。Console 常時パネル(state-view から分離)。LineTrace 改修(ソース列廃止・行高さ統一・スクロール同期・#varMeta 表示管理・D&D 列並び替え)。TraceTable に対象列追加(env diff・CallExpression・ReturnStatement)。テスト 42 件 |
| 0.8 | 2026-06-02 | callStack 順序バグ修正([0]=最外側・[last]=最内側に訂正)。CallTree ビュー新規追加(src/views/call-tree/)・TraceBuilder に buildCallTree() 追加。LineTrace 2ペイン化(ソースパネル+リサイズ+スクロール同期刷新)。ScopeView/StateView スコープ統合(mergeScopesForDisplay・formatFrameLabel)。Heatmap 時系列ドット+割合表示。RecursionTree 引数展開・NODE_W/H 拡大。Console パネル高さドラッグ変更(jsv-console-h)。localStorage jsv-lt-src-w 追加 |
| 0.9 | 2026-06-03 | mergeScopesForDisplay を lexical scope 対応に刷新(旧: 2スコープ/関数仮定 → 新: 最内側関数が全 env チェーンをマージ)。StateView CALL STACK: formatFrameLabel 未インポートバグ修正+スコープフレーム表示に変更。buildRecursionTree: 再帰呼び出しのみフィルタリング+cost プロパティ付与。buildCallTree: #buildFullCallTree() を内部共有メソッドとして独立化。RecursionTree: cost 表示追加(左下角 cost:N)+「再帰呼び出しがありません」メッセージ。Heatmap: 動的背景色(ステップ別更新)・ドット幅 3 倍(360px)・実行済み/未実行色分け・N回/M回 表示。MemoryView: mergeScopesForDisplay で正しいフレームラベル表示。テスト 49 件(buildCallTree テスト追加、buildRecursionTree テスト刷新) |
| 1.0 | 2026-06-04 | JSInterpreter に Environment.snapshotOwn() メソッドと Recorder.frameEnvStack(アクティブフレームの live Environment 参照スタック)を追加。各 TraceEvent に frameEnvs: Object[](外→内の callEnv スナップショット配列)を記録。mergeScopesForDisplay(scopes, callStack, frameEnvs) の第 3 引数を追加し、外側フレームの表示を reconstructFrameVars(args ベース)から frameEnvs[i](callEnv スナップショット)に変更。params・デフォルト引数・function-body 変数を正確に表示。V-01/V-04/V-13 が state.frameEnvs を参照するよう更新。AppState に frameEnvs フィールド追加。sv-scroll を flex→block 化(overflow-y: auto のスクロールバー修正) |
| 1.1 | 2026-06-04 | ScopeView・CallStackView をタブ非登録(非アクティブ)に変更。LineTrace を 2 ペイン構成から単一ペイン+行番号スニペット(lt-lineno-num + lt-lineno-snippet、先頭 15 文字)構成に刷新(#srcPanel・#srcLines・#setupScrollSync・#setupSrcResizer および jsv-lt-src-w を削除)。ColorBox: タブ名「配列」・複数配列同時選択(#selectedArrays: Set<string>)・ポインタ変数を変数ごと個別行表示・文字列切り詰めなし。Timeline: #renderSVG() 内で選択変数のみの dynMin/dynMax を計算して Y 軸を動的スケール化。Heatmap: #buildDots() で SVG polyline を含む .hm-connect-svg を生成し「連結線」ボタン(.hm-btn-lines)で .hm-show-lines クラスをトグル。JSInterpreter super() 呼び出しバグ修正(CallExpression ハンドラに node.callee.type === 'Super' の早期リターンを追加)。tests/core/samples.test.js 新規追加(17 サンプル全エラーなし・trace ≥ 1 を確認)。テスト総数 49 → 66 件。view-switcher 登録ビュー数 15 → 13(ScopeView・CallStackView 非登録) |
| 1.2 | 2026-06-04 | buildHumanIndices() に while/do-while/for 条件式・更新式 exit をイテレーションごと追加(matchIdx 範囲内の深さ D+1 exit を走査)。WhileStatement/ForStatement enter は humanStep から除外。LineTrace・ExecTrace に buildConditionExitSet() + 改訂 buildCondInfo() を追加し条件列を正確表示。ExecTrace(実行トレースタブ)を設計文書化。タブ登録順: 実行トレース → 全ステップ(app.js で入れ替え)。Heatmap: .hm-btn-lines トグルボタン廃止。#drawConnectLines() を init() 内で rAF 経由で呼び出し常時表示へ変更。.hm-overlay-svg(position:absolute)+ <line class="hm-vline"> で異なる行間を縦線表示。ColorBox: #scanTrace() を 2 パス化し配列ごとの maxWidth/maxGridHeight を事前計算。#render() で .cb-grid に min-width/min-height を設定(空配列時も同様)。.cb-box-area を flex-wrap:wrap 化・.cb-array-block に枠線+背景色・.cb-grid の min-width:100% 削除。JSInterpreter formatLogArg(v, depth=0): depth > 0 の文字列を 'str' 形式で表示(Node.js 互換) |
| 1.4 | 2026-06-08 | ExprTrace 改善: (1) VariableDeclaration: VariableDeclarator イベントが trace に存在しないため位置取得をソース正規表現+trace スキャンに変更。(2) セクション検出対象を拡張(IfStatement test・WhileStatement test イテレーション別・ReturnStatement 引数・ForStatement init/test/update イテレーション別)。(3) extractVarNames: 式テキスト内の識別子のみ(env 全変数追加の B を削除)。(4) buildSectionRows: Row 0 = enterIdx env、中間行 = exit 時点 env、最終行(rows≥2)= exitIdx env。(5) ExprTrace クラスに #trace フィールドを追加し、update() でアクティブ行の TD を trace[cursor].env からリアルタイム書き換え |
| 1.5 | 2026-06-16 | (1) format.js に formatValueDiff(val, prevVal) を追加(差分強調 HTML 生成)。LineTrace の update() でアクティブ行の変数セルに適用。ExecTrace の init() で全行に一括適用(let prevEnvMap = new Map() で前行の env を追跡)。CSS: --v-diff(ライト #c05000・ダーク #ff9f5e)と .v-diff クラスを追加。(2) ObjectGraph を力学的レイアウトから階層型レイアウトに全面改訂。hierarchicalLayout(nodes, edges) で Kahn トポソート + 最長パス法、layoutGraph(nodes, edges) で BFS 連結成分分離 + 縦積み上げ。肘型エッジコネクタ・ポートスプレッド(srcPort/dstPort Map)・ノード背景 6 色パレット・連結成分点線境界矩形(.og-comp-bg)を実装。(3) JSInterpreter Environment.snapshot() / snapshotOwn() を修正: 変数ごとに独立した seen WeakMap で deepClone を呼んでいた設計を、スコープチェーン全体で seen を共有するよう変更。同一元オブジェクトが複数変数から参照されるとき同一クローンにマッピングされ、ObjectGraph・MemoryView の WeakMap 追跡が正しく機能するよう修正 |
| 1.6 | 2026-06-16 | JSInterpreter の var/let/const セマンティクスを ES2022 仕様に準拠させる大規模修正。Environment に kind('block'/'function'/'global')・immutables: Set<string>・getFunctionScope()・markConst() を追加、TDZ_SENTINEL = Symbol('TDZ') を導入。hoistVars(var 宣言の関数スコープ巻き上げ)・hoistLexicals(let/const の TDZ 事前定義)・checkNoRedecl(let/const 再宣言検出)・markConstNames(const 不変マーク)を追加。ForStatement の for (let …) でイテレーションごとの iterEnv(クロージャ用)と updateEnv(更新式専用コピー)を生成し、クロージャが正しく各イテレーションの値を捕捉することを保証。全 249 テストがパス。詳細は § 1.3 を参照 |
| 1.3 | 2026-06-05 | SubstTrace(代入展開)・ExprTrace(式評価)ビューを新規追加。タブ登録数 14 → 16。SubstTrace: computeReturnExpr が ReturnStatement 引数を Identifier/CallExpression 逐次置換し buildSubstitutionLines で展開行を構築。CSS .stx-*。ExprTrace: buildSectionRows が exit イベントを走査して置換リスト(addSubstitution/applySubstitutions)を更新し行を生成。srcPosToDispPos / srcRangeToDispRange でソース座標→表示座標変換。CSS .xev-*。両ビューに expanded(橙)/ pending(青太字)の 2 色ハイライトを実装。app.js に SubstTrace・ExprTrace のタブ登録を追加 |
| 1.7 | 2026-06-16 | (1) エラー位置ジャンプ&ブリンク: debugger-adapter.js の load() でエラー発生時に loc(行・列)を抽出し CustomEvent('error') の detail に付与(抽出順: err.loc → err.line/column → メッセージ正規表現 `[Parser |
| 1.8 | 2026-07-17 | (1) タブ整理: trace-table(全ステップ表)・bar-chart(棒グラフ)・timeline(時系列)をタブ非登録に変更。タブ登録数 16 → 13(詳細は §3.6 の各ビュー節に非アクティブ注記を追加)。(2) ControlFlow 刷新: 旧 buildControlFlow()(エッジベース SVG)を buildCFG()(AST ベース DOM フローチャート)に置換。§3.6 control-flow/ 節を全面書き換え。詳細は ADR-018。(3) execCount 修正: CfgBuilder の行実行回数カウントを「行遷移時のみ」カウントに修正(同一行への複数 AST enter を 1 回として扱う)。(4) SubstTrace・ExprTrace オブジェクト展開: fmtPlain(v, depth) を追加し depth < 3 では値のみ再帰展開、depth >= 3 で {…} に省略。(5) サンプル拡充: Study Tasks 4 種追加(studyWarmup/studyTask1〜3)。サンプル総数 17 → 21、テスト総数 66 → 70 件 |
| 1.9 | 2026-07-20 | ヘッダーレイアウト刷新・ビュー説明バー: (1) Edit モードは Edit/Run ボタン+サンプルセレクト、Run モードは Edit/Run ボタン+ステップ操作バーを .app-header 中央に表示するモード切替に変更(footer 廃止)。.app-header.run-mode クラスで CSS 表示切替。(2) .step-controls-area を .ctrl-grid(ボタン群)+ .slider-area(スライダー+カウンタ)に分割し、flex-wrap で slider-area が 180px 未満のとき 2 行目に折り返す。body { min-width: 820px } + html { overflow-x: auto } で最小幅未満は横スクロール。(3) ビュー説明バー: ViewSwitcher コンストラクタが view-container 直前に .view-desc 要素を自動生成。register() の第 4 引数 description をタブ切り替え時に表示(詳細は §4.2 の更新箇所)。(4) ライトモード UI 改善: アクティブタブを白背景+青トップボーダー+青文字+太字(:root:not([data-theme="dark"]))。コンソール背景をライトモードのみ白に変更 |
| 2.0 | 2026-07-20 | 言語切替(i18n)システムを新規追加(ADR-025 参照)。src/i18n.js(STRINGS・t()/getLang()/setLang()・langchange イベント)を新設。§3.8 として詳細設計を追加。ViewSwitcher.register() の label/description が文字列 or {ja,en} オブジェクトを受け付けるよう拡張し resolveStr() ヘルパーを追加、setLang() メソッドで再描画(§4.2 更新)。localStorage('jsv-lang') を新設。§3.7 として session-logger.js(ADR-024・2026-07-16 導入)の詳細設計も本書に追記(従来 ADR のみに記載され本書に未反映だったため) |
| 2.1 | 2026-08-05 | Call Stack ビューの簡略化+CallTree/RecursionTreeの統合(ADR-026・ADR-027)。StateViewをCallStackViewにリネームし、重複していたCurrent Step/Variablesカードを削除してCall Stackカード1枚構成に変更。mergeScopesForDisplay()が返すlabel==='global'のフレームを返却順序によらず常に先頭表示するよう変更し、callStackが空でもグローバル変数を可視化(§3.6 state-view節を刷新)。CallTree/RecursionTreeはノード表示形式・cost計算(TraceBuilder#computeCost())を統合し、RecursionTreeのタブ登録をコメントアウトして非アクティブ化(タブ登録数13→12)。.ct-retval等の配色を--sortedカスタムプロパティに統一しコントラスト改善(§3.6 recursion-tree/call-tree節を刷新) |
| 2.2 | 2026-08-11 | 外部埋め込み利用者向けリアルタイム操作ログ送信フック(# BHV:タグ)を追加(ADR-028)。BhvVisualizer(別リポジトリ)からの<iframe>埋め込み・postMessageのinitハンドシェイクを受理した場合のみsessionLoggerが自動起動しリアルタイム送信するBHV経路を、既存のSTUDY経路(手動起動・手動エクスポート)に追加。logRunをsuccess/error統合形に拡張、logStepにloc/callDepthを追加、logVisibility/logLifecycleを新設(§3.7を全面刷新)。ハンドシェイクなし・埋め込みなしでは一切の副作用がないことをverify-bhv-hook.mjsで確認。以降、外部埋め込み利用者からの要求によるJSVisualizerの変更は都度ADRを追加する運用ルールを制定 |
| 2.3 | 2026-08-12 | URLクエリ(exerciseId/codeId/bhvApiBase)によるコード読み込みを追加(ADR-029)。src/core/exercise-source.jsを新設し§3.9として詳細設計を追加。クエリなし時は既定のFibonacciサンプル表示を維持する加算的な設計。同日中にADR-031で「完全なURL」方式へ再設計されたため、本バージョンのパラメータ形式は後続のv2.5で置き換えられた |
| 2.4 | 2026-08-12 | error-msgのhidden属性がCSSカスケードで無効化される不具合を修正(ADR-030)。作者定義の.error-msg{display:flex}がブラウザ既定の[hidden]{display:none}を上書きしていたため、.error-msg[hidden]{display:none}を追加し属性セレクタで詳細度を上げて解決(§4.3更新)。あわせて、重要な設計判断を伴う変更はJSVisualizer単体の改良・外部埋め込み利用者からの要求のいずれでも都度ADRを追加する運用ルールに改定 |
| 2.5 | 2026-08-12 | URLクエリを「ID+ベースURL」方式から「完全なURL」方式へ再設計(ADR-031)。exerciseId/codeId/bhvApiBaseを廃止しexercise/code(呼び出し元がfetch可能な完全なURLを直接渡す)に変更。レスポンス形式をtitle/codeのみに簡素化し、ID突き合わせロジックを廃止する代わりにexerciseのみ指定時は先頭コードを自動読み込みする仕様を追加(§3.9を現行仕様に更新)。後方互換シムなし(本番投入前のため) |
| 2.6 | 2026-08-12 | exerciseレスポンスのtitleをサンプル選択のプレースホルダに表示(ADR-032)。code-editor.jsにsetPlaceholderLabel(title)を追加し、data-i18n属性を外して言語切替で上書きされないようにする(§3.9・§4.3更新) |
| 2.7 | 2026-08-12 | exercise/code指定時に組み込みサンプルをサンプル選択から取り除く(ADR-033)。実機確認で「─ Exercise ─」グループが組み込み8グループの後ろに埋もれ見つけにくいという指摘を受け、addRemoteGroup()をsetRemoteCodes(items)に置き換え、組み込み<optgroup>を全削除してから指定コードだけを選択肢にする方式に変更(§3.9更新) |
| 2.8 | 2026-08-12 | code単体指定時もプレースホルダをコードタイトルにし、サンプル選択を選択不可にする(ADR-034)。disableSampleSelect()を追加し#sampleSelectLockedフラグでsetRunningMode()によるEdit/Run切替後も選択不可状態を維持する(§3.9・§4.3更新) |
| 2.9 | 2026-08-12 | エディタの折り返し表示を常時有効化(ADR-035)。EditorView.lineWrappingを常時オンにし、BhvVisualizer埋め込み時の問いコメント等、長い1行が横スクロールなしで折り返されるようにする(トグルは設けない、§4.3更新) |
| 2.10 | 2026-08-27 | URLクエリ(view)による初期表示ビューの指定を追加(ADR-036)。BhvVisualizer連携のセッション内pre/post設問で、ViewSwitcherが前回アクティブだったタブをlocalStorageから復元する既定挙動のままだと学生ごとに体験が揃わない問題があったため、ViewSwitcher.setInitialView(id)を新設し最初の実行1回だけlocalStorageの保存値より優先させる(localStorage自体は書き換えない)。§3.9・§4.2を更新 |
| 2.11 | 2026-09-24 | ExecTraceへのArraysポインタ・オーバーレイ統合(ADR-037)。評価実験ログの深掘り分析で、アニメーション型のArraysビューでは「ポインタが毎回1つズレている」というイテレーション横断のパターンに気づけなかった参加者がいたことが判明。ポインタ検出・配列グリッド描画ロジックをsrc/utils/array-grid.jsに共通化し、時間軸型のExecTraceの各行にも同じミニ図を統合(ポインタ検出時のみ描画、セル幅は変数名長から動的算出、表示枠幅はドラッグ+localStorageで変更可)。§3.6 exec-trace節・§5ディレクトリ構造を更新。tests/utils/array-grid.test.js(新規10件)を含めテスト総数96件(§9.1更新) |
| 2.12 | 2026-09-24 | 宣言前(TDZ)の変数がSymbol(TDZ)と表示される不具合を修正(ADR-038)。format.jsにisTDZ()(description === 'TDZ'判定)を追加し、formatValue()/formatValueDiff()は宣言前を空欄(lt-empty)で返す。あわせて Variable・ExecTrace・ExprTrace の「スコープに存在するか」の判定をv === undefinedからhas()系に改め、宣言前(空欄)と宣言後・未代入(undefined)を区別。§1.3 に表示側の扱いを追記。tests/utils/format.test.js(新規5件) |
| 2.13 | 2026-09-24 | 文単位ステップの不具合修正(初期位置での暴走・1文2クリック)(ADR-039)。step-controller.jsでdbg.stepOver()/stepBack()を直接呼ぶのをやめ、入れ物ノード(Program/BlockStatement)を読み飛ばす#stmtForwardOnce()と、それを先頭から再生して直前の着地点を求める#stmtBackwardOnce()に置き換え。JSInterpreter のstepOver()は変更しない。§3.2 を更新。step-controller.test.jsに6件追加 |
| 2.14 | 2026-09-28 | BhvVisualizer埋め込み時は設定パネルの評価実験用UIを取り除く(ADR-040)。index.htmlの STUDY MODE ブロックに#study-mode-sectionを追加し、app.jsのinit受理処理(# BHV:)で取り除く。§3.7・§4.5 を更新 |
| 2.15 | 2026-09-29 | 利用者向けマニュアル(日英)とヘッダーの使い方ボタン(ADR-041)。web/manual.html・web/manual.en.html・スクリーンショット(web/manual/{ja,en}、scripts/capture-manual-screenshots.mjsで撮り直し)を追加。ヘッダーの#btn-helpはapplyI18n()で表示言語に合わせたリンク先に切り替え、init受理時(# BHV:)に取り除く。あわせて、英語固定だった各ビューのデータなし表示(「No variables」等)をi18n.jsのキーに移した(コミット8b23054)。§3.7・§3.8・§5 を更新 |
| 2.16 | 2026-09-29 | Lifetime(変数寿命)ビューの記述を実装に合わせて訂正。§3.6 lifetime/ 節とディレクトリ構成の注記が、2026-06-16(v1.7 と同日、コミットd22ec59)にフレームグラフへ全面改定する前の「変数ライフタイム SVG Gantt」のままだった。データ取得(ビュー内 buildFlameSegments())・軸・レイアウト定数・色・バー内容・update() を現行実装どおりに書き直し、buildLifetime() がビューから使われていないことを注記した。ADRを伴わない変更だったため、v2.1〜2.11 の追いつき(ADR基準)からも漏れていた |
┌─────────────────────────────────────────────────────────────────────────┐
│ ブラウザ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐│
│ │ app.js (全体協調・イベントバス)/ i18n.js(日英切替・全モジュール横断) ││
│ └─────┬───────────┬─────────────────────────┬────────────────────────┘ │
│ │ │ │ │
│ ┌─────▼───────┐ ┌─▼──────────────────┐ ┌────▼──────────────────────┐ │
│ │ components/ │ │ core/ │ │ views/ │ │
│ │ ─────────── │ │ ───────────────── │ │ ───────────────────── │ │
│ │ code-editor │ │ debugger-adapter │ │ code-view ✅ │ │
│ │ pane- │ │ step-controller │ │ state-view ✅ │ │
│ │ resizer │ │ trace-builder │ │ scope-view ✅ │ │
│ │ step- │ │ session-logger │ │ line-trace ✅ │ │
│ │ controls │ └────────┬───────────┘ │ trace-table ✅ │ │
│ │ view- │ │ │ bar-chart ✅ │ │
│ │ switcher │ │ │ color-box ✅ │ │
│ │ settings- │ │ │ ... ✅ │ │
│ │ study-panel │ │ │ │ │
│ │ (STUDY用) │ │ │ timeline ✅ │ │
│ └─────────────┘ │ │ heatmap ✅ │ │
│ │ │ recursion-tree ✅ │ │
│ │ │ lifetime ✅ │ │
│ │ │ control-flow ✅ │ │
│ │ │ memory-view ✅ │ │
│ │ │ object-graph ✅ │ │
│ │ └───────────────────────────┘ │
│ ┌─────────────────────────▼──────────────────────────────────────────┐ │
│ │ interpreter.bundle.js │ │
│ │ (JSInterpreter を esbuild でバンドル) │ │
│ │ JSDebugger / trace[] / TraceEvent │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
ユーザーがコードを入力して ▶ Run
│
▼
debugger-adapter.js
new JSDebugger(source) → trace[](全ステップ記録)
adapter.moveTo(0) → 'ready' イベント dispatch
│
▼
app.js の 'ready' ハンドラ
new TraceBuilder(trace, source) → 事前集計データを生成
switcher.onReady(state, builder) → アクティブビューを再マウント
codeView.setSource(source) → コード行を描画
codeView.setTrace(trace) → callSiteEndMap を構築
│
ステップ操作(ボタン / キーボード / スライダー)
│
▼
step-controller.js
adapter.moveTo(nextCursor) → 'step' イベント dispatch
│
├──▶ codeView.update(state) → 3層ハイライト更新
├──▶ stepControls.update(state) → ボタン有効化・カウンタ更新
└──▶ switcher.update(state) → アクティブビューの update() を呼ぶ
外側フレームの変数を正確にキャプチャするため、以下の拡張を加えた。
Environment.snapshotOwn() (environment.js):
snapshotOwn() {
const frame = {};
const seen = new WeakMap(); // フレーム内で共有(同一オブジェクトを同一クローンにマッピング)
for (const [k, v] of this.bindings) {
frame[k] = deepClone(v, seen);
}
return frame;
}Environment.snapshot() (environment.js):
snapshot() {
const frames = [];
const seen = new WeakMap(); // スコープチェーン全体で共有(← v1.5 での重要な修正)
let cur = this;
while (cur) {
const frame = {};
for (const [k, v] of cur.bindings) {
frame[k] = deepClone(v, seen); // 同一元オブジェクト → 同一クローン参照
}
frames.push(frame);
cur = cur.parent;
}
return frames;
}v1.5 修正: 旧実装では各
deepClone(v)呼び出しが独立したseenWeakMap を持つため、グローバルスコープのlistと関数スコープのheadが同じ元オブジェクトでも別々のクローンとなり、ObjectGraph・MemoryView の WeakMap 追跡が機能しなかった。seenを全バインディングで共有することで同一元オブジェクトは常に同一クローンにマッピングされる。
Recorder.frameEnvStack (interpreter.js):
// Recorder コンストラクタ
this.frameEnvStack = []; // アクティブフレームの live Environment 参照スタック
// callFunction / newInstance で関数呼び出し時
recorder.frameEnvStack.push(callEnv); // push: フレーム開始
// 関数終了時
recorder.frameEnvStack.pop(); // pop: フレーム終了
// record() でイベント生成時
const frameEnvs = this.frameEnvStack.map(e => e.snapshotOwn());
// → TraceEvent.frameEnvs に格納frameEnvStack には callEnv(bindParams でパラメータを束縛した後の live な Environment)が積まれるため、
関数実行中に変化する変数(デフォルト引数、function-body let/const/var)もステップごとに正確に取得できる。
ES2022 仕様に従い、3 種の変数宣言を正しく区別するために以下の拡張を行った。
let/const は宣言前アクセスで ReferenceError(TDZ)を投げる必要がある。
宣言前のバインディングを undefined ではなく専用の sentinel 値で区別する:
const TDZ_SENTINEL = Symbol('TDZ');Environment.get() は取得値が TDZ_SENTINEL の場合 RuntimeError を投げる:
get(name, loc) {
if (this.bindings.has(name)) {
const v = this.bindings.get(name);
if (v === TDZ_SENTINEL)
throw new RuntimeError(`変数 '${name}' は初期化前にはアクセスできません`, ...);
return v;
}
...
}JSVisualizer 側の表示(ADR-038): deepClone() は Symbol を素通しするため、
トレースの env スナップショットには TDZ_SENTINEL がそのまま値として入る。src/utils/format.js の
isTDZ(v)(typeof v === 'symbol' && v.description === 'TDZ'。TDZ_SENTINEL はバンドルから export されていないため
description で判定)で検出し、formatValue()/formatValueDiff() は空欄(<span class="lt-empty">—</span>)を返す。
Variable・ExecTrace・ExprTrace は、変数が「そのステップのスコープに存在するか」を値の undefined 比較ではなく
Map.has() / hasOwnProperty(ExprTrace は hasVarInEnv())で判定する。これにより
「宣言前(空欄)→ 宣言後・未代入(undefined)→ 代入後(値)」の3状態を区別して表示する。
| フィールド / メソッド | 型 | 説明 |
|---|---|---|
kind |
'block'/'function'/'global' |
スコープ種別。コンストラクタで指定 |
immutables |
Set<string> |
const でバインドされた名前。set() で再代入を禁止 |
getFunctionScope() |
() → Environment |
kind === 'block' の間親をたどり、最初の function/global スコープを返す |
markConst(name) |
(string) → void |
immutables に名前を追加 |
set() での const チェック:
set(name, value, loc) {
if (this.bindings.has(name)) {
if (this.immutables.has(name))
throw new RuntimeError(`代入できません: '${name}' は const です`, ...);
...
}
}| 関数 | タイミング | 動作 |
|---|---|---|
hoistVars(node, funcEnv) |
Program enter・callFunction 前 |
AST 全体を走査し var 宣言を undefined で funcEnv に事前定義 |
hoistLexicals(node, env) |
Program・BlockStatement enter |
直接子の let/const 宣言を TDZ_SENTINEL で env に事前定義 |
checkNoRedecl(pattern, env) |
VariableDeclaration 処理時 |
パターン内の名前が env 内に TDZ_SENTINEL 以外で既存なら RuntimeError |
markConstNames(pattern, env) |
const 宣言の bindPattern 後 |
パターン内の全名前を env.markConst() |
var は常に env.getFunctionScope() にバインドされる:
const targetEnv = node.kind === 'var' ? env.getFunctionScope() : env;クロージャが各イテレーションの変数値を正しく捕捉するため、3 つの環境を使い分ける:
| 環境 | 役割 |
|---|---|
forEnv |
init 式の評価・test 式の評価・次イテレーションへの値引き継ぎ |
iterEnv |
各イテレーションのボディと、そのイテレーション内で定義されるクロージャが捕捉する環境 |
updateEnv |
update 式(i++ 等)を実行するための一時コピー。iterEnv を変更せずに次の値を計算し、結果を forEnv に書き戻す |
iterEnv が変更されないことで、update 実行後もクロージャは「更新前の値」を参照できる:
// 概略
const iterEnv = new Environment(env, 'block');
for (const name of loopVars) iterEnv.define(name, forEnv.bindings.get(name));
// → ボディ・クロージャは iterEnv を使う
const updateEnv = new Environment(env, 'block');
for (const name of loopVars) updateEnv.define(name, iterEnv.bindings.get(name));
evaluate(node.update, updateEnv, ...);
// → 結果を forEnv に書き戻すが iterEnv は不変のまま
for (const name of loopVars)
if (updateEnv.bindings.has(name)) forEnv.bindings.set(name, updateEnv.bindings.get(name));JSInterpreter が各実行ステップで生成するオブジェクト。trace[] 配列の各要素。
/**
* @typedef {Object} TraceEvent
* @property {'enter'|'exit'} phase
* 'enter': AST ノードへの処理を開始した時点(子ノード未評価・値未確定)
* 'exit': AST ノードの処理が完了した時点(値が ev.value に確定)
* @property {string} nodeType AST ノード種別(例: 'AssignmentExpression', 'IfStatement')
* @property {{line:number, column:number}} loc ノード開始位置(1始まり)
* @property {{line:number, column:number}} [end] ノード終了位置(式ノードのみ存在、inclusive)
* @property {number} depth AST ノードの深さ
* @property {number} callDepth 関数呼び出しの深さ(グローバルスコープ = 0)
* @property {Array} callStack 現在のコールスタック。push 順: [0]=最外側フレーム、[length-1]=最内側フレーム
* frame: { name, loc, args }
* loc = その関数を呼び出した CallExpression の start 位置
* 最内側フレームの取得: callStack[callStack.length - 1]
* @property {Array} env スコープチェーン(env[0] が最内側スコープ)
* @property {Object[]} frameEnvs 各アクティブフレームの callEnv スナップショット(外→内順、callStack と同一インデックス)
* Recorder.frameEnvStack の live Environment を snapshotOwn() で取得したもの。
* params・デフォルト引数・function-body 変数を含む(ブロックスコープは含まない)
* @property {any} [value] exit 時に確定した値(enter 時は undefined)
* @property {number} [matchIdx] stepOver() 用の対応 exit ステップのインデックス
*/phase の詳細:
インタープリタは AST を深さ優先で走査するため、各ノードに「入るとき」(enter)と「出るとき」(exit)の 2 回イベントを発火する。a = 1 + 2 の実行順序:
enter AssignmentExpression ← 代入式に入る(値未確定)
enter BinaryExpression ← 1+2 の計算を開始
enter Literal(1)
exit Literal(1) value=1
enter Literal(2)
exit Literal(2) value=2
exit BinaryExpression value=3
exit AssignmentExpression value=3 ← a=3 が確定
ev.valueが存在するのは exit イベントのみ- enter では子ノードがまだ評価されていないため、値は常に
undefined
全ステップ表 / AnimatedTrace での表示変換:
const symbol = ev.phase === 'enter' ? '▶' : '◀';
// 例: '▶ Assign'(代入式を開始), '◀ Assign'(代入式が完了し値確定)humanStep の判定基準 (buildHumanIndices() 内):
人間が紙でトレースする際に「記録する」タイミングを、enter/exit の組み合わせで定義する。
| 条件 | 対象ノード種別 |
|---|---|
| 文ノードの enter | ExpressionStatement, IfStatement, ForOfStatement, ForInStatement, BreakStatement, ContinueStatement |
| 副作用ノードの exit | VariableDeclaration, AssignmentExpression, UpdateExpression, ReturnStatement, ThrowStatement, CallExpression |
| while/do-while 条件式の exit(イテレーションごと) | WhileStatement/DoWhileStatement enter の matchIdx 範囲内で、深さ D+1・BlockStatement 以外の exit |
| for 条件式・更新式の exit(イテレーションごと) | ForStatement enter の matchIdx 範囲内で、深さ D+1・VariableDeclaration・BlockStatement 以外の exit |
WhileStatement/ForStatementの enter 自体は humanStep に含まない(各イテレーションの条件式 exit で代替)。
matchIdxでループ範囲を限定することで、ネストしたループに誤検出しない。
exit を採用する副作用ノードでは、値確定後(exit)に記録することで、変数の新しい値を表に反映できる。
責務: JSDebugger のライフサイクル管理・状態の正規化・差分検出
class DebuggerAdapter extends EventTarget {
#debugger = null // JSDebugger インスタンス
#prevEnv = null // 前ステップの env スナップショット
/** コードをコンパイルして全ステップを記録 → 'ready' イベント */
load(source) { ... }
/** cursor を移動して状態を更新 → 'step' イベント(payload = AppState) */
moveTo(nextCursor) { ... }
/** 正規化された現在状態を返す */
getState() { ... } // → AppState
/** trace-builder / code-view 用の全トレースデータを返す */
getTrace() { ... } // → TraceEvent[]
}
/**
* @typedef {Object} AppState
* @property {number} cursor 現在の cursor 値
* @property {number} totalSteps trace.length
* @property {TraceEvent|null} event 現在の TraceEvent
* @property {Object} variables getVariables('all') の結果
* @property {Object[]} scopes env[] スコープチェーン(env[0] = 最内)
* @property {Object[]} callStack getCallStack() の結果
* @property {Object[]} frameEnvs 各アクティブフレームの callEnv スナップショット(外→内順、callStack と対応)
* @property {string[]} changedVars 前ステップから変化した変数名
* @property {Object[]} consoleOutput getConsoleOutput() の結果
* @property {boolean} done isDone()
*/エラー種別判定 (load() の catch ブロック):
JSInterpreter のパーサーエラーは標準の SyntaxError クラスではなく、[Parser] 1:20: ',' を期待 形式のメッセージを持つ独自エラーオブジェクトとして投げられる。判定は以下の優先順で行う:
const isParseError = err instanceof SyntaxError
|| err?.name === 'SyntaxError'
|| /^\[Parser\]/i.test(msg) // JSInterpreter パーサー形式
|| /^(Unexpected token|Unexpected end of|SyntaxError|Invalid or unexpected)/i.test(msg);
// 'error' イベントの detail に errorType を追加
this.dispatchEvent(new CustomEvent('error', {
detail: {
message: err.message ?? String(err),
errorType: isParseError ? 'parse' : 'runtime',
},
}));app.js では e.detail.errorType を editor.showError(message, errorType) に渡す。
frameEnvs の取得 (#buildState() 内):
frameEnvs: ev?.frameEnvs ?? [],TraceEvent.frameEnvs は JSInterpreter の Recorder が各イベント生成時に
frameEnvStack.map(e => e.snapshotOwn()) で作成したスナップショット配列。
debugger-adapter.js はそのまま AppState.frameEnvs として公開する。
責務: 粒度別ステップ操作を統一インターフェースで提供
実装済みの公開メソッド一覧:
class StepController {
#adapter // DebuggerAdapter
goToStart() { this.#adapter.moveTo(0); }
goToEnd() { this.#adapter.moveTo(this.#adapter.getTrace().length); }
jumpTo(cursor) { this.#adapter.moveTo(cursor); }
// 式粒度(cursor ±1)
stepExprForward() { this.#adapter.moveTo(dbg.cursor + 1); }
stepExprBackward() { this.#adapter.moveTo(dbg.cursor - 1); }
// 文粒度(1クリック=1文、ADR-039)
stepStmtForward() { this.#stmtForwardOnce(dbg); this.#adapter.moveTo(dbg.cursor); }
stepStmtBackward() { this.#stmtBackwardOnce(dbg); this.#adapter.moveTo(dbg.cursor); }
// 人にやさしい粒度
stepHumanForward() { dbg.humanStep(); this.#adapter.moveTo(dbg.cursor); }
stepHumanBackward() { dbg.humanStepBack(); this.#adapter.moveTo(dbg.cursor); }
// 関数呼び出し粒度(callDepth 変化点まで cursor を移動)
stepCallForward() {
const trace = dbg.trace;
const startDepth = trace[dbg.cursor]?.callDepth ?? 0;
let next = dbg.cursor + 1;
while (next < trace.length && trace[next].callDepth === startDepth) next++;
this.#adapter.moveTo(Math.min(next, trace.length));
}
stepCallBackward() {
const trace = dbg.trace;
const startDepth = trace[dbg.cursor]?.callDepth ?? 0;
let prev = dbg.cursor - 1;
while (prev > 0 && trace[prev].callDepth === startDepth) prev--;
this.#adapter.moveTo(prev);
}
}文粒度の実装(ADR-039): 以前は JSInterpreter の dbg.stepOver()/stepBack() を
そのまま呼んでいたが、enter Program や関数本体の enter BlockStatement にも stepOver() が適用され、
実行直後に「文」を押すと最後まで飛ぶ・1文に2クリックかかる、という不具合があった。現在は次の2つで実装する
(JSInterpreter の stepOver() の意味は変えない)。
#stmtForwardOnce(dbg):STMT_CONTAINER_TYPES = new Set(['Program', 'BlockStatement'])のenterと前の文のexitをstepIn()で読み飛ばし、実際の文のenterに着いたらstepOver()でそのexitへ進む(1文の着地点)#stmtBackwardOnce(dbg):cursor=0から#stmtForwardOnce()を再生し、目的のcursorの直前の着地点に戻る (matchIdxの逆算ではネストしたブロックに入るべきかを判定できないため。前進と構造的に対称になる)
責務: trace 配列を一度だけ走査して各ビューが必要な集計データを生成。すべての結果はキャッシュ済み(2回目以降は O(1))。
class TraceBuilder {
#trace // TraceEvent[]
#source // 元ソースコード(string)
#humanIndicesCache // Set<number> | null
#heatmapCache // Map<number, number> | null
#recursionTreeCache // Object[] | null
#callTreeCache // Object[] | null
#lifetimeCache // Object[] | null
#controlFlowCache // Object | null
constructor(trace, source = '') { ... }
// ── Phase 1 ─────────────────────────────────────────────────────────────
/**
* humanStep で停止するトレースインデックスの Set を返す。
* 停止条件: 文ノードの enter(ExpressionStatement 等)+
* 副作用ノードの exit(AssignmentExpression 等)
* @returns {Set<number>}
*/
buildHumanIndices()
/**
* humanStep インデックスの配列をソート済みで返す。
* @returns {number[]}
*/
getHumanStepList()
// ── Phase 3 ─────────────────────────────────────────────────────────────
/**
* 行ごとの実行回数を返す(enter フェーズのみカウント)。
* @returns {Map<number, number>} Map<行番号(1始まり), 実行回数>
*/
buildHeatmap()
// ── Phase 4 ─────────────────────────────────────────────────────────────
/**
* 再帰呼び出しのみを含むツリーのルートノード配列を返す。
* #buildFullCallTree() で全呼び出しツリーを構築後、
* child.funcName === parent.funcName の子のみ保持(再帰フィルタリング)。
* 再帰的な子を持たないルートは除外(非再帰プログラムでは空配列)。
* cost プロパティ: node.cost = 1 + Σ(子のcost)(サブツリーサイズ)。
* ノード: { id, funcName, args, returnVal,
* callStepIdx, returnStepIdx, treeDepth, children[], cost }
* @returns {Object[]} ルートノード配列
*/
buildRecursionTree()
/**
* 全関数呼び出しツリーのルートノード配列を返す(CallTree ビュー用)。
* 内部の #buildFullCallTree() を利用し、全ノードに #computeCost()
* (buildRecursionTree() と共有)で cost プロパティを付与する。
* @returns {Object[]} ルートノード配列
*/
buildCallTree()
/**
* 変数ライフタイム情報を返す(humanStep 単位)。
* ※ 2026-06-16 に Lifetime ビューをフレームグラフへ改定して以降、ビューからは使われていない(テストのみ)。
* エントリ: { varName, callDepth, startHi, endHi }
* 同名変数が異なる callDepth で現れる場合は別エントリ。
* @returns {Array<{varName:string, callDepth:number, startHi:number, endHi:number}>}
*/
buildLifetime()
/**
* 制御フローグラフデータを返す。
* humanStep を順に辿り、行番号の遷移からノード・エッジを構築。
* ノード: { lineNo, text, count, firstSeen }
* エッジ: { from, to, count }
* @returns {{ nodes: Object[], edges: Object[], humanSteps: number[] }}
*/
buildControlFlow()
// ── ゲッター ─────────────────────────────────────────────────────────────
get trace() // TraceEvent[](ビュー側での参照用)
get source() // string(元ソースコード)
get length() // number(trace.length)
}isFunctionVal(v) ヘルパー(TraceBuilder 内部・Variable(旧LineTrace)・ObjectGraph で共通使用):
function isFunctionVal(v) {
if (typeof v === 'function') return true;
if (v && typeof v === 'object') {
return v.__type__ === 'JSFunction' || v.__type__ === 'JSClass';
}
return false;
}全ビューが実装するメソッド:
class BaseView {
/**
* @param {HTMLElement} container マウント先 DOM 要素
* @param {TraceBuilder} builder 事前集計データ(null の場合もあるが init 時は常に渡される)
*/
init(container, builder) { throw new Error('not implemented'); }
/**
* ステップ変化時に呼ばれる
* @param {AppState} state
*/
update(state) { throw new Error('not implemented'); }
/** 状態を初期化(コード再実行時) */
reset() { throw new Error('not implemented'); }
/** DOM をアンマウント(ビュー切り替え時) */
destroy() { throw new Error('not implemented'); }
}Phase 4 / Phase 5 の SVG ビューは共通パターンに従って実装されている。
| パターン | 説明 | 使用ビュー |
|---|---|---|
| 静的 SVG | init() で全要素を生成し update() で属性変更のみ |
CallTree, Lifetime, ControlFlow |
| 動的 SVG | init() でレイアウト計算&生成、update() で位置・色を更新 |
Timeline, ObjectGraph |
| SVG オーバーレイ矢印 | DOM 要素上に position: absolute の SVG を重ねてベジェ曲線を描画 |
MemoryView |
| rAF 遅延描画 | DOM レイアウト確定後に requestAnimationFrame で矢印座標を計算 |
MemoryView |
| 階層型レイアウト | Kahn トポソート + 最長パス法で列割当。同じプロパティエッジが左→右に統一 | ObjectGraph |
SVG 座標系の共通規則:
PAD_X/PAD_Yで描画領域にパディングを確保viewBoxではなくwidth/heightを動的に設定- マーカー要素(矢印頭)は
<defs>内に定義しmarker-end属性で参照
構造:
CodeView
├── #linesEl (.cv-lines)
│ ├── .cv-line[data-line="1"]
│ │ ├── .cv-line-num
│ │ └── .cv-line-code (position: relative; isolation: isolate)
│ │ ├── [syntax spans]
│ │ ├── .cv-expr-highlight (position: absolute)
│ │ └── .cv-callsite-highlight (position: absolute)
│ └── ...
├── #exprHighlightEls[]
├── #callSiteHighlightEls[]
└── #callSiteEndMap Map<"line:col", {line, column}>
公開 API: init(container), setSource(source), setTrace(trace), update(state), reset()
ハイライト配置の仕組み:
.cv-line-code { position: relative; isolation: isolate; }
.cv-expr-highlight, .cv-callsite-highlight {
position: absolute;
left: calc(startCh * 1ch); /* JS で style.left に設定 */
width: calc(lengthCh * 1ch); /* JS で style.width に設定 */
z-index: -1;
}
呼び出し元 end 位置の取得フロー:
setTrace(trace) で CallExpression.enter イベントを走査
→ key = "loc.line:loc.column"
→ value = ev.end
→ callSiteEndMap に格納
update(state) で callStack.length > 0 の場合:
→ topFrame = callStack[callStack.length - 1] ← 最内側フレーム([0]=最外側、[last]=最内側)
→ key = "topFrame.loc.line:topFrame.loc.column"
→ end = callSiteEndMap.get(key)
→ setHighlight(topFrame.loc, end, 'cv-callsite-highlight', ...)
構成カード(スクロール可能な縦並び、カードは Call Stack の1枚のみ):
- Call Stack —
mergeScopesForDisplay()が返すフレームを描画。label === 'global'のフレームは返却順序(関数呼び出し中は末尾)に関わらず常に先頭に「Global」として表示し、callStack が空でもグローバル変数を可視化する。変化した変数にvar-flashアニメーション
旧 Current Step カード(phase/nodeType/depth/callDepth 等)・旧 Variables カード(Call Stack の最内側フレームと重複していた)は ADR-026 で削除済み。 Console 出力は
#console-panel(debug-pane下部固定)に分離済み。app.jsのupdateConsolePanel(state)が'ready'/'step'イベントごとに更新する。
スクロール実装 (.sv-scroll の CSS):
display:flex; flex-direction:column 内で overflow-y:auto は機能しないため、.sv-scroll を block 表示に変更。カード間の余白は隣接兄弟セレクタ (.debug-card + .debug-card { margin-top: 8px }) で設定する。
.sv-scroll {
flex: 1 1 0;
overflow-y: auto;
padding: 8px;
min-height: 0;
/* display:flex を除去することで overflow-y: auto が正常動作 */
}スコープ統合表示 (mergeScopesForDisplay(scopes, callStack, frameEnvs) in format.js):
JavaScript は lexical scoping を採用しており、JSInterpreter の callFunction は
new Environment(callee.closure) でスコープを作成する(呼び出し元スコープではなく定義元スコープが親)。
このため同一スコープレベルで定義された関数間(例: quickSort と partition)や
再帰呼び出し(factorial(3)→factorial(2))では、外側フレームのスコープが
env チェーンに含まれない。
外側フレームの変数を正確に表示するため、JSInterpreter の Recorder は各フレームの
callEnv(Environment オブジェクト)への参照を frameEnvStack で管理し、
TraceEvent 生成時に snapshotOwn()(自スコープのみのスナップショット)を呼んで
frameEnvs として記録する。これにより params・デフォルト引数・function-body 変数が
正確にキャプチャされる。
callStack の順序: [0]=最外側, [N-1]=最内側(現在実行中)
scopes の順序: [0]=最内側スコープ, [M-1]=グローバル
frameEnvs の順序: [0]=最外側フレーム, [N-1]=最内側フレーム(callStack と同一インデックス)
アルゴリズム(v1.0 以降):
最内側関数: scopes[0]〜scopes[M-2] を外→内の順でマージ(内側が外側を上書き)
ブロックスコープ・クロージャチェーンを含む完全な変数リスト
外側関数: frameEnvs[i] を使用(callEnv スナップショット)
params・デフォルト引数・function-body let/const/var を含む
グローバル: scopes[M-1]
表示順: innermost-first(最内側が先頭)
<div class="scv-frame scv-frame--active">
<div class="scv-frame-header">
<span class="scv-frame-name">factorial(6)</span>
</div>
<div class="scv-vars">
<div class="var-row"> n = <span class="v-num">6</span> </div>
</div>
</div>DOM 構造:
.lt-outer (flex column)
└── .lt-wrap (flex column)
├── .lt-toolbar (列表示切替ボタン群)
└── .lt-table-wrap (縦スクロール領域)
└── .lt-table
├── thead .lt-thead-row
│ ├── th[0]: 行番号列(.lt-lineno-num + .lt-lineno-snippet)
│ └── th[n]: 変数列
└── tbody .lt-tbody
└── tr[data-line] × ソース行数
├── td.lt-lineno: <span class="lt-lineno-num">N</span>
│ <span class="lt-lineno-snippet">先頭15文字</span>
└── td[n]: 変数値セル
動作:
init()でソース行をパースして行番号・スニペット(先頭 15 文字)付きの<tr>を静的生成(ソースパネルなし)update()でhumanSteps[0..cursor]を走査:- 各 humanStep の
flattenEnv(ev.env)から変数スナップショットを取得 - 新規変数が出現したら列を追加(
#rebuildColumnsで<th>+ 全行に<td>を挿入) - 変化したセルに
.lt-flash→ CSS flash アニメーション - 現在実行行の
<tr>に.lt-row--activeを付与してスクロール追従
- 各 humanStep の
関数・クラス値は列から除外: isFunctionVal(val) で判定
列メタデータ管理:
/** @type {Array<{name: string, visible: boolean}>} */
#varMeta = [];visible: false の列ヘッダー・セルには .lt-col-hidden(display: none)を付与。
ツールバー(列表示切替): #rebuildToolbar() でヘッダー上部に .lt-var-toggle ボタンを生成。クリックで #toggleVar(name) → #varMeta[i].visible を切り替え
ドラッグ&ドロップ列並び替え:
<th draggable="true">にdragstart/dragover/dropを設定drop時:#varMetaの src/dst インデックスを入れ替え →#rebuildColumnsを再呼び出し
タブ名: 実行トレース
init()で全行を実行順(humanStep インデックス順)に一括描画。行は文の実行ごと(src/utils/line-exec.jsのbuildExecRows(trace, humanSteps)):buildLineExecOwners()(Heatmap と共通)で同じ文の実行に属する連続した humanStep を 1 行にまとめる(console.log(x);の ExpressionStatement enter と CallExpression exit、i++;の ExpressionStatement enter と UpdateExpression exit など)。変数値はその行の最後の humanStep 時点(文の実行後)、行番号は最初の humanStep のものを使う。途中で関数呼び出しの中に入る文は連続しないため、呼び出し前と戻った後の 2 行に分かれる。Program enter は行にしない(update()でカーソルが Program enter にあるときはどの行も強調しない)update()はet-row--activeクラスの付け替えと scrollIntoView のみ(O(n))
列構成: # | 行 | コード(先頭 30 文字)| 配列列(配列が登場する場合のみ)| 変数値列(出現順)| 条件式列(出現順)
差分強調: init() 時に let prevEnvMap = new Map() で前行の env を追跡し、変数セルを formatValueDiff(v, prevEnvMap.get(name)) で描画する。
配列列(配列+ポインタのミニ図、ADR-037):
評価実験ログの深掘り分析(docs/study/paper-research-notes.md)で、Arrays(アニメーション型、現在の1ステップのみ描画)に長時間費やしても「ポインタが毎回1つズレている」というイテレーション横断のパターンに気づけなかった参加者がいたことが判明した。ExecTrace は既に「行=humanStep のタイムライン」を持つため、Arrays と同じポインタ・オーバーレイ表現をここに統合すれば、縦スクロールするだけでそのパターンを比較できる。
- ポインタ検出・配列グリッド描画ロジックは
src/utils/array-grid.jsに共通化し、Arrays(color-box/index.js)と共有する:computeSubscriptVars(source): ソース中でidentifier[varNameの形に登場する識別子(配列添字候補)のホワイトリストを抽出する純関数detectPointerVars(vars, arr, subscriptVars, arrayVarNames): 1スナップショットからポインタ変数(BUILTIN_NAMES除外・他の配列変数名除外・subscriptVars必須・整数かつ[0, arr.length))を検出しMap<name, idx>を返すrenderArrayGrid({ arrName, arr, ptrByName, cellPx, fontPx, maxVal, idxHeightPx, valHeightPx, ptrHeightPx, ... }): インデックス行・値行・ポインタラベル行の3行を持つグリッドHTMLを生成する。行高(idxHeightPx/valHeightPx/ptrHeightPx)はセル幅(cellPx)から独立して指定でき、ExecTrace 側はコンパクトな固定値を渡す(Arrays 側は従来通りcellPxに比例させる)valueToBoxColor(val, maxVal): 値の大きさに応じた背景色(Arrays から移設)
init()の変数名収集ループで、値がArray.isArray()となる変数名も同時にarrayVarNames: Set<string>として収集する(追加のトレース走査なし)。配列が1つも登場しないプログラムでは配列列自体を出さない- 各行では、そのステップでポインタが1つ以上検出された配列のみミニ図を描画する(ポインタが無い配列・ステップでは描画しない。数値自体は既存の変数列で見えているため、ミニ図は「ポインタ位置の比較」という狙いに絞る)
- セル幅はポインタ候補の最長変数名(
minIdx等)の文字数から動的に算出し、ラベルが見切れないようにする - 表示枠の幅(
.et-col-diagram/.et-diag-scrollのmax-width)はCSS変数--et-diag-wで管理し、列ヘッダーのハンドル(.et-diag-resize-handle)をドラッグして変更できる。pane-resizer.jsと同じ「mousedownで開始→documentのmousemoveで追従→mouseupで終了」パターンで、100〜500pxにクランプしlocalStorage('jsv-exectrace-diagram-w')に永続化する。ViewSwitcher.onReady()によるタブ切替のたびのdestroy()→再マウントに対応するため、documentに追加したmousemove/mouseupリスナーはdestroy()で確実に解除する
条件式列の実装(Variable と共通ロジック):
// 事前計算: while/do-while/for の条件式 exit インデックスを Set に収集
function buildConditionExitSet(trace) { ... }
// 各 humanStep で条件式情報を取得
function buildCondInfo(trace, si, lines, conditionExitSet) {
// Case 1: while/for 条件式 exit → イベント自体の value を使用
if (conditionExitSet.has(si)) { return { text: extractCondText(...), value: ev.value }; }
// Case 2: IfStatement/ConditionalExpression enter → 直後 boolean exit を探索
if (ev.phase === 'enter' && CONDITION_NODES.has(ev.nodeType)) { ... }
}extractCondText(lines, loc, end) は loc・end の 1-based column を使って lineText.slice(col-1, end.column) でソーステキストを抽出する。
init()でbuilder.getHumanStepList()の全行を一括描画update()はtt-row--activeクラスの付け替えとスクロールのみ
「対象」列の実装(#buildRow(humanNum, stepIdx, trace, prevStepIdx)):
switch (ev.nodeType) {
case 'VariableDeclaration':
case 'AssignmentExpression':
case 'UpdateExpression': {
// env diff で変化した最初の変数名を取得(関数・クラス値は除外)
const prev = flattenEnv(prevEv?.env ?? []);
const curr = flattenEnv(ev.env ?? []);
target = firstChangedVar(prev, curr); // BUILTIN_NAMES・isFunctionVal でフィルタ
break;
}
case 'ReturnStatement':
target = 'return';
break;
case 'CallExpression': {
const cs = ev.callStack;
const frame = cs?.[cs.length - 1]; // 最内側フレーム([0]=最外側、[last]=最内側)
if (ev.phase === 'enter') target = `${frame?.name}(${args.join(', ')})`;
else target = frame?.name ?? '?';
break;
}
}列の CSS: .tt-col-target { width: 140px; color: var(--accent); font-family: monospace; }
init()で空テーブルを生成update()で新ステップ時に行を先頭挿入(.at-row--newslide-in)、ステップバック時に削除- ※
line-traceが同機能をより見やすく代替しているため、現在はタブ非登録
表示対象: 数値変数・数値配列(init() 時に trace を走査して自動検出)
チップ: 変数ごとの選択トグル。複数選択可。選択状態は #selectedVars: Set<string> で管理
棒の色: valueToHsl(val, maxVal) — HSL 220(青)→ 0(赤)の線形補間
DOM 構造:
<div class="bc-wrap">
<div class="bc-chips"> <!-- 変数選択チップ -->
<button class="bc-chip bc-chip--active">arr</button>
</div>
<div class="bc-chart"> <!-- グラフ本体 -->
<div class="bc-group" data-var="arr">
<div class="bc-bars">
<div class="bc-bar-wrap">
<div class="bc-bar" style="height: 40%; background: hsl(...)"></div>
<span class="bc-label">3</span>
</div>
</div>
</div>
</div>
</div>アニメーション: height に transition: height 0.2s ease を適用(CSS)
表示対象: 配列変数(init() 時に trace を走査して自動検出)
チップ: 配列変数の複数選択可能トグル(#selectedArrays: Set<string>)。最後の 1 つは選択解除不可
ポインタ検出: スコープ内の整数変数をポインタ候補として自動検出し、対応する配列インデックスの箱をハイライト。ポインタ変数はポインタ変数ごとに個別の .cb-ptr-row として表示
文字列値: 切り詰めなしで全文表示
オブジェクト・配列要素(2026-08-05 改修): 従来は '?' プレースホルダーだったが、formatValue(v)(format.js、色付きHTML)でキーと値のペアをそのままセルに表示するよう変更。セル幅は要素の中で最も長いオブジェクトのプレーンテキスト表現(formatObjectPlain(v)、幅計測専用のヘルパー)の文字数から #cellWidth(len, maxObjectTextLen) で算出し、通常の長さベース幅より広く必要な場合は自動的に拡大する。オブジェクト要素を含む配列はフォントサイズを固定(OBJ_FONT = 10px)にし、セル幅に比例する数値セル用のフォント計算式は使わない(幅が文字数由来のため比例させると破綻する)。
最大サイズ事前計算 (#scanTrace() 第 2 パス):
// 各 humanStep で #cellWidth(len, maxObjectTextLen) を用いて CELL・IDX_H・PTR_H を計算し最大値を記録
m.maxWidth = Math.max(m.maxWidth, len * CELL);
m.maxGridHeight = Math.max(m.maxGridHeight, IDX_H + CELL + ptrCount * PTR_H);#render() で .cb-grid に min-width: ${maxWidth}px; min-height: ${maxGridHeight}px を inline style で設定する。空配列時(arr.length === 0)も同じ min-width/min-height を設定し、「配列が空です」メッセージを内包する。
折り返しレイアウト: .cb-box-area が display: flex; flex-wrap: wrap; align-content: flex-start; align-items: flex-start で、幅不足時に次の行へ折り返す。
枠線・背景色: .cb-array-block に border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); margin: 4px を設定し、配列ブロックの境界を視覚化する。
DOM 構造:
<div class="cb-wrap">
<div class="cb-chips">...</div>
<div class="cb-box-area"> <!-- flex-wrap: wrap -->
<!-- 選択配列ごとに1ブロック(枠線・背景付き) -->
<div class="cb-array-block">
<div class="cb-array-name">arr</div>
<div class="cb-grid" style="min-width:Npx;min-height:Npx">
<div class="cb-row cb-idx-row">...</div>
<div class="cb-row cb-val-row">...</div>
<!-- ポインタ変数ごとに個別行 -->
<div class="cb-row cb-ptr-row">...</div>
</div>
</div>
</div>
</div>事前計算 (init() 内): builder.getHumanStepList() を走査し、humanStep ごとの数値変数スナップショット #history を構築
#history = [
{ stepIdx: 42, vars: Map<name, number> },
...
]SVG 構造: チップで選択した変数ごとに折れ線グラフ(<polyline>)を描画。カーソル縦線(<line class="tl-cursor">)が update() 時に X 座標のみ更新
Y 軸動的スケール: #renderSVG() 内で、描画前に選択変数のみの値から dynMin/dynMax を計算する。未選択変数はスケールに影響しない。
let dynMin = Infinity, dynMax = -Infinity;
for (const snap of this.#history) {
for (const name of this.#selectedVars) {
const v = snap.vars.get(name);
if (v !== undefined) { if (v < dynMin) dynMin = v; if (v > dynMax) dynMax = v; }
}
}
if (!isFinite(dynMin)) dynMin = this.#minVal;
if (!isFinite(dynMax)) dynMax = this.#maxVal;
if (dynMin === dynMax) { dynMin -= 1; dynMax += 1; }座標変換:
const PAD = { top: 12, bottom: 28, left: 44, right: 12 };
const xOf = (i) => PAD.left + (i / (history.length - 1)) * (svgW - PAD.left - PAD.right);
const yOf = (val) => PAD.top + (1 - (val - dynMin) / (dynMax - dynMin)) * (svgH - PAD.top - PAD.bottom);初期化: ドット配置のみ静的に生成。背景色は update() で動的更新。
lineTimeline: Map<lineNo, number[]> — 各行が実行された humanStep インデックスの配列を事前計算。
行の実行の数え方(src/utils/line-exec.js の buildLineExecOwners(trace, humanSteps)、ExecTrace と共通): 1 つの文が複数の humanStep を持つことがある(sum += count; は ExpressionStatement enter と AssignmentExpression exit、return n * f(n - 1); は CallExpression exit と ReturnStatement exit)。humanStep をそのまま数えると実行回数が 2 倍になるため、humanStep を囲む文のうち「同じ行・同じ callDepth にあるループ以外の最も外側の文」を単位とし、同じ文の実行に属する humanStep は最初の 1 つ(代表 hi)に寄せる。while/do-while/for の条件式・更新式はイテレーションごとに 1 回と数える。Program enter(hi=0)は数えない(-1)。ドット・連結線・回数はすべて代表 hi だけで構成し、update() では現在の hi を代表 hi に置き換えてから past/current を判定する。背景色の上限 maxTotal も lineTimeline の長さから求める(buildHeatmap() は使わない)。
動的背景色 (update() で毎ステップ更新):
// バイナリサーチで現在ステップまでの実行回数を算出
const alpha = currentCount === 0 ? 0 : 0.08 + (currentCount / maxTotal) * 0.47;
el.style.background = `rgba(255,140,0,${alpha.toFixed(3)})`;実行回数表示: 各行の右端に ${currentCount}回 / ${totalCount}回 を表示(currentCount = 現在ステップまでの回数、totalCount = 全体での総回数)。ステップごとに更新。
時系列ドット: 各行を実行した humanStep インデックスごとに <span class="hm-dot"> を生成
style="left: ${(hi / total) * 100}%"で水平位置を決定(幅 360px の相対配置コンテナ内)- 表示上限 DOT_MAX=200 個(超過時は先頭を省略)
- クラス分類 (
update()毎に全ドットを更新):hi < cursor_hi→.hm-dot--past(アクセントカラー、実行済み)hi === cursor_hi→.hm-dot--current(強調表示)- それ以外 → デフォルト(薄いグレー、未実行)
連結線(常時表示): init() 完了後に requestAnimationFrame(() => this.#drawConnectLines()) で描画を開始。.hm-lines(position: relative)内に単一のオーバーレイ SVG(.hm-overlay-svg、position: absolute; top:0; left:0; pointer-events:none)を配置し、異なる行に遷移する連続 humanStep ペア(#crossLinePairs)のドット間を <line class="hm-vline"> で結ぶ。座標は getBoundingClientRect() と scrollTop で .hm-lines コンテンツ座標に変換する。トグルボタン(.hm-btn-lines)は廃止。
// #drawConnectLines() 座標計算
const x1 = rA.right - linesRect.left;
const y1 = rA.top + rA.height / 2 - linesRect.top + scrollTop;
const x2 = rB.left - linesRect.left;
const y2 = rB.top + rB.height / 2 - linesRect.top + scrollTop;CSS: .hm-vline { stroke: var(--accent); stroke-width: 1; stroke-opacity: 0.4; fill: none; }
update(): 全行の背景色・カウントテキスト・ドットクラスを更新し、アクティブ行に .hm-line--active を付与
ADR-027 により、下記 call-tree/ がノード表示形式・cost 表示を統合したため非アクティブ化。以下は参照実装としての設計記録(call-tree/ も同一のレイアウト・表示ロジックを採用している)。
データ取得: builder.buildRecursionTree() → ルートノード配列
レイアウト定数:
const NODE_W=160, NODE_H=80, COL_GAP=20, ROW_GAP=52, PAD_X=24, PAD_Y=24;
// NODE_W/H を拡大して引数の 2 行表示に対応サブツリー幅の計算(再帰):
function calcSubtreeWidth(node) {
if (node.children.length === 0) return NODE_W;
const childrenW = node.children.reduce((s, c) => s + calcSubtreeWidth(c), 0);
return childrenW + (node.children.length - 1) * COL_GAP;
}ノード状態クラス:
| 状態 | 条件 | CSS クラス |
|---|---|---|
| 未呼び出し | callStepIdx > cursor |
rt-node--future |
| 実行中 | returnStepIdx === null または > cursor |
rt-node--active |
| 完了 | returnStepIdx <= cursor |
rt-node--done |
引数表示: fmtArgsLines(args) で引数リストを最大 2 行に分割して表示。配列値は要素を展開して [1,2,3] 形式で表示。
SVG 要素: ノードごとに <g class="rt-node rt-node--*"> 内に以下の要素を配置:
<rect class="rt-rect">— ノード枠<text class="rt-name" y=18>— 関数名(行 1)<text class="rt-args" y=35>— 引数行 1(行 2)<text class="rt-args" y=50>— 引数行 2(行 3、長い場合のみ)<text class="rt-retval" y=65 or 52>— 戻り値(引数が 1 行なら y=52)<text class="rt-state-icon" y=14>— 状態アイコン(右上角)
エッジは <line class="rt-edge">
色覚多様性対応 (Phase 6 追加):
各ノードの右上角(x=NODE_W-8, y=14)に状態アイコンテキストを配置し、色に依存しない状態識別を実現する。
// update() でのアイコン設定
stateT.textContent = stCls === 'rt-node--future' ? '…'
: stCls === 'rt-node--active' ? '▶'
: '✓';CSS スタイル(style.css):
/* 未実行: 破線ボーダー + 薄い表示 */
.rt-node--future .rt-rect { stroke-dasharray: 5 3; opacity: 0.60; }
/* 実行中: 太線ボーダー + 太字テキスト */
.rt-node--active .rt-rect { stroke-width: 3; stroke-dasharray: none; }
.rt-node--active .rt-name { font-weight: 700; }
/* 完了: 通常ボーダー */
.rt-node--done .rt-rect { stroke-dasharray: none; }
/* 状態アイコン */
.rt-state-icon { font-size: 10px; fill: var(--text-muted); }
.rt-node--active .rt-state-icon { fill: var(--accent); }
.rt-node--done .rt-state-icon { fill: #4ce884; }データ取得: builder.buildCallTree() → ルートノード配列(buildRecursionTree() と同一構造。cost プロパティも同じ #computeCost() で付与)
レイアウト定数・ノード表示形式: RecursionTree と共通(ADR-027 で統合)。
const NODE_W=160, NODE_H=80, COL_GAP=20, ROW_GAP=52, PAD_X=24, PAD_Y=24;SVG 要素: ノードごとに <g class="ct-node ct-node--*"> 内に:
<rect class="ct-rect">— ノード枠<text class="ct-name" y=18>— 関数名(行 1)<text class="ct-args" y=35>— 引数行 1(行 2、fmtArgsLines())<text class="ct-args" y=50>— 引数行 2(行 3、長い場合のみ)<text class="ct-retval" y=65 or 52>— 戻り値(引数が 1 行なら y=52)<text class="ct-state-icon" y=14>— 状態アイコン(右上角: …/▶/✓)<text class="ct-cost" x=6 y=NODE_H-6>— cost(左下角、「cost:N」)
エッジは <line class="ct-edge">
状態クラス: ct-node--future / ct-node--active / ct-node--done(RecursionTree の rt-node--* と同じ論理)。戻り値・完了アイコンの色は var(--sorted) を使用(旧 #4ce884 固定色はライトテーマでコントラストが弱かったため置換)
2026-06-16(コミット
d22ec59)に、変数ごとの Gantt チャートからフレームグラフに全面改定。 旧実装が使っていたbuilder.buildLifetime()はtrace-builder.jsに残っているが、ビューからは使われていない(テストのみ)。
データ取得: builder.getHumanStepList() と builder.trace から、ビュー内の buildFlameSegments(trace, humanSteps) が
深さごとの区間を作る → { segments: { depth, startHi, endHi, name }[], maxDepth }。
深さ 0 は (global)、深さ d≥1 は各 humanStep の callStack[d - 1].name。同じ深さで同じ関数が連続する区間を 1 セグメントにまとめる。
軸:
- X 軸 = humanStep インデックス
- Y 軸 = 呼び出し深さ(depth 0 = グローバルが最下段、深いほど上)
レイアウト定数:
const ROW_H=68, LABEL_W=32, MIN_CHART_W=580, CHAR_PX=5, BAR_PAD=14, PAD_T=36, PAD_B=8;チャート幅は、最も短いバーにもラベルが収まるようセグメントごとに必要幅を計算し、MIN_CHART_W〜MIN_CHART_W*3 でクランプする(v1.7)。
深さごとの色パレット (6色、depthColor(d) で循環):
const DEPTH_COLORS = [
'rgba(76, 155, 232, 0.72)', 'rgba(232, 107, 76, 0.72)', 'rgba(76, 200, 132, 0.72)',
'rgba(200, 76, 232, 0.72)', 'rgba(232, 200, 76, 0.72)', 'rgba(76, 232, 232, 0.72)',
];バーの内容: init() 時に foreignObject + HTML で静的描画する(buildBarHTML())。バー末尾の humanStep の
mergeScopesForDisplay(env, callStack, frameEnvs) から該当深さのフレームを取り出し、実引数付き関数名と変数一覧を表示する。
X 座標変換:
const hiToX = (hi) => LABEL_W + (hi / MAX_HI) * CHART_W; // MAX_HI = max(1, humanSteps.length - 1)update(): カーソル線 <line class="lf-cursor"> の x1/x2 を更新し、現在の humanStep を含むバーに lf-bar--active を付ける
データなし: humanStep またはセグメントが無い場合は t('view-no-data') を表示
2026-07-17(v1.8)に SVG エッジベースの旧実装から刷新(ADR-018 参照)。 旧実装
buildControlFlow()はtrace-builder.jsに後方互換のため残置しているが未使用。
データ取得: builder.buildCFG() → ScopeNode[](グローバル/関数スコープごとに独立したツリー)
各スコープは CfgItem[] を持ち、CfgItem.type は以下のいずれか:
stmt | return | jump | if | while | for | do-while | seq
CfgItem.execCount に実行回数を保持(未実行なら 0)。SVG ではなく 通常の DOM 要素
(<div class="cf-node"> 等)でフローチャートを描画する点が他の構造系ビュー
(RecursionTree・CallTree・Lifetime・ObjectGraph)と異なる(§3.5 参照)。
ノード種別と描画:
type |
描画 | CSS クラス |
|---|---|---|
stmt |
通常の矩形ブロック | cf-node |
return |
緑枠ブロック | cf-node cf-node--return |
jump(break/continue) |
紫枠ブロック | cf-node cf-node--jump |
if |
◇ アイコン+ true/false の 2 列を横並び表示 | cf-node cf-node--cond |
while / for / do-while |
↺ アイコン+条件+インデントされた本体 | cf-node cf-node--cond cf-node--loop |
seq |
文列のグループ(入れ子コンテナ) | — |
未実行ノードのグレーアウト: item.execCount === 0 のとき cf-node--dead クラスを付与し、
実行回数バッジ(cf-exec-badge、×N 形式)は非表示にする。実行済みノードには
バッジで ×3 のように実行回数を表示する。通らなかった分岐(if の未実行側、
呼ばれなかったループ)が一目でわかることが本ビュー最大の狙い。
ラベル省略: clip(s) が MAX_LABEL=46 文字を超えるラベルを … で切り詰める。
update(): state.event.loc.line と一致する最初の .cf-node[data-line] を
querySelector で検索し .cf-node--active を付与(前回のアクティブは解除)。
execCount のカウント方式(CfgBuilder、trace-builder.js 内部クラス): 全 AST enter
イベントではなく「直前と異なる行に enter したときだけ」カウントする
(同一行に複数の AST ノードが enter しても 1 実行として扱う。v1.8 で修正済み — 修正前は
式ネストの数だけ多重カウントするバグがあった)。
パネル構成: 左列 = スタック(スコープフレーム)、右列 = ヒープ(オブジェクト・配列)
ヒープ登録ロジック (buildHeap(scopes)):
function buildHeap(scopes) {
const heap = [];
const refMap = new WeakMap(); // object reference → heapId
function register(v, depth = 0) {
if (!isHeapVal(v)) return null;
if (refMap.has(v)) return refMap.get(v); // 循環参照・共有参照を検出
const id = heap.length;
refMap.set(v, id);
// エントリ先行登録(循環参照対策)
const entry = { id, ref: v, label: heapLabel(v), entries: [] };
heap.push(entry);
// 子を再帰登録(depth < 5 まで)
...
return id;
}
for (const scope of scopes) {
for (const [name, val] of Object.entries(scope ?? {})) register(val);
}
return { heap, refMap };
}スタックレンダリング: #renderStack(scopes, callStack, changed, refMap, heap, frameEnvs) が mergeScopesForDisplay(scopes, callStack, frameEnvs) を呼びフレームごとの変数を取得する。外側フレームは frameEnvs[i](callEnv スナップショット)から params・デフォルト引数・function-body 変数を表示。
参照セルの HTML: スタック・ヒープともに参照値のセル(.mv-var-row/.mv-heap-cell--ref)に data-ref-heap="N" 属性を付与し、内部に実際のバッジ要素 .mv-ref-indicator(「→ #N」)を持つ
SVG 矢印描画 (#drawArrows()、2026-08-05 改修):
requestAnimationFrame → getBoundingClientRect() → layoutEl 基準の座標計算
→ data-ref-heap 要素を対象 heapId ごとにグループ化(Map<heapId, refEl[]>)
→ 始点: 親行/セルではなく .mv-ref-indicator 要素自体の中心 x・上端/下端
→ 終点: 親ヒープボックス全体ではなく .mv-heap-title 要素の中心 x・上端/下端
→ 上端/下端の選択: from/to の中心Y比較で要素自身を跨がない側を選ぶ
→ 同一 heapId に集まる矢印は .mv-heap-title 幅内で終点 x を均等分散(矢じり重なり回避)
→ ベジェ曲線パス M x1,y1 C x1,my x2,my x2,y2(my = (y1+y2)/2、垂直方向のS字カーブ)
→ SVG に <path class="mv-arrow" marker-end="url(#mv-arr)"> を追加
レイアウト定数:
const NODE_W = 110; // ノード幅
const NODE_H_MIN = 32; // ノード最小高
const ROW_H = 13; // プロパティ行高
const MAX_PROPS = 8; // 表示プロパティ上限
const COLUMN_GAP = 80; // 列間水平間隔(エッジラベル 8 文字以上)
const ROW_GAP = 16; // 同列内ノード間垂直間隔
const COMP_GAP = 24; // 連結成分間垂直間隔
const NODE_BG = ['var(--og-bg-0)', ..., 'var(--og-bg-5)']; // 6 色パレットグラフ構築 (buildGraph(variables, scopes)):
WeakMap<ref, id>でオブジェクト同一性を追跡(循環参照・共有参照対応)- 再帰深さ上限 6
- プリミティブ変数は
rootVars(type='prim')として左上に一覧表示 - ルート変数名は対応ノードの上に
og-root-labelとして表示
プリミティブ変数一覧とルートラベルの重なり回避(2026-08-05 改修): og-prim-vars は従来 translate(minX+8, minY+8) に固定配置されており、minY はノード群の上端から PAD=20px だけ離れた位置だった。ルート変数ラベル(og-root-label、ノード上 y=-4)もこの同じ PAD 内に描画されるため、両者が重なっていた。修正: #render() で primVars.length * ROW_H + 10 を primH として事前計算し、minY = Math.min(...ys) - PAD - primH とすることでプリミティブ変数一覧専用の帯をノード群のさらに上に確保する(og-prim-vars 自体の座標式は変更していない)。
階層型レイアウト (hierarchicalLayout(nodes, edges)):
function hierarchicalLayout(nodes, edges) {
// 1. Kahn のトポロジカルソート(in-degree = 0 から BFS)
// 2. 最長パス法: col[n] = max(col[predecessor]) + 1
// 3. 列番号 → x = col * (NODE_W + COLUMN_GAP)
// 4. 同列内は上から ROW_GAP 間隔で配置
}連結成分分離 (layoutGraph(nodes, edges)):
function layoutGraph(nodes, edges) {
// 無向 BFS で連結成分を検出
// 各成分を hierarchicalLayout() で独立配置
// 成分ごとに yOffset を COMP_GAP ずつ増やして縦積み上げ
// return: [{nodes, edges}](成分配列)
}エッジ描画:
- 肘型コネクタ:
M x1,y1 H mx V y2 H x2(出口右→縦→入口右) - ポートスプレッド: 同一ノードから複数エッジが出る場合、出口 y 座標をノード高さ内で均等分散(
srcPort/dstPortMap) - ラベル: 縦セグメント左側(
x = mx - 2, text-anchor = 'end')
連結成分境界矩形: 成分 ≥2 のとき <rect class="og-comp-bg"> で点線境界を描画
ノード背景色: rect.style.fill = NODE_BG[ni % 6] でノード順に色を循環割り当て
ノード表示内容: オブジェクトは {key: val} 形式、配列は [v0, v1, ...] 形式でセル表示。参照フィールドは → Obj / → Array インジケーター
責務: 操作ログの蓄積・JSON/CSV エクスポート(STUDY 経路、ADR-024)に加え、ADR-028 で外部埋め込み利用者(BhvVisualizer)へのリアルタイム送信(BHV 経路)を追加。2つの経路は同じ #entries 蓄積・同じイベント型を共有するが、起動トリガーと出力先が異なる(STUDY はユーザーがボタンで手動起動+手動エクスポート、BHV は postMessage の init ハンドシェイクで自動起動+逐次送信)。
class SessionLogger {
#entries = [] // { t, type, ... }[]
#sessionStart = null // Date.now() またはセッション未開始なら null
#listeners = [] // (count: number) => void
#remoteSessionId = null // BHV: 親から受け取ったセッションID
#remoteTargetOrigin = null // BHV: postMessage 送信先オリジン(未設定なら送信しない)
get isActive() { return this.#sessionStart !== null; }
get count() { return this.#entries.length; }
startSession() { this.#entries = []; this.#sessionStart = Date.now(); this.#notify(); }
// run: success/error 両方を1メソッドに統合(ADR-028 で拡張。旧 logRun(sampleName, traceLength) から変更)
logRun({ sampleName, code, success, errorType = null, errorMessage = null, errorLoc = null, traceLength = null }) {
this.#log({ type: 'run', sampleName, code, success, errorType, errorMessage, errorLoc, traceLength });
}
logReset() { this.#log({ type: 'reset' }); }
// step: loc/callDepth を追加(ADR-028。行番号ベースの躓き分析に必要)
logStep(action, cursorBefore, cursorAfter, loc = null, callDepth = null) {
this.#log({ type: 'step', action, cursorBefore, cursorAfter, loc, callDepth });
}
logView(viewId) { this.#log({ type: 'view', viewId }); }
logVisibility(state) { this.#log({ type: 'visibility', state }); } // BHV: 'hidden'|'visible'
logLifecycle(phase) { this.#log({ type: 'lifecycle', phase }); } // BHV: 'start'|'end'
logMarker(label) { this.#log({ type: 'marker', label: label.trim() || '(無題)' }); }
// BHV: init ハンドシェイク受理時のみ呼ばれる。以後 #log() のたびに #postToParent() も実行される
enableRemoteLogging(sessionId, targetOrigin) {
this.#remoteSessionId = sessionId; this.#remoteTargetOrigin = targetOrigin;
}
exportJSON() { /* Blob + <a> ダウンロード, ファイル名 jsv-log-YYYYMMDD-HHmmss.json */ }
exportCSV() { /* 同上 .csv、ヘッダ: t_ms,type,action,cursor_before,cursor_after,view_id,sample_name,trace_length,label */ }
onCountChange(fn) { this.#listeners.push(fn); } // UI カウンタ更新用
#log(entry) {
if (!this.#sessionStart) return; // 非アクティブ時は完全 no-op
const full = { t: Date.now() - this.#sessionStart, ...entry };
this.#entries.push(full);
this.#notify();
this.#postToParent(full); // BHV: #remoteTargetOrigin 未設定なら何もしない
}
#postToParent(entry) {
if (!this.#remoteTargetOrigin || window.parent === window) return;
window.parent.postMessage({ source: 'jsvisualizer', sessionId: this.#remoteSessionId, ...entry }, this.#remoteTargetOrigin);
}
}
export const sessionLogger = new SessionLogger(); // モジュール単位シングルトン呼び出し元:
| モジュール | 呼び出し |
|---|---|
step-controller.js |
全ステップ操作で logStep(action, before, after, loc, callDepth)(dbg.trace[dbg.cursor]?.loc/callDepth を付与) |
view-switcher.js |
#activate() 内で logView(id) |
app.js |
resetAll() で logReset()。adapter の 'ready'/'error' 両方のリスナーから統合 logRun() を呼ぶ(旧: 'ready' のみでエラー時は未記録だった)。visibilitychange/pagehide リスナーで logVisibility()/logLifecycle('end') |
study-panel.js(STUDY) |
Start Session / ワンクリックマーカー9個 / 自由入力マーカー / JSON・CSV エクスポートボタンの配線 |
BHV 経路の起動条件(ADR-028): app.js が window の message イベントを購読し、event.origin が BHV_ALLOWED_ORIGINS(固定の許可オリジン一覧)に一致し、かつ { source: 'bhv', type: 'init', sessionId } 形式のメッセージを受け取った場合のみ sessionLogger.startSession() と enableRemoteLogging(sessionId, event.origin) を呼ぶ。この init を受け取らない限り、<iframe> に埋め込まれていても記録・送信は一切発生しない(スタンドアロン起動・公開デモ・他の埋め込み利用者に影響しない)。
init 受理時の UI 除去(# BHV:): 同じ init 受理処理の末尾で、学習者に不要な UI を DOM から取り除く(hidden ではなく remove()。再表示の経路がないため)。
#study-mode-section(設定パネルの Session Log・マーカー・JSON/CSV): 学習者がマーカーを押すと BhvVisualizer の記録にmarkerイベントが紛れ込むため(ADR-040)。study-panel.jsは取り除かれた要素への参照を持ち続けるが、DOM 外の要素を更新するだけで副作用はない#btn-help(ヘッダーの使い方ボタン): BhvVisualizer は学習者に JSVisualizer を意識させない方針で、独自の学生向けマニュアルを持つため(ADR-041)
非アクティブ時 no-op の設計: #log() の先頭で #sessionStart の有無を見て早期リターンする。
これにより実験モード(STUDY)も外部埋め込み(BHV)も使わない通常利用時は一切のオーバーヘッド・
副作用が発生しない。実験終了後に study-panel.js と index.html の <!-- STUDY MODE -->
ブロックを削除するだけで STUDY 経路は無効化できる(session-logger.js 本体・BHV 経路・各モジュールの
logStep/logView 呼び出しは残置しても無害)。
責務: UI 文字列の日英切り替え。ADR-025 参照。
const STRINGS = { ja: { 'btn-edit': '✏ 編集', ... }, en: { 'btn-edit': '✏ Edit', ... } };
const STORAGE_KEY = 'jsv-lang';
let currentLang = /* localStorage から復元、'ja'|'en' 以外は 'ja' */;
export function t(key) { return STRINGS[currentLang]?.[key] ?? STRINGS['en']?.[key] ?? key; }
export function getLang() { return currentLang; }
export function setLang(lang) {
if (lang === currentLang) return;
currentLang = lang;
try { localStorage.setItem(STORAGE_KEY, lang); } catch { /* ignore */ }
document.dispatchEvent(new CustomEvent('langchange', { detail: lang }));
}app.js 側の初期化・配線(i18n.js 自体は DOM に触れない、純粋な状態管理モジュール):
function applyI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
document.documentElement.lang = getLang();
const btnLang = $('btn-lang');
if (btnLang) btnLang.textContent = getLang() === 'ja' ? 'EN' : '日'; // 次に切り替わる言語を表示
// 使い方ボタン: 表示言語に合わせたマニュアルを開く(BHV埋め込み時は取り除かれているので null、ADR-041)
const btnHelp = $('btn-help');
if (btnHelp) {
btnHelp.href = getLang() === 'ja' ? 'manual.html' : 'manual.en.html';
btnHelp.title = t('help-title');
btnHelp.setAttribute('aria-label', t('help-title'));
}
}
applyI18n(); // 起動時に初期言語を適用
$('btn-lang').addEventListener('click', () => setLang(getLang() === 'ja' ? 'en' : 'ja'));
document.addEventListener('langchange', (e) => {
applyI18n(); // [data-i18n] 要素・html[lang]・ボタン文字を更新
switcher.setLang(e.detail); // タブラベル・ビュー説明バーを更新(§4.2 参照)
});2 系統のローカライズ方式:
| 対象 | 方式 | 更新関数 |
|---|---|---|
| 静的 HTML(ボタン・見出し等) | data-i18n="key" 属性 → t(key) |
applyI18n()(app.js) |
| タブラベル・ビュー説明文(JS が動的生成する文字列) | { ja: '...', en: '...' } オブジェクトを直接渡す → resolveStr(v, lang) |
ViewSwitcher.setLang()(§4.2) |
resolveStr(v, lang)(view-switcher.js 内のヘルパー)は v が文字列ならそのまま返し、
{ja, en} オブジェクトなら v[lang] を返す。この関数のおかげで ViewSwitcher.register() は
呼び出し側がどちらの型で渡してきても区別せずに扱える。
各ビューのデータなし表示(v2.15、コミット8b23054): 以前は各ビューに英語で直書きされていた「No variables」「No function calls」等を、
view-no-data・view-no-source・view-no-func-calls・subst-empty・recursiontree-empty・objgraph-no-vars・objgraph-no-objects・
barchart-no-data のキーで t() から取得する(オブジェクト・代入展開・制御フロー・呼び出しツリー・変数寿命・変数、および非アクティブの棒グラフ・再帰ツリー)。
対象外: エラーメッセージ(JSInterpreter 由来で追跡困難)・サンプルプログラム名(固有名詞的)。
責務: URLクエリ(exercise/code/view)から外部(BhvVisualizer等)のコードを fetch してエディタへ反映する。BhvVisualizerの存在を前提としない汎用機能で # BHV: タグは付けない(ADR-029・ADR-031)。クエリが無ければ何もせず、既定のFibonacciサンプル表示・21種の組み込みサンプルは変更しない加算的な機能。
クエリパラメータ(現行、ADR-031で確定):
?exercise=<演習一覧を取得する完全なURL> GET → { title?: string, codes: [{ title, code }] }
?code=<コード1件を取得する完全なURL> GET → { title: string, code: string }
?view=<登録済みビューID> 例: memory, exectrace, colorbox 等
exerciseId/codeId/bhvApiBaseという「ID + ベースURL」方式(ADR-029の初期実装)は同日中にADR-031で「呼び出し元が完全なURLを直接渡す」方式へ再設計され、廃止済み(後方互換シムなし)。
loadExerciseFromQuery(editor, opts) の処理フロー:
parseQuery()でexercise/code/viewを読むexerciseが指定されていればfetchし、titleがあればeditor.setPlaceholderLabel(title)(ADR-032)、codes一覧でeditor.setRemoteCodes(codes)(組み込み21サンプルの<optgroup>を全削除してから演習のコードだけを選択肢にする、ADR-033)し、先頭(codes[0])を自動でエディタに読み込むcodeが指定されていれば独立にfetchし、exerciseを伴わない場合のみeditor.setPlaceholderLabel(code.title)+editor.setRemoteCodes([code])+editor.disableSampleSelect()(選択肢が1件しかなく選ぶ意味がないため選択不可化、ADR-034)。その後editor.setCode(code.code, code.title)でエディタへ反映(exercise+codeの場合はcode側の読み込みが最後に評価されるためcodeが優先される)viewが指定されていれば、コード読み込みとは独立にswitcher.setInitialView(viewId)を呼ぶ(ADR-036)。次節参照
exercise/codeともに指定なしの場合: 何もしない。既定のFibonacciサンプルが表示され、21種の組み込みサンプルが選択できる(回帰なし)。
404・ネットワークエラー: editor.showError('コードが見つからないか非公開です' 等) を表示する。
code-editor.js 側の対応公開API:
| メソッド | 役割 |
|---|---|
setCode(code, label, selectValue?) |
エディタ内容を反映(既存の change リスナーと共用する #applyCode() に一本化) |
setRemoteCodes(items) |
組み込みサンプルの <optgroup> を全削除し、items だけを選択肢にする(ADR-033、旧 addRemoteGroup() を置き換え) |
setPlaceholderLabel(title) |
サンプル選択のプレースホルダ(value=""のoption)をtitleに置き換え、data-i18n属性を外す(言語切替で上書きされないようにするため) |
disableSampleSelect() |
#sampleSelectLockedフラグを立ててサンプル選択をdisabledにする。setRunningMode()のEdit/Run切替によるdisabled上書きと共存できるよう disabled = running || #sampleSelectLocked で判定する |
編集可能ヒント: ソースペインヘッダーに常時表示の editor-hint(i18n対応)を追加し、コードが自由に編集・実行できることを示す(ADR-029)。
ボタン構成(2行×4列グリッド):
⏮(高) │ [btn-stmt-back] [btn-expr-back] [btn-expr-forward] [btn-stmt-forward] │ ⏭(高)
│ [btn-call-back] [btn-human-back][btn-human-forward][btn-call-forward] │
キーボードバインド:
switch (e.key) {
case 'ArrowLeft':
case 'b': ctrl.stepExprBackward(); break;
case 'ArrowRight':
case 'n': ctrl.stepExprForward(); break;
case 'h': ctrl.stepHumanForward(); break;
case 'H': ctrl.stepHumanBackward(); break;
case 'v': ctrl.stepStmtForward(); break;
case 'V': ctrl.stepStmtBackward(); break;
case 'f': ctrl.stepCallForward(); break;
case 'F': ctrl.stepCallBackward(); break;
case 'Home': ctrl.goToStart(); break;
case 'End': ctrl.goToEnd(); break;
}<textarea> / <input> フォーカス中は無効化。
状態:
#registry: Map<id, { label, ViewClass, instance }>— 登録されたビュー#activeId: string | null#builder: TraceBuilder | null#lastState: AppState | null#keyHandler: Function | null— キーボードイベントハンドラ(解除用に保持)
localStorage 永続化:
const STORAGE_KEY_TAB = 'jsv-active-tab';重要メソッド:
register(id, label, ViewClass, description = '')
// タブボタンを生成して registry に登録
// label / description は文字列または { ja, en } オブジェクトのどちらも受け付ける
// (resolveStr(v, lang) が現在言語のキーを解決。文字列ならそのまま返す)
onReady(state, builder)
// アクティブビューを destroy → 再 init する
// → ビューは常に最新の builder を持つことが保証される
// → キーボードショートカットを登録(#registerKeyboard)
// → 初回のアクティブビュー決定は「setInitialView 指定 → localStorage の前回タブ → 先頭タブ」の優先順位
setInitialView(id)
// ADR-036: URLクエリ(view)からの指定を localStorage より優先させる(#presetId に保持)。
// そのページの最初の実行(Run)1回だけ有効で、使用後は #presetId を null 化して消費する。
// localStorage の保存値自体は書き換えない(以後の通常利用の「前回タブ」復元には影響しない)
update(state)
// アクティブビューの update(state) を呼ぶ
reset()
// 全 instance を destroy して null にする
// → キーボードショートカットを解除(#unregisterKeyboard)
setLang(lang)
// 'langchange' イベント購読で呼ばれる。全タブボタンの textContent と
// #descEl(下記)を resolveStr(entry.label/description, lang) で再描画する
// ビューの init/update は再実行しない(表示中の可視化データ自体は言語非依存のため)ビュー説明バー(#descEl):
コンストラクタが view-container の直前に .view-desc 要素(#descEl)を自動生成・挿入する。
#activate(id) 実行時(タブ切り替え時)と setLang(lang) 実行時の両方で、
resolveStr(entry.description, getLang()) を #descEl.textContent に反映する。
説明文が空文字列(Edit モード等)のときは CSS の .view-desc:empty { display: none } で非表示になる。
キーボードショートカット設計 (#registerKeyboard / #unregisterKeyboard):
#registerKeyboard() {
if (this.#keyHandler) return; // 二重登録防止
const ids = [...this.#registry.keys()]; // 登録順の ID 配列
this.#keyHandler = (e) => {
const tag = document.activeElement?.tagName;
if (tag === 'TEXTAREA' || tag === 'INPUT') return; // エディタ無効化
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const id = ids[digit - 1]; // 1→ids[0], 2→ids[1], ...
if (id) { e.preventDefault(); this.#activate(id); }
}
};
document.addEventListener('keydown', this.#keyHandler);
}
#unregisterKeyboard() {
if (this.#keyHandler) {
document.removeEventListener('keydown', this.#keyHandler);
this.#keyHandler = null;
}
}タブ永続化 (#activate 内):
#activate(id) {
if (id === this.#activeId) return;
// ... destroy/init 処理 ...
this.#activeId = id;
try { localStorage.setItem(STORAGE_KEY_TAB, id); } catch { /* quota over 等は無視 */ }
// ...
}CodeMirror 6 エディタ:
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import { Compartment } from '@codemirror/state';
// themeCompartment で動的テーマ切り替え
const themeCompartment = new Compartment();
// MutationObserver で html[data-theme] 変化を監視 → テーマ再設定
const obs = new MutationObserver(() => {
const isDark = document.documentElement.dataset.theme === 'dark';
view.dispatch({ effects: themeCompartment.reconfigure(isDark ? oneDark : lightTheme) });
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });主要 API:
getCode()→view.state.doc.toString()setRunningMode(running)→container.hidden = running(実行中は CM エディタを非表示)。disabled = running || #sampleSelectLockedでサンプル選択の無効化状態を判定(ADR-034、§3.9参照)#programNameEl.textContent→ サンプル選択時にプログラム名を更新(直接入力時はクリア)EditorView.lineWrappingを常時有効化(ADR-035)。トグルは設けず常時オン。長い行(埋め込み時の問いコメント等)が横スクロールなしで折り返されるsetCode()/setRemoteCodes()/setPlaceholderLabel()/disableSampleSelect()— URLクエリによるコード読み込み(§3.9)から呼ばれる公開API。詳細は §3.9 参照
サンプルコード(21 種類):
// グループ構成
{ label: '─ 探索 ─', keys: ['linearSearch', 'binarySearch'] },
{ label: '─ ソート(基本)─', keys: ['bubbleSort', 'selectionSort'] },
{ label: '─ ソート(高度)─', keys: ['quickSort', 'mergeSort'] },
{ label: '─ ソート(オブジェクト)─', keys: ['sortByNumKey', 'sortByStrKey'] },
{ label: '─ 数学・アルゴリズム ─', keys: ['euclidLoop', 'euclidRecursive', 'factorial', 'fibonacci', 'fibonacciDP'] },
{ label: '─ データ構造 ─', keys: ['binaryTree', 'linkedList'] },
{ label: '─ スコープ・オブジェクト ─', keys: ['closure', 'classExample'] },
{ label: '─ Study Tasks ─', keys: ['studyWarmup', 'studyTask1', 'studyTask2', 'studyTask3'] }, // CELDA 2026評価実験用エラー表示 API (showError(msg, errorType, loc)):
loc({line, column})を第3引数に取るよう拡張済み(err.loc → err.line/column → メッセージ正規表現 [Parser|Lexer|Runtime] N:M: の順で抽出)。loc があれば #moveCursorToError(loc) でカーソルをエラー行へ移動し、box-shadow: inset によるブリンクアニメーションでハイライトする(CodeMirror テーマが background: transparent !important を上書きするため background ではなく box-shadow を使用)。エラーバッジクリックで再ジャンプ・再ブリンクする。詳細な実装(ダブル RAF パターン・フォーカス維持等)は CLAUDE.md「エラーハンドリング」節を参照。
showError(msg, errorType = null, loc = null) {
if (msg) {
const typeLabel = errorType === 'parse' ? '構文エラー'
: errorType === 'runtime' ? '実行エラー'
: null;
this.#errorEl.innerHTML = typeLabel
? `<span class="error-badge">${typeLabel}</span> ${_esc(msg)}`
: _esc(msg);
this.#errorEl.dataset.errorType = errorType ?? '';
this.#errorEl.hidden = false;
if (loc) this.#moveCursorToError(loc); // カーソルジャンプ + ブリンク
} else {
this.#errorEl.innerHTML = '';
this.#errorEl.hidden = true;
}
}.error-msg[hidden] { display: none; }(ADR-030): 作者定義の .error-msg { display: flex; } がブラウザ既定の [hidden] { display: none } を打ち消していたバグの修正。属性セレクタで詳細度を上げ、hidden 属性と実際の表示が一致するようにした。
_esc(str)— HTML エスケープヘルパー(&,<,>,"を実体参照に変換)data-error-type属性で CSS スタイルを切り替え("parse"時は赤、"runtime"時は橙背景)
責務: エディタペインと可視化ペインの境界をドラッグでリサイズする
class PaneResizer {
constructor(divider, mainEl, storageKey = 'jsv-editor-pct')
// mousedown → mousemove: 幅を % で計算して CSS 変数更新
// clamp: 15% 〜 75%
// mouseup: localStorage に保存
}CSS 変数: .app-main { --editor-pct: 30; }
.editor-pane { width: calc(var(--editor-pct) * 1%); }
JS: mainEl.style.setProperty('--editor-pct', String(clamped));
テーマ適用の仕組み:
ライトテーマ(デフォルト): <html> に data-theme 属性なし
ダークテーマ: <html data-theme="dark">
評価実験用 UI: テーマ設定の下の STUDY MODE ブロック(Session Log 等、study-panel.js)は <div id="study-mode-section"> で囲まれており、
BhvVisualizer から init を受け取ったときは取り除かれる(§3.7、ADR-040)。テーマ設定は残る。
FOUC 防止スクリプト (web/index.html <head> 内):
<script>
(function () {
if (localStorage.getItem('jsv-theme') === 'dark') {
document.documentElement.dataset.theme = 'dark';
}
}());
</script>JSVisualizer/
├── src/
│ ├── app.js ← エントリポイント・全体協調
│ ├── core/
│ │ ├── debugger-adapter.js ← JSDebugger ラッパー・差分検出
│ │ ├── step-controller.js ← 粒度別ステップ操作
│ │ ├── trace-builder.js ← humanStepList・buildHeatmap・buildRecursionTree・buildCallTree・buildLifetime・buildCFG
│ │ ├── session-logger.js ← 操作ログ(STUDY: 手動エクスポート+BHV: リアルタイム送信、§3.7)
│ │ └── exercise-source.js ← URLクエリ(exercise/code/view)によるコード読み込み(§3.9、ADR-029/031/036)
│ ├── utils/
│ │ ├── format.js ← formatValue / formatValueDiff / flattenEnv / BUILTIN_NAMES / esc / formatFrameLabel / mergeScopesForDisplay
│ │ ├── array-grid.js ← 配列+ポインタのグリッド描画(Arrays・ExecTrace共通、ADR-037)
│ │ └── line-exec.js ← humanStep を文の実行単位にまとめる(buildLineExecOwners / buildExecRows、Heatmap・ExecTrace共通)
│ ├── views/
│ │ ├── base-view.js ← BaseView 基底クラス
│ │ ├── code-view/
│ │ │ └── index.js ✅ 3層ハイライト・setTrace()
│ │ ├── state-view/
│ │ │ └── index.js ✅ CallStackView・Global疑似フレーム+関数フレーム(ADR-026)
│ │ ├── scope-view/
│ │ │ └── index.js ✅ スコープチェーン枠表示
│ │ ├── line-trace/
│ │ │ └── index.js ✅ Variable・行×変数マトリクス表(動的列追加)
│ │ ├── exec-trace/
│ │ │ └── index.js ✅ 実行順トレース表(humanStep 順・配列+ポインタ列・変数列+条件列、ADR-037)
│ │ ├── trace-table/
│ │ │ └── index.js ✅ 全ステップ静的テーブル
│ │ ├── animated-trace/
│ │ │ └── index.js ✅ 動的行追記トレース(非アクティブ)
│ │ ├── bar-chart/
│ │ │ └── index.js ✅ 棒グラフ CSS アニメーション
│ │ ├── color-box/
│ │ │ └── index.js ✅ Arrays・色付き箱・ポインタ自動検出
│ │ ├── timeline/
│ │ │ └── index.js ✅ 時系列 SVG 折れ線グラフ
│ │ ├── heatmap/
│ │ │ └── index.js ✅ 実行頻度ヒートマップ
│ │ ├── recursion-tree/
│ │ │ └── index.js ✅ 再帰ツリー SVG(タブ非登録・非アクティブ、ADR-027で call-tree に統合)
│ │ ├── call-tree/
│ │ │ └── index.js ✅ 全関数呼び出しツリー SVG(RecursionTreeと表示形式統一・cost表示、ADR-027)
│ │ ├── lifetime/
│ │ │ └── index.js ✅ 変数寿命(コールスタックのフレームグラフ)
│ │ ├── control-flow/
│ │ │ └── index.js ✅ 制御フロー SVG フローチャート
│ │ ├── memory-view/
│ │ │ └── index.js ✅ スタック/ヒープ + SVG 矢印
│ │ └── object-graph/
│ │ └── index.js ✅ 階層型レイアウト SVG グラフ(連結成分分離・ポートスプレッド)
│ └── components/
│ ├── code-editor.js ← コードエディタ(CodeMirror 6・URLクエリ読み込み対応API・lineWrapping)
│ ├── step-controls.js ← ステップ操作バー
│ ├── view-switcher.js ← ビュー切り替えタブ(12ビュー登録 + keyboard/localStorage/setInitialView)
│ ├── pane-resizer.js ← ペインリサイザー(editor/viz 幅変更・localStorage 永続化)
│ ├── settings-panel.js ← テーマ切り替え設定パネル
│ └── study-panel.js ← 評価実験用UI(STUDY: タグ、削除可)
├── web/
│ ├── index.html ← FOUC防止スクリプト含む
│ ├── manual.html ← 利用者向けマニュアル(日本語、ヘッダーの「?」から開く、ADR-041)
│ ├── manual.en.html ← 利用者向けマニュアル(英語)
│ ├── manual/{ja,en}/ ← マニュアルのスクリーンショット(scripts/capture-manual-screenshots.mjs で生成)
│ ├── style.css ← ライト/ダークテーマ CSS(全ビュー含む)
│ ├── app.bundle.js ← esbuild 生成(git 管理外)
│ └── interpreter.bundle.js ← esbuild 生成(git 管理外)
├── tests/
│ ├── core/
│ │ ├── step-controller.test.js
│ │ ├── trace-builder.test.js
│ │ ├── exercise-source.test.js ← parseQuery()・loadExerciseFromQuery() のユニットテスト
│ │ └── samples.test.js ← 21サンプル全エラーなし・trace ≥ 1 を確認
│ └── utils/
│ ├── array-grid.test.js ← computeSubscriptVars/detectPointerVars/renderArrayGrid のユニットテスト
│ └── format.test.js ← formatValue/formatValueDiff の TDZ 値の扱い(ADR-038)
├── scripts/
│ └── capture-manual-screenshots.mjs ← マニュアルのスクリーンショット撮り直し(npm run build の後に実行、ADR-041)
├── .github/
│ └── workflows/
│ └── deploy.yml ← GitHub Pages 自動デプロイ(CI/CD)
├── docs/
│ ├── functional-spec.md
│ ├── functional-spec.en.md
│ ├── design.md
│ ├── development-plan.md
│ └── adr/ ← Architecture Decision Records(ADR-001〜、随時追加)
├── CLAUDE.md
├── README.md
├── README.en.md
└── package.json
CSS カスタムプロパティで 2 テーマを管理する。
/* ライトテーマ(デフォルト): Catppuccin Latte ベース */
:root {
--bg: #eff1f5;
--surface: #e6e9ef;
--surface2: #ccd0da;
--border: #acb0be;
--text: #4c4f69;
--text-muted: #9ca0b0;
--text-dim: #6c6f85;
--accent: #1e66f5;
--accent-bg: #dde5fd;
--changed: #d20f39;
--changed-bg: #fce8ee;
--access: #fe640b;
--compare: #04a5e5;
--sorted: #40a02b;
/* シンタックスハイライト */
--tok-keyword: #8839ef;
--tok-string: #40a02b;
--tok-number: #fe640b;
--tok-comment: #8c8fa1;
/* 値の型色 */
--v-num: #fe640b; --v-str: #40a02b; --v-bool: #04a5e5;
--v-null: #9ca0b0; --v-undef: #9ca0b0; --v-fn: #8839ef; --v-obj: #df8e1d;
/* ハイライト変数 */
--hl-expr: rgba(254, 100, 11, 0.18);
--hl-expr-act: rgba(254, 100, 11, 0.32);
--hl-call: rgba(136, 57, 239, 0.12);
--hl-call-act: rgba(136, 57, 239, 0.22);
--hl-call-bdr: rgba(136, 57, 239, 0.55);
--hl-orange: #e86b4c;
}
/* ダークテーマ: Catppuccin Mocha ベース */
[data-theme="dark"] {
--bg: #1e1e2e;
--surface: #2a2a3e;
--surface2: #313244;
/* ... */
--hl-expr: rgba(250, 179, 135, 0.30);
--hl-expr-act: rgba(250, 179, 135, 0.45);
--hl-call: rgba(203, 166, 247, 0.20);
--hl-call-act: rgba(203, 166, 247, 0.35);
--hl-call-bdr: rgba(203, 166, 247, 0.70);
}各ビューは独立した BEM 風の接頭辞でクラスをスコープする。
| ビュー | 接頭辞 | 例 |
|---|---|---|
| code-view | cv- |
.cv-line, .cv-expr-highlight |
| state-view | sv- |
.sv-card, .sv-var-group |
| scope-view | scv- |
.scv-frame, .scv-frame--active |
| line-trace | lt- |
.lt-table, .lt-cell--changed |
| trace-table | tt- |
.tt-row, .tt-row--active |
| bar-chart | bc- |
.bc-bar, .bc-chip--active |
| color-box | cb- |
.cb-box, .cb-box--ptr |
| timeline | tl- |
.tl-svg, .tl-cursor |
| heatmap | hm- |
.hm-line, .hm-line--active |
| recursion-tree | rt- |
.rt-node--active, .rt-rect(非アクティブ) |
| call-tree | ct- |
.ct-node--active, .ct-rect, .ct-name, .ct-cost |
| lifetime | lf- |
.lf-bar, .lf-bar--active, .lf-cursor |
| control-flow | cf- |
.cf-node--active, .cf-edge--back |
| memory-view | mv- |
.mv-frame, .mv-arrows |
| object-graph | og- |
.og-node, .og-edge |
.cv-line-code {
position: relative;
isolation: isolate;
}
.cv-expr-highlight,
.cv-callsite-highlight {
position: absolute;
top: 0.1em;
height: 1.35em;
z-index: -1;
pointer-events: none;
}
.cv-expr-highlight { background: var(--hl-expr); }
.cv-callsite-highlight { background: var(--hl-call); border-bottom: 2px dashed var(--hl-call-bdr); }@keyframes var-flash {
0% { background: var(--changed-bg); }
100% { background: transparent; }
}
.var-row--changed { animation: var-flash var(--anim-flash) ease-out; }.error-msg {
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
}
.error-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
background: var(--changed);
color: #fff;
}
.error-msg[data-error-type="runtime"] {
background: color-mix(in srgb, var(--changed-bg), var(--surface) 30%);
}data-error-type="parse"→ 赤バッジ(--changedカラー)data-error-type="runtime"→ 橙背景(--changed-bgベース)
/* 未実行: 破線ボーダー + 薄い表示 */
.ct-node--future .ct-rect { stroke-dasharray: 5 3; opacity: 0.60; }
/* 実行中: 太線ボーダー + 太字テキスト */
.ct-node--active .ct-rect { stroke-width: 3; stroke-dasharray: none; }
.ct-node--active .ct-name { font-weight: 700; }
/* 完了: 通常ボーダー */
.ct-node--done .ct-rect { stroke-dasharray: none; }
/* 状態アイコン(右上角: …/▶/✓) */
.ct-state-icon { font-size: 10px; fill: var(--text-muted); }
.ct-node--active .ct-state-icon { fill: var(--accent); }
.ct-node--done .ct-state-icon { fill: var(--sorted); }/* ライト */
:root { --v-diff: #c05000; }
/* ダーク */
[data-theme="dark"] { --v-diff: #ff9f5e; }
/* v-diff クラスと子スパンの色を上書き */
b.v-diff,
b.v-diff .v-num, b.v-diff .v-str, b.v-diff .v-bool,
b.v-diff .v-obj, b.v-diff .v-null, b.v-diff .v-undef {
color: var(--v-diff);
}/* ノード背景色パレット(6色) */
:root {
--og-bg-0: rgba(100,149,237,0.18); --og-bg-1: rgba(144,238,144,0.18);
--og-bg-2: rgba(255,165, 96,0.18); --og-bg-3: rgba(221,160,221,0.18);
--og-bg-4: rgba(240,230,140,0.18); --og-bg-5: rgba(135,206,235,0.18);
}
/* 連結成分の境界矩形 */
.og-comp-bg {
fill: none;
stroke: var(--border);
stroke-width: 1;
stroke-dasharray: 6 4;
opacity: 0.6;
}/* 戻りエッジ(ループバック): 橙色の破線 */
.cf-edge--back {
stroke: var(--hl-orange, #e86b4c);
stroke-width: 2;
stroke-dasharray: 6 3;
}{
"type": "module",
"scripts": {
"build:interp": "esbuild ../JSInterpreter/src/interpreter/debugger.js --bundle --format=esm --outfile=web/interpreter.bundle.js",
"build:app": "esbuild src/app.js --bundle --format=esm --outfile=web/app.bundle.js",
"build": "npm run build:interp && npm run build:app",
"dev": "npm run build:interp && esbuild src/app.js --bundle --format=esm --outfile=web/app.bundle.js --servedir=web --watch"
}
}
interpreter.bundle.jsとapp.bundle.jsは.gitignoreで管理外とする。
| ステップ | 内容 |
|---|---|
| ① Checkout | actions/checkout@v4 で JSVisualizer をチェックアウト |
| ② Clone JSInterpreter | git clone https://github.com/tntetsu/JSInterpreter.git ../JSInterpreter(package.json の file:../JSInterpreter 参照に合わせた配置) |
| ③ Node.js setup | actions/setup-node@v4(Node 20 + npm キャッシュ) |
| ④ Install deps | npm ci |
| ⑤ Test | npm test(Jest 66 テスト) |
| ⑥ Build | npm run build(esbuild で web/ に成果物生成) |
| ⑦ Upload artifact | actions/upload-pages-artifact@v3(web/ ディレクトリ) |
| ⑧ Deploy | actions/deploy-pages@v4 |
トリガー: main ブランチへの push または workflow_dispatch(手動実行)
同時実行制御: concurrency: { group: pages, cancel-in-progress: true }
権限: contents: read, pages: write, id-token: write
デプロイ URL: https://tntetsu.github.io/JSVisualizer/
| 対象 | テストファイル | テスト数 | テスト内容 |
|---|---|---|---|
trace-builder.js |
tests/core/trace-builder.test.js |
40 件 | buildHeatmap・buildHumanIndices・getHumanStepList・buildRecursionTree・buildCallTree・buildLifetime・buildCFG 等 |
step-controller.js |
tests/core/step-controller.test.js |
16 件 | 粒度別ステップ(expr/stmt/human/call)の cursor 移動。文粒度の入れ物ノード読み飛ばし・前進/後退の対称性(ADR-039) |
| 21 サンプル | tests/core/samples.test.js |
21 件 | 全サンプルコードがエラーなく実行でき trace ≥ 1 を確認 |
exercise-source.js |
tests/core/exercise-source.test.js |
15 件 | parseQuery()(exercise/code/view)・loadExerciseFromQuery() の各分岐(ADR-029/031/032/033/034/036) |
array-grid.js |
tests/utils/array-grid.test.js |
10 件 | computeSubscriptVars・detectPointerVars・renderArrayGrid(ADR-037) |
format.js |
tests/utils/format.test.js |
5 件 | formatValue/formatValueDiff が TDZ 値を空欄で返し Symbol(TDZ) を含まないこと(ADR-038) |
合計: 107 テスト(npm test で全実行)
npm test # 全テスト実行(107 件)
npm run test:watch # ウォッチモードテストは必ず
npm test経由で実行すること("type": "module"のため--experimental-vm-modulesフラグが必要)
callDepth の変化でシミュレートするヘルパー makeCallTrace(calls) を使用:
- 1 回の呼び出し → 1 ルートノード
- 2 回の連続呼び出し → 2 ルートノード
- ネストした呼び出し → 子ノードとして追加
humanStep となる ExpressionStatement.enter + env を持つヘルパーイベント humanEv(line, envChain, callDepth) を使用:
BUILTIN_NAMES(console等)は含まない- 異なる
callDepthの同名変数は別エントリ
- 通過した行のノード存在確認
- エッジの from/to ペア確認
- ループバック(同じ行への繰り返し遷移)でカウント増加確認
firstSeen順のソート確認