Compare commits

23 Commits
Author SHA1 Message Date
ewdernaandClaude Opus 5.5 85f333cb24 chore: Markdownのインデント幅を4に固定し、リストの継続行をスペース4つに揃える
継続行がスペース2つだと、VS Codeがインデント幅を2と自動判定する。
.vscode/settings.json でMarkdownの自動判定を切って幅4に固定し、
既存の継続行もリストの記号から4つ下げた位置に揃える。
変更したファイルに markdownlint --fix をかけた分(journalのURL囲みと空行)も含む。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
2026-10-19 08:16:12 +09:00
ewdernaandClaude Opus 5.5 5c9f4c75ab provisional: PHY Editorの所感(作り直し1回目)を追記する
作り直したモックを触った所感を、見出しごとに整理して残す。
選択枠の重なり順、キー追加の位置、長押しの方式比較、原点表示、
switchOffsetの入力欄、スイッチ・スタビライザー位置のオーバーレイなど。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
2026-10-19 08:07:56 +09:00
ewdernaandClaude Opus 5.5 4d1eb23cf8 mock: PHY Editorを作り直す(T-29)
provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。
App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。

- 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した
- 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた
- 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
2026-10-07 20:54:37 +09:00
ewdernaandClaude Opus 5.5 beac52c20b provisional: IDX Editorのバリアント選択と、欠番を作らないことを決める
- 欠番は作らない(現行仕様のまま)。構成をまたいで番号をそろえる場面を挙げられないため
- 複数行にまたがるキーは上端の行の位置に置く
- 左ペインをVariantGroupのツリーにし、キャンバスには選択した構成だけを表示して並べ替える
- 隠れたキーは同じVariantGroupで選択中のキーにくっつけ、決まらなければ手前のキーにくっつける

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYqQUp9AZ5TqvtELdpcwC6
2026-10-06 08:43:40 +09:00
ewdernaandClaude Opus 5.5 fd21d64bcf provisional: IDX Editorの行と列の判定規則と、適用前のプレビューを決める
- そろっている軸は座標でまとめ、ずれている軸は隣とのつながりでたどる(両方たどると循環する)
- 隣り合う列の対応は1対1にする。誤った1か所で2行が合体するため
- 縦長キーは上端の行、横長キーは左端の列に属させ、外れたら手で直す
- 適用前に判定した行を表示し、列ごとに↑/↓で1行ずらして直せるようにする
- なぞって連番を振る操作は後回しにし、キー列から連番で並べ直す操作を共通の入り口にする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGuRxrp4fZwztsK45G21jt
2026-10-06 08:17:47 +09:00
ewdernaandClaude Opus 5.5 af2aa31518 provisional: IDX Editorのプリセットを対象・走査・向きの組み合わせにする
- 種類ごとのプリセットをやめ、対象(全体/選択範囲)・走査・向きの3軸で表す
- 既存定義(QMK layout[]・VIA・KLE)からの取り込みは別の操作にする
- 行は隣の列で最も近いy座標のキーとつなげて判定する
- 排他キー同士の前後は出力番号に影響しないので任意とする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGuRxrp4fZwztsK45G21jt
2026-10-06 08:17:47 +09:00
ewdernaandClaude Opus 5.5 1d24de0e79 provisional: PHY Editorの作り直しの範囲と、貼り付け・クラスター内の隙間の扱いを決める
- 作り直すのは ui/phy/ だけ。domain/・io/ とテストは流用する(T-29)
- 貼り付け・複製は選択中のノードと同じ親に、グリッド1刻みずらして置く
- コピーはアプリ内に持ち、システムのクリップボードからはKLE JSONだけを読む
- クラスターの矩形内の隙間は何もない場所として扱う

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmj4oW3phJrVNNcjMVqo8Q
2026-10-05 08:31:02 +09:00
ewdernaandClaude Opus 5.5 88f18b3b20 provisional: 移動の刻みをグリッドの状態にし、候補を1U・0.25U・0.125U・1/12Uにする
T-28の固定0.25Uは、所感の「刻みはグリッドの状態として持つ」と食い違っていた。
1/12Uは実際に使う刻みなので候補に含める。0.5Uは使わないので入れない。
サイズは0.25U・回転は15度の固定のまま。
キー割り当て案の古い「コピー&ペーストは未議論」をT-11への参照に直す。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmj4oW3phJrVNNcjMVqo8Q
2026-10-05 08:29:55 +09:00
ewdernaandClaude Opus 5.5 65db845dbb provisional: コピー&ペーストのIDと参照の扱いを仮決定に足す
- 複製の範囲の中で閉じた参照(VariantGroupのdefault)は新しいIDへ付け替える
- 複製したキーはIDXに登録せず未登録のままにする。MAPの割り当ても当面複製しない
- 同じプロジェクト内のカット&ペーストはreparentとして扱い、IDと参照を保つ
- PHY Editorではクリップボードの KLE JSON を貼り付けで取り込めるようにする
- 別のプロジェクトへ貼るときのProfileの扱いは問いとして残す

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmj4oW3phJrVNNcjMVqo8Q
2026-10-05 08:16:21 +09:00
ewdernaandClaude Opus 5.5 d7c35add15 chore: provisional/decisions.md にMarkdownのlint修正をかける
内容は変えない。見出し直後の空行と表の区切り行の書式だけ。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmj4oW3phJrVNNcjMVqo8Q
2026-10-05 08:16:14 +09:00
ewdernaandClaude Opus 5.5 2ecc535c16 provisional: PHY Editorの呼び方・見出しとバリアントの切り替え方を合意に移す
- 要素の呼び方を仕様書に揃え、バリアントに「レイアウト」を使わない
- reparentは「移し先…」とし、候補の一覧で行き先を見せる
- 操作ボタンはキャンバスの上に置き、階層パネルとキャンバスの見出しをなくす
- バリアントはグループ右上のバッジとV / Shift+Vで切り替える
- タブ名と検証結果欄の呼び方は全エディタ共通の課題として残す

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABrbf2SmjALke8YHxqeLuC
2026-10-04 20:24:46 +09:00
ewdernaandClaude Opus 5.5 101ffb73d1 provisional: PHY Editorのキー割り当てを「モックで試して決める」に移す
叩き台ができたので、残りはモックに入れて触って詰める。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABrbf2SmjALke8YHxqeLuC
2026-10-04 18:53:32 +09:00
ewdernaandClaude Opus 5.5 3894df8069 provisional: PHY Editorのキー割り当ての叩き台を置く
KLEから移ってくる利用者を想定し、KLEの割り当てを土台にした。
Shift+矢印はサイズ変更(KLEと同じ)。移動の刻みは修飾キーでなくグリッドの状態で持ち、ドラッグと共有する。
回転はPgUp/PgDnでPgUpが正方向。4方向で隣のキーへ移る操作は当面作らず、Tabの順送りで試す。
キーの判定は記号・文字をevent.key、Shift+数字をevent.codeで行う基準を足した。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABrbf2SmjALke8YHxqeLuC
2026-10-04 18:47:26 +09:00
ewdernaandClaude Opus 5.5 c5ac41b1cc provisional: PHY Editorのクラスター選択とフォーカスモードの方向を合意に移す
クラスターは長押しで選び、押している間に直近の親から順に上がってループする。
フォーカスモードはダブルクリック(始めた時点で選ばれていたクラスター)かEnterで入り、Escで出る。
キー操作の候補一覧とモディファイアを振る基準を phy-editor-keybindings.md に置いた。
要検討は「モックで試して決める」と「画面仕様までに決める」に分けた。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SMS99uQoYnBW1LHuyyufQc
2026-10-03 18:11:29 +09:00
ewdernaandClaude Opus 5.5 f604ca4043 chore: Markdownのlint規則をリポジトリに置き、コミット前に修正をかける
VS Codeは保存時にmarkdownlintの修正を入れるが、規則(MD007のインデント4)がユーザー設定にしかなく、
CLIからは参照されなかった。そのためコミット直後に差分が出ていた。
規則を .markdownlint.jsonc に移し、コミット前に変更したファイルへ --fix をかける指示をCLAUDE.mdに足した。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsCdfhhE6uxxHkrdJtwFXF
2026-10-01 23:09:55 +09:00
ewdernaandClaude Opus 5.5 8f6e74758e provisional: MAP Editorモックの所感を画面仕様の入力としてまとめる
PHY・IDX・ELC Editorの所感と同じ構成にし、Remapの現状を参考として添えた。
Remapをほぼ正解とみなし、引くものと足すものを洗い出す形で進める。
StaticとDynamicでモードを分けるかは要検討として残す。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsCdfhhE6uxxHkrdJtwFXF
2026-10-01 23:05:01 +09:00
ewdernaandClaude Opus 5.5 91f21dc191 provisional: ELC Editorモックの所感を画面仕様の入力としてまとめる
PHY・IDX Editorの所感と同じ構成で、合意できた方向・要検討・モックの実装上の問題に分けた。
片側だけの配線を保存できるようにする方向と、ボードを提供者にする方向は合意したが、本決定は別途行う。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcF371oTXSea3TGHnZFfGC
2026-10-01 08:57:24 +09:00
ewdernaandClaude Opus 5.5 f03858daa9 backlog: GPIOに直結したLEDの表し方をELC-P-4として追加する
ELCはWS2812系のチェーンしか定めておらず、インジケータなどの直結LEDを表す場所がない。
チェーンとキーの配線は直結LEDと独立に扱えるので保留にする。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcF371oTXSea3TGHnZFfGC
2026-10-01 08:57:24 +09:00
ewdernaandClaude Opus 5.5 ef55ff9c81 provisional: IDX Editorモックの所感を画面仕様の入力としてまとめる
PHY Editorの所感と同じ構成で、合意できた方向・要検討・モックの実装上の問題に分けた。
欠番の要否は、構成をまたいで番号を揃えたい具体例が出るまで現行の連番方式で進める。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcF371oTXSea3TGHnZFfGC
2026-10-01 08:27:53 +09:00
ewdernaandClaude Opus 5.5 c23513bae7 provisional: PHY Editorモックの所感を画面仕様の入力としてまとめる
モックを触った所感と議論を、合意できた方向・要検討・モックの実装上の問題に分けた。
画面仕様を一から作り直すときの入力にし、仕様へ反映したら消す。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3k9PGptCqsJ2ozisjemH7
2026-09-28 09:00:21 +09:00
ewdernaandClaude Opus 5.5 7695d0f51d provisional: kbd-editorの見直しをT-5から外す
kbd-editorを止めたのは失敗ではなく、目的が違うと分かったため(ユーザー談)。フレームワークの本決定の前提にしない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
2026-09-25 22:47:42 +09:00
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。
T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。

- KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7)
- 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した
- 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build
- TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した
- 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
2026-09-25 22:42:30 +09:00
ewdernaandClaude Opus 5.5 937fc27dbd provisional: モックの仮実装に向けて未決事項を仮決定し、provisional/ に退避する
メインドキュメントの未確認事項・backlog・暗黙の未決事項について、モック用に進路を1つに固定した。
仕様ではなく、本決定は /decide で行う。ドキュメント間の食い違いは直さず一覧にした。
Codex(Sol・Astra)への相談の原文を consultation/ に置く。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
2026-09-25 17:31:54 +09:00
114 changed files with 19505 additions and 239 deletions
+3 -3
View File
@@ -28,11 +28,11 @@ argument-hint: [決定の要旨]
- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。
- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- 比較表・検討過程・ベンチマーク結果をここに書かない。
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
### 2. 非採用一覧に残す案を選ぶ
+16 -16
View File
@@ -23,7 +23,7 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR
検出する項目:
| 種別 | 内容 |
|---|---|
| --- | --- |
| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない |
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
@@ -61,28 +61,28 @@ WARNから課題の追加や本文の削除を自動決定しない。
こちらが本体。ファイルを読んで判断する。
- **「なぜBではないのか」が理由として残っていないか。**
却下理由ではなく、現在効いている制約として書かれているか。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
却下理由ではなく、現在効いている制約として書かれているか。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
記述を探し、`[推測:P-n]` が付いているか確認する。
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
記述を探し、`[推測:P-n]` が付いているか確認する。
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
件数・古さ・重複だけで一律に追加・削除しない。
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
件数・古さ・重複だけで一律に追加・削除しない。
- **本文の未決記述は、読者に何を伝えるためにあるか。**
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
- **コミット規約**(必要なら `git log --oneline` を見る)
- 1コミット1決定になっているか
- journal を伴う決定のメッセージに `Journal:` 行があるか
- 1コミット1決定になっているか
- journal を伴う決定のメッセージに `Journal:` 行があるか
## 3. 報告する
@@ -94,8 +94,8 @@ WARNから課題の追加や本文の削除を自動決定しない。
## 注意
- 既存ドキュメントへのルール適用は段階的に進めている。
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
再編前のファイルの WARN を個別に直そうとしない。
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
再編前のファイルの WARN を個別に直そうとしない。
スクリプトを変更した場合の回帰検査:
+4 -4
View File
@@ -29,7 +29,7 @@ argument-hint: <ファイル名>
各ブロック(見出し単位、または段落単位)を次のどれかに分ける。
| 分類 | 行き先 |
|---|---|
| --- | --- |
| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す |
| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) |
| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` |
@@ -50,8 +50,8 @@ argument-hint: <ファイル名>
- a を中心に再構成する。b は文中に自然に置く。
- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。
### 5. 未確認事項を立てる
@@ -92,5 +92,5 @@ Journal: journal/2026-09-16-<元ファイル名>.md
## 注意
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
+3 -3
View File
@@ -28,11 +28,11 @@ argument-hint: [決定の要旨]
- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。
- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- 比較表・検討過程・ベンチマーク結果をここに書かない。
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
### 2. 非採用一覧に残す案を選ぶ
+16 -16
View File
@@ -23,7 +23,7 @@ ERROR があれば終了コード 1。存在しない引数のファイルもERR
検出する項目:
| 種別 | 内容 |
|---|---|
| --- | --- |
| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない |
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
@@ -61,28 +61,28 @@ WARNから課題の追加や本文の削除を自動決定しない。
こちらが本体。ファイルを読んで判断する。
- **「なぜBではないのか」が理由として残っていないか。**
却下理由ではなく、現在効いている制約として書かれているか。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
却下理由ではなく、現在効いている制約として書かれているか。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
記述を探し、`[推測:P-n]` が付いているか確認する。
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
記述を探し、`[推測:P-n]` が付いているか確認する。
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
件数・古さ・重複だけで一律に追加・削除しない。
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
件数・古さ・重複だけで一律に追加・削除しない。
- **本文の未決記述は、読者に何を伝えるためにあるか。**
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
- **コミット規約**(必要なら `git log --oneline` を見る)
- 1コミット1決定になっているか
- journal を伴う決定のメッセージに `Journal:` 行があるか
- 1コミット1決定になっているか
- journal を伴う決定のメッセージに `Journal:` 行があるか
## 3. 報告する
@@ -94,8 +94,8 @@ WARNから課題の追加や本文の削除を自動決定しない。
## 注意
- 既存ドキュメントへのルール適用は段階的に進めている。
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
再編前のファイルの WARN を個別に直そうとしない。
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
再編前のファイルの WARN を個別に直そうとしない。
スクリプトを変更した場合の回帰検査:
+4 -4
View File
@@ -29,7 +29,7 @@ argument-hint: <ファイル名>
各ブロック(見出し単位、または段落単位)を次のどれかに分ける。
| 分類 | 行き先 |
|---|---|
| --- | --- |
| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す |
| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) |
| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` |
@@ -50,8 +50,8 @@ argument-hint: <ファイル名>
- a を中心に再構成する。b は文中に自然に置く。
- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- ✗ `階層構造はKLEが非対応なので諦めた`
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。
### 5. 未確認事項を立てる
@@ -92,5 +92,5 @@ Journal: journal/2026-09-16-<元ファイル名>.md
## 注意
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
+4
View File
@@ -0,0 +1,4 @@
// VS Codeのユーザー設定(markdownlint.config)と同じ規則にそろえる
{
"MD007": { "indent": 4 }
}
+7
View File
@@ -0,0 +1,7 @@
{
"[markdown]": {
"editor.detectIndentation": false,
"editor.tabSize": 4,
"editor.insertSpaces": true
}
}
+25 -22
View File
@@ -9,7 +9,7 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
## ドキュメント地図
| ファイル | 内容 |
|---|---|
| --- | --- |
| `keyboard-design-layers.md` | レイヤー構造とツール分割方針(最上位) |
| `PHYレイヤー.md` | PHY層の仕様 |
| `ELCレイヤー.md` | ELC層の仕様 |
@@ -40,13 +40,13 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
- 複数案から選んだ場合、「なぜAなのか」を1〜3行で書く。「なぜBではないのか」は書かない。
- 却下案の比較表・検討過程・ベンチマーク結果はここに置かない(→ `journal/`)。
- 却下案の再提案を防ぐ情報は、比較ではなく**制約文**として書く。
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- ✗ `B案はKLEパーサーが壊れるので却下した`
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
- 落とした案があれば、末尾に `## 検討済み・非採用` を置き、**1案1行**で残す。理由は書かず、journalファイル名を参照する。
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
- 載せるのは、実際に検討した別方針のうち再提案されうるものに限る。本文の制約文で足りるなら重複させない。
- 目安5行まで。増えたら、再提案を防ぐ必要性が低いものから落とす。長くなるのは書きすぎのサイン。
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
- 載せるのは、実際に検討した別方針のうち再提案されうるものに限る。本文の制約文で足りるなら重複させない。
- 目安5行まで。増えたら、再提案を防ぐ必要性が低いものから落とす。長くなるのは書きすぎのサイン。
### 2. 履歴と根拠
@@ -54,13 +54,13 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
- **なぜそう決めたか / 調査 / 案の比較** → `journal/` に置き、その決定と**同じコミット**に含める。
- コミットメッセージ末尾に参照を1行入れる: `Journal: journal/2026-09-16-cluster-metadata.md`
- journal は作業ツリーから削除する前提(人間にとってのノイズになるため)。履歴には残るので、
削除後は `git show <sha>:journal/...` で読む。
削除後は `git show <sha>:journal/...` で読む。
### 3. journal の書き方
- 1ファイル1トピック。`journal/YYYY-MM-DD-<topic>.md`。トピックを詰め込まない。
- 構成は上から順に: **結論** → **案の比較(簡潔な表)** → 検証ログ・根拠・捨てた案。
冒頭20行で結論と選択肢が分かること。
冒頭20行で結論と選択肢が分かること。
- 他のファイルから journal へリンクを張らない。journal 同士も張らない。参照経路はコミットメッセージのみ。
- 結論がメインドキュメントに反映されたら削除してよい。
- 整形は最低限でよい。人間向けの読みやすさは要求しない。
@@ -68,15 +68,15 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
### 4. 事実と推測
- **裏取りの済んだ事実**は、ドキュメント内のどこに書いてもよい。文中に自然に混ぜてよい。
ただし同じ事実を複数ドキュメントに複製しない。必要ならリンクする。
ただし同じ事実を複数ドキュメントに複製しない。必要ならリンクする。
- **現在の仕様・実装が答えに依存する未確認事項**は、仕様書の末尾に残す。2種類ある:
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
- 実装前に判断が必要な未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
- 実装前に判断が必要な未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
- 本文が未決の問いに触れるときは、「未決」と書いて終わらせず該当項目へリンクする。
同一ファイル内は `[P-3](#p-3-...)`、他ファイルからは `[MAPのP-3](MAPレイヤー.md#p-3-...)` の形とする。
問いの内容は `## 未確認事項` にだけ書き、本文へ写さない(二重管理を避ける)。
同一ファイル内は `[P-3](#p-3-...)`、他ファイルからは `[MAPのP-3](MAPレイヤー.md#p-3-...)` の形とする。
問いの内容は `## 未確認事項` にだけ書き、本文へ写さない(二重管理を避ける)。
- 未確認の推測には、**何を確認できれば事実に格上げできるか**を「格上げ条件」として書く。
設計判断には、**何を決めれば解決するか**を「完了条件」として書く。意思決定を事実確認と混同しない。
設計判断には、**何を決めれば解決するか**を「完了条件」として書く。意思決定を事実確認と混同しない。
- 答えに依存せず進められる課題は `backlog/` に置く(ルール5)。保留中の対応範囲・暫定仕様は仕様書に残す。
- 検証できたらタグを外し、該当項目を削除する。
- 「今回検討して捨てた仮説」は `## 未確認事項` に書かない。journal に残して消す。
@@ -98,20 +98,20 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
- 当面は `backlog/README.md` にまとめる。増えて読みにくくなったらレイヤー別・トピック別に分ける。
- 項目には、関連仕様・未決事項・保留できる理由・再開条件・完了条件を書く。
再開条件は「いつ考え直すか」、完了条件は「何を確認または決定すれば終わるか」を示す。
再開条件は「いつ考え直すか」、完了条件は「何を確認または決定すれば終わるか」を示す。
- 仕様書から移す項目は、元の番号にレイヤー名などを付けて識別する(例: `PHY-P-14`)。
判断に必要な候補や制約は短く残す。長い比較・調査ログは `journal/` に置く。
判断に必要な候補や制約は短く残す。長い比較・調査ログは `journal/` に置く。
- 問いの本文は二重管理しない。仕様書には保留中の対応範囲・暫定仕様を残し、必要に応じてbacklogへリンクする。
- 仕様書から保留課題を分離した場合、末尾の管理セクションを `## 保留課題` → `## 検討済み・非採用` →
`## 未確認事項` の順に置く(存在するものだけ)。`## 保留課題` には下記のリンク箇条書きだけを書く。
項目名の列挙・問いの要約・保留理由・対応範囲は書かない。対応範囲は仕様本文、問いの詳細はbacklogに置く。
`## 未確認事項` の順に置く(存在するものだけ)。`## 保留課題` には下記のリンク箇条書きだけを書く。
項目名の列挙・問いの要約・保留理由・対応範囲は書かない。対応範囲は仕様本文、問いの詳細はbacklogに置く。
- リンクは `- [<区分>の保留課題](backlog/README.md#<区分の見出し>)` の形とする。
分割後は該当するbacklogファイルへ向ける。複数区分を参照する場合は1リンク1行とし、参照先の見出しまで指定する。
該当課題がなくなったらリンクを削除し、空になった `## 保留課題` はセクションごと削除する。
分割後は該当するbacklogファイルへ向ける。複数区分を参照する場合は1リンク1行とし、参照先の見出しまで指定する。
該当課題がなくなったらリンクを削除し、空になった `## 保留課題` はセクションごと削除する。
- 現在の仕様が依存する未確認の前提は、backlogへ移して隠さない。仕様書の `## 未確認事項` に残す。
- backlogへの移動は仮説の採用や実装の許可を意味しない。実装は採用済みの仕様・対応範囲に従う。
- 解決したら必要な結論を仕様書へ反映し、項目と不要になった参照を削除する。取り下げた項目も削除し、完了一覧は蓄積しない。
調査・判断の経緯はルール2・3に従って履歴に残す。
調査・判断の経緯はルール2・3に従って履歴に残す。
- 未解決の課題は作業ツリーに残す。結論反映後に削除できるjournalとは寿命が異なる。
仕様書側の書式:
@@ -127,6 +127,9 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
- ドキュメントを更新するときは、上のルール1〜5のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
- Markdownをコミットする前に、変更したファイルへ `npx markdownlint-cli2 --fix <files>` をかける。
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
規則は `.markdownlint.jsonc`。変更していないファイルの指摘は直さない。
## 未確認事項
+8 -8
View File
@@ -20,7 +20,7 @@ HSTのデータは、ホスト環境ごとのプロファイルの集まりと
プロファイルは、OSとホスト配列の組で識別する。必要な場合だけ、入力方式やその設定を加える。
- ホスト配列は、OSがキーコードを解釈するための配列の設定である。キーキャップの刻印やOSの表示言語とは独立に決まる
(日本語のWindowsで英語配列を設定する、など)。
(日本語のWindowsで英語配列を設定する、など)。
- Linux(XKB)では、配列(layout)・変種(variant)・オプション(options)の組になる。
- `win-jp` のような短い名前は、人が選ぶためのプリセット名として付ける。
- 各OSの識別子(WindowsのKLID、XKBの名前、macOSの入力ソースID)と対応付けられるようにする。
@@ -43,7 +43,7 @@ IMEが今ONか、のような実行時の状態は識別に含めない(次節
```
- ホスト状態: CapsLock、かなロック、IMEのON/OFFなど、同じキーコードの結果を変える状態。
HSTは状態の種類を引数として記述するだけで、実行時の状態を観測しない。
HSTは状態の種類を引数として記述するだけで、実行時の状態を観測しない。
- OS固有のキーの意味: `LANG1`/`LANG2`(Macのかな/英数)、変換/無変換、Cmd/Ctrlの役割など。
- 成立の前提: 動作に必要なOS側の設定(Unicode入力の方式など)。HSTは設定を変更する機能を持たない。
- 対応可否: 対応済み・未対応・不明の3値とする。カタログに載っていない動作を「使えない」と扱わない。
@@ -55,9 +55,9 @@ HSTは逆引き(文字から、それを入力するキー操作を求めるこ
- 順方向の表から導けるので、両方を持つと二重管理になる。
- 逆引きの候補には、ファームウェアの機能に依存するものがある。
例えば、US刻印のキーキャップをJIS配列のホストで使う場合、Shiftの有無で文字の組が合わないキーには、
Shift時に別のキーコードを送る機能(QMKのKey Override)が必要になる。
これはホストの知識ではないため、HSTに持たせない。
例えば、US刻印のキーキャップをJIS配列のホストで使う場合、Shiftの有無で文字の組が合わないキーには、
Shift時に別のキーコードを送る機能(QMKのKey Override)が必要になる。
これはホストの知識ではないため、HSTに持たせない。
使う側の担当は次のとおり。
@@ -67,7 +67,7 @@ HSTは逆引き(文字から、それを入力するキー操作を求めるこ
## MAPとの境界
- MAPは具体的なキーコードを保存する。逆引きは編集時の入力補助であり、選んだ候補のキーコードをMAPへ書く一方向の変換とする。
実機に残るのはキーコードだけなので、この形なら実機との往復を単純に保てる。
実機に残るのはキーコードだけなので、この形なら実機との往復を単純に保てる。
- HSTは割り当てを所有・変更しない。PHYのキーIDも参照しない。
## 対象外
@@ -88,10 +88,10 @@ HSTは逆引き(文字から、それを入力するキー操作を求めるこ
- 位置づけ: 本文「使う側の責務」が前提にしている。HSTにファームウェアの知識を持たせない線引きは、これが成り立つことに依存する。
- 格上げ条件: US刻印のキーキャップをJIS配列のホストで使う構成で、Shiftの有無で文字の組が合わないキーについて、
HSTの順方向の表と、Generatorのビルド設定(有効な機能)だけから、MAPエディタがKey Overrideを含む候補を作れることを試作で確認する。
HSTの順方向の表と、Generatorのビルド設定(有効な機能)だけから、MAPエディタがKey Overrideを含む候補を作れることを試作で確認する。
### P-2 対象ホストを複数持つか
- 位置づけ: 未解決の問い。Win/Mac両用のキーボードでは、表示と検証を複数のホストに対して行う必要がある。
OSの切り替えをMAPのレイヤーで表す既存の慣習と、どう対応付けるかも関わる。
OSの切り替えをMAPのレイヤーで表す既存の慣習と、どう対応付けるかも関わる。
- 完了条件: プロジェクトが参照するプロファイルの数と、MAPのレイヤーとの対応付けの有無をユーザーが決める。
+1 -1
View File
@@ -36,7 +36,7 @@ PHYの座標・ツリー順をキー番号に使わない。
ELCのマトリクス位置をキー番号に使わない。
- マトリクスの並びとキー番号の並びは一致しない。分割キーボードでは、マトリクスは片方の半身の行を
並べてからもう片方の行を並べるが、キー番号は視覚的な行ごとに左右を交互に取るのが通例である
並べてからもう片方の行を並べるが、キー番号は視覚的な行ごとに左右を交互に取るのが通例である
- マトリクスには欠番がある。キー番号は実在するキーに対して連続だが、マトリクスは疎である
- 基板リビジョンでピンを差し替えたときに、キー番号が動いてはならない
+1 -1
View File
@@ -111,7 +111,7 @@ VIAプロトコル0x000Dのコマンドのうち、MAPエディタが扱うも
- エンコーダの割り当て([MAP-P-4](backlog/README.md#map-p-4-onoff以外の入力への割り当て))
- layout option([P-3](#p-3-プロジェクトと実機の対応状態管理))
- 当面対応しない: キーマップ・EEPROMのリセット、ブートローダへの移行、デバイスの識別表示、
マトリクス状態の読み出し(キーテスター)、custom channel(照明・オーディオ・キーボード独自メニュー)
マトリクス状態の読み出し(キーテスター)、custom channel(照明・オーディオ・キーボード独自メニュー)
実機のファームウェアでどの機能が有効かは、VIAでは読み出せない。
新規キーボードではFirmware Generatorのビルド設定を正とする。既存キーボードでは、利用者が同じ形式で申告した内容を信用する。
+4 -4
View File
@@ -153,7 +153,7 @@ PHYは全バリアントのキーを同時に持つ。基板上のフットプ
- Variantの中に別のVariantGroupを置ける。あるバリアントを選んだときだけ現れる選択肢を表す
- 選択箇所が複数ある場合はVariantGroupを複数置く。同じ親の下に並べてよい
- 選択が互いに独立なら、VariantGroupを分けて選択肢の数を減らしてよい。
ANSI/ISOとBackspaceの分割のように、独立な2つの選択が1つの選択肢リストに畳まれている基板がある
ANSI/ISOとBackspaceの分割のように、独立な2つの選択が1つの選択肢リストに畳まれている基板がある
VariantGroupはクラスターと同じ変換を持つ。Variantは変換を持たない。
バリアントは同じ場所を占める代替物であり、選択によって配下の絶対配置が動く構造を作らないため。
@@ -198,7 +198,7 @@ Transform は回転と平行移動だけを持つ剛体変換であり、合成
- 組み替えの対象は同一の親を持つノードに限る。複数の親にまたがる選択は組み替えできない
- クラスター化したときの初期値は、原点を選択の境界矩形の左上、基準角度を0とする。
ただし選択したノード全部が同じ回転角度を持つ場合はその角度を基準角度に採り、境界矩形もその角度の座標系で取る
ただし選択したノード全部が同じ回転角度を持つ場合はその角度を基準角度に採り、境界矩形もその角度の座標系で取る
- 組み替えで生じた非グリッド値はそのまま保持する。クラスターを解除して親に戻ったノードも同じ
当面、reframeは通常の移動・回転と別の明示的な操作とし、原点の指定は数値入力で行う。
@@ -336,14 +336,14 @@ const kleData = (ConfigObject | KeyLabel)[][]
- 回転角度は正規化せずそのまま出力する。KLEのデータは任意の角度を受け付け、15度刻みはKLEのUI上の操作単位にすぎない
- rx, ry はPHYに持たないので、出力時に選ぶ。同じ絶対角度を持つキーには共通の値を与える
(クラスターの原点が使える)。KLEが1行にまとめるのは r, rx, ry が一致し y が等しいキーだけという制約による
(クラスターの原点が使える)。KLEが1行にまとめるのは r, rx, ry が一致し y が等しいキーだけという制約による
- 書き出す x, y は丸める。行のまとまりは y の厳密一致で決まるので、絶対座標への変換で入った誤差が残ると行が割れる
- ユニットサイズ(mm)、ビジュアルオフセット、クラスター構造、部品ノード、per-key LEDはKLE JSONに出力されない
- バリアント構造はKLE JSONに出力しない。VariantGroupごとにバリアントを1つ選び、その配下だけを畳んで出力する
- 部品グループ・スイッチの向きはKLE JSONに出力しない。sm, sb, st には取り込み時に保持した値をそのまま書き戻す
- r, rx, ry は行の先頭にのみ置く。KLE本家はそれ以外の位置に置かれたものを読まない
- p, sm, sb, st は空文字に戻す指定がKLE側で無視される。値を持つキーと持たないキーが混在する配置は、
KLEで開いた時点で後続のキーに前の値が残る
KLEで開いた時点で後続のキーに前の値が残る
- KLEの `p` はキーキャップのプロファイル名(SA、DCSなど)であり、PHYのプロファイルとは対応させない
### KLE JSONの取り込み
+30 -21
View File
@@ -13,14 +13,14 @@
- 保留できる理由: 新規PHYとしての読み込みは、既存ノードとの照合なしで実装できる。
- 再開条件: 既存PHYへのKLE JSONの照合・更新に着手するとき。
- 完了条件: 照合規則と曖昧な候補の扱いを決め、KLE本家でキーの移動・追加・削除をしたJSONを取り込み、
意図したIDに対応づき、他レイヤーの参照を誤って付け替えないことを確認する。
意図したIDに対応づき、他レイヤーの参照を誤って付け替えないことを確認する。
### PHY-P-6 キーキャップの見た目情報を持つ層
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「KLE JSONの取り込み」、[JSON Schema](../phase1-json-schema.md)の `kleCompatibility`
- 未決事項: キーキャップの見た目編集をBeyond KLEの範囲に含めるか、正式にはどの層で情報を持つか。
対象はキー単位の c, t, p, a, f系, l, n と、退避する sm, sb, st、ファイル単位の
switchMount, switchBrand, switchType, backcolor, radii, background, css。
対象はキー単位の c, t, p, a, f系, l, n と、退避する sm, sb, st、ファイル単位の
switchMount, switchBrand, switchType, backcolor, radii, background, css。
- 保留できる理由: 現行Schemaの `kleCompatibility` で、正式な所属層や編集機能を決めずに保持・書き戻しができる。
- 再開条件: 見た目の編集機能、または退避先から正式な所属層への移行を検討するとき。
- 完了条件: 扱う範囲と所属層をユーザーが決め、既存の退避情報を失わない保持・移行方法を定める。
@@ -71,7 +71,7 @@
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「ピンとピン提供者」
- 未決事項: 選択ピンを共有して共通ピンを分ける構成と、enableで切り替える構成の実配線・制御方法。
どちらが一般的かは未確認。前者は複数providerの `select` に同じ `PinRef` を書く形で表現できる。
どちらが一般的かは未確認。前者は複数providerの `select` に同じ `PinRef` を書く形で表現できる。
- 保留できる理由: providerごとの参照を保存する形は維持でき、複数muxの走査出力を検証済みの構成に限定できる。
- 再開条件: 複数muxを使う構成のファームウェア出力に対応するとき。
- 完了条件: 実在のキーボードの配線でピン共有とenableの使い方を確認し、現行モデルでの表現と必要な制御を確かめる。
@@ -80,11 +80,20 @@
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「配線トポロジを区分として持たない」
- 未決事項: 各方式で出力器が決められる走査方法と、ユーザーの追加指定が必要になる範囲。
接点マトリクスではダイオードの向き、directでは電源レールの参照から役割を判断できる範囲を確認する。
接点マトリクスではダイオードの向き、directでは電源レールの参照から役割を判断できる範囲を確認する。
- 保留できる理由: 走査方向の決定・追加指定の責任は出力器側と決まり、ELCの配線保存はそのまま進められる。
- 再開条件: 各方式のファームウェア出力に着手するとき。
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
全方式で走査手順が一意に決まることの証明は求めない。
全方式で走査手順が一意に決まることの証明は求めない。
### ELC-P-4 GPIOに直結したLEDの表し方
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「LEDの識別番号」
- 未決事項: インジケータなど、チェーンに入らずマイコンのピンで直接点灯するLEDをELCにどう持つか。
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
- 保留できる理由: チェーン方式(WS2812系)のLEDとキーの配線は、直結LEDと独立に扱える。
- 再開条件: インジケータLEDの出力に対応するとき、またはLED配線の編集画面の仕様を作るとき。
- 完了条件: 実在の基板で直結LEDの配線を確認し、ELCでの表し方と、ファームウェア出力に必要な情報を定める。
## IDX
@@ -92,12 +101,12 @@
- 関連仕様: [IDXレイヤー](../IDXレイヤー.md)「レイアウトバリアント」
- 未決事項: 採用済みの方式で既存コミュニティレイアウトの要求順を再現できる範囲の実測。
同じPHYキーIDを使う共通キー同士の要求順がレイアウト間で逆転する場合は、単一の `order` からの取捨では表せないが、その実例の有無は未確認。
同じPHYキーIDを使う共通キー同士の要求順がレイアウト間で逆転する場合は、単一の `order` からの取捨では表せないが、その実例の有無は未確認。
- 保留できる理由: 全バリアントのキーを別々の要素として並べ、選択後に番号を詰める仕様は決定済み。
既存レイアウト全般との互換性を前提にせず実装できる。
既存レイアウト全般との互換性を前提にせず実装できる。
- 再開条件: 既存コミュニティレイアウトとの互換出力を検証するとき。
- 完了条件: 実データのキーを共通キーとバリアント別のPHYキーに対応づけ、全体の `order` から各レイアウトの要求順を取捨で再現できるか確認する。
再現できないものが残る場合は、対応範囲と扱いを決める。
再現できないものが残る場合は、対応範囲と扱いを決める。
## MAP
@@ -105,9 +114,9 @@
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「MAPデータと他レイヤーの関係」、[PHYレイヤー](../PHYレイヤー.md)「部品ノード」
- 未決事項: ロータリーエンコーダの回転のように、ON/OFFのキーではない入力へ割り当てる方法。
MAPはPHYのキーIDを参照するが、エンコーダはPHYで部品ノードとして置かれる。
参照先の種類、入力ごとの割り当て先(回転方向など)、VIAでの読み書きの対象にするかが未定。
アナログ値の解釈の所属は[全体方針のP-1](../keyboard-design-layers.md#p-1-アナログ値の解釈をどの層が持つか)で扱い、この項目とは分ける。
MAPはPHYのキーIDを参照するが、エンコーダはPHYで部品ノードとして置かれる。
参照先の種類、入力ごとの割り当て先(回転方向など)、VIAでの読み書きの対象にするかが未定。
アナログ値の解釈の所属は[全体方針のP-1](../keyboard-design-layers.md#p-1-アナログ値の解釈をどの層が持つか)で扱い、この項目とは分ける。
- 保留できる理由: キーへの割り当てと実機との読み書きは、キー以外の入力を扱わなくても実装できる。
- 再開条件: エンコーダ等を載せたキーボードの割り当てをMAPで扱うとき。
- 完了条件: 対象とする入力の種類と、MAPからの参照方法・保存形式をユーザーが決め、VIAで読み書きする場合は必要なコマンドを確認する。
@@ -116,7 +125,7 @@
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「持たないもの」、[HSTレイヤー](../HSTレイヤー.md)
- 未決事項: MAPエディタがキーコードをどのOS配列の文字で表示するか。HSTのどのプロファイルを使うか。
少なくともUS/JISの切り替えは欲しい。利用者が増えれば他言語への対応も求められる。
少なくともUS/JISの切り替えは欲しい。利用者が増えれば他言語への対応も求められる。
- 保留できる理由: キーコードの割り当てと実機との読み書きは、表示に使うOS配列と独立して実装できる。
- 再開条件: MAPエディタの表示を実装するとき、またはHSTの[P-2](../HSTレイヤー.md#p-2-対象ホストを複数持つか)を決めるとき。
- 完了条件: 表示に使うHSTプロファイルの指定方法と置き場所をユーザーが決める。
@@ -125,18 +134,18 @@
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「DynamicとStatic」
- 未決事項: 当面非対応としたdevelopブランチの機能とCommunity Modules(VIAモジュール以外)を、DynamicとStaticの対象に含めるか。
含める場合、developブランチのコミットの指定方法と、UIでの対応バージョンの示し方。
含める場合、developブランチのコミットの指定方法と、UIでの対応バージョンの示し方。
- 保留できる理由: QMK本家masterの公式機能だけで、Dynamicの読み書きとStaticの生成を実装できる。
- 再開条件: masterの機能だけでは足りない動作や設定が必要になったとき。
または、VIAのモジュール移行([qmk_firmware#26315](https://github.com/qmk/qmk_firmware/pull/26315))がdevelopに入ったとき。
VIAモジュールへの対応はmasterに入る前に済ませる([MAPレイヤー「DynamicとStatic」](../MAPレイヤー.md#dynamicとstatic))。
または、VIAのモジュール移行([qmk_firmware#26315](https://github.com/qmk/qmk_firmware/pull/26315))がdevelopに入ったとき。
VIAモジュールへの対応はmasterに入る前に済ませる([MAPレイヤー「DynamicとStatic」](../MAPレイヤー.md#dynamicとstatic))。
- 完了条件: 対象に含める範囲と、対応バージョンの管理方法をユーザーが決める。
### MAP-P-7 再ビルド時に実機の調整を失わないワークフロー
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「DynamicとStatic」、[MAPのP-3](../MAPレイヤー.md#p-3-プロジェクトと実機の対応状態管理)
- 未決事項: QMK本家のVIAは、実機に保存したVIAのデータ(キーマップ・マクロ・layout option)を、ビルド日時をもとにした値で検証し、一致しなければ初期化する(`quantum/via.c` の `via_eeprom_is_valid()`。実機では未検証)。
再ビルドしたファームウェアを書き込む前に、実機の調整をプロジェクトへ読み出す手順を、ワークフローでどう保証するか。警告、読み出しの強制、Firmware Generatorとの連携などが候補。
再ビルドしたファームウェアを書き込む前に、実機の調整をプロジェクトへ読み出す手順を、ワークフローでどう保証するか。警告、読み出しの強制、Firmware Generatorとの連携などが候補。
- 保留できる理由: 実機からの読み出しとStaticの初期キーマップへの反映はすでに仕様にある。手作業の手順で運用できる。
- 再開条件: Firmware Generatorでの書き込みや、プロジェクトと実機の状態管理(P-3)を設計するとき。
- 完了条件: 再ビルドの前に実機の調整を取り込めるワークフローをユーザーが決め、書き込み後にVIAのデータが初期化されることを実機で確認する。
@@ -153,12 +162,12 @@
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「MAPデータと他レイヤーの関係」「DynamicとStatic」、[全体方針のFirmware Generator](../keyboard-design-layers.md#firmware-generator)
- 未決事項: QMKのkeymap.jsonでは、タップダンス・コンボ・キーオーバーライドや、キーごとの設定(`get_tapping_term` 等のコールバック)を表現できない。
JSONを正本にしたままこれらをCとして生成する方法と、ユーザーが用意したC/hで一部を上書きする方法。
QMKのコールバックは1つの関数として定義するため、生成したCとユーザーのCが同じ関数を定義するとビルドできない。候補:
JSONを正本にしたままこれらをCとして生成する方法と、ユーザーが用意したC/hで一部を上書きする方法。
QMKのコールバックは1つの関数として定義するため、生成したCとユーザーのCが同じ関数を定義するとビルドできない。候補:
- 生成した関数の先頭でユーザー用のフックを呼び、フックが処理しなかった場合はJSONの値を使う(有力)
- ユーザーが引き受ける関数をJSONまたはhのプロトタイプで宣言し、Generatorはその関数を出力しない
`ACTION_TAP_DANCE_FN*` やリーダーキーのように、本質的にCで書く動作をJSONから参照する方法も含む。
MAPの「キーマップ固有のコードには原則として対応しない」との関係も整理する。
`ACTION_TAP_DANCE_FN*` やリーダーキーのように、本質的にCで書く動作をJSONから参照する方法も含む。
MAPの「キーマップ固有のコードには原則として対応しない」との関係も整理する。
- 保留できる理由: レイヤー別の割り当てとVIAでの読み書きは、keymap.jsonで表現できる範囲だけで実装できる。
- 再開条件: Firmware Generatorでタップダンス・コンボ・キーごとの設定等を出力するとき、またはP-1で動作定義とキーごとの設定の保存形式を決めるとき。
- 完了条件: 生成するCの構成と、ユーザーのC/hを組み込む方法をユーザーが決め、両方を含む構成でQMKのビルドが通ることを確認する。
+21 -16
View File
@@ -7,14 +7,14 @@
- ijprest/keyboard-layout-editor の改造は不可。全権利留保で、作者はフォークを避けたいと明記している
- 改造の現実的な候補は adamws/kle-ng (MIT, Vue3+TS, 2026-09時点で活発)。法的には可
- ただし KLE も kle-ng も内部データはフラットな Key[] (絶対座標 + rotation_x/y)。
PHY座標モデル(相対座標の木)を採った以上、改造しても編集操作の中核は書き直しになる
PHY座標モデル(相対座標の木)を採った以上、改造しても編集操作の中核は書き直しになる
- 自作寄り。KLE JSON の読み書きはライブラリで賄える(kle-serial / kle-serial2, いずれもMIT)
- 技術選定(言語・UIフレームワーク・描画・ライブラリ)は別途やり直す。Vue案はNuxtに慣れているからというだけ(ユーザー談)
## 案の比較
| 案 | ライセンス | 流用できるもの | 書き直しになるもの |
|---|---|---|---|
| --- | --- | --- | --- |
| ijprest KLE 改造 | 全権利留保。個別許諾要 | 描画, プロパティUI | データモデル, 編集操作。AngularJS 1.2.28/bower の保守 |
| kle-ng 改造 | MIT | 描画(ISO形状含む), プロパティUI, undo, plate/PCB生成 | keyboard.ts ストア(62KB)と、それを直接書き換える移動・回転・履歴 |
| 自作 | - | kle-serial(読), kle-serial2(読み書き) | 全部。ただし木モデルに合わせて最初から書ける |
@@ -22,14 +22,15 @@
## 検証ログ
### ijprest/keyboard-layout-editor
- LICENSE.md (ローカル HEAD d2945e5 / 本家 master 2026-09-18 取得、同文)
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
- "But it's not permissively licensed!"
- "In the near-term I would like to avoid forks of the website, since I feel it would cause confusion
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
- "But it's not permissively licensed!"
- "In the near-term I would like to avoid forks of the website, since I feel it would cause confusion
in the community to have several similar-but-different general-purpose layout editors available."
- 別用途(メーカーのカスタマイズ用エディタ等)なら無償で特別ライセンスを出す、とある。
Beyond KLE は汎用レイアウトエディタそのものなので、この例外には当てはまりにくい
- "I claim no ownership over the input (your keyboard layouts) or output" → ビュワーとして使う・JSONを扱うのは問題なし
- 別用途(メーカーのカスタマイズ用エディタ等)なら無償で特別ライセンスを出す、とある。
Beyond KLE は汎用レイアウトエディタそのものなので、この例外には当てはまりにくい
- "I claim no ownership over the input (your keyboard layouts) or output" → ビュワーとして使う・JSONを扱うのは問題なし
- 本家最終コミット 580b916 2024-09-15 "Update to https"。その前は 2019-07-04。実質停止
- 規模(HEAD): kb.js 1810 / kb.html 1410 / kb.css 453 / serial.js 374 / render.js 275 行
- 依存: angular 1.2.28, jquery 2.0.2, bootstrap 3.3.5, bower
@@ -37,36 +38,40 @@
- ローカルクローン(Documents/Dev/keyboard-layout-editor)の未コミット差分はPNG解像度調整と円形プロファイル追加のみ(ユーザー談)。本件と無関係
### kle-serial (ijprest)
- MIT, Copyright (c) 2013-2019 Ian Prest。npm @ijprest/kle-serial 0.15.1 (2019-06-30)
- README: "This library currently only handles deserialization; the serialization code still needs to be ported."
### kle-serial2 (adamws, ijprest/kle-serial のフォーク)
- MIT。2025-08-22作成、最終push 2026-02-14、star 1
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r _rs a f fa w h w2 h2 x2 y2 l n d
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r_rs a f fa w h w2 h2 x2 y2 l n d
- 出自の懸念: 元READMEは serialize を「KLE本体から移植予定」としていた。kle-serial2 の serialize 冒頭
(current, cluster={r,rx,ry}, align=4, rows/row)は KLE serial.js:166- と構造が似る。
ただしKLEフォーマット自体がこの構造を要求するので、流用の証拠にはならない。未確認
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
(current, cluster={r,rx,ry}, align=4, rows/row)は KLE serial.js:166- と構造が似る。
ただしKLEフォーマット自体がこの構造を要求するので、流用の証拠にはならない。未確認
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
### adamws/kle-ng
- MIT, Copyright (c) 2025 adamws。Vue 3 + Pinia + TS + Vite, bootstrap 5, three.js, ergogen, makerjs
- 最終コミット 9eb350f 2026-09-03 (0.48.0)。star 267
- 規模: TypeScript 2.97MB + Vue 0.99MB (GitHub languages API, テスト込み)
- src/stores/keyboard.ts: `const keys: Ref<Key[]> = ref([])`。フラット、絶対座標 + rotation_x/y。階層・グループなし
- 移動: key.x = D.add(originalPos.x, snappedDeltaX) と直接書き換え
- 回転: key.rotation_angle を直接書き換え、lock-rotations時は rotation_x/y も更新
- undo: keys 全体の JSON スナップショット、50件
- 移動: key.x = D.add(originalPos.x, snappedDeltaX) と直接書き換え
- 回転: key.rotation_angle を直接書き換え、lock-rotations時は rotation_x/y も更新
- undo: keys 全体の JSON スナップショット、50件
- matrix-drawing.ts (28KB), pcbGenerator.ts, plateGenerator.ts あり。IDX/ELC の参考になりうる(未読)
- ijprest のコードから派生したかは README に記載なし。未確認
### その他
- ymkn/keyboard-layout-studio: MIT, Vue3, SVG, KLE import, QMK keyboard.json / Vial 出力, matrix割り当て。star 4, 21 commits。回転・グループの記載なし
- ローカル Documents/Dev/kbd-editor: Nuxt3 + Pinia, 2025-07, 約2,500行 (deserializer.ts, Key.vue, KeyPropEditor.vue 等)。ユーザーが以前自作を試みたもの。うまくいかなかった記憶あり(ユーザー談)。中身の確認は保留
## 規模見積もり(PHY Editor MVP, 1人・フルタイム換算の粗い目安)
| 項目 | 自作 | kle-ng改造 |
|---|---|---|
| --- | --- | --- |
| KLE読込 | 1-2日 (kle-serial) | 既存 |
| KLE書出 + 畳み込み | 2-4日 | 畳み込みだけ新規 |
| 木モデル・変換合成・逆変換 | 3-5日 | 3-5日 + 既存ストアへの差し込み |
+5 -5
View File
@@ -10,7 +10,7 @@
## 案の比較
| 案 | 出典 | 問題 |
|---|---|---|
| --- | --- | --- |
| labelにIDを埋め込み、別ファイルのクラスターJSONと紐づけ | PHYレイヤー.md(旧) | VIAのlabelハックと同じ道。IDが長くなりラベルが見づらい |
| `_beyondkle` メタデータ + labels[8] のハイブリッド | kle-compatibility.md | 同上。独自プロパティはKLEが保存時に落とす可能性 |
| ゴーストキーにクラスター情報を格納 | kle-compatibility-analysis.md | 下記。所属情報が残らず、結局labelハックに戻る |
@@ -28,12 +28,12 @@ PHY座標モデルの決定(相対座標の木が正本)により、KLE JSONは
### ゴーストキー案の穴
- キーの所属は `key.clusterId` という独自プロパティで持たせる設計だった(kle-compatibility-analysis.md の createCluster)。
KLEのシリアライザは既知のプロパティしか書き出さないはずで(未確認)、保存時に所属が消える。
残すには各キーのlabelにIDを入れることになり、labelハックに戻る
KLEのシリアライザは既知のプロパティしか書き出さないはずで(未確認)、保存時に所属が消える。
残すには各キーのlabelにIDを入れることになり、labelハックに戻る
- 矩形に含まれるかで所属を判定する方法は、入れ子や回転があると曖昧になる
- ゴーストキー自体も labels[0]="CLUSTER_META"、labels[4]=JSON という形で、汚す対象がダミーキーに変わっただけ
- KLE JSONを読む他ツール(QMK変換、KiCad配置プラグインなど)がゴーストキーを実在するキーとして扱う懸念(未確認)。
KLE JSONを出力する目的は互換性なので、偽のキーを混ぜるのは本末転倒
KLE JSONを出力する目的は互換性なので、偽のキーを混ぜるのは本末転倒
### KLEを改造するか自作するかとは独立
@@ -45,4 +45,4 @@ KLEを改造する場合でも、内部データと保存形式は自分で決
- 照合基準の詳細(許容誤差、ラベルの扱い、複数候補の解決)。PHYレイヤー.md の未確認事項 P-2
- KLEが保存時にキー順を並べ替えるか(未確認)。配列順を照合に使えるかに関わる
- kle-compatibility.md / kle-compatibility-analysis.md の埋め込み案の記述は今回の決定と食い違うが、
既存ドキュメントの再編は別タスクのため触っていない
既存ドキュメントの再編は別タスクのため触っていない
+3 -3
View File
@@ -11,7 +11,7 @@
## 案の比較
| 項目 | 案 | 採否 |
|---|---|---|
| --- | --- | --- |
| d, g | PHYでキーとして持つ / 取り込み時に捨てる | 持つ |
| 見た目属性 | 捨てる / 解釈せず保持して書き出し時に戻す | 保持。KLEをビュワーに使うとき色などが戻る方が実用的 |
| ラベル | 捨てる / 参考情報として持つ | 持つ。照合に必要 |
@@ -19,8 +19,8 @@
## ユーザーから得た事実・所感
- l(ステップキー)にはスイッチ位置がずれるものがある(Stepped Caps Lockが典型)。ただし l なら必ずずれるわけではない(昔のXFERなど)
→ 「キャップの位置・サイズからスイッチ座標を特定する」という本文と食い違う。P-5として分離
→ 「キャップの位置・サイズからスイッチ座標を特定する」という本文と食い違う。P-5として分離
- l, n はキーキャップの見た目属性であり、PHYとは別の層が要りそう。キーキャップは設計時に決める必要が薄く、
カスタムキーボードの文脈に属する。Beyond KLEの範囲かは結論なし
カスタムキーボードの文脈に属する。Beyond KLEの範囲かは結論なし
- 一部のキーだけ違うスイッチを使うキーボードもあるので、スイッチ種別はキーごとの上書きが要るかもしれない。PCB用なのでPHYに要るか
- ラベルのうちどれがMAPに適用できるかは判断できない見込み。自動で適用してユーザーが落とす案もある
+2 -2
View File
@@ -7,7 +7,7 @@
## 案の比較
| 案 | 採否 |
|---|---|
| --- | --- |
| キャップ形状(l 等)からスイッチ位置を導出 | 不可。l でもずれないキーがある(昔のXFER) |
| キーごとにオフセットを持つ | 採用 |
| PHY以外の層(キーキャップ層など)で持つ | 不採用 |
@@ -17,5 +17,5 @@
- Stepped Caps Lockなど、スイッチがキャップ中心からずれるキーがある(ユーザー提供の事実)
- ずれるかどうかはキャップ形状から決まらないので、キーごとに持つしかない
- スイッチ位置はPCB・プレートの配置を決める物理情報であり、PHYの責務。キーキャップの見た目情報が別層に移っても(P-6)、
キャップ外形とスイッチの相対位置は配置そのものなのでPHYに残る
キャップ外形とスイッチの相対位置は配置そのものなのでPHYに残る
- ローカル座標系にしたのは、キーの回転に追従させるため
@@ -14,7 +14,7 @@ PHYレイヤー.md の既存記述が2箇所でこれを前提にしていた。
## 案の比較
| 案 | 過去のKLE資産 | 誤り |
|---|---|---|
| --- | --- | --- |
| 自動推定 | 一発で階層が付く場合がある | 推定を外したときユーザーが直せない(直す手段=手動操作がないため) |
| 手動の組み替え | 選択してまとめる手数がかかる | ユーザーの意図がそのまま入るので誤りがない |
| 両方 | 同上 | 手動操作があれば推定の価値は初期値の提案に落ちる。初期値は小さく済む(下記) |
@@ -43,7 +43,7 @@ Size / switchOffset / orientation はローカル値で親の変換を受けな
- スナップは手作業の入力時のみ。計算結果には適用しない
- グリッドは操作開始時の値を基準とする相対グリッド。ローカル原点基準の絶対グリッドにしない
(非グリッド値を持つキーを後から掴んだとき、絶対配置が勝手に動くのを防ぐ)
(非グリッド値を持つキーを後から掴んだとき、絶対配置が勝手に動くのを防ぐ)
## 浮動小数の累積(実装時の注意、本文には書かない)
+5 -5
View File
@@ -13,7 +13,7 @@
### per-key LEDの表現
| 案 | 位置 | 採否 |
|---|---|---|
| --- | --- | --- |
| キーのboolean | 持たない | ○ |
| キーに追随する自動生成ノード | ノードが座標を持つ | × 座標に自由度がない |
| ユーザーが1個ずつ置く | 持つ | × キー数ぶんの手作業 |
@@ -21,7 +21,7 @@
### 独立して置く部品
| 案 | 対象 | 採否 |
|---|---|---|
| --- | --- | --- |
| 部品ノード(汎用) | LED / OLED / エンコーダ / ネジ穴 / MCU など | ○ |
| LEDノード専用 | LEDのみ | × 次の部品で同じ議論をやり直す |
@@ -57,9 +57,9 @@ partGroup(中身を解釈しないラベル)をそのまま流用できるので
## 捨てた案・送った先
- ファイル単位の「per-key LEDがある」フラグ: キーごとのbooleanから導出できるので存在フラグとしては不要。
既定値を与える設定としては意味があるが、ファイル単位設定の置き場は他にも要求があり別トピックへ。
今回はキーごとのbooleanのみ決め、省略時は false とする
既定値を与える設定としては意味があるが、ファイル単位設定の置き場は他にも要求があり別トピックへ。
今回はキーごとのbooleanのみ決め、省略時は false とする
- レイアウトによって条件付きでLEDの有無が変わる構成: ANSI/ISO Enter のようなキー自体の排他と同じ問題で、
LED固有ではない。マルチレイアウト(未導入の概念)側へ送る
LED固有ではない。マルチレイアウト(未導入の概念)側へ送る
- 部品の表裏をPHYが持つ: PCB設計ツールが決める情報。ただし描画の描き分けとの絡みが残るので P-12
- チェーン順序をPHYが持つ: 電気的な接続なのでELC。ただし接点マトリクスと同じ層でよいかに確信がなく P-11
+5 -5
View File
@@ -8,7 +8,7 @@ PHYは端子配置(terminal) / ソケット方式(socket) / スイッチの向
## 案の比較
| 論点 | 案 | 採否 |
|---|---|---|
| --- | --- | --- |
| ブランド | KLEの sb 相当をPHYで持つ | 不採用 |
| ブランド | 持たない | 採用 |
| 種別の定義 | (a) スイッチ側の足の配置。MX-3pin と MX-5pin は別タイプ | 採用 |
@@ -24,13 +24,13 @@ PHYは端子配置(terminal) / ソケット方式(socket) / スイッチの向
- ブランドは同じボードに対して利用者が選ぶもので、穴もパッドも配線対象も変えない。ボード設計の情報ではない
- KLEの st は `MX1A-11Nx` のようなベンダー品番で、ブランド・種別・バリエーションが混ざった文字列。ここから穴形状を機械的に導けないため、
Beyond KLEの端子配置との対応づけは成立しない。名前も意味もKLEと別物なので、同名を避けて terminal とした
Beyond KLEの端子配置との対応づけは成立しない。名前も意味もKLEと別物なので、同名を避けて terminal とした
- 端子配置は電気的インターフェース(2端子接点 / アナログ / 光学)も決める。PHYが持てばELCが別途スイッチ情報を持つ必要がない。
これがPHYに置く根拠になった
これがPHYに置く根拠になった
- 向きは90度と270度で別物(LED穴・ボス穴の位置が変わる)なので4方向。キーの回転とは別の値で、キーのローカル座標系に対して持つ
- 階層化を採らなかったのは、欲しいのが分類ラベルであって継承ではないため。低背かどうかも定義表の列で表せる
- 未知の種別を特別扱いしなかったのは、KiCadのフットプリント名がライブラリ依存で、既知の種別でも出力時のマッピングが要るため。
未知は「マッピングが未登録」なだけになる
未知は「マッピングが未登録」なだけになる
## (b)案を捨てた経緯
@@ -43,4 +43,4 @@ LED穴も (b) と同じ形の論点だが、LED専用ノードを立てる可能
## 捨てた仮説
- 「マウント方式」をKLEの sm と同じ軸だと考えていた。KLEの sm は cherry / alps のような軸規格で、
プレートマウント/PCBマウントでもホットスワップ/直付けでもない。ユーザーの意図はソケット有無だったため socket に落ち着いた
プレートマウント/PCBマウントでもホットスワップ/直付けでもない。ユーザーの意図はソケット有無だったため socket に落ち着いた
+9 -8
View File
@@ -66,10 +66,11 @@ ZMKの matrix transform `map`:
引数の数え間違いでキーが1つずれるのが定番のバグであることも、この層が手書きである傍証になる。
出典:
- https://docs.qmk.fm/reference_configurator_support
- https://docs.qmk.fm/reference_info_json
- https://zmk.dev/docs/config/layout
- https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md
- <https://docs.qmk.fm/reference_configurator_support>
- <https://docs.qmk.fm/reference_info_json>
- <https://zmk.dev/docs/config/layout>
- <https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md>
## 根拠: マトリクス順 ≠ キー番号順 の実物
@@ -96,7 +97,7 @@ QMKの分割も同じ構造。
> Since the matrix scanning procedure operates on entire rows, it first populates the left half's rows,
> then the right half's. Thus, the matrix as QMK views it has double the rows instead of double the columns.
出典: https://docs.qmk.fm/features/split_keyboard
出典: <https://docs.qmk.fm/features/split_keyboard>
## 保存形式を `order: string[]` にした理由
@@ -109,7 +110,7 @@ ELCの `LEDChain.order` で既に「並びの位置が番号」の形を採っ
## 捨てた仮説
- 「IDXはdecal/ghostの区別を持つべきではないか」。decalはPHYが `decal` フラグを持っているので
そこから分かる。ghostは何を意味するかがKLE由来で曖昧なので、IDXの側で規定しない。
`order` に挙げたキーだけが番号を持つ、で足りる
そこから分かる。ghostは何を意味するかがKLE由来で曖昧なので、IDXの側で規定しない。
`order` に挙げたキーだけが番号を持つ、で足りる
- 「VIAとの往復でキー番号が要るのでは」。VIAのプロトコルはrow/col指定なので、キー番号は出てこない。
ただし裏取りしていないので、ドキュメントには書かなかった
ただし裏取りしていないので、ドキュメントには書かなかった
+4 -4
View File
@@ -5,12 +5,12 @@
公開されているKLE JSON 1505件(キー111,619個)を集め、PHYへ変換して書き戻す往復を通した。
- 幾何(位置・サイズ・回転・2つ目の矩形・decal・ghost)は全件で無損失。PHYのデータモデルで表せないKLEの
幾何情報は無かった
幾何情報は無かった
- 置き場がないのは見た目の属性だけ(c, t, p, a, f, f2, fa, n, l と sm, sb, st、ファイル単位の
backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの
backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの
- KLE本家のデシリアライザが読めないJSONが実在する(1505件中1件)
- `l`(stepped)が付いたキーが16個あった。KLEはstepped Caps Lockに「段差がある」ことしか持たず、
スイッチ位置のオフセットは持たない。PHYの `switchOffset` は取り込みでは決まらない
スイッチ位置のオフセットは持たない。PHYの `switchOffset` は取り込みでは決まらない
## コーパス
@@ -39,7 +39,7 @@ KLE本家のリポジトリ (ijprest/keyboard-layout-editor) から `serial.js`
- 1504/1504 で配置が一致(1件は手順1で本家が例外を投げるため対象外)
- 直列化結果そのものが本家の出力と完全一致: 1408件。小数4桁に丸めると一致(誤差のみ): 20件。
残り76件は行の切り方が違うだけで、読み直した配置は一致する
残り76件は行の切り方が違うだけで、読み直した配置は一致する
出現頻度(キー単位、111,619個中):
+5 -5
View File
@@ -20,10 +20,10 @@
木案はそこが弱いが、次の点で勝る。
- 条件付きの選択肢(あるバリアントを選んだときだけ現れる選択肢)が入れ子で自然に出る。
タグ案では選択肢の依存関係を別に持つ必要がある
タグ案では選択肢の依存関係を別に持つ必要がある
- 「どのキーがどの選択肢に属するか」を木の上で直接見られる。タグ案は全ノードを走査しないと分からない
- 空のVariant(「エンコーダーを載せない」)が、子のいないノードとしてそのまま置ける。
タグ案ではファイル単位の宣言が必須になる(キーのタグから選択肢を復元できないため)
タグ案ではファイル単位の宣言が必須になる(キーのタグから選択肢を復元できないため)
別レイヤー案は、選択箇所が「どのスイッチを実装するか」という物理の話であり、
PHY以外の3レイヤーがすべてPHYのノードIDを参照する構造からも外れるので落とした。
@@ -58,7 +58,7 @@ supersetの並びから選択外のキーを落とした派生情報として出
## 捨てた検討
- キーごとに `variants: string[]` を持たせて複数の選択肢にまたがらせる形。
そもそも複数選択肢で共通のキーが実在するかを確認できていないので、必要になってから決める
(PHYレイヤー.md P-15)
そもそも複数選択肢で共通のキーが実在するかを確認できていないので、必要になってから決める
(PHYレイヤー.md P-15)
- 選択状態(どのバリアントを選んでいるか)をPHYとは別ファイルに持つ案。
既定のバリアントをVariantGroupに持たせれば足りる。編集中の表示切り替えはエディタの状態
既定のバリアントをVariantGroupに持たせれば足りる。編集中の表示切り替えはエディタの状態
+1 -1
View File
@@ -57,7 +57,7 @@ VariantGroupを「ANSI/ISO」と「Backspace分割の有無」に分ければ、
- 入れ子のVariantGroupで表す: 出現集合が交差するので不可能
- 独立したVariantGroupに分けて、成立しない組み合わせを禁止する仕組みを足す:
複製を許せば要らない。禁止の表現を足すほうが重い
複製を許せば要らない。禁止の表現を足すほうが重い
- 同じ位置のキーに違う番号が振られたら警告する: 排他キーは位置もサイズも違ってよいので基準にならない
## この調査の限界
+7 -7
View File
@@ -28,16 +28,16 @@ VIA向けのキーボード定義生成と、MAPの割り当ての読み書き
公式文書・実装を参照した。実機による読み書きの検証は行っていない。
- RemapはWebHIDを使い、QMKのVIAクライアント向け機能へアクセスする。Workbenchにはソース編集・ビルド・書き込みもある。
参照: https://github.com/remap-keys/remap#references
参照: https://remap-keys.app/docs/workbench
参照: <https://github.com/remap-keys/remap#references>
参照: <https://remap-keys.app/docs/workbench>
- VIAのキーボード定義には配置とmatrix座標の対応がある。QMKのVIA実装はキー単位の読み書きにlayer・row・colを使う。
参照: https://caniusevia.com/docs/specification/
参照: https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c
参照: <https://caniusevia.com/docs/specification/>
参照: <https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c>
- Vialではタップダンスやコンボを実機へ保存できるが、枠数等にはビルド時の制約がある。
参照: https://get.vial.today/manual/tap-dance.html
参照: https://get.vial.today/manual/combos.html
参照: <https://get.vial.today/manual/tap-dance.html>
参照: <https://get.vial.today/manual/combos.html>
- ZMK Studioは既存behaviorの割り当てを変更できるが、新しいbehavior定義等は対象外。実機に保存した変更とソース側の設定の扱いにも注意が必要。
参照: https://zmk.dev/docs/features/studio
参照: <https://zmk.dev/docs/features/studio>
## 未採用の提案との区別
+29 -22
View File
@@ -3,13 +3,14 @@
## 結論
当面の対象ファームウェアはQMK。
- Dynamic: QMK本家のVIAプロトコルが公開する項目のみ
- Static: QMK本家の公式機能のうちVIAで変更できないものは、Firmware Generatorで生成する対象
- 非公式機能(Vial等のフォーク、XAP等のmaster以外のブランチ)は当面非対応
- developブランチ・Community Modulesは当面非対応(将来広げたい→backlog MAP-P-6)。キーボード・キーマップ固有コードは原則非対応
- 対応QMKは特定コミットに固定し、更新タイミングはユーザーが個別に判断する
- UIにはVIAプロトコルとキーコード体系のバージョンを表示し、実機とはこの2つで照合。不一致の実機は非対応
(当初はUIにコミットを出す案だったが、実機から取れるのがこの2つだけなのでユーザーがバージョン表記に変更)
(当初はUIにコミットを出す案だったが、実機から取れるのがこの2つだけなのでユーザーがバージョン表記に変更)
- Staticで定義する動作(タップダンス等)はキーから名前で参照し、出力時に番号へ変換する
- VIA管理外のEEPROM設定は読み出せない旨を制約として明記
@@ -29,23 +30,25 @@
実機での検証はしていない。
### A. QMK本家 VIA
参照: https://raw.githubusercontent.com/qmk/qmk_firmware/master/quantum/via.h
参照: <https://raw.githubusercontent.com/qmk/qmk_firmware/master/quantum/via.h>
VIA_PROTOCOL_VERSION = 0x000D
command:
- 0x01 get_protocol_version
- 0x02/0x03 get/set_keyboard_value
- 0x01 uptime(get), 0x02 layout_options(get/set), 0x03 switch_matrix_state(get),
0x04 firmware_version(get), 0x05 device_indication(set), 0x06 keycodes_version(get)
- 0x01 uptime(get), 0x02 layout_options(get/set), 0x03 switch_matrix_state(get),
0x04 firmware_version(get), 0x05 device_indication(set), 0x06 keycodes_version(get)
- 0x04/0x05 dynamic_keymap_get/set_keycode (layer,row,col)
- 0x06 dynamic_keymap_reset
- 0x07/0x08/0x09 custom_set/get_value, custom_save
- channel 0 custom(キーボード独自メニュー)
- 1 backlight: brightness, effect
- 2 rgblight: brightness, effect, effect_speed, color
- 3 rgb_matrix: brightness, effect, effect_speed, color
- 4 audio: enable, clicky_enable
- 5 led_matrix: brightness, effect, effect_speed
- channel 0 custom(キーボード独自メニュー)
- 1 backlight: brightness, effect
- 2 rgblight: brightness, effect, effect_speed, color
- 3 rgb_matrix: brightness, effect, effect_speed, color
- 4 audio: enable, clicky_enable
- 5 led_matrix: brightness, effect, effect_speed
- 0x0A eeprom_reset
- 0x0B bootloader_jump
- 0x0C..0x10 dynamic_keymap_macro_get_count / get_buffer_size / get_buffer / set_buffer / reset
@@ -57,8 +60,9 @@ MAPに関係するもの: キーコード、レイヤー数、エンコーダ、
照明・オーディオはMAPの所有データ外(MAPレイヤー.md「持たないもの」)。
### B. Vial(フォーク、今回非対応)
参照: https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/vial.h
参照: https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/qmk_settings.c
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/vial.h>
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/qmk_settings.c>
VIAL_PROTOCOL_VERSION = 6
- keyboard_id、圧縮キーボード定義JSONの取得(get_size/get_def)
@@ -66,24 +70,27 @@ VIAL_PROTOCOL_VERSION = 6
- dynamic entry: tap dance, combo, key override, alt repeat key
- 拡張マクロ(EXT_TAP/DOWN/UP)
- QMK Settings (QSID):
1 grave_esc_override, 2 combo_term, 3 auto_shift, 4 auto_shift_timeout,
5 osk_tap_toggle, 6 osk_timeout, 7 tapping_term, 9-17 mousekey各種(MK_3_SPEED以外),
18 tap_code_delay, 19 tap_hold_caps_delay, 20 tapping_toggle,
21 magic_settings(Ctrl/Caps, Alt/GUI, Ctrl/GUI, Grave/Esc, BSpc/BSlash入替, GUI無効, NKRO),
22 permissive_hold, 23 hold_on_other_key_press, 24 retro_tapping,
25 quick_tap_term, 26 chordal_hold, 27 flow_tap_term
1 grave_esc_override, 2 combo_term, 3 auto_shift, 4 auto_shift_timeout,
5 osk_tap_toggle, 6 osk_timeout, 7 tapping_term, 9-17 mousekey各種(MK_3_SPEED以外),
18 tap_code_delay, 19 tap_hold_caps_delay, 20 tapping_toggle,
21 magic_settings(Ctrl/Caps, Alt/GUI, Ctrl/GUI, Grave/Esc, BSpc/BSlash入替, GUI無効, NKRO),
22 permissive_hold, 23 hold_on_other_key_press, 24 retro_tapping,
25 quick_tap_term, 26 chordal_hold, 27 flow_tap_term
- VialRGB(RGB Matrixエフェクト列挙・設定)。ソース未確認
### C. プロトコル外で実機上だけ変わるもの(Dynamicの定義外)
当初「すべてEEPROMに保存される」と書いたが誤り。確認結果:
- 保存されない(再起動で消える): ダイナミックマクロ(docs/features/dynamic_macros.md
"lost when the keyboard is unplugged or otherwise rebooted")、DT_UP/DT_DOWN(docs/tap_hold.md "*not* persistent")
"lost when the keyboard is unplugged or otherwise rebooted")、DT_UP/DT_DOWN(docs/tap_hold.md "*not* persistent")
- EEPROMに保存される(quantum/eeconfig.h): keymap config(Magic)、default layer、unicode mode、audio、backlight、
rgblight/rgb_matrix/led_matrix、haptic、steno mode、handedness、connection、kb/user固有データ
rgblight/rgb_matrix/led_matrix、haptic、steno mode、handedness、connection、kb/user固有データ
- QMK本家via.cにはこれらを読み出すコマンドがない。照明・オーディオはcustom channel 1〜5で読めるがMAPの所有外。
custom channel 0はweakの via_custom_value_command_kb() へ流れる(キーボード固有コード、原則非対応)。
custom channel 0はweakの via_custom_value_command_kb() へ流れる(キーボード固有コード、原則非対応)。
- 参考: via_eeprom_is_valid() はビルド日時由来のmagicで検証し、不一致ならVIAのデータ(キーマップ・マクロ・layout options)を
初期化する。再ビルドして書き込むとDynamicの変更は消える前提になる。
初期化する。再ビルドして書き込むとDynamicの変更は消える前提になる。
### XAP
QMK公式の次世代プロトコルとして開発されていたが、masterには入っていない認識。未確認。
+5 -5
View File
@@ -18,12 +18,12 @@ JSONを第一級にしてCを自動生成し、ユーザーのC/hで一部を上
## 根拠(QMK 0.34.4)
- keymap.jsonで書けるもの: keyboard, keymap, layout, layers, encoders, dip_switches, macros, keycodes(名前宣言), config, modules,
host_language 等(data/schemas/keymap.jsonschema)。タップダンス・コンボ・キーオーバーライド・キーごとの設定はない。
host_language 等(data/schemas/keymap.jsonschema)。タップダンス・コンボ・キーオーバーライド・キーごとの設定はない。
- キーごとの設定は `*_PER_KEY` を定義した上での `get_tapping_term(keycode, record)` 等のコールバック。keyrecordを受けるので
キーコード単位でも位置単位でも書ける。
キーコード単位でも位置単位でも書ける。
- keymap.jsonとkeymap.cを並べると、json2cで生成したkeymap.cがユーザーのkeymap.cを取り込む
(builddefs/build_keyboard.mk:148-157、OTHER_KEYMAP_C)。QMK自体がJSON + ユーザーCの共存に対応している。
(builddefs/build_keyboard.mk:148-157、OTHER_KEYMAP_C)。QMK自体がJSON + ユーザーCの共存に対応している。
- 取り込みは同一翻訳単位なので、生成側weak + ユーザー側strongは再定義エラーになる。QMK本体のweak既定実装もあるため、
別ファイルにしても生成側をweakにする方式は選ばれる定義が不定。
別ファイルにしても生成側をweakにする方式は選ばれる定義が不定。
- プロトタイプの再宣言はCでは合法(同一シグネチャなら何度でも)。QMKが宣言済みの関数をユーザーのhに書いてもよい。
シグネチャ違いはコンパイルエラーになるので検出にも使える。
シグネチャ違いはコンパイルエラーになるので検出にも使える。
+5 -5
View File
@@ -13,7 +13,7 @@
## 案の比較
| 論点 | 当初の提案(人との議論) | Claude | Codex | 採用 |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| 名前 | SYS | HOST/HST | SYS → 最終的にHOST/HSTに同意 | HST |
| 識別子 | WIN-JAP (OS×言語) | OS×配列の構造 | OS×配列×入力方式+プロファイル名 | OS×配列(+入力方式) |
| 範囲 | OS・言語ごとのキーの振る舞い・表示 | ホストの解釈の記述 | 意図を環境ごとの出力に変換、まで候補 → 最終的に「対応関係を提供、変更はしない」 | 記述のみ |
@@ -24,12 +24,12 @@
- SYSは、HIDのSystem Control用途ページ(電源・スリープ)や「システム設定全般」と紛らわしい。hostはHIDでキーボードの接続相手を指す用語で、QMKも「host keyboard layout」と呼ぶ。
- 言語は軸として誤り: 日本語Windowsで英語配列を使える。MacではUS/JISが入力ソースとキーボード種別で決まる。XKBはlayout/variant/optionsの3要素。
「英語」にもUS・UKがある。`JAP` はISOコードでもない(ja/jpn)。
「英語」にもUS・UKがある。`JAP` はISOコードでもない(ja/jpn)。
- 逆引きをプロファイルに持たせない理由: (1) 順方向の表から導けるので二重管理になる。
(2) 候補がファームウェアの機能に依存する。例: US刻印+JISホストでShiftの組が合わないキーにはKey Overrideが要る。HSTにQMKの知識が混ざる。
(2) 候補がファームウェアの機能に依存する。例: US刻印+JISホストでShiftの組が合わないキーにはKey Overrideが要る。HSTにQMKの知識が混ざる。
- Codexの指摘で採用したもの: 表示の切り替えと送信内容の変更を混ぜない。知らない動作を「使えない」と扱わない(対応可否を3値にする)。OS設定への依存は記述する。
- MAPの正本をキーコードにする理由の修正: Claudeは当初「VIAでは実機にキーコードしか残らず、意図を復元できないので、キーコードでなければならない」とした。
Codexが、意図をプロジェクト側に残す設計も可能で、難しくなるのは実機で変更された値の取り込みだけだと反論し、これが正しい。
採用する理由は「実機との往復を単純に保てるから」とする。
Codexが、意図をプロジェクト側に残す設計も可能で、難しくなるのは実機で変更された値の取り込みだけだと反論し、これが正しい。
採用する理由は「実機との往復を単純に保てるから」とする。
- 「コピー」のような操作の辞書は、アプリやユーザー設定まで相手が広がるので対象外。
- 未採用のアイデア: キートップの刻印生成にHSTを使う(PHYのラベルへ流す)。検討していないので仕様には書かない。
+10 -8
View File
@@ -3,9 +3,9 @@
## 結論
- 対象: QMK `0.34.4`(tag, commit `08c662f286ddfd12a985f57b584b02eca5af0ae6`, 2026-09-05)。masterの最新リリース。
ユーザーが最初の対応環境として0.34.4を採用(2026-09-24)。
- キーコード体系 `QMK_KEYCODES_VERSION` = `0.0.9`(quantum/keycodes.h)
- `VIA_PROTOCOL_VERSION` = `0x000D`(quantum/via.h。9/23時点の調査と同じ)
ユーザーが最初の対応環境として0.34.4を採用(2026-09-24)。
- キーコード体系 `QMK_KEYCODES_VERSION` = `0.0.9`(quantum/keycodes.h)
- `VIA_PROTOCOL_VERSION` = `0x000D`(quantum/via.h。9/23時点の調査と同じ)
- MAPから見たQMKの機能は次の6種類に分かれる(下の表)。スキーマ・編集機能への落とし込みは未着手。
- 既存ドキュメントに影響しうる事実が2つある:
1. **VIAのモジュール化**(20260830 ChangeLog「VIA Feature Migration」#26315)。VIA統合の保守をVIAチームへ移し、
@@ -15,8 +15,8 @@
2. **VIAのマトリクス状態取得の無効化**(20250831 ChangeLog #25414)。`id_switch_matrix_state` は既定で全0を返す。
`VIA_INSECURE` 定義時、または `SECURE_ENABLE` でアンロック中のみ実値。Remap等のキーテスターは既定では動かない。
- QMKのキーごとの設定(tapping term等)はデータテーブルではなく、keycode/keyrecordを受けるCのコールバック関数で表現される。
keymap.json(data-driven)で書けるのはレイヤー・エンコーダ・DIPスイッチ・マクロ・カスタムキーコード・config・modules まで。
タップダンス・コンボ・キーオーバーライド・キーごとの設定はCでしか書けない。
keymap.json(data-driven)で書けるのはレイヤー・エンコーダ・DIPスイッチ・マクロ・カスタムキーコード・config・modules まで。
タップダンス・コンボ・キーオーバーライド・キーごとの設定はCでしか書けない。
## 分類の概要
@@ -53,7 +53,7 @@ data/constants/keycodes/*.hjson を 0.0.1〜0.0.9 の順にマージして得た
| 0x7400–0x743F | QK_JOYSTICK | ボタン0–31 |
| 0x7440–0x747F | QK_PROGRAMMABLE_BUTTON | 1–32 |
| 0x7480–0x74BF | QK_AUDIO | Audio/Clicky/Music 15件 |
| 0x74C0–0x751F | QK_STENO | 0.0.9で拡張・改名(STN_* → QK_STENO_* / ST_*、旧名はエイリアス) |
| 0x74C0–0x751F | QK_STENO | 0.0.9で拡張・改名(STN_*→ QK_STENO_* / ST_*、旧名はエイリアス) |
| 0x7700–0x777F | QK_MACRO | QK_MACRO_0–31(名前付き)。VIA/keymap.jsonのマクロ |
| 0x7780–0x77BF | QK_CONNECTION | 出力先切替(USB/2.4GHz/BT)、BTプロファイル1–5等15件 |
| 0x77C0–0x77FF | QK_COMMUNITY_MODULE | Community Modules用 |
@@ -69,6 +69,7 @@ Key Lock、One Shot on/off/toggle、Key Override on/off/toggle、Secure 4、Dyna
Autocorrect 3、Tri Layer Lower/Upper、Repeat/Alt Repeat、Layer Lock。
補足:
- 20250831で旧RGB・マウスの非推奨キーコードを削除(#25444)。
- VIAはキーマップを16bit値で読み書きするため、上のどの値もキーへ書ける。ファームウェアで無効な機能の値は何もしない。
@@ -116,7 +117,7 @@ Autocorrect 3、Tri Layer Lower/Upper、Repeat/Alt Repeat、Layer Lock。
| その他 | TAP_CODE_DELAY、TAP_HOLD_CAPS_DELAY、DEBOUNCE | — |
- キーごとのコールバックは keycode と keyrecord(押された位置・レイヤーを含む)を受ける。
つまり「キーコード単位」「物理位置単位」のどちらでも書ける(docs/tap_hold.md「Why do we include the key record」)。
つまり「キーコード単位」「物理位置単位」のどちらでも書ける(docs/tap_hold.md「Why do we include the key record」)。
- Chordal Holdの手の割り当ては物理位置(マトリクス)単位のデータ。
## E. キー以外の入力への割り当て
@@ -147,6 +148,7 @@ QMK本家VIAにはこれらを読み出すコマンドがない(9/23調査の再
## VIA 0x000D の読み書き範囲(0.34.4で再確認)
9/23の journal(qmk-dynamic-scope)と同じコマンド構成。差分・補足:
- `id_switch_matrix_state` は既定で0を返す(上記)。
- `id_eeprom_reset` は `VIA_EEPROM_ALLOW_RESET` 定義時のみ有効。
- エンコーダは `ENCODER_MAP_ENABLE` 時のみ。
@@ -158,5 +160,5 @@ QMK本家VIAにはこれらを読み出すコマンドがない(9/23調査の再
- 機能一覧: docs/_sidebar.json、builddefs/generic_features.mk の GENERIC_FEATURES。
- キーコード: data/constants/keycodes/keycodes_*.hjson をバージョン順にマージ。
- 変更点: docs/ChangeLog/20250223〜20260830。
- VIA移行PR: https://github.com/qmk/qmk_firmware/pull/26315(develop向けDraft。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持する方針と記載)
- VIA移行PR: <https://github.com/qmk/qmk_firmware/pull/26315(develop向けDraft。VIA_EEPROM_MAGIC_ADDR> 以外は後方互換を維持する方針と記載)
- 実機での検証はしていない。
+9 -9
View File
@@ -4,8 +4,8 @@
- QMKはVIA統合をCommunity Modules形式へ移し、旧実装を将来削除すると告知(20260830 ChangeLog「VIA Feature Migration」#26315)。
- 方針「QMKとVIAの両方に追従する」は変えない(ユーザー判断)。
→ Community Modulesは当面非対応のままだが、VIAモジュールだけは例外として、移行がmasterに入る前に対応する。
最悪、Community Modulesへの対応はVIAだけでもよい(ユーザー発言)。
→ Community Modulesは当面非対応のままだが、VIAモジュールだけは例外として、移行がmasterに入る前に対応する。
最悪、Community Modulesへの対応はVIAだけでもよい(ユーザー発言)。
- キーテスター(switch_matrix_state)が既定で動かない件は気にしない(ユーザー判断)。
## 案の比較
@@ -19,14 +19,14 @@
## 根拠(2026-09-24時点)
- docs/ChangeLog/20260830.md(QMK 0.34.4):
"upkeep of the VIA integration will transition to the VIA team" /
"in a future release, the old VIA feature-based implementation is expected to be removed."
- PR https://github.com/qmk/qmk_firmware/pull/26315 : develop向けDraft。追加ファイルは
modules/qmk/via/{qmk_module.json, via.c, via.h, post_config.h, post_rules.mk}。
qmk_module.json にバージョン欄なし。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持すると記載。
"upkeep of the VIA integration will transition to the VIA team" /
"in a future release, the old VIA feature-based implementation is expected to be removed."
- PR <https://github.com/qmk/qmk_firmware/pull/26315> : develop向けDraft。追加ファイルは
modules/qmk/via/{qmk_module.json, via.c, via.h, post_config.h, post_rules.mk}。
qmk_module.json にバージョン欄なし。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持すると記載。
- 0.34.4時点のVIAは従来どおり quantum/via.c、VIA_PROTOCOL_VERSION 0x000D。
- 同梱モジュールの有効化は keymap.json の `"modules": ["qmk/via"]` の形(data/schemas/keymap.jsonschema の modules、
builddefs/build_keyboard.mk の generate-community-modules-*)。対応コストは小さい見込み。
builddefs/build_keyboard.mk の generate-community-modules-*)。対応コストは小さい見込み。
- 保守がVIAチームへ移るため、将来QMKリポジトリ外へ出る可能性はあるが未確認。
## 追記: 対応環境の指定方法
@@ -42,5 +42,5 @@ QMK同梱モジュールはQMKのコミットで決まる。実機との照合
- きっかけ: ユーザーが「QMKに加えてVIAのバージョンも明記したほうがよいか」と相談。
- #26315の案ではVIAは modules/qmk/via(QMK同梱)で、qmk_module.json にバージョン欄がない。
この案のままならQMKのコミットだけで決まるが、保守移管後に外へ出る可能性に備える。
この案のままならQMKのコミットだけで決まるが、保守移管後に外へ出る可能性に備える。
- モジュールのバージョンは実機から取得できないため、照合には使わない。
+2 -2
View File
@@ -6,7 +6,7 @@
- 別課題: エンコーダ(MAP-P-4)、layout option(P-3)
- 当面非対応: リセット系・ブートローダ移行、デバイスの識別表示、キーテスター、custom channel
- 有効な機能: 新規キーボードはGeneratorのビルド設定を正とする。既存キーボードは利用者の申告を信用する(ユーザー案)。
申告はGeneratorの設定と同じ形式にし、新規と既存を1つのモデルで扱う。
申告はGeneratorの設定と同じ形式にし、新規と既存を1つのモデルで扱う。
- 容量(レイヤー数・マクロ個数・容量)は実機から読んで照合する。
- P-2は判断が済んだので、実機で未検証という推測(P-2)に置き換えた。
@@ -28,6 +28,6 @@
## 機能・容量の確認
- VIAから読めるのは容量(レイヤー数、マクロ個数、マクロバッファサイズ)とバージョンだけ。
どの機能を有効にしてビルドしたか(タップダンス、コンボ等)は読めない。キーボード固有のVIA定義JSONにも含まれない。
どの機能を有効にしてビルドしたか(タップダンス、コンボ等)は読めない。キーボード固有のVIA定義JSONにも含まれない。
- 無効な機能のキーコードを書いてもエラーにならず、実機で何もしないだけ。申告の誤りは壊れる方向には働かない。
- 既存キーボードは初期対応の前提ではない(MAPレイヤー.md「概要」)が、申告方式ならモデルを増やさずに扱える。
+22 -22
View File
@@ -11,9 +11,9 @@
- **ツール**: KLE (Keyboard Layout Editor)
- **作業内容**:
- キーの物理配置を決定
- キーサイズ・間隔・角度を調整
- 視覚的な配置確認
- キーの物理配置を決定
- キーサイズ・間隔・角度を調整
- 視覚的な配置確認
- **出力**: KLE JSON ファイル
- **Beyond KLE対応**: **PHYレイヤー**
@@ -21,9 +21,9 @@
- **ツール**: 手動計算、KiCad等の回路図エディタ
- **作業内容**:
- キーマトリックスの行列構成を決定
- マイコンピンとの対応付け
- ダイオード配置の検討
- キーマトリックスの行列構成を決定
- マイコンピンとの対応付け
- ダイオード配置の検討
- **出力**: 回路図、ピン配置表
- **現在の課題**: KLEからマトリックス情報への変換が手動
- **Beyond KLE対応**: **ELCレイヤー**。行列構成という工程は持たず、キーの端子とピンの対応として書く
@@ -34,9 +34,9 @@
- **ツール**: KiCad、Eagle、Fusion360等
- **作業内容**:
- KLE JSONから座標を抽出(手動またはスクリプト)
- スイッチフットプリントの配置
- マイコン・コネクタ等の部品配置
- KLE JSONから座標を抽出(手動またはスクリプト)
- スイッチフットプリントの配置
- マイコン・コネクタ等の部品配置
- **現在の課題**: KLEとPCBツール間の座標変換
- **Beyond KLE対応**: **PHYレイヤー** → PCBツール連携。渡すのは配置であって、フットプリントの選択はPCBツール側に残る
@@ -44,8 +44,8 @@
- **ツール**: PCB CADソフト
- **作業内容**:
- マトリックス配線の実装
- 電源・信号線のルーティング
- マトリックス配線の実装
- 電源・信号線のルーティング
- **Beyond KLE対応**: **ELCレイヤー**情報の活用
### 3. ファームウェア設定段階
@@ -54,24 +54,24 @@
- **ツール**: QMK、KMK、VIA等
- **作業内容**:
- マトリックス定義(ROW/COL)
- ピン配置設定
- 物理レイアウト定義
- キーマップ配列の並び順を決める(QMKなら `LAYOUT` マクロの引数順、ZMKなら matrix transform の
`map`、KMKなら `coord_mapping`)
- マトリックス定義(ROW/COL)
- ピン配置設定
- 物理レイアウト定義
- キーマップ配列の並び順を決める(QMKなら `LAYOUT` マクロの引数順、ZMKなら matrix transform の
`map`、KMKなら `coord_mapping`)
- **現在の課題**: KLEとファームウェア設定の手動変換。並び順は手書きで、引数の数え間違いがそのまま
キーのずれになる
キーのずれになる
- **Beyond KLE対応**: **PHY** + **IDX** + **ELC** → ファームウェア自動生成。
ROW/COLはELCの配線から出力時に割り当てる。並び順は**IDXレイヤー**が全バリアントにわたって正本として持ち、
出力時に選択したバリアントで使うキーだけを残して、キー番号を0から連続する番号に詰める
ROW/COLはELCの配線から出力時に割り当てる。並び順は**IDXレイヤー**が全バリアントにわたって正本として持ち、
出力時に選択したバリアントで使うキーだけを残して、キー番号を0から連続する番号に詰める
#### b) キーマップ設定
- **ツール**: QMK Configurator、VIA、Remap等
- **作業内容**:
- 各キーへのキーコード割り当て
- レイヤー設計
- マクロ・特殊機能の設定
- 各キーへのキーコード割り当て
- レイヤー設計
- マクロ・特殊機能の設定
- **Beyond KLE対応**: **MAPレイヤー**。キーの参照にはPHYのキーIDを使い、配列への並べ替えは出力時にバリアント選択後のIDXの順で行う
### 4. 製造・組み立て段階
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
test-results/
playwright-report/
+1
View File
@@ -0,0 +1 @@
{ "printWidth": 110 }
+170
View File
@@ -0,0 +1,170 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { readFile } from "node:fs/promises";
async function projectJson(page: import("@playwright/test").Page) {
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const file = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(file);
return JSON.parse(await readFile(file, "utf8"));
}
async function loadProject(page: import("@playwright/test").Page, project: unknown) {
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooser
).setFiles({
name: "case.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(project)),
});
}
test("provider, key terminals and diode edit wiring and show collisions", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "ELC" }).click();
await page.getByTestId("provider-id").fill("MCU");
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
await expect(page.getByText(/MCU · mcu · RP2040/)).toBeVisible();
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
const first = view.locator(".kv-key").nth(0);
await first.click();
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
await page.getByRole("button", { name: "配線を保存" }).click();
await expect(page.getByTestId("unwired-count")).toHaveText("15");
await expect(first).toHaveClass(/ok/);
await expect(first).toContainText("GP0/GP1");
await view.locator(".kv-key").nth(1).click();
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
await page.getByRole("button", { name: "配線を保存" }).click();
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
await expect(view.locator(".kv-key.error")).toHaveCount(2);
});
test("batch wiring applies selected keys in row-major PHY order", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "ELC" }).click();
await page.getByTestId("provider-id").fill("MCU");
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
const views = page.locator(".wiring-box [data-testid=keyboard-view]");
const batchView = views.nth(1);
await batchView.locator(".kv-key").nth(0).click();
await batchView.locator(".kv-key").nth(1).click();
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
await page.getByRole("button", { name: "rowへ追加" }).click();
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
await page.getByRole("button", { name: "colへ追加" }).click();
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
await page.getByRole("button", { name: "colへ追加" }).click();
await page.getByTestId("batch-wire").click();
await expect(page.getByTestId("batch-preview")).toBeVisible();
await expect(page.getByTestId("batch-preview").locator("tbody tr").nth(0)).toContainText("MCU.GP0");
await page.getByTestId("batch-confirm").click();
await expect(page.getByTestId("unwired-count")).toHaveText("14");
await expect(views.first().locator(".kv-key").nth(0)).toContainText("GP0/GP1");
await expect(views.first().locator(".kv-key").nth(1)).toContainText("GP0/GP2");
});
test("batch wiring previews overwritten assignments and blocks insufficient capacity", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "ELC" }).click();
await page.getByTestId("provider-id").fill("MCU");
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
await view.locator(".kv-key").first().click();
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP5");
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP6");
await page.getByRole("button", { name: "配線を保存" }).click();
const batchView = page.locator(".wiring-box [data-testid=keyboard-view]").nth(1);
for (let i = 0; i < 3; i++) await batchView.locator(".kv-key").nth(i).click();
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
await page.getByRole("button", { name: "rowへ追加" }).click();
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
await page.getByRole("button", { name: "colへ追加" }).click();
await page.getByTestId("batch-wire").click();
await expect(page.getByTestId("batch-capacity-error")).toBeVisible();
await expect(page.getByTestId("batch-confirm")).toBeDisabled();
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
await page.getByRole("button", { name: "colへ追加" }).click();
await page.getByTestId("batch-wire").click();
const rows = page.getByTestId("batch-preview").locator("tbody tr");
await expect(rows).toHaveCount(3);
await expect(rows.first()).toContainText("上書き");
});
test("diode direction is part of collision identity and unknown MCU parts accept free pin names", async ({
page,
}) => {
await page.goto("/");
let source = await projectJson(page);
const [k1, k2] = source.idx.order;
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
source.elc.keys = [
{ key: k1, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "a-to-b" },
{ key: k2, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "b-to-a" },
];
await loadProject(page, source);
await page.getByRole("button", { name: "ELC" }).click();
await expect(page.getByText("wiring-collision", { exact: false })).toHaveCount(0);
source.elc.keys[1].diode = "a-to-b";
await loadProject(page, source);
await page.getByRole("button", { name: "ELC" }).click();
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
await page.getByTestId("provider-id").fill("ALT");
await page.locator("input[list=mcu-parts]").fill("Unknown-MCU");
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
await expect(page.getByText("未知の品番はピン名を自由入力します。")).toBeVisible();
const key = page.locator(".wiring-box .kv-key").nth(2);
await key.click();
await page.getByLabel("a端子自由入力").fill("ALT.PIN_X");
await page.getByLabel("b端子自由入力").fill("ALT.PIN_Y");
await page.getByRole("button", { name: "配線を保存" }).click();
await expect(page.getByTestId("unwired-count")).toHaveText("13");
});
test("LED chain candidates include only per-key LED keys and @led parts across all variants", async ({
page,
}) => {
await page.goto("/");
const source = await projectJson(page);
const keys = [] as any[];
const walk = (nodes: any[]) =>
nodes.forEach((n) => {
if (n.type === "key") keys.push(n);
else if (n.type === "cluster") walk(n.children);
else if (n.type === "variantGroup") n.children.forEach((v: any) => walk(v.children));
});
walk(source.phy.root);
keys[0].perKeyLED = true;
keys[1].perKeyLED = false;
source.phy.root.push({
type: "component",
id: "cmp-test-led",
transform: { x: 0, y: 0, rotation: 0 },
partGroup: "@led",
});
source.phy.root.push({
type: "component",
id: "cmp-test-other",
transform: { x: 0, y: 0, rotation: 0 },
partGroup: "@screw",
});
await loadProject(page, source);
await page.getByRole("button", { name: "ELC" }).click();
await page.getByLabel("チェーンID").fill("LED1");
await page.getByLabel("データピン").fill("MCU.DATA");
await page.getByRole("button", { name: "作成" }).click();
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[0].id}"]`)).toHaveCount(1);
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-led"]')).toHaveCount(1);
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[1].id}"]`)).toHaveCount(0);
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-other"]')).toHaveCount(0);
});
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
test("generates the 4x4 preview and blocks output when a wire is removed", async ({ page }) => {
await page.goto("/");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const file = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(file);
const source = JSON.parse(await (await import("node:fs/promises")).readFile(file, "utf8"));
const keyIds: string[] = source.idx.order;
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
source.elc.keys = keyIds.map((key, i) => ({
key,
detect: "contact",
terminals: { a: `MCU.GP${Math.floor(i / 4)}`, b: `MCU.GP${4 + (i % 4)}` },
diode: "a-to-b",
}));
source.map.layers[0].keymap = keyIds.map((key, i) => ({ keyId: key, keycode: `KC_${i % 10}` }));
const uploadProject = async (obj: unknown) => {
const chooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooserPromise
).setFiles({
name: "fixture.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(obj)),
});
};
await uploadProject(source);
await page.getByRole("button", { name: "Generator" }).click();
for (let i = 0; i < 4; i++) {
await page.getByTestId("row-pin-select").selectOption(`MCU.GP${i}`);
await page.getByRole("button", { name: "row追加" }).click();
await page.getByTestId("col-pin-select").selectOption(`MCU.GP${i + 4}`);
await page.getByRole("button", { name: "col追加" }).click();
}
await page.getByTestId("generate-button").click();
await expect(page.getByTestId("mapping-count")).toHaveText("16行");
const rows = page.locator(".preview tbody tr");
await expect(rows).toHaveCount(16);
await expect(rows.nth(0).locator("td").nth(2)).toHaveText("0");
await expect(rows.nth(0).locator("td").nth(3)).toHaveText("0");
await expect(rows.nth(4).locator("td").nth(2)).toHaveText("1");
await expect(rows.nth(4).locator("td").nth(3)).toHaveText("0");
await page.getByRole("button", { name: "ELC" }).click();
await page.locator(".wiring-box [data-testid=keyboard-view] .kv-key").first().click();
await page.getByRole("button", { name: "配線解除" }).click();
await page.getByRole("button", { name: "Generator" }).click();
await page.getByTestId("generate-button").click();
await expect(page.locator(".errors").getByText("unwired-key", { exact: true })).toBeVisible();
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
});
test("project undo invalidates a result generated from the newer project state", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "ELC" }).click();
await page.getByTestId("provider-id").fill("MCU");
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
await page.getByRole("button", { name: "Generator" }).click();
await page.getByTestId("generate-button").click();
await expect(page.locator(".errors")).toBeVisible();
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".errors")).toHaveCount(0);
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator(".errors")).toHaveCount(0);
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "IDX", exact: true }).click();
});
test("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
await page.getByRole("button", { name: "キーを足す" }).click();
const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id");
await page.getByRole("button", { name: "IDX", exact: true }).click();
const row = page.getByTestId(`idx-row-${keyId}`);
await expect(row).toBeVisible();
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
await expect(previewKey).toHaveText("16");
await row.getByRole("button", { name: `${keyId}を上へ` }).click();
await expect(previewKey).toHaveText("15");
await page.getByRole("button", { name: "Undo" }).click();
await expect(previewKey).toHaveText("16");
});
test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({
page,
}) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
const keys = page.locator('[data-node-type="key"]');
const firstId = await keys.nth(0).getAttribute("data-node-id");
const secondId = await keys.nth(1).getAttribute("data-node-id");
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" });
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
await expect(remaining).toHaveCount(1);
});
+103
View File
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { readFile } from "node:fs/promises";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "MAP", exact: true }).click();
});
test("assign codes and rename referenced layers safely", async ({ page }) => {
await page.getByTestId("map-add-layer").click();
await expect(page.getByRole("alert")).toContainText("レイヤー名を入力してください");
await page.getByLabel("追加するレイヤー名").fill("Base");
await page.getByTestId("map-add-layer").click();
await expect(page.getByRole("alert")).toContainText("already exists");
const keys = page.locator('[data-testid="keyboard-view"] [data-key-id]');
const firstId = await keys.nth(0).getAttribute("data-key-id");
const secondId = await keys.nth(1).getAttribute("data-key-id");
await keys.nth(0).click();
await page.getByLabel("キーコード").fill("KC_A");
await page.getByRole("button", { name: "割り当て", exact: true }).click();
await expect(page.locator(`[data-key-id="${firstId}"] text`)).toContainText("KC_A");
await page.getByLabel("追加するレイヤー名").fill("Nav");
await page.getByTestId("map-add-layer").click();
await expect(page.getByRole("tab", { name: "Nav" })).toHaveAttribute("aria-selected", "true");
await keys.nth(1).click();
await page.getByLabel("キーコード").fill("MO(Nav)");
await page.getByRole("button", { name: "割り当て", exact: true }).click();
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Nav)");
await page.getByLabel("新しい名前").fill("Navigation");
await page.getByTestId("map-rename-layer").click();
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Navigation)");
await page.getByTestId("map-delete-layer").click();
await expect(page.getByRole("alert")).toContainText("referenced");
});
test("assignment click creates one undo step and unknown existing codes stay read-only", async ({ page }) => {
const key = page.locator('[data-testid="keyboard-view"] [data-key-id]').first();
const id = await key.getAttribute("data-key-id");
await key.click();
await page.getByLabel("キーコード").fill("KC_A");
await page.getByRole("button", { name: "割り当て", exact: true }).click();
await expect(page.locator(`[data-key-id="${id}"] text`)).toContainText("KC_A");
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(`[data-key-id="${id}"] text`)).not.toContainText("KC_A");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(filePath);
const file = await readFile(filePath, "utf8");
const source = JSON.parse(file);
source.map.layers[0].keymap = [{ keyId: id, keycode: "KC_UNKNOWN_VENDOR_CODE" }];
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooser
).setFiles({
name: "unknown.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(source)),
});
await page.getByRole("button", { name: "MAP", exact: true }).click();
await page.locator(`[data-testid="keyboard-view"] [data-key-id="${id}"]`).click();
await expect(page.getByRole("status")).toContainText("未知のキーコード");
await expect(page.getByRole("button", { name: "割り当て", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: "割り当て解除" })).toBeDisabled();
});
test("MAP editing controls are disabled when profile unit sizes differ", async ({ page }) => {
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
await download.saveAs(filePath);
const source = JSON.parse(await readFile(filePath, "utf8"));
source.phy.profiles.push({
...source.phy.profiles[0],
id: "alternate",
name: "Alternate",
unitSize: { x: 18, y: 18 },
});
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooser
).setFiles({
name: "unit-mismatch.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(source)),
});
await page.getByRole("button", { name: "MAP", exact: true }).click();
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
await expect(page.getByTestId("map-rename-layer")).toBeDisabled();
await expect(page.getByTestId("map-delete-layer")).toBeDisabled();
await expect(page.getByLabel("キーコード")).toHaveCount(0);
});
+475
View File
@@ -0,0 +1,475 @@
import { expect, test } from "@playwright/test";
import { readFile } from "node:fs/promises";
import path from "node:path";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
});
async function makeCluster(page: import("@playwright/test").Page) {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
return page.locator('[data-node-type="cluster"]').first();
}
test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.reload();
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
const key = page.locator('[data-node-type="key"]').first();
const before = await key.getAttribute("transform");
const box = await key.boundingBox();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
await page.mouse.up();
await expect.poll(() => key.getAttribute("transform")).not.toBe(before);
const after = await key.getAttribute("transform");
await page.getByRole("button", { name: "Undo" }).click();
await expect(key).toHaveAttribute("transform", before!);
await page.getByRole("button", { name: "Redo" }).click();
await expect(key).toHaveAttribute("transform", after!);
expect(errors).toEqual([]);
});
test("クラスター化で画面位置を保つ", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const centers = async () =>
keys.evaluateAll((es) =>
es.slice(0, 2).map((e) => {
const r = e.getBoundingClientRect();
return [r.x + r.width / 2, r.y + r.height / 2];
}),
);
const before = await centers();
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
const after = await centers();
for (let i = 0; i < 2; i++) {
expect(after[i]![0]).toBeCloseTo(before[i]![0]!, 0);
expect(after[i]![1]).toBeCloseTo(before[i]![1]!, 0);
}
});
test("複数選択のドラッグが全件に適用される", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const before = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
const box = await keys.nth(0).boundingBox();
if (!box) throw new Error("key missing");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 27, box.y + box.height / 2 + 10, { steps: 3 });
await page.mouse.up();
const after = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
expect(after[0]).not.toBe(before[0]);
expect(after[1]).not.toBe(before[1]);
});
test("回転クラスターの矢印移動は親軸で動き、ドラッグもポインタに追従する", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).click();
await page.getByLabel("Rotation").fill("90");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = page.locator('[data-node-type="key"]').first();
const beforeBox = await key.boundingBox();
const beforeTransform = await key.getAttribute("transform");
await key.click();
const pane = page.locator(".canvas-pane");
await pane.focus();
await page.keyboard.press("ArrowRight");
const afterBox = await key.boundingBox();
expect(afterBox!.y).toBeGreaterThan(beforeBox!.y + 4);
const dragBox = await key.boundingBox();
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + dragBox!.height / 2);
await page.mouse.down();
await page.mouse.move(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 });
await page.mouse.up();
const dragAfterBox = await key.boundingBox();
expect(await key.getAttribute("transform")).not.toBe(beforeTransform);
expect(dragAfterBox!.x).toBeGreaterThan(dragBox!.x + 10);
expect(dragAfterBox!.y).toBeCloseTo(dragBox!.y, 0);
});
test("長押しでクラスターを選び、ダブルクリックでフォーカスしてEscで戻る", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const key = page.locator('[data-node-type="key"]').first();
const b = await key.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 });
await page.mouse.up();
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
const keyBox = await key.boundingBox();
await page.mouse.dblclick(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2, { delay: 80 });
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
const pane = page.locator(".canvas-pane");
await pane.focus();
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "root", exact: true })).toHaveCount(0);
});
test("入れ子の回転クラスターをフォーカスできる", async ({ page }) => {
const outer = await makeCluster(page);
const outerId = await outer.getAttribute("data-node-id");
const childIds = await outer
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
await page.getByText("キー・部品 2件", { exact: true }).click();
await page.locator(`[data-tree-id="${childIds[0]}"]`).first().click();
await page
.locator(`[data-tree-id="${childIds[1]}"]`)
.first()
.click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスターにする" }).click();
const inner = outer.locator('[data-node-type="cluster"]').first();
const innerId = await inner.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = inner.locator('[data-node-type="key"]').first();
await expect(key).toBeVisible();
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
await expect(page.locator(`[data-node-id="${childIds[0]}"]`).last()).toBeVisible();
const focusedKey = page.locator(`[data-node-id="${childIds[0]}"]`).last();
const focusedBox = await focusedKey.boundingBox();
await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
await page.mouse.down();
await page.waitForTimeout(700);
await expect(page.locator(`[data-node-id="${innerId}"] .group-sel.candidate`)).toBeVisible({
timeout: 1800,
});
await page.mouse.up();
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-long-press-in-focus.png") });
const clickBox = await focusedKey.boundingBox();
await page.mouse.dblclick(clickBox!.x + clickBox!.width / 2, clickBox!.y + clickBox!.height / 2, {
delay: 80,
});
await expect(page.locator(`[data-node-id="${innerId}"]`)).toHaveCount(1);
});
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.first().click();
const outline = page.locator('[data-node-type="key"] .sel').first();
await expect(outline).toBeVisible();
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("2px");
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
const group = page.locator('[data-node-type="variantGroup"]').first();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const badge = group.locator(".badge");
await expect(badge).toBeVisible();
await badge.locator("rect").click();
await page
.locator(".popover")
.getByRole("button", { name: /Option B/ })
.click();
await expect(keys).toHaveCount(14);
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
await badge.locator("rect").click();
const focusedBadgeBox = await badge.boundingBox();
const focusedPopoverBox = await page.locator(".popover").boundingBox();
expect(
Math.hypot(focusedPopoverBox!.x - focusedBadgeBox!.x, focusedPopoverBox!.y - focusedBadgeBox!.y),
).toBeLessThanOrEqual(80);
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-badge-in-focus.png") });
await page
.locator(".popover")
.getByRole("button", { name: /Option A/ })
.click();
await expect(keys).toHaveCount(16);
});
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await key.click();
await page.getByRole("button", { name: "削除", exact: true }).click();
const confirm = page.locator(".confirm-float");
await expect(confirm).toContainText("参照");
await expect(confirm).toContainText(id!);
await confirm.getByRole("button", { name: "削除", exact: true }).click();
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0);
});
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
const original = await key.getAttribute("transform");
await key.click();
const marker = await page.evaluate(() => {
const data = new DataTransfer();
window.dispatchEvent(
new ClipboardEvent("copy", { bubbles: true, cancelable: true, clipboardData: data }),
);
return data.getData("text/plain");
});
await page.evaluate((marker) => {
const data = new DataTransfer();
data.setData("text/plain", marker);
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
}, marker);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(17);
const pasted = page.locator('[data-node-type="key"]').last();
expect(await pasted.getAttribute("data-node-id")).not.toBe(id);
expect(await pasted.getAttribute("transform")).not.toBe(original);
await page.evaluate(() => {
const data = new DataTransfer();
data.setData("text/plain", JSON.stringify([["Pasted"]]));
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
});
await expect(page.locator('[data-node-type="key"]')).toHaveCount(18);
});
test("カット貼り付けはIDとワールド位置を保つ", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
const transform = await key.getAttribute("transform");
await key.click();
const marker = await page.evaluate(() => {
const data = new DataTransfer();
window.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData: data }));
return data.getData("text/plain");
});
await expect(key).toHaveClass(/cut/);
await page.evaluate((value) => {
const data = new DataTransfer();
data.setData("text/plain", value);
window.dispatchEvent(
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
);
}, marker);
const cutKey = page.locator(`[data-node-id="${id}"]`);
await expect(cutKey).toHaveAttribute("transform", transform!);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
});
test("プロジェクト保存読み込みとKLEから60キー新規を往復する", async ({ page }) => {
await page.locator('[data-node-type="key"]').first().click();
await page.getByLabel("Width").fill("1.5");
await page.getByLabel("Width").dispatchEvent("change");
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const savePath = path.join(test.info().outputDir, "project.json");
await download.saveAs(savePath);
const saved = await readFile(savePath);
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooser
).setFiles({ name: "roundtrip.bkle.json", mimeType: "application/json", buffer: saved });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", "1.5");
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
const kleChooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
});
test("unitSize不一致では追加と数値編集が無効", async ({ page }) => {
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
]);
const savePath = path.join(test.info().outputDir, "project.json");
await download.saveAs(savePath);
const source = JSON.parse(await readFile(savePath, "utf8"));
source.phy.profiles.push({
...source.phy.profiles[0],
id: "alternate",
name: "Alternate",
unitSize: { x: 18, y: 18 },
});
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooser
).setFiles({
name: "unit-mismatch.bkle.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(source)),
});
await expect(page.getByText("unitSize が一致しないため編集できません。")).toBeVisible();
await expect(page.getByRole("button", { name: "キーを足す" })).toBeDisabled();
await page.locator('[data-node-type="key"]').first().click();
await expect(page.getByLabel("Width")).toBeDisabled();
const before = await page.locator('[data-node-type="key"] .key-face').first().getAttribute("width");
await page.getByLabel("Width").evaluate((e: HTMLInputElement) => {
e.value = "2";
e.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", before!);
});
test("KLE読込直後に全体表示し、cr0wn60の全キーがキャンバス内に収まる", async ({ page }) => {
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
const chooser = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
await (await chooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
const canvas = await page.locator("svg.canvas").boundingBox();
const boxes = await page.locator('[data-node-type="key"]').evaluateAll((es) =>
es.map((e) => {
const r = e.getBoundingClientRect();
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
}),
);
expect(canvas).not.toBeNull();
for (const b of boxes) {
expect(b.left).toBeGreaterThanOrEqual(canvas!.x);
expect(b.top).toBeGreaterThanOrEqual(canvas!.y);
expect(b.right).toBeLessThanOrEqual(canvas!.x + canvas!.width);
expect(b.bottom).toBeLessThanOrEqual(canvas!.y + canvas!.height);
}
await page.screenshot({ path: path.join(test.info().outputDir, "cr0wn60-fit.png") });
});
test("キャンバス上のShift選択で2件をクラスター化し、選択解除と親ドラッグを行う", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await expect(page.locator(".sel")).toHaveCount(2);
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Control+g");
const cluster = page.locator('[data-node-type="cluster"]').first();
await expect(cluster.locator('[data-node-type="key"]')).toHaveCount(2);
const clusterBefore = await cluster.getAttribute("transform");
const childrenBefore = await cluster
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("transform")));
const key = cluster.locator('[data-node-type="key"]').first();
const b = await key.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await page.mouse.move(b!.x + b!.width / 2 + 28, b!.y + b!.height / 2 + 5, { steps: 3 });
await page.mouse.up();
await expect(cluster).not.toHaveAttribute("transform", clusterBefore!);
expect(
await cluster
.locator('[data-node-type="key"]')
.evaluateAll((es) => es.map((e) => e.getAttribute("transform"))),
).toEqual(childrenBefore);
const third = keys.nth(3);
await third.click();
await third.click({ modifiers: ["Shift"] });
await expect(page.locator(".sel")).toHaveCount(0);
});
test("削除のキーボード確認とバリアントポップオーバーを対象の近くに置く", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
await key.click();
const keyBox = await key.boundingBox();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Delete");
const confirm = page.locator(".confirm-float");
const confirmBox = await confirm.boundingBox();
const pane = await page.locator(".canvas-pane").boundingBox();
expect(confirmBox!.width).toBeLessThanOrEqual(400);
expect(confirmBox!.x).toBeGreaterThanOrEqual(pane!.x);
expect(confirmBox!.y).toBeGreaterThanOrEqual(pane!.y);
expect(confirmBox!.x + confirmBox!.width).toBeLessThanOrEqual(pane!.x + pane!.width);
expect(confirmBox!.y + confirmBox!.height).toBeLessThanOrEqual(pane!.y + pane!.height);
const gapX = Math.max(
0,
confirmBox!.x - (keyBox!.x + keyBox!.width),
keyBox!.x - (confirmBox!.x + confirmBox!.width),
);
const gapY = Math.max(
0,
confirmBox!.y - (keyBox!.y + keyBox!.height),
keyBox!.y - (confirmBox!.y + confirmBox!.height),
);
expect(Math.hypot(gapX, gapY)).toBeLessThanOrEqual(150);
await page.screenshot({ path: path.join(test.info().outputDir, "delete-confirm.png") });
await page.keyboard.press("Escape");
await keysSelectVariantGroup(page);
const badge = page.locator('[data-node-type="variantGroup"] .badge').first();
await badge.locator("rect").click();
const badgeBox = await badge.boundingBox();
const popoverBox = await page.locator(".popover").boundingBox();
expect(Math.hypot(popoverBox!.x - badgeBox!.x, popoverBox!.y - badgeBox!.y)).toBeLessThanOrEqual(80);
await page.screenshot({ path: path.join(test.info().outputDir, "variant-popover.png") });
});
async function keysSelectVariantGroup(page: import("@playwright/test").Page) {
const keys = page.locator('[data-node-type="key"]');
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
}
test("Alt+上矢印は選択を親へ上げ、フォーカス描画にID重複を作らない", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).click();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = cluster.locator('[data-node-type="key"]').first();
const transform = await key.getAttribute("transform");
await key.click();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
await expect(key).toHaveAttribute("transform", transform!);
await page.keyboard.press("Enter");
const ids = await page
.locator("svg.canvas [data-node-id]")
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
expect(new Set(ids).size).toBe(ids.length);
await page.screenshot({ path: path.join(test.info().outputDir, "focus-mode.png") });
const pane = await page.locator("svg.canvas").boundingBox();
await page.mouse.click(pane!.x + 8, pane!.y + 8);
await expect(page.locator(".sel")).toHaveCount(0);
});
test("長押し候補は離すまで選択を変えず、離すとクラスターを選ぶ", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const keys = page.locator('[data-node-type="key"]');
await keys.nth(4).click();
await expect(keys.nth(4).locator(".sel")).toBeVisible();
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 });
await expect(keys.nth(4).locator(".sel")).toBeVisible();
await page.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") });
await page.mouse.up();
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
});
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
const select = page.getByRole("combobox", { name: "部品を足す" });
await select.selectOption("その他");
await expect(select).toHaveValue("");
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
});
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="ja">
<head><meta charset="UTF-8" /><title>Beyond KLE Mock</title></head>
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "beyond-kle-mock",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"e2e": "playwright test"
},
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"vue": "^3.5.43"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.6.2",
"@vitejs/plugin-vue": "^6.0.9",
"prettier": "^3.9.9",
"typescript": "^6.0.3",
"vite": "^8.3.1",
"vitest": "^5.0.1",
"vue-tsc": "^3.3.11"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: { browserName: "chromium", headless: true, baseURL: "http://127.0.0.1:5173" },
webServer: {
command: "npx vite --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
});
+991
View File
@@ -0,0 +1,991 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
ajv:
specifier: ^8.20.0
version: 8.20.0
ajv-formats:
specifier: ^3.0.1
version: 3.0.1(ajv@8.20.0)
vue:
specifier: ^3.5.43
version: 3.5.43(typescript@6.0.3)
devDependencies:
'@playwright/test':
specifier: ^1.63.0
version: 1.63.0
'@types/node':
specifier: ^26.6.2
version: 26.6.2
'@vitejs/plugin-vue':
specifier: ^6.0.9
version: 6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))
prettier:
specifier: ^3.9.9
version: 3.9.9
typescript:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^8.3.1
version: 8.3.1(@types/node@26.6.2)
vitest:
specifier: ^5.0.1
version: 5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2))
vue-tsc:
specifier: ^3.3.11
version: 3.3.11(typescript@6.0.3)
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.9':
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxc-project/types@0.151.0':
resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@rolldown/binding-android-arm-eabi@1.2.11':
resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/binding-android-arm64@1.2.11':
resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.11':
resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.11':
resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.11':
resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.11':
resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-arm64-musl@1.2.11':
resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
'@rolldown/binding-linux-s390x-gnu@1.2.11':
resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@rolldown/binding-linux-x64-gnu@1.2.11':
resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-linux-x64-musl@1.2.11':
resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@rolldown/binding-openharmony-arm64@1.2.11':
resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.11':
resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.11':
resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@26.6.2':
resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
'@vitejs/plugin-vue@6.0.9':
resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vitest/mocker@5.0.1':
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/spy@5.0.1':
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
'@volar/source-map@2.4.28':
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue/compiler-core@3.5.43':
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
'@vue/compiler-dom@3.5.43':
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
'@vue/compiler-sfc@3.5.43':
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
'@vue/compiler-ssr@3.5.43':
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
'@vue/language-core@3.3.11':
resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==}
'@vue/reactivity@3.5.43':
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
'@vue/runtime-core@3.5.43':
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
'@vue/runtime-dom@3.5.43':
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
'@vue/server-renderer@3.5.43':
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
'@vue/shared@3.5.43':
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.4.2:
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
playwright@1.63.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.9.9:
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
engines: {node: '>=14'}
hasBin: true
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
rolldown@1.2.11:
resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
engines: {node: '>=18'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
vite@8.3.1:
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.7.1
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@5.0.1:
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.1
'@vitest/browser-preview': 5.0.1
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.1
'@vitest/coverage-v8': 5.0.1
'@vitest/ui': 5.0.1
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.2.0:
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
vue-tsc@3.3.11:
resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.43:
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.9':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@oxc-project/types@0.151.0': {}
'@playwright/test@1.63.0':
dependencies:
playwright: 1.63.0
'@rolldown/binding-android-arm-eabi@1.2.11':
optional: true
'@rolldown/binding-android-arm64@1.2.11':
optional: true
'@rolldown/binding-darwin-arm64@1.2.11':
optional: true
'@rolldown/binding-darwin-x64@1.2.11':
optional: true
'@rolldown/binding-freebsd-x64@1.2.11':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.11':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.11':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.11':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.11':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.11':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.11':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.11':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.11':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/node@26.6.2':
dependencies:
undici-types: 8.9.0
'@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.1(@types/node@26.6.2)
vue: 3.5.43(typescript@6.0.3)
'@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.1(@types/node@26.6.2)
'@vitest/spy@5.0.1': {}
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.2.0
'@vue/compiler-core@3.5.43':
dependencies:
'@babel/parser': 7.29.9
'@vue/shared': 3.5.43
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.43':
dependencies:
'@vue/compiler-core': 3.5.43
'@vue/shared': 3.5.43
'@vue/compiler-sfc@3.5.43':
dependencies:
'@babel/parser': 7.29.9
'@vue/compiler-core': 3.5.43
'@vue/compiler-dom': 3.5.43
'@vue/compiler-ssr': 3.5.43
'@vue/shared': 3.5.43
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.28
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.43':
dependencies:
'@vue/compiler-dom': 3.5.43
'@vue/shared': 3.5.43
'@vue/language-core@3.3.11':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.43
'@vue/shared': 3.5.43
alien-signals: 3.2.1
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.7
'@vue/reactivity@3.5.43':
dependencies:
'@vue/shared': 3.5.43
'@vue/runtime-core@3.5.43':
dependencies:
'@vue/reactivity': 3.5.43
'@vue/shared': 3.5.43
'@vue/runtime-dom@3.5.43':
dependencies:
'@vue/reactivity': 3.5.43
'@vue/runtime-core': 3.5.43
'@vue/shared': 3.5.43
csstype: 3.2.3
'@vue/server-renderer@3.5.43':
dependencies:
'@vue/compiler-ssr': 3.5.43
'@vue/runtime-dom': 3.5.43
'@vue/shared': 3.5.43
'@vue/shared@3.5.43': {}
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.8
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alien-signals@3.2.1: {}
assertion-error@2.0.1: {}
chai@6.2.2: {}
csstype@3.2.3: {}
detect-libc@2.1.2: {}
entities@7.0.1: {}
es-module-lexer@2.3.2: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
fast-uri@3.1.8: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fsevents@2.3.3:
optional: true
json-schema-traverse@1.0.0: {}
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
magic-string@1.4.2:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
muggle-string@0.4.1: {}
nanoid@3.3.19: {}
obug@2.2.1: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
playwright-core@1.63.0: {}
playwright@1.63.0:
dependencies:
playwright-core: 1.63.0
postcss@8.5.28:
dependencies:
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.1
prettier@3.9.9: {}
require-from-string@2.0.2: {}
rolldown@1.2.11:
dependencies:
'@oxc-project/types': 0.151.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.11
'@rolldown/binding-android-arm64': 1.2.11
'@rolldown/binding-darwin-arm64': 1.2.11
'@rolldown/binding-darwin-x64': 1.2.11
'@rolldown/binding-freebsd-x64': 1.2.11
'@rolldown/binding-linux-arm-gnueabihf': 1.2.11
'@rolldown/binding-linux-arm64-gnu': 1.2.11
'@rolldown/binding-linux-arm64-musl': 1.2.11
'@rolldown/binding-linux-ppc64-gnu': 1.2.11
'@rolldown/binding-linux-s390x-gnu': 1.2.11
'@rolldown/binding-linux-x64-gnu': 1.2.11
'@rolldown/binding-linux-x64-musl': 1.2.11
'@rolldown/binding-openharmony-arm64': 1.2.11
'@rolldown/binding-win32-arm64-msvc': 1.2.11
'@rolldown/binding-win32-x64-msvc': 1.2.11
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.2.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
typescript@6.0.3: {}
undici-types@8.9.0: {}
vite@8.3.1(@types/node@26.6.2):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.11
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.2
fsevents: 2.3.3
vitest@5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.4.2
obug: 2.2.1
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.1(@types/node@26.6.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.2
transitivePeerDependencies:
- msw
vscode-uri@3.2.0: {}
vue-tsc@3.3.11(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.3.11
typescript: 6.0.3
vue@3.5.43(typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.43
'@vue/compiler-sfc': 3.5.43
'@vue/runtime-dom': 3.5.43
'@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.43
optionalDependencies:
typescript: 6.0.3
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+129
View File
@@ -0,0 +1,129 @@
import type { ProjectFile } from "./project";
import type { Node, Variant, VariantGroupNode } from "./phy";
export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string };
type DeleteTarget =
{ kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode };
function descendants(node: Node | Variant): string[] {
const ids = [node.id];
if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child)));
if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant));
if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child)));
return ids;
}
/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */
export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] {
const target = findTarget(p.phy.root, nodeId);
if (!target) throw new Error(`Unknown node: ${nodeId}`);
const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant));
const out: CascadeReference[] = [];
p.idx.order.forEach((id) => {
if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id });
});
p.elc.keys.forEach((w, i) => {
if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key });
});
p.elc.ledChains?.forEach((chain) =>
chain.order.forEach((id) => {
if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id });
}),
);
p.map.layers.forEach((layer) =>
layer.keymap.forEach((assignment) => {
if (ids.has(assignment.keyId))
out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId });
}),
);
return out;
}
/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */
export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile {
const o = structuredClone(p);
const target = findTarget(o.phy.root, nodeId);
if (!target) throw new Error(`Unknown node: ${nodeId}`);
const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant));
removeTarget(o.phy.root, target);
o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id));
o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key));
if (o.elc.ledChains)
o.elc.ledChains = o.elc.ledChains.map((chain) => ({
...chain,
order: chain.order.filter((id) => !deletedIds.has(id)),
}));
o.map.layers = o.map.layers.map((layer) => ({
...layer,
keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)),
}));
if (target.kind === "variant") {
if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id;
if (o.output.variantSelection[target.group.id] === nodeId)
o.output.variantSelection[target.group.id] = target.group.default;
}
pruneVariantSelection(o);
return o;
}
function findTarget(nodes: Node[], id: string): DeleteTarget | undefined {
for (const node of nodes) {
if (node.id === id) return { kind: "node", node };
if (node.type === "cluster") {
const found = findTarget(node.children, id);
if (found) return found;
}
if (node.type === "variantGroup")
for (const variant of node.children) {
if (variant.id === id) return { kind: "variant", variant, group: node };
const found = findTarget(variant.children, id);
if (found) return found;
}
}
return undefined;
}
function removeTarget(nodes: Node[], target: DeleteTarget): void {
const id = target.kind === "node" ? target.node.id : target.variant.id;
if (target.kind === "variant") {
const { group, variant } = target;
if (group.children.length <= 2)
throw new Error("Cannot delete a variant leaving fewer than two variants");
group.children.splice(group.children.indexOf(variant), 1);
if (group.default === id) group.default = group.children[0]!.id;
return;
}
const index = nodes.findIndex((node) => node.id === id);
if (index >= 0) {
nodes.splice(index, 1);
return;
}
for (const node of nodes) {
if (node.type === "cluster") removeTarget(node.children, target);
if (node.type === "variantGroup")
for (const variant of node.children) removeTarget(variant.children, target);
}
}
function pruneVariantSelection(project: ProjectFile): void {
const valid = new Map<string, { ids: Set<string>; defaultId: string }>();
const walk = (nodes: Node[]) =>
nodes.forEach((node) => {
if (node.type === "cluster") walk(node.children);
if (node.type === "variantGroup") {
valid.set(node.id, {
ids: new Set(node.children.map((variant) => variant.id)),
defaultId: node.default,
});
node.children.forEach((variant) => walk(variant.children));
}
});
walk(project.phy.root);
for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) {
const group = valid.get(groupId);
if (!group) delete project.output.variantSelection[groupId];
else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId;
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { PhyReference } from "./idx";
export type PinRef = string;
export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown };
export type AnalogMuxProvider = {
id: string;
kind: "analog-mux";
channels: number;
select: PinRef[];
common: PinRef;
enable?: PinRef;
[key: string]: unknown;
};
export type ContactWiring = {
key: string;
detect: "contact";
terminals: { a: PinRef; b: PinRef };
diode?: "a-to-b" | "b-to-a";
[key: string]: unknown;
};
export type AnalogWiring = {
key: string;
detect: "analog";
terminals: { out: PinRef; vcc: PinRef; gnd: PinRef };
[key: string]: unknown;
};
export type KeyWiring = ContactWiring | AnalogWiring;
export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown };
export type ElcFile = {
version: string;
phyReference: PhyReference;
providers: (McuProvider | AnalogMuxProvider)[];
keys: KeyWiring[];
ledChains?: LedChain[];
[key: string]: unknown;
};
/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */
export function mcuPinCandidates(provider: McuProvider): string[] {
return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : [];
}
/** Creates an empty ELC file. */
export function createElc(ref: PhyReference): ElcFile {
return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] };
}
/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */
export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile {
if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'");
const o = structuredClone(elc);
const i = o.providers.findIndex((x) => x.id === p.id);
if (i < 0) o.providers.push(structuredClone(p));
else {
const old = o.providers[i]!;
const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]);
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as
McuProvider | AnalogMuxProvider;
}
return o;
}
/** Removes a provider and its dependent references. */
export function removeProvider(elc: ElcFile, id: string): ElcFile {
const o = structuredClone(elc),
removed = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const p of o.providers)
if (p.kind === "analog-mux" && !removed.has(p.id)) {
const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])];
if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) {
removed.add(p.id);
changed = true;
}
}
}
const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + "."));
o.providers = o.providers.filter((p) => !removed.has(p.id));
o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v)));
if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data));
return o;
}
/** Adds or edits terminal and diode configuration for a key. */
export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile {
const o = structuredClone(elc);
const i = o.keys.findIndex((x) => x.key === w.key);
if (i < 0) o.keys.push(structuredClone(w));
else {
const old = o.keys[i]!;
const known = new Set(["key", "detect", "terminals", "diode"]);
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]);
const terminalExtensions = Object.fromEntries(
Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)),
);
const updated = {
...extensions,
...(old.detect === w.detect ? old : {}),
...w,
terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals },
} as KeyWiring;
if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode;
o.keys[i] = updated;
}
return o;
}
/** Removes wiring for a key. */
export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile {
const o = structuredClone(elc);
o.keys = o.keys.filter((x) => x.key !== keyId);
return o;
}
/** Returns keys without any ELC entry. */
export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] {
const wired = new Set(elc.keys.map((k) => k.key));
return keyIds.filter((k) => !wired.has(k));
}
/** Creates an LED chain with an independent ordered node list. T-11. */
export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile {
const o = structuredClone(elc);
o.ledChains ??= [];
if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID");
o.ledChains.push({ id, data, order: [] });
return o;
}
/** Replaces LED order while preserving the chain's other fields. */
export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile {
const o = structuredClone(elc);
const c = o.ledChains?.find((x) => x.id === id);
if (!c) throw new Error("Unknown LED chain");
c.order = [...order];
return o;
}
+26
View File
@@ -0,0 +1,26 @@
import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc";
/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */
export function wireMatrixKeys(
elc: ElcFile,
keyIds: string[],
rowPins: string[],
colPins: string[],
diode?: "a-to-b" | "b-to-a",
): ElcFile {
if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required");
if (keyIds.length > rowPins.length * colPins.length)
throw new Error("Matrix does not have enough row/column positions");
let next = elc;
keyIds.forEach((key, i) => {
const wiring: ContactWiring = {
key,
detect: "contact",
terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! },
...(diode ? { diode } : {}),
};
// Keep unknown extension fields on an existing ELC entry, just like normal edits.
next = setKeyWiring(next, wiring);
});
return next;
}
+102
View File
@@ -0,0 +1,102 @@
import type { ProjectFile } from "./project";
import { flattenKeys } from "./geometry";
import { validateProject, type ValidationIssue } from "./validate";
export type MockTarget = {
format: "mock-target";
version: "0.1.0";
layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>;
idx: string[];
matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] };
keymaps: Array<{ name: string; keycodes: string[] }>;
mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>;
};
export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] };
/** Generates preview output after validation, without modifying the project. T-2/T-19. */
export function generateMockTarget(p: ProjectFile): GeneratorResult {
const errors = validateProject(p).filter(
(x) => x.severity === "blocksOutput" || x.severity === "blocksSave",
);
if (!p.output.matrix)
errors.push({
severity: "blocksOutput",
code: "matrix-settings-missing",
message: "Matrix rowPins and colPins must be configured",
});
if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix")
errors.push({
severity: "blocksOutput",
code: "unsupported-scan-mode",
message: `${p.output.matrix.kind} scan mode is not supported`,
});
if (errors.length) return { errors };
const selected = flattenKeys(p.phy, p.output.variantSelection),
active = new Set(selected.map((x) => x.key.id)),
order = p.idx.order.filter((id) => active.has(id)),
matrix = p.output.matrix!;
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
const wiring = new Map(p.elc.keys.map((x) => [x.key, x]));
const row: number[] = [],
col: number[] = [];
for (const id of order) {
const w = wiring.get(id);
if (!w || w.detect !== "contact")
return {
errors: [
{
severity: "blocksOutput",
code: "unsupported-detection",
message: "Only contact matrix wiring is supported",
ids: [id],
},
],
};
const a = w.terminals.a,
b = w.terminals.b;
let r: number | undefined, c: number | undefined;
if (rowIndex.has(a) && colIndex.has(b)) {
r = rowIndex.get(a);
c = colIndex.get(b);
} else if (rowIndex.has(b) && colIndex.has(a)) {
r = rowIndex.get(b);
c = colIndex.get(a);
}
if (r === undefined || c === undefined)
return {
errors: [
{
severity: "blocksOutput",
code: "unsupported-matrix-wiring",
message: "Wiring is not a supported row/column matrix",
ids: [id],
},
],
};
row.push(r);
col.push(c);
}
const poses = new Map(selected.map((x) => [x.key.id, x]));
const keymaps = p.map.layers.map((l) => ({
name: l.name,
keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode),
}));
const target: MockTarget = {
format: "mock-target",
version: "0.1.0",
layout: order.map((id) => {
const x = poses.get(id)!;
return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h };
}),
idx: order,
matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col },
keymaps,
mapping: order.map((id, i) => ({
number: i,
keyId: id,
row: row[i]!,
col: col[i]!,
label: poses.get(id)!.key.labels?.find(Boolean) ?? "",
})),
};
return { target, errors: [] };
}
+71
View File
@@ -0,0 +1,71 @@
import type { FlatKey, Node, PhyFile, Transform } from "./phy";
/** Translate·Rotate のアフィン行列を返す。 */
export function matrix(t: Transform): Affine {
const a = (t.rotation * Math.PI) / 180,
c = Math.cos(a),
s = Math.sin(a);
return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y };
}
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
/** 左から右の順でなく、通常の行列積 left·right を返す。 */
export function compose(left: Affine, right: Affine): Affine {
return {
a: left.a * right.a + left.c * right.b,
b: left.b * right.a + left.d * right.b,
c: left.a * right.c + left.c * right.d,
d: left.b * right.c + left.d * right.d,
e: left.a * right.e + left.c * right.f + left.e,
f: left.b * right.e + left.d * right.f + left.f,
};
}
/** 剛体変換の逆行列を返す。 */
export function inverse(m: Affine): Affine {
return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) };
}
/** 点に行列を適用する。 */
export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } {
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
}
/** PHY ノードのワールド行列を計算する。 */
export function worldTransform(file: PhyFile, id: string): Affine {
const walk = (nodes: Node[], parent: Affine): Affine | undefined => {
for (const n of nodes) {
const own = compose(parent, matrix(n.transform));
if (n.id === id) return own;
if (n.type === "cluster") {
const r = walk(n.children, own);
if (r) return r;
}
if (n.type === "variantGroup")
for (const v of n.children) {
const r = walk(v.children, own);
if (r) return r;
}
}
return undefined;
};
const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
if (!found) throw new Error(`Unknown node: ${id}`);
return found;
}
/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */
export function flattenKeys(file: PhyFile, selection: Record<string, string> = {}): FlatKey[] {
const out: FlatKey[] = [];
const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => {
for (const n of nodes) {
const wm = compose(parent, matrix(n.transform)),
p = [...path, n.id],
absoluteRotation = parentRotation + n.transform.rotation;
if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p });
else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p);
else if (n.type === "variantGroup") {
const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default));
if (!v) throw new Error(`Unknown variant for ${n.id}`);
visit(v.children, wm, absoluteRotation, [...p, v.id]);
}
}
};
visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []);
return out;
}
+76
View File
@@ -0,0 +1,76 @@
import type { ProjectFile } from "./project";
/** Snapshot history for whole-project undo/redo. T-17. */
export class ProjectHistory {
private items: ProjectFile[];
private cursor = 0;
private pending = false;
constructor(
initial: ProjectFile,
private readonly limit = 100,
) {
this.items = [structuredClone(initial)];
}
/** Adds one undoable snapshot unless a grouped edit is active. */
push(value: ProjectFile): void {
if (this.pending) {
this.replaceTop(value);
return;
}
this.add(value);
}
/** Starts grouping subsequent pushes into a single operation. T-17. */
begin(): void {
if (this.pending) throw new Error("History group already active");
this.items = this.items.slice(0, this.cursor + 1);
this.items.push(structuredClone(this.items[this.cursor]!));
this.cursor = this.items.length - 1;
if (this.items.length > this.limit) {
this.items.shift();
this.cursor--;
}
this.pending = true;
}
/** Commits the final grouped snapshot as one undo step. T-17. */
commit(value?: ProjectFile): void {
if (!this.pending) throw new Error("No history group active");
if (value) this.items[this.cursor] = structuredClone(value);
if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) {
this.items.pop();
this.cursor--;
}
this.pending = false;
}
/** Replaces the current snapshot during a grouped drag. T-17. */
replaceTop(value: ProjectFile): void {
this.items[this.cursor] = structuredClone(value);
}
/** Moves to the previous snapshot. */
undo(): ProjectFile {
if (!this.canUndo) throw new Error("Nothing to undo");
return structuredClone(this.items[--this.cursor]!);
}
/** Moves to the next snapshot. */
redo(): ProjectFile {
if (!this.canRedo) throw new Error("Nothing to redo");
return structuredClone(this.items[++this.cursor]!);
}
get canUndo(): boolean {
return this.cursor > 0;
}
get canRedo(): boolean {
return this.cursor < this.items.length - 1;
}
/** Returns a copy of the current snapshot. */
current(): ProjectFile {
return structuredClone(this.items[this.cursor]!);
}
private add(value: ProjectFile) {
this.items = this.items.slice(0, this.cursor + 1);
this.items.push(structuredClone(value));
this.cursor = this.items.length - 1;
if (this.items.length > this.limit) {
this.items.shift();
this.cursor--;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */
export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string {
return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`;
}
/** T-11: ノード種別ごとのIDを生成する。 */
export function createNodeId(
kind: "key" | "cluster" | "component" | "variantGroup" | "variant",
uuid?: () => string,
): string {
return createId(
{ key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind],
uuid,
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { PhyFile, Node, KeyNode } from "./phy";
export type PhyReference = { source: string; checksum: string; [key: string]: unknown };
export type IdxFile = {
version: string;
phyReference: PhyReference;
order: string[];
[key: string]: unknown;
};
/** Collects keys in document order, including ghost keys and excluding decals. T-18. */
export function eligibleKeyIds(phy: PhyFile): string[] {
const ids: string[] = [];
const visit = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "key") {
if (!n.decal) ids.push(n.id);
} else if (n.type === "cluster") visit(n.children);
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
});
visit(phy.root);
return ids;
}
/** Makes initial index order from KLE occurrence order. T-18. */
export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile {
const valid = new Set(eligibleKeyIds(phy));
return {
version: "0.1.0",
phyReference: structuredClone(phyReference),
order: keyOrder.filter((id) => valid.has(id)),
};
}
/** Lists eligible PHY keys omitted from the saved order. T-18. */
export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] {
const registered = new Set(idx.order);
return eligibleKeyIds(phy).filter((id) => !registered.has(id));
}
/** Registers new keys at the end without rebuilding existing order. T-18. */
export function appendKeys(idx: IdxFile, ids: string[]): IdxFile {
const out = structuredClone(idx);
for (const id of ids) if (!out.order.includes(id)) out.order.push(id);
return out;
}
/** Removes one key from index order. */
export function excludeKey(idx: IdxFile, keyId: string): IdxFile {
const o = structuredClone(idx);
o.order = o.order.filter((x) => x !== keyId);
return o;
}
/** Reorders the saved key list. */
export function reorderKeys(idx: IdxFile, order: string[]): IdxFile {
if (
order.length !== idx.order.length ||
new Set(order).size !== order.length ||
order.some((x) => !idx.order.includes(x))
)
throw new Error("Order must contain every registered key exactly once");
return { ...structuredClone(idx), order: [...order] };
}
+76
View File
@@ -0,0 +1,76 @@
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
/** Small US keyboard display map for common QMK basic keys. T-23. */
export const keycodeLabels: Record<string, string> = {
KC_A: "A",
KC_B: "B",
KC_C: "C",
KC_D: "D",
KC_E: "E",
KC_F: "F",
KC_G: "G",
KC_H: "H",
KC_I: "I",
KC_J: "J",
KC_K: "K",
KC_L: "L",
KC_M: "M",
KC_N: "N",
KC_O: "O",
KC_P: "P",
KC_Q: "Q",
KC_R: "R",
KC_S: "S",
KC_T: "T",
KC_U: "U",
KC_V: "V",
KC_W: "W",
KC_X: "X",
KC_Y: "Y",
KC_Z: "Z",
KC_1: "1",
KC_2: "2",
KC_3: "3",
KC_4: "4",
KC_5: "5",
KC_6: "6",
KC_7: "7",
KC_8: "8",
KC_9: "9",
KC_0: "0",
KC_SPC: "Space",
KC_ENTER: "Enter",
KC_ESC: "Escape",
KC_TAB: "Tab",
KC_MINUS: "-",
KC_EQUAL: "=",
KC_LBRC: "[",
KC_RBRC: "]",
KC_BSLS: "\\",
KC_SCLN: ";",
KC_QUOT: "'",
KC_GRV: "`",
KC_COMM: ",",
KC_DOT: ".",
KC_SLSH: "/",
};
const qmkCodeByName = new Map(
qmkBasicKeycodes.flatMap((entry) =>
[entry.key, ...entry.aliases].map((name) => [name, entry.key] as const),
),
);
/** Looks up the existing US display label, resolving a QMK alias to its formal name. */
export function getKeycodeLabel(code: string): string | undefined {
const formalName = qmkCodeByName.get(code);
if (!formalName) return keycodeLabels[code];
return (
keycodeLabels[code] ??
keycodeLabels[formalName] ??
qmkBasicKeycodes
.find((entry) => entry.key === formalName)
?.aliases.map((alias) => keycodeLabels[alias])
.find((label): label is string => label !== undefined)
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { PhyReference } from "./idx";
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown };
export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown };
export type MapFile = {
version: string;
phyReference: PhyReference;
layers: MapLayer[];
[key: string]: unknown;
};
export type ParsedKeycode = {
kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown";
code: string;
layer?: string;
inner?: ParsedKeycode;
};
const codesFor = (groups: QmkKeyGroup[]) =>
new Set(
qmkBasicKeycodes
.filter((keycode) => groups.includes(keycode.group))
.flatMap(({ key, aliases }) => [key, ...aliases]),
);
type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"];
const codesForKey = (key: string) => {
const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key);
return new Set(entry ? [entry.key, ...entry.aliases] : []);
};
const basic = codesFor(["basic"]);
const modifiers = codesFor(["modifiers"]);
const noneCodes = codesForKey("KC_NO");
const transparentCodes = codesForKey("KC_TRANSPARENT");
/** Parses supported QMK keycode syntax without evaluation. T-22. */
export function parseKeycode(s: string): ParsedKeycode {
const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/);
if (m) return { kind: "layer", code: m[1]!, layer: m[2]! };
const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/);
if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) };
const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/);
if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) };
if (noneCodes.has(s)) return { kind: "none", code: s };
if (transparentCodes.has(s)) return { kind: "transparent", code: s };
if (basic.has(s)) return { kind: "basic", code: s };
if (modifiers.has(s)) return { kind: "modifier", code: s };
return { kind: "unknown", code: s };
}
function simpleEditableKeycode(parsed: ParsedKeycode): boolean {
return (
parsed.kind === "basic" ||
parsed.kind === "modifier" ||
parsed.kind === "none" ||
parsed.kind === "transparent"
);
}
/** Returns whether a keycode uses syntax editable under T-22. */
export function isEditableKeycode(keycode: string): boolean {
const parsed = parseKeycode(keycode);
if (parsed.kind === "unknown") return false;
if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner);
if (parsed.kind === "layer")
return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner));
return true;
}
/** Creates a MAP with one empty Base layer. T-22. */
export function createMap(ref: PhyReference): MapFile {
return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] };
}
/** Adds a uniquely named layer. T-22. */
export function addLayer(map: MapFile, name: string): MapFile {
if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists");
const o = structuredClone(map);
o.layers.push({ name, keymap: [] });
return o;
}
/** Renames a layer and rewrites encoded layer references. T-22. */
export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile {
if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists");
const o = structuredClone(map);
const l = o.layers.find((x) => x.name === oldName);
if (!l) throw new Error("Unknown layer");
l.name = newName;
for (const x of o.layers)
for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName);
return o;
}
function rewriteLayerRef(s: string, oldName: string, newName: string): string {
const p = parseKeycode(s);
if (p.kind === "layer" && p.layer === oldName) {
if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`;
return `${p.code}(${newName})`;
}
return s;
}
/** Moves a layer to a new array position. */
export function reorderLayer(map: MapFile, name: string, index: number): MapFile {
const o = structuredClone(map),
i = o.layers.findIndex((x) => x.name === name);
if (i < 0) throw new Error("Unknown layer");
const [l] = o.layers.splice(i, 1);
o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!);
return o;
}
/** Deletes an unreferenced layer; references block deletion. T-22. */
export function deleteLayer(map: MapFile, name: string): MapFile {
if (map.layers.length < 2) throw new Error("At least one layer is required");
if (
map.layers.some((l) =>
l.keymap.some((a) => {
const p = parseKeycode(a.keycode);
return p.kind === "layer" && p.layer === name;
}),
)
)
throw new Error("Layer is referenced");
const o = structuredClone(map);
o.layers = o.layers.filter((l) => l.name !== name);
return o;
}
/** Assigns a keycode to a key, replacing an existing assignment. T-22. */
export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile {
if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`);
const parsed = parseKeycode(keycode);
if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer))
throw new Error(`Unknown MAP layer reference: ${parsed.layer}`);
const o = structuredClone(map),
l = o.layers.find((x) => x.name === layerName);
if (!l) throw new Error("Unknown layer");
const i = l.keymap.findIndex((a) => a.keyId === keyId),
old = i < 0 ? undefined : l.keymap[i],
a = { ...old, keyId, keycode };
if (i < 0) l.keymap.push(a);
else l.keymap[i] = a;
return o;
}
/** Removes a key assignment. */
export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile {
const o = structuredClone(map),
l = o.layers.find((x) => x.name === layerName);
if (!l) throw new Error("Unknown layer");
l.keymap = l.keymap.filter((a) => a.keyId !== keyId);
return o;
}
/** Lists keys not assigned in a layer. T-22. */
export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] {
const l = map.layers.find((x) => x.name === layerName);
const assigned = new Set(l?.keymap.map((x) => x.keyId));
return keyIds.filter((x) => !assigned?.has(x));
}
+53
View File
@@ -0,0 +1,53 @@
/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */
export type Point = { x: number; y: number };
export type Transform = { x: number; y: number; rotation: number };
export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number };
export type Profile = {
id: string;
name: string;
unitSize: Point;
visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number };
};
export type KleCompatibility = Record<string, unknown>;
export type KeyNode = {
type: "key";
id: string;
profile: string;
transform: Transform;
size: Size;
switchOffset?: Point;
partGroup?: string;
orientation?: 0 | 90 | 180 | 270;
perKeyLED?: boolean;
decal?: boolean;
ghost?: boolean;
labels?: string[];
kleCompatibility?: KleCompatibility;
};
export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string };
export type ClusterNode = {
type: "cluster";
id: string;
name?: string;
transform: Transform;
children: Node[];
};
export type Variant = { type: "variant"; id: string; name: string; children: Node[] };
export type VariantGroupNode = {
type: "variantGroup";
id: string;
name: string;
transform: Transform;
default: string;
children: Variant[];
};
export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode;
export type PhyFile = {
version: string;
metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string };
profiles: Profile[];
root: Node[];
kleCompatibility?: KleCompatibility;
};
export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] };
+494
View File
@@ -0,0 +1,494 @@
import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy";
import { apply, compose, inverse, matrix } from "./geometry";
import { createNodeId } from "./ids";
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
function containers(f: PhyFile): Map<string, Container> {
const m = new Map<string, Container>();
const rec = (ns: Node[], w: typeof ident, angle: number) => {
for (const n of ns) {
m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle });
const nw = compose(w, matrix(n.transform));
const nextAngle = angle + n.transform.rotation;
if (n.type === "cluster") rec(n.children, nw, nextAngle);
if (n.type === "variantGroup")
for (const v of n.children) {
m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle });
rec(v.children, nw, nextAngle);
}
}
};
rec(f.root, ident, 0);
return m;
}
function find(f: PhyFile, id: string): Node {
const c = containers(f).get(id);
const n = c?.nodes.find((x) => x.id === id);
if (!n) throw new Error(`Unknown node: ${id}`);
return n;
}
function world(f: PhyFile, id: string): typeof ident {
const c = containers(f).get(id);
const n = c?.nodes.find((x) => x.id === id);
if (!n) throw new Error(`Unknown node: ${id}`);
return compose(c!.parentWorld, matrix(n.transform));
}
function worldRotation(f: PhyFile, id: string): number {
const c = containers(f).get(id);
const n = c?.nodes.find((x) => x.id === id);
if (!n || !c) throw new Error(`Unknown node: ${id}`);
return c.parentRotation + n.transform.rotation;
}
function mutable(f: PhyFile): PhyFile {
return structuredClone(f);
}
function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
return createNodeId(kind, gen);
}
/** 新しいPHYを返す。T-11。 */
export function addKey(
f: PhyFile,
at: Transform,
size: { w: number; h: number },
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
const o = mutable(f);
o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
return o;
}
/** 新しいPHYを返す。T-11。 */
export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile {
const o = mutable(f);
o.root.push({
type: "component",
id: nodeId("component", gen),
transform: { ...at },
...(partGroup === undefined ? {} : { partGroup }),
});
return o;
}
/** 指定コンテナへキーを追加する。 */
export function addKeyToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
size: { w: number; h: number },
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
return o;
}
/** 指定コンテナへ部品を追加する。 */
export function addComponentToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
partGroup?: string,
gen?: () => string,
): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.push({
type: "component",
id: nodeId("component", gen),
transform: { ...at },
...(partGroup ? { partGroup } : {}),
});
return o;
}
/** root / cluster / variant の指定先にノード群を挿入する。 */
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
const o = mutable(f),
target = parentNodes(o, parentId);
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
return o;
}
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
if (!parentId) return f.root;
const n = find(f, parentId);
if (n.type === "cluster") return n.children;
if (n.type === "variantGroup") throw new Error("Choose a variant as the parent");
return findVariant(f, parentId).children;
}
/** ローカル座標の差分でノードを移動する。 */
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
const o = mutable(f),
n = find(o, id);
n.transform.x += dx;
n.transform.y += dy;
return o;
}
/** ノードを回転する。 */
export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile {
const o = mutable(f),
n = find(o, id);
n.transform.rotation += delta;
return o;
}
/** キーのサイズを変更する。 */
export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile {
const o = mutable(f),
n = find(o, id);
if (n.type !== "key") throw new Error(`${id} is not a key`);
n.size = { ...size };
return o;
}
/** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */
export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile {
if (ids.length < 1) throw new Error("Select at least one node");
const o = mutable(f),
cs = containers(o),
first = cs.get(ids[0]!);
if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes))
throw new Error("Selected nodes must be siblings");
const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!);
if (selected.some((n) => !n)) throw new Error("Unknown selection");
const angles = selected.map((n) => world(o, n.id));
const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation);
const angle = selectedAngles[0]!;
const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9);
const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }),
basisInverse = inverse(basis);
const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!));
if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f }));
const localOrigins = shapePoints.map((p) => apply(basisInverse, p));
const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) };
const origin = apply(basis, min);
const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 });
const local = compose(inverse(first.parentWorld), wt);
const id = nodeId("cluster", gen),
cluster: ClusterNode = {
type: "cluster",
id,
name,
transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation },
children: [],
};
const inv = inverse(wt);
cluster.children = selected.map((n) => {
const w = world(o, n.id),
rel = compose(inv, w);
return {
...n,
transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) },
} as Node;
});
const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b);
const insert = indices[0]!;
first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id)));
first.nodes.splice(insert, 0, cluster);
return o;
}
/** clusterの子を同じ親へ戻し、絶対変換を保つ。 */
export function uncluster(f: PhyFile, id: string): PhyFile {
const o = mutable(f),
cs = containers(o),
c = cs.get(id),
cluster = c?.nodes.find((n) => n.id === id);
if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`);
const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation;
const cw = compose(c!.parentWorld, matrix(cluster.transform)),
converted = cluster.children.map((n) => {
const w = compose(cw, matrix(n.transform)),
rel = compose(inverse(c!.parentWorld), w);
return {
...n,
transform: {
x: rel.e,
y: rel.f,
rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation,
},
} as Node;
});
const index = c!.nodes.indexOf(cluster);
c!.nodes.splice(index, 1, ...converted);
return o;
}
/** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */
export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile {
const o = mutable(f),
cs = containers(o),
src = cs.get(id),
n = src?.nodes.find((x) => x.id === id);
if (!n) throw new Error(`Unknown node: ${id}`);
const oldW = world(o, id),
oldRotation = worldRotation(o, id);
let target: Node[] = o.root,
parentW = ident,
parentRotation = 0;
if (newParentId) {
const p = find(o, newParentId);
if (p.type !== "cluster")
throw new Error("Target parent must be a cluster; use reparentToVariant for variant children");
if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant");
target = p.children;
parentW = world(o, p.id);
parentRotation = worldRotation(o, p.id);
}
const oldIndex = src!.nodes.indexOf(n),
sameParent = target === src!.nodes;
src!.nodes.splice(oldIndex, 1);
const rel = compose(inverse(parentW), oldW);
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
let at = index ?? target.length;
if (sameParent && index !== undefined && oldIndex < index) at--;
target.splice(Math.max(0, Math.min(at, target.length)), 0, n);
return o;
}
/** variant内へ移動するreparent。T-27。 */
export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile {
const o = mutable(f),
cs = containers(o),
src = cs.get(id),
n = src?.nodes.find((x) => x.id === id);
const v = findVariant(o, variantId);
if (!n || !src) throw new Error(`Unknown node: ${id}`);
if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant");
const old = world(o, id),
oldRotation = worldRotation(o, id),
parent = srcVariantWorld(o, variantId),
rel = compose(inverse(parent), old);
const parentRotation = srcVariantRotation(o, variantId);
src.nodes.splice(src.nodes.indexOf(n), 1);
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
v.children.splice(index ?? v.children.length, 0, n);
return o;
}
function containsNode(n: Node, id: string): boolean {
if (n.id === id) return true;
if (n.type === "cluster") return n.children.some((c) => containsNode(c, id));
if (n.type === "variantGroup")
return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id)));
return false;
}
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
if (n.type === "key") {
const points = [
{ x: 0, y: 0 },
{ x: n.size.w, y: 0 },
{ x: n.size.w, y: n.size.h },
{ x: 0, y: n.size.h },
];
if (
n.size.w2 !== undefined ||
n.size.h2 !== undefined ||
n.size.x2 !== undefined ||
n.size.y2 !== undefined
) {
const x = n.size.x2 ?? 0;
const y = n.size.y2 ?? 0;
const w = n.size.w2 ?? n.size.w;
const h = n.size.h2 ?? n.size.h;
points.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h });
}
return points.map((point) => apply(worldMatrix, point));
}
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
if (n.type === "cluster")
return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))));
const selected = n.children.find((variant) => variant.id === n.default);
return selected
? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))))
: [{ x: worldMatrix.e, y: worldMatrix.f }];
}
function findVariant(f: PhyFile, id: string): Variant {
for (const n of f.root) {
const r = walk(n, id);
if (r) return r;
}
throw new Error(`Unknown variant: ${id}`);
}
function walk(n: Node, id: string): Variant | undefined {
if (n.type === "variantGroup") {
for (const v of n.children) {
if (v.id === id) return v;
for (const c of v.children) {
const r = walk(c, id);
if (r) return r;
}
}
}
if (n.type === "cluster")
for (const c of n.children) {
const r = walk(c, id);
if (r) return r;
}
return undefined;
}
function srcVariantWorld(f: PhyFile, id: string): typeof ident {
const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => {
for (const n of ns) {
const nw = compose(w, matrix(n.transform));
if (n.type === "variantGroup")
for (const v of n.children) {
if (v.id === id) return nw;
const r = rec(v.children, nw);
if (r) return r;
}
if (n.type === "cluster") {
const r = rec(n.children, nw);
if (r) return r;
}
}
return undefined;
};
const x = rec(f.root, ident);
if (!x) throw new Error(`Unknown variant ${id}`);
return x;
}
function srcVariantRotation(f: PhyFile, id: string): number {
const rec = (nodes: Node[], parentRotation: number): number | undefined => {
for (const n of nodes) {
const angle = parentRotation + n.transform.rotation;
if (n.type === "variantGroup")
for (const v of n.children) {
if (v.id === id) return angle;
const found = rec(v.children, angle);
if (found !== undefined) return found;
}
if (n.type === "cluster") {
const found = rec(n.children, angle);
if (found !== undefined) return found;
}
}
return undefined;
};
const found = rec(f.root, 0);
if (found === undefined) throw new Error(`Unknown variant ${id}`);
return found;
}
/** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */
export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile {
const o = mutable(f),
n = find(o, id);
if (n.type !== "cluster") throw new Error(`${id} is not a cluster`);
const c = containers(o).get(id)!,
oldRotation = c.parentRotation + n.transform.rotation,
old = compose(c.parentWorld, matrix(n.transform)),
next = compose(c.parentWorld, matrix(frame)),
correction = compose(inverse(next), old);
n.children = n.children.map((ch) => {
const t = compose(correction, matrix(ch.transform));
return {
...ch,
transform: {
x: t.e,
y: t.f,
rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation),
},
} as Node;
});
n.transform = { ...frame };
return o;
}
/** VariantGroupを作成する。T-11。 */
export function createVariantGroup(
f: PhyFile,
name: string,
at: Transform,
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
gen?: () => string,
): PhyFile {
const o = mutable(f),
children = variants.map((x) => ({
type: "variant" as const,
id: nodeId("variant", gen),
name: x.name,
children: structuredClone(x.children ?? []),
}));
o.root.push({
type: "variantGroup",
id: nodeId("variantGroup", gen),
name,
transform: { ...at },
default: children[0]!.id,
children,
});
return o;
}
/** Variantを追加する。T-11。 */
export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile {
const o = mutable(f),
g = find(o, groupId);
if (g.type !== "variantGroup") throw new Error("Not a variant group");
g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] });
return o;
}
/** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */
export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
const o = mutable(f),
g = find(o, groupId);
if (g.type !== "variantGroup") throw new Error("Not a variant group");
if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants");
const i = g.children.findIndex((v) => v.id === variantId);
if (i < 0) throw new Error("Unknown variant");
g.children.splice(i, 1);
if (g.default === variantId) g.default = g.children[0]!.id;
return o;
}
/** default Variantを変更する。 */
export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
const o = mutable(f),
g = find(o, groupId);
if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId))
throw new Error("Invalid group or variant");
g.default = variantId;
return o;
}
/** ノードを削除する。 */
export function deleteNode(f: PhyFile, id: string): PhyFile {
const o = mutable(f),
c = containers(o).get(id);
if (!c) throw new Error(`Unknown node: ${id}`);
const i = c.nodes.findIndex((n) => n.id === id);
c.nodes.splice(i, 1);
return o;
}
/** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
const o = mutable(f),
n = find(o, id),
copy = cloneNodesWithNewIds([n], gen)[0]!;
copy.transform.x += 0.25;
copy.transform.y += 0.25;
const c = containers(o).get(id)!;
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
return o;
}
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
const copies = structuredClone(nodes);
const remap = new Map<string, string>();
const renew = (x: Node) => {
const oldId = x.id;
x.id = nodeId(x.type, gen);
remap.set(oldId, x.id);
if (x.type === "cluster") x.children.forEach(renew);
if (x.type === "variantGroup")
for (const v of x.children) {
const oldVariantId = v.id;
v.id = nodeId("variant", gen);
remap.set(oldVariantId, v.id);
v.children.forEach(renew);
}
};
copies.forEach(renew);
const remapDefaults = (x: Node) => {
if (x.type === "cluster") x.children.forEach(remapDefaults);
if (x.type === "variantGroup") {
x.default = remap.get(x.default) ?? x.default;
x.children.forEach((v) => v.children.forEach(remapDefaults));
}
};
copies.forEach(remapDefaults);
return copies;
}
+11
View File
@@ -0,0 +1,11 @@
import type { PhyFile } from "./phy";
import type { PhyReference } from "./idx";
import { sha256Jcs } from "../io/json";
/** Computes the project-local PHY reference checksum. T-12. */
export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise<PhyReference> {
return { source, checksum: await sha256Jcs(phy) };
}
/** Checks whether a stored checksum matches PHY. T-12. */
export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise<boolean> {
return ref.checksum === (await sha256Jcs(phy));
}
+64
View File
@@ -0,0 +1,64 @@
import type { PhyFile, Node } from "./phy";
import { createId } from "./ids";
import { importKle } from "../io/kleImport";
import { sha256Jcs } from "../io/json";
import { createIdx, type IdxFile } from "./idx";
import { createElc, type ElcFile } from "./elc";
import { createMap, type MapFile } from "./map";
export type MatrixOutputSettings = {
rowPins: string[];
colPins: string[];
kind?: "matrix" | "charlieplexing" | "direct";
[key: string]: unknown;
};
export type ProjectFile = {
format: "beyond-kle-mock-project";
version: string;
projectId: string;
phy: PhyFile;
elc: ElcFile;
idx: IdxFile;
map: MapFile;
output: { variantSelection: Record<string, string>; matrix?: MatrixOutputSettings; [key: string]: unknown };
ui?: unknown;
[key: string]: unknown;
};
/** Creates default variant selections from PHY. T-10/T-20. */
export function defaultVariantSelection(phy: PhyFile): Record<string, string> {
const out: Record<string, string> = {};
const walk = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children);
if (n.type === "variantGroup") {
out[n.id] = n.default;
const v = n.children.find((x) => x.id === n.default);
if (v) walk(v.children);
}
});
walk(phy.root);
return out;
}
/** Creates an empty project with default reference values. T-10/T-13. */
export async function createProject(phy: PhyFile, gen?: () => string): Promise<ProjectFile> {
const ref = { source: "project:phy", checksum: await sha256Jcs(phy) };
return {
format: "beyond-kle-mock-project",
version: "0.1.0",
projectId: createId("project-", gen),
phy: structuredClone(phy),
elc: createElc(ref),
idx: createIdx(phy, [], ref),
map: createMap(ref),
output: { variantSelection: defaultVariantSelection(phy) },
};
}
/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */
export async function createProjectFromKle(
input: unknown,
options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {},
): Promise<ProjectFile> {
const { phy, keyOrder } = importKle(input, options);
const p = await createProject(phy, options.projectIdGenerator);
p.idx = createIdx(phy, keyOrder, p.idx.phyReference);
return p;
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
import type { ProjectFile } from "./project";
import { flattenKeys } from "./geometry";
import type { Node } from "./phy";
import { eligibleKeyIds, unregisteredKeys } from "./idx";
import { parseKeycode } from "./map";
import { validateSchema } from "../io/validateSchema";
export type ValidationIssue = {
severity: "blocksSave" | "blocksOutput" | "warning";
code: string;
message: string;
path?: string;
ids?: string[];
};
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
export function validateProject(p: ProjectFile): ValidationIssue[] {
const out: ValidationIssue[] = [];
for (const layer of ["phy", "idx", "elc", "map"] as const)
for (const e of validateSchema(layer, p[layer]))
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
if (p.format !== "beyond-kle-mock-project")
out.push({
severity: "blocksSave",
code: "project-format",
message: "Unsupported ProjectFile format",
path: "/format",
});
if (p.version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: "Unsupported ProjectFile version",
path: "/version",
});
for (const layer of ["phy", "idx", "elc", "map"] as const)
if (p[layer].version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: `Unsupported ${layer} version`,
path: `/${layer}/version`,
});
const ids = new Map<string, string[]>();
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
const walk = (ns: Node[], path: string) =>
ns.forEach((n, i) => {
const q = `${path}/${i}`;
add(n.id, q);
if (n.type === "cluster") walk(n.children, q + "/children");
if (n.type === "variantGroup") {
if (!n.children.some((v) => v.id === n.default))
out.push({
severity: "blocksSave",
code: "missing-default-variant",
message: "VariantGroup default does not reference a child",
path: q + "/default",
ids: [n.id, n.default],
});
for (const v of n.children) {
add(v.id, q + "/children");
walk(v.children, q + `/children/${v.id}/children`);
}
}
});
walk(p.phy.root, "/phy/root");
for (const [id, paths] of ids)
if (paths.length > 1)
out.push({
severity: "blocksSave",
code: "duplicate-id",
message: "Duplicate PHY node ID",
ids: [id],
path: paths[1],
});
const nodes = new Set(ids.keys()),
keys = new Set(eligibleKeyIds(p.phy));
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
if (!valid.has(id))
out.push({
severity: "blocksSave",
code: "missing-reference",
message: `Unknown reference: ${id}`,
path,
ids: [id],
});
};
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
p.map.layers.forEach((l, i) =>
l.keymap.forEach((a, j) => {
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
const keycode = parseKeycode(a.keycode);
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
out.push({
severity: "blocksSave",
code: "missing-map-layer-reference",
message: `Unknown MAP layer reference: ${keycode.layer}`,
path: `/map/layers/${i}/keymap/${j}/keycode`,
ids: [l.name, keycode.layer ?? ""],
});
}),
);
const providerIds = new Map<string, number>();
p.elc.providers.forEach((provider, index) => {
const first = providerIds.get(provider.id);
if (first !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-provider-id",
message: "Provider IDs must be unique",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
else providerIds.set(provider.id, index);
if (!provider.id || provider.id.includes("."))
out.push({
severity: "blocksSave",
code: "invalid-provider-id",
message: "Provider IDs must be nonempty and cannot contain '.'",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
});
const chainIds = new Set<string>();
p.elc.ledChains?.forEach((chain, index) => {
if (chainIds.has(chain.id))
out.push({
severity: "blocksSave",
code: "duplicate-led-chain-id",
message: "LED chain IDs must be unique",
path: `/elc/ledChains/${index}/id`,
ids: [chain.id],
});
chainIds.add(chain.id);
});
const wiredKeys = new Set<string>();
p.elc.keys.forEach((wiring, index) => {
if (wiredKeys.has(wiring.key))
out.push({
severity: "blocksSave",
code: "duplicate-key-wiring",
message: "A key may have only one ELC wiring entry",
path: `/elc/keys/${index}/key`,
ids: [wiring.key],
});
wiredKeys.add(wiring.key);
});
const validatePinRef = (pin: string, path: string) => {
if (pin === "VCC" || pin === "GND") return;
const parts = pin.split(".");
const providerId = parts[0];
const pinName = parts[1];
if (parts.length !== 2 || !providerId || !pinName) {
out.push({
severity: "blocksSave",
code: "invalid-pin-reference",
message: `Malformed PinRef: ${pin}`,
path,
ids: [pin],
});
return;
}
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
if (!provider) {
out.push({
severity: "blocksSave",
code: "missing-pin-provider",
message: `Unknown PinRef provider: ${providerId}`,
path,
ids: [providerId, pin],
});
return;
}
let valid = true;
if (provider.kind === "analog-mux")
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
if (!valid)
out.push({
severity: "blocksSave",
code: "unknown-provider-pin",
message: `Unknown pin ${pinName} on provider ${providerId}`,
path,
ids: [providerId, pinName],
});
};
p.elc.providers.forEach((provider, index) => {
if (provider.kind === "analog-mux") {
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
validatePinRef(provider.common, `/elc/providers/${index}/common`);
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
}
});
p.elc.keys.forEach((wiring, index) =>
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
),
);
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
const checkProfiles = (ns: Node[]) =>
ns.forEach((n, i) => {
if (n.type === "key" && !profileIds.has(n.profile))
out.push({
severity: "blocksSave",
code: "missing-profile",
message: `Unknown profile: ${n.profile}`,
path: `/phy/key/${n.id}/profile`,
ids: [n.id, n.profile],
});
if (n.type === "cluster") checkProfiles(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
});
checkProfiles(p.phy.root);
for (const [i, pr] of p.phy.profiles.entries()) {
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
out.push({
severity: "blocksSave",
code: "positive-unit-size",
message: "unitSize values must be positive",
path: `/phy/profiles/${i}/unitSize`,
});
}
const keyWalk = (ns: Node[]) =>
ns.forEach((n) => {
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
out.push({
severity: "blocksSave",
code: "positive-key-size",
message: "Key size values must be positive",
path: `/phy/key/${n.id}/size`,
ids: [n.id],
});
if (n.type === "cluster") keyWalk(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
});
keyWalk(p.phy.root);
const unreg = unregisteredKeys(p.phy, p.idx);
for (const id of unreg)
out.push({
severity: "blocksOutput",
code: "idx-unregistered",
message: "Eligible key is not in IDX order",
ids: [id],
});
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
if (new Set(profileSizes).size > 1)
out.push({
severity: "warning",
code: "unit-size-mismatch",
message: "Profile unitSize values differ; geometry editing is restricted",
path: "/phy/profiles",
});
let active: string[] = [];
try {
active = flattenKeys(p.phy, p.output.variantSelection)
.filter((x) => !x.key.decal)
.map((x) => x.key.id);
} catch (e) {
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
}
for (const id of active) {
if (!p.elc.keys.some((k) => k.key === id))
out.push({
severity: "blocksOutput",
code: "unwired-key",
message: "Selected key has no wiring",
ids: [id],
});
for (const l of p.map.layers)
if (!l.keymap.some((a) => a.keyId === id))
out.push({
severity: "blocksOutput",
code: "unassigned-key",
message: `Selected key is not assigned in layer ${l.name}`,
ids: [id, l.name],
});
}
const branches = new Map<string, Array<[string, string]>>();
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
ns.forEach((n) => {
if (n.type === "key") branches.set(n.id, branch);
if (n.type === "cluster") exclusivity(n.children, branch);
if (n.type === "variantGroup")
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
});
exclusivity(p.phy.root, []);
const mutuallyExclusive = (a: string, b: string) => {
for (const [group, variant] of branches.get(a) ?? [])
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
return false;
};
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
for (let i = 0; i < contacts.length; i++)
for (let j = i + 1; j < contacts.length; j++) {
const a = contacts[i]!,
b = contacts[j]!;
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
const ap = a.diode
? a.diode === "a-to-b"
? [a.terminals.a, a.terminals.b]
: [a.terminals.b, a.terminals.a]
: null;
const bp = b.diode
? b.diode === "a-to-b"
? [b.terminals.a, b.terminals.b]
: [b.terminals.b, b.terminals.a]
: null;
const same =
ap && bp
? ap[0] === bp[0] && ap[1] === bp[1]
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
if (same)
out.push({
severity: "blocksOutput",
code: "wiring-collision",
message: "Non-exclusive keys share the same electrical connection",
ids: [a.key, b.key],
});
}
const layerNames = new Set<string>();
for (const [i, l] of p.map.layers.entries()) {
if (layerNames.has(l.name))
out.push({
severity: "blocksSave",
code: "duplicate-layer-name",
message: "Layer names must be unique",
path: `/map/layers/${i}/name`,
ids: [l.name],
});
layerNames.add(l.name);
}
for (const [li, l] of p.map.layers.entries()) {
const assignments = new Map<string, number>();
for (const [ai, a] of l.keymap.entries()) {
const prev = assignments.get(a.keyId);
if (prev !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-map-key",
message: "A key may occur only once in a layer keymap",
path: `/map/layers/${li}/keymap/${ai}/keyId`,
ids: [a.keyId, l.name],
});
assignments.set(a.keyId, ai);
}
}
return out;
}
+5
View File
@@ -0,0 +1,5 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
+26
View File
@@ -0,0 +1,26 @@
/** RFC 8785 JCS の UTF-8 対象文字列を生成する。T-12。 */
export function canonicalize(value: unknown): string {
return encode(value);
}
function encode(v: unknown): string {
if (v === null) return "null";
if (typeof v === "string") return JSON.stringify(v);
if (typeof v === "boolean") return v ? "true" : "false";
if (typeof v === "number") {
if (!Number.isFinite(v)) throw new TypeError("JCS does not support non-finite numbers");
return JSON.stringify(Object.is(v, -0) ? 0 : v);
}
if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`;
if (typeof v === "object") {
const o = v as Record<string, unknown>;
const keys = Object.keys(o).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${encode(o[k])}`).join(",")}}`;
}
throw new TypeError(`Unsupported JCS value: ${typeof v}`);
}
/** JCS正規化したUTF-8をSHA-256し、指定形式で返す。T-12。 */
export async function sha256Jcs(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(canonicalize(value));
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
return `sha256-jcs:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
}
+88
View File
@@ -0,0 +1,88 @@
import type { FlatKey, KleCompatibility, PhyFile } from "../domain/phy";
import { flattenKeys } from "../domain/geometry";
import { labelsToKle } from "./kleImport";
type Obj = Record<string, unknown>;
const defaults: KleCompatibility = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
/** PHYをバリアント選択に従ってKLE JSONへ平坦化する。座標は6桁丸め。T-20。 */
export function exportKle(phy: PhyFile, variantSelection: Record<string, string> = {}): unknown[] {
const keys = flattenKeys(phy, variantSelection);
const order = [...keys].sort((a, b) => a.rotation - b.rotation || a.y - b.y || a.x - b.x);
const centers = new Map<number, { x: number; y: number }>();
for (const key of order) if (!centers.has(key.rotation)) centers.set(key.rotation, { x: key.x, y: key.y });
const previous: KleCompatibility = { ...defaults };
let previousGhost = false;
const rows: unknown[] = [];
for (const flat of order) {
const key = flat.key;
const center = centers.get(flat.rotation)!;
const rad = (flat.rotation * Math.PI) / 180;
const dx = flat.x - center.x;
const dy = flat.y - center.y;
const localX = center.x + Math.cos(rad) * dx + Math.sin(rad) * dy;
const localY = center.y - Math.sin(rad) * dx + Math.cos(rad) * dy;
const compat = { ...defaults, ...(key.kleCompatibility ?? {}) };
const cfg: Obj = {
r: flat.rotation,
rx: round(center.x),
ry: round(center.y),
x: round(localX - center.x),
y: round(localY - center.y),
};
for (const field of ["c", "t", "p", "a", "sm", "sb", "st"] as const) {
const next = compat[field];
if (!same(next, previous[field])) cfg[field] = structuredClone(next);
previous[field] = structuredClone(next);
}
const oldOverride = previous.fa !== undefined ? "fa" : previous.f2 !== undefined ? "f2" : "none";
const nextOverride = compat.fa !== undefined ? "fa" : compat.f2 !== undefined ? "f2" : "none";
const fontChanged = !same(compat.f, previous.f);
if (fontChanged || (nextOverride === "none" && oldOverride !== "none")) cfg.f = compat.f;
if (nextOverride === "fa" && (oldOverride !== "fa" || !same(compat.fa, previous.fa)))
cfg.fa = structuredClone(compat.fa);
if (nextOverride === "f2" && (oldOverride !== "f2" || !same(compat.f2, previous.f2)))
cfg.f2 = structuredClone(compat.f2);
previous.f = structuredClone(compat.f);
delete previous.f2;
delete previous.fa;
if (nextOverride === "fa") previous.fa = structuredClone(compat.fa);
if (nextOverride === "f2") previous.f2 = structuredClone(compat.f2);
if (key.ghost !== previousGhost) {
cfg.g = key.ghost === true;
previousGhost = key.ghost === true;
}
if (key.decal) cfg.d = true;
if (compat.l === true) cfg.l = true;
if (compat.n === true) cfg.n = true;
const w = key.size.w;
const h = key.size.h;
if (w !== 1) cfg.w = round(w);
if (h !== 1) cfg.h = round(h);
if (key.size.x2 !== undefined && key.size.x2 !== 0) cfg.x2 = round(key.size.x2);
if (key.size.y2 !== undefined && key.size.y2 !== 0) cfg.y2 = round(key.size.y2);
const w2 = key.size.w2 ?? w;
const h2 = key.size.h2 ?? h;
if (w2 !== w) cfg.w2 = round(w2);
if (h2 !== h) cfg.h2 = round(h2);
rows.push([cfg, labelsToKle(key.labels, typeof compat.a === "number" ? compat.a : 4)]);
}
if (phy.kleCompatibility) rows.unshift(structuredClone(phy.kleCompatibility));
return rows;
}
/** KLE JSON文字列を生成する。 */
export function stringifyKle(phy: PhyFile, selection: Record<string, string> = {}): string {
return JSON.stringify(exportKle(phy, selection));
}
function same(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function round(value: number): number {
return Number(value.toFixed(6));
}
+192
View File
@@ -0,0 +1,192 @@
import type { KleCompatibility, KeyNode, PhyFile } from "../domain/phy";
import { createNodeId } from "../domain/ids";
type Obj = Record<string, unknown>;
type Atom = string | Obj;
// Output slot -> newline-field index. These fixed tables reproduce KLE's a=0..7 layout.
const labelMap: readonly (readonly (number | null)[])[] = [
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
[null, null, null, null, 0, null, null, null, null, null, 4, null],
];
const fileDefaults: Obj = {
backcolor: "#eeeeee",
name: "",
author: "",
notes: "",
background: null,
radii: "",
switchMount: "",
switchBrand: "",
switchType: "",
};
const keyDefaults: Obj = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
function isObj(value: unknown): value is Obj {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function num(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function labelsFromKle(serialized: string, alignment: number): string[] {
const fields = serialized.split("\n");
if (fields.length > 12) throw new Error("A KLE label cannot exceed 12 slots");
const map = labelMap[alignment] ?? labelMap[4]!;
const labels = Array<string>(12).fill("");
map.forEach((field, slot) => {
if (field !== null && field < fields.length) labels[slot] = fields[field]!;
});
while (labels.at(-1) === "") labels.pop();
return labels;
}
function oneShotArray(state: Obj, key: string): void {
delete state[key];
}
/** KLEの厳密JSON(またはVIA layouts.keymap)を新規PHYへ取り込む。T-7/T-18/T-20。 */
export function importKle(
input: unknown,
options: { idGenerator?: () => string; name?: string } = {},
): { phy: PhyFile; keyOrder: string[] } {
let data: unknown = input;
if (isObj(data)) {
const layouts = data.layouts;
if (isObj(layouts) && Array.isArray(layouts.keymap)) data = layouts.keymap;
else throw new Error("Expected KLE array or VIA layouts.keymap");
}
if (!Array.isArray(data)) throw new Error("KLE input must be a JSON array");
const rows = [...data] as unknown[];
let rawMeta: Obj = {};
if (rows.length && isObj(rows[0])) rawMeta = rows.shift() as Obj;
const gen = options.idGenerator ?? (() => globalThis.crypto.randomUUID());
const state: Obj = { ...keyDefaults };
let cursorX = 0;
let cursorY = 0;
let rotation = 0;
let rotationX = 0;
let rotationY = 0;
const root: KeyNode[] = [];
const keyOrder: string[] = [];
for (const row of rows) {
if (!Array.isArray(row)) throw new Error("KLE rows must be arrays");
cursorX = rotationX;
for (const atom of row as Atom[]) {
if (typeof atom === "string") {
const w = num(state.w, 1);
const h = num(state.h, 1);
cursorX += num(state.x, 0);
cursorY += num(state.y, 0);
const radians = (rotation * Math.PI) / 180;
const dx = cursorX - rotationX;
const dy = cursorY - rotationY;
const x = rotationX + Math.cos(radians) * dx - Math.sin(radians) * dy;
const y = rotationY + Math.sin(radians) * dx + Math.cos(radians) * dy;
const a = num(state.a, 4);
const id = createNodeId("key", gen);
const labels = labelsFromKle(atom, a);
const compatibility: KleCompatibility = {
c: structuredClone(state.c),
t: structuredClone(state.t),
p: structuredClone(state.p),
a,
f: num(state.f, 3),
...(state.f2 === undefined ? {} : { f2: structuredClone(state.f2) }),
...(state.fa === undefined ? {} : { fa: structuredClone(state.fa) }),
l: state.l === true,
n: state.n === true,
sm: structuredClone(state.sm),
sb: structuredClone(state.sb),
st: structuredClone(state.st),
};
const w2 = num(state.w2, 0) || w;
const h2 = num(state.h2, 0) || h;
const key: KeyNode = {
type: "key",
id,
profile: "default",
transform: { x, y, rotation },
size: {
w,
h,
...(num(state.x2, 0) === 0 ? {} : { x2: num(state.x2, 0) }),
...(num(state.y2, 0) === 0 ? {} : { y2: num(state.y2, 0) }),
...(w2 === w ? {} : { w2 }),
...(h2 === h ? {} : { h2 }),
},
labels,
kleCompatibility: compatibility,
};
if (state.d === true) key.decal = true;
if (state.g === true) key.ghost = true;
root.push(key);
if (!key.decal) keyOrder.push(id);
cursorX += w;
for (const field of ["w", "h", "x", "y", "x2", "y2", "w2", "h2", "d", "l", "n"])
oneShotArray(state, field);
} else if (isObj(atom)) {
for (const [key, value] of Object.entries(atom)) {
if (key === "r") rotation = num(value, rotation);
else if (key === "rx") {
rotationX = num(value, rotationX);
cursorX = rotationX;
cursorY = rotationY;
} else if (key === "ry") {
rotationY = num(value, rotationY);
cursorX = rotationX;
cursorY = rotationY;
} else if (key === "f") {
state.f = value;
delete state.f2;
delete state.fa;
} else if (key === "f2") {
state.f2 = value;
delete state.fa;
} else if (key === "fa") {
state.fa = structuredClone(value);
delete state.f2;
} else state[key] = structuredClone(value);
}
} else throw new Error("KLE row entries must be strings or objects");
}
cursorY += 1;
}
const metadata = { ...fileDefaults, ...structuredClone(rawMeta) };
const phy: PhyFile = {
version: "0.1.0",
metadata: { ...(options.name ? { name: options.name } : {}) },
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
root,
kleCompatibility: metadata,
};
return { phy, keyOrder };
}
/** JSON文字列を厳密にパースして KLE を取り込む。 */
export function parseKleJson(
json: string,
options: { idGenerator?: () => string; name?: string } = {},
): ReturnType<typeof importKle> {
return importKle(JSON.parse(json) as unknown, options);
}
/** 正準12欄のラベルをKLEのa別固定欄順に戻す。 */
export function labelsToKle(labels: string[] | undefined, alignment: number): string {
const map = labelMap[alignment] ?? labelMap[4]!;
const fields = Array<string>(12).fill("");
map.forEach((sourceSlot, slot) => {
if (sourceSlot !== null) fields[sourceSlot] = labels?.[slot] ?? "";
});
while (fields.at(-1) === "") fields.pop();
return fields.join("\n");
}
+177
View File
@@ -0,0 +1,177 @@
import type { ProjectFile } from "../domain/project";
import { validateProject, type ValidationIssue } from "../domain/validate";
import { makePhyReference, phyReferenceMatches } from "../domain/phyReference";
import { validateSchema, type LayerName } from "./validateSchema";
export type ProjectReadResult = { project?: ProjectFile; errors: ValidationIssue[] };
export type ProjectSaveResult = { json?: string; errors: ValidationIssue[] };
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/** Validates project and output container shapes before semantic checks. T-10/T-16. */
function validateProjectShape(value: unknown): ValidationIssue[] {
const errors: ValidationIssue[] = [];
if (!isRecord(value))
return [{ severity: "blocksSave", code: "schema", message: "Expected a ProjectFile object", path: "/" }];
const required = ["format", "version", "projectId", "phy", "idx", "elc", "map", "output"];
for (const field of required)
if (!(field in value))
errors.push({
severity: "blocksSave",
code: "schema",
message: `Missing required ProjectFile field: ${field}`,
path: `/${field}`,
});
if (value.format !== "beyond-kle-mock-project")
errors.push({
severity: "blocksSave",
code: "schema",
message: "Invalid ProjectFile format",
path: "/format",
});
if (typeof value.version !== "string")
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/version" });
if (typeof value.projectId !== "string")
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/projectId" });
for (const layer of ["phy", "idx", "elc", "map"] as const) {
if (!(layer in value)) continue;
for (const issue of validateSchema(layer, value[layer]))
errors.push({
severity: "blocksSave",
code: "schema",
message: issue.message,
path: `/${layer}${issue.path === "/" ? "" : issue.path}`,
});
}
const output = value.output;
if (!isRecord(output)) {
errors.push({ severity: "blocksSave", code: "schema", message: "Expected an object", path: "/output" });
} else {
if (!isRecord(output.variantSelection))
errors.push({
severity: "blocksSave",
code: "schema",
message: "Expected an object of variant IDs",
path: "/output/variantSelection",
});
else
for (const [key, variant] of Object.entries(output.variantSelection))
if (typeof variant !== "string")
errors.push({
severity: "blocksSave",
code: "schema",
message: "Expected a variant ID string",
path: `/output/variantSelection/${key}`,
});
if (output.matrix !== undefined) {
if (!isRecord(output.matrix))
errors.push({
severity: "blocksSave",
code: "schema",
message: "Expected an object",
path: "/output/matrix",
});
else {
for (const field of ["rowPins", "colPins"] as const) {
const pins = output.matrix[field];
if (!Array.isArray(pins) || pins.some((pin) => typeof pin !== "string"))
errors.push({
severity: "blocksSave",
code: "schema",
message: "Expected an array of pin references",
path: `/output/matrix/${field}`,
});
}
if (
output.matrix.kind !== undefined &&
!["matrix", "charlieplexing", "direct"].includes(String(output.matrix.kind))
)
errors.push({
severity: "blocksSave",
code: "schema",
message: "Unknown output scan mode",
path: "/output/matrix/kind",
});
}
}
}
return errors;
}
/** Serializes a project and refreshes PHY references unless T-16 blocks saving. T-12/T-13. */
export async function saveProject(project: ProjectFile, pretty = false): Promise<ProjectSaveResult> {
const shapeErrors = validateProjectShape(project);
if (shapeErrors.length) return { errors: shapeErrors };
const errors = validateProject(project);
if (errors.some((issue) => issue.severity === "blocksSave")) return { errors };
const p = structuredClone(project);
const ref = await makePhyReference(p.phy);
for (const layer of ["idx", "elc", "map"] as const)
p[layer].phyReference = { ...p[layer].phyReference, ...ref };
return { json: JSON.stringify(p, null, pretty ? 2 : undefined), errors };
}
/** Parses a project, checking all schemas before semantic validation and retaining unknown fields. T-13/T-14. */
export async function loadProject(json: string): Promise<ProjectReadResult> {
let data: unknown;
try {
data = JSON.parse(json) as unknown;
} catch (e) {
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
}
const errors = validateProjectShape(data);
if (errors.length) return { errors };
const project = data as ProjectFile;
const semantic = validateProject(project);
const unsupportedVersion = semantic.filter((issue) => issue.code === "version");
if (unsupportedVersion.length) return { errors: semantic };
for (const layer of ["idx", "elc", "map"] as const)
if (!(await phyReferenceMatches(project.phy, project[layer].phyReference)))
semantic.push({
severity: "warning",
code: "phy-reference-mismatch",
message: `${layer} PHY checksum does not match the current PHY`,
path: `/${layer}/phyReference`,
});
return { project, errors: semantic };
}
/** Writes a single layer as JSON. */
export function writeLayer(layer: LayerName, value: unknown, pretty = false): string {
const errors = validateSchema(layer, value);
if (errors.length) throw new Error(errors.map((issue) => `${issue.path} ${issue.message}`).join("; "));
return JSON.stringify(value, null, pretty ? 2 : undefined);
}
/** Reads and schema-validates one layer file. */
export function readLayer(layer: LayerName, json: string): { value?: unknown; errors: ValidationIssue[] } {
let value: unknown;
try {
value = JSON.parse(json) as unknown;
} catch (e) {
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
}
const errors = validateSchema(layer, value).map((issue) => ({
severity: "blocksSave" as const,
code: "schema",
message: issue.message,
path: issue.path,
}));
if (
value &&
typeof value === "object" &&
"version" in value &&
(value as { version?: unknown }).version !== "0.1.0"
)
errors.push({
severity: "blocksSave",
code: "version",
message: "Unsupported layer version",
path: "/version",
});
return errors.length ? { errors } : { value, errors: [] };
}
+134
View File
@@ -0,0 +1,134 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://beyond-kle.org/schemas/elc-layer.json",
"title": "ELC Layer - Electrical Wiring",
"description": "Wiring between provider pins and key terminals",
"type": "object",
"required": ["version", "phyReference", "providers", "keys"],
"properties": {
"version": { "type": "string" },
"phyReference": {
"type": "object",
"required": ["source", "checksum"],
"properties": {
"source": { "type": "string" },
"checksum": { "type": "string" }
}
},
"providers": {
"type": "array",
"items": { "$ref": "#/$defs/provider" }
},
"keys": {
"type": "array",
"items": { "$ref": "#/$defs/keyWiring" }
},
"ledChains": {
"type": "array",
"items": { "$ref": "#/$defs/ledChain" }
}
},
"$defs": {
"pinRef": {
"type": "string",
"pattern": "^(VCC|GND|[^.]+\\.[^.]+)$",
"description": "\"<provider id>.<pin name>\", or the reserved power rails VCC / GND"
},
"provider": {
"oneOf": [{ "$ref": "#/$defs/mcuProvider" }, { "$ref": "#/$defs/analogMuxProvider" }]
},
"mcuProvider": {
"type": "object",
"required": ["id", "kind", "part"],
"properties": {
"id": { "type": "string" },
"kind": { "const": "mcu" },
"part": {
"type": "string",
"description": "Part number. Used to interpret pin names"
}
}
},
"analogMuxProvider": {
"type": "object",
"required": ["id", "kind", "channels", "select", "common"],
"properties": {
"id": { "type": "string" },
"kind": { "const": "analog-mux" },
"channels": {
"type": "integer",
"minimum": 1,
"description": "Number of logical pins provided. Pin names are \"0\"..\"channels-1\""
},
"select": {
"type": "array",
"items": { "$ref": "#/$defs/pinRef" },
"description": "Select pins, least significant bit first"
},
"common": { "$ref": "#/$defs/pinRef" },
"enable": { "$ref": "#/$defs/pinRef" }
}
},
"keyWiring": {
"oneOf": [{ "$ref": "#/$defs/contactKey" }, { "$ref": "#/$defs/analogKey" }]
},
"contactKey": {
"type": "object",
"required": ["key", "detect", "terminals"],
"properties": {
"key": {
"type": "string",
"description": "Reference to PHY layer key ID"
},
"detect": { "const": "contact" },
"terminals": {
"type": "object",
"description": "Non-polar. a and b carry no ordering meaning",
"required": ["a", "b"],
"properties": {
"a": { "$ref": "#/$defs/pinRef" },
"b": { "$ref": "#/$defs/pinRef" }
}
},
"diode": {
"type": "string",
"enum": ["a-to-b", "b-to-a"],
"description": "Anode side comes first. Omitted means no diode"
}
}
},
"analogKey": {
"type": "object",
"required": ["key", "detect", "terminals"],
"properties": {
"key": {
"type": "string",
"description": "Reference to PHY layer key ID"
},
"detect": { "const": "analog" },
"terminals": {
"type": "object",
"required": ["out", "vcc", "gnd"],
"properties": {
"out": { "$ref": "#/$defs/pinRef" },
"vcc": { "$ref": "#/$defs/pinRef" },
"gnd": { "$ref": "#/$defs/pinRef" }
}
}
}
},
"ledChain": {
"type": "object",
"required": ["id", "data", "order"],
"properties": {
"id": { "type": "string" },
"data": { "$ref": "#/$defs/pinRef" },
"order": {
"type": "array",
"items": { "type": "string" },
"description": "PHY node IDs. The position in the array is the LED number"
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://beyond-kle.org/schemas/idx-layer.json",
"title": "IDX Layer - Key Identification",
"description": "Ordered PHY key IDs across all variants. Output key numbers are contiguous positions after filtering by variant selection",
"type": "object",
"required": ["version", "phyReference", "order"],
"properties": {
"version": { "type": "string" },
"phyReference": {
"type": "object",
"required": ["source", "checksum"],
"properties": {
"source": { "type": "string" },
"checksum": { "type": "string" }
}
},
"order": {
"type": "array",
"uniqueItems": true,
"description": "Each PHY key has its own entry, including mutually exclusive keys wired to the same terminals. Variant selection does not modify this list",
"items": {
"type": "string",
"description": "Reference to PHY layer key ID"
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://beyond-kle.org/schemas/map-layer.json",
"title": "MAP Layer - Keymap Configuration",
"description": "Key code assignments and layers",
"type": "object",
"required": ["version", "phyReference", "layers"],
"properties": {
"version": { "type": "string" },
"phyReference": {
"type": "object",
"required": ["source", "checksum"],
"properties": {
"source": { "type": "string" },
"checksum": { "type": "string" }
}
},
"layers": {
"type": "array",
"items": {
"$ref": "#/$defs/layer"
}
}
},
"$defs": {
"layer": {
"type": "object",
"required": ["name", "keymap"],
"properties": {
"name": {
"type": "string",
"description": "Layer identifier, unique within the MAP. Layer order is the array order"
},
"keymap": {
"type": "array",
"items": {
"$ref": "#/$defs/keyAssignment"
}
}
}
},
"keyAssignment": {
"type": "object",
"required": ["keyId", "keycode"],
"properties": {
"keyId": {
"type": "string",
"description": "Reference to PHY layer key ID"
},
"keycode": { "type": "string" }
}
}
}
}
+253
View File
@@ -0,0 +1,253 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://beyond-kle.org/schemas/phy-layer.json",
"title": "PHY Layer - Physical Key Layout",
"description": "Physical positioning and sizing of keys",
"type": "object",
"required": ["version", "metadata", "profiles", "root"],
"properties": {
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Schema version (semver)"
},
"metadata": {
"type": "object",
"properties": {
"name": { "type": "string" },
"author": { "type": "string" },
"description": { "type": "string" },
"created": { "type": "string", "format": "date-time" },
"modified": { "type": "string", "format": "date-time" }
}
},
"kleCompatibility": {
"type": "object",
"description": "File-level opaque KLE fields kept for round-trip output (switchMount, switchBrand, switchType, backcolor, radii, background, css). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
"additionalProperties": true
},
"profiles": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/profile"
}
},
"root": {
"type": "array",
"items": {
"oneOf": [
{ "$ref": "#/$defs/key" },
{ "$ref": "#/$defs/cluster" },
{ "$ref": "#/$defs/component" },
{ "$ref": "#/$defs/variantGroup" }
]
}
}
},
"$defs": {
"transform": {
"type": "object",
"description": "Placement relative to the parent: rotate by rotation around the local origin, then translate by (x, y)",
"required": ["x", "y", "rotation"],
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"rotation": { "type": "number", "description": "Degrees" }
}
},
"profile": {
"type": "object",
"required": ["id", "name", "unitSize"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"unitSize": {
"type": "object",
"description": "Millimeters per 1U",
"required": ["x", "y"],
"properties": {
"x": { "type": "number", "minimum": 0 },
"y": { "type": "number", "minimum": 0 }
}
},
"visualOffset": {
"type": "object",
"description": "Key top rectangle, in millimeters: inward distance from each edge of the outline, plus corner radius",
"required": ["top", "right", "bottom", "left", "radius"],
"properties": {
"top": { "type": "number" },
"right": { "type": "number" },
"bottom": { "type": "number" },
"left": { "type": "number" },
"radius": { "type": "number", "minimum": 0 }
}
}
}
},
"key": {
"type": "object",
"required": ["type", "id", "profile", "transform", "size"],
"properties": {
"type": {
"type": "string",
"const": "key"
},
"id": {
"type": "string",
"description": "Unique key identifier"
},
"profile": {
"type": "string",
"description": "Reference to profile ID"
},
"transform": { "$ref": "#/$defs/transform" },
"size": {
"type": "object",
"description": "Local size. Not affected by the parent transform",
"required": ["w", "h"],
"properties": {
"w": { "type": "number", "minimum": 0 },
"h": { "type": "number", "minimum": 0 },
"x2": {
"type": "number",
"description": "Second rectangle for non-rectangular keys, relative to the local origin"
},
"y2": { "type": "number" },
"w2": { "type": "number", "minimum": 0 },
"h2": { "type": "number", "minimum": 0 }
}
},
"switchOffset": {
"type": "object",
"description": "Switch position, offset from the center of the (w, h) rectangle. Key local coordinates, unit U. Defaults to 0",
"required": ["x", "y"],
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
}
},
"partGroup": {
"type": "string",
"description": "Label grouping keys that use the same part. Not interpreted by Beyond KLE"
},
"orientation": {
"type": "integer",
"enum": [0, 90, 180, 270],
"default": 0,
"description": "Switch orientation in the key local coordinates"
},
"perKeyLED": {
"type": "boolean",
"default": false,
"description": "Whether a per-key LED is mounted"
},
"decal": { "type": "boolean", "default": false },
"ghost": { "type": "boolean", "default": false },
"labels": {
"type": "array",
"items": { "type": "string" },
"description": "Reference information. Used for matching on KLE JSON import"
},
"kleCompatibility": {
"type": "object",
"description": "Opaque KLE fields kept for round-trip output (c, t, p, a, f, f2, fa, l, n, sm, sb, st). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
"additionalProperties": true
}
}
},
"component": {
"type": "object",
"description": "Non-key part placed by the designer (LED, OLED, encoder, screw hole, ...)",
"required": ["type", "id", "transform"],
"properties": {
"type": {
"type": "string",
"const": "component"
},
"id": { "type": "string" },
"transform": { "$ref": "#/$defs/transform" },
"partGroup": {
"type": "string",
"description": "Names starting with @ are reserved for editor rendering"
}
}
},
"variantGroup": {
"type": "object",
"description": "Mutually exclusive layout alternatives. Children are variants only",
"required": ["type", "id", "name", "transform", "default", "children"],
"properties": {
"type": {
"type": "string",
"const": "variantGroup"
},
"id": { "type": "string" },
"name": {
"type": "string",
"description": "Heading shown for the choice in the editor"
},
"transform": { "$ref": "#/$defs/transform" },
"default": {
"type": "string",
"description": "id of the variant used when a single layout must be chosen"
},
"children": {
"type": "array",
"minItems": 2,
"description": "Display order",
"items": { "$ref": "#/$defs/variant" }
}
}
},
"variant": {
"type": "object",
"description": "One alternative. Appears only directly under a variantGroup. Carries no transform",
"required": ["type", "id", "name", "children"],
"properties": {
"type": {
"type": "string",
"const": "variant"
},
"id": { "type": "string" },
"name": { "type": "string" },
"children": {
"type": "array",
"description": "May be empty",
"items": {
"oneOf": [
{ "$ref": "#/$defs/key" },
{ "$ref": "#/$defs/cluster" },
{ "$ref": "#/$defs/component" },
{ "$ref": "#/$defs/variantGroup" }
]
}
}
}
},
"cluster": {
"type": "object",
"required": ["type", "id", "transform", "children"],
"properties": {
"type": {
"type": "string",
"const": "cluster"
},
"id": { "type": "string" },
"name": { "type": "string" },
"transform": { "$ref": "#/$defs/transform" },
"children": {
"type": "array",
"items": {
"oneOf": [
{ "$ref": "#/$defs/key" },
{ "$ref": "#/$defs/cluster" },
{ "$ref": "#/$defs/component" },
{ "$ref": "#/$defs/variantGroup" }
]
}
}
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import phySchema from "./schemas/phy.schema.json";
import idxSchema from "./schemas/idx.schema.json";
import elcSchema from "./schemas/elc.schema.json";
import mapSchema from "./schemas/map.schema.json";
export type LayerName = "phy" | "idx" | "elc" | "map";
export type SchemaIssue = { path: string; message: string };
const ajv = new Ajv2020({ allErrors: true, strict: false });
addFormats(ajv);
const validators = {
phy: ajv.compile(phySchema),
idx: ajv.compile(idxSchema),
elc: ajv.compile(elcSchema),
map: ajv.compile(mapSchema),
};
/** Validates one layer against its draft 2020-12 schema. */
export function validateSchema(layer: LayerName, value: unknown): SchemaIssue[] {
const valid = validators[layer](value);
return valid
? []
: (validators[layer].errors ?? []).map((e) => ({
path: `${e.instancePath || "/"}${e.params && "missingProperty" in e.params ? `/${e.params.missingProperty}` : ""}`,
message: e.message ?? "Schema validation failed",
}));
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./ui/App.vue";
createApp(App).mount("#app");
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
import { createProjectFromKle } from "../domain/project";
import type { ProjectFile } from "../domain/project";
import { ProjectHistory } from "../domain/history";
import { validateProject } from "../domain/validate";
import { saveProject, loadProject } from "../io/projectFile";
import { exportKle } from "../io/kleExport";
import { defaultLayout } from "./defaultLayout";
import type { Dispatch } from "./types";
import PhyEditor from "./phy/PhyEditor.vue";
import IdxPanel from "./panels/IdxPanel.vue";
import ElcPanel from "./panels/ElcPanel.vue";
import MapPanel from "./panels/MapPanel.vue";
import GeneratorPanel from "./panels/GeneratorPanel.vue";
const project = shallowRef<ProjectFile | null>(null),
activeTab = ref("PHY"),
errorText = ref("");
const importInput = ref<HTMLInputElement>(),
openInput = ref<HTMLInputElement>();
let history: ProjectHistory | null = null;
const epoch = ref(0);
const mismatch = computed(
() =>
!!project.value &&
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
);
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
severity = ["blocksSave", "blocksOutput", "warning"] as const;
const canUndo = computed(() => {
epoch.value;
return !!history?.canUndo;
}),
canRedo = computed(() => {
epoch.value;
return !!history?.canRedo;
});
const dispatch: Dispatch = (label, update) => {
if (!project.value || mismatch.value) return;
const next = update(project.value);
project.value = next;
history?.push(next);
epoch.value++;
errorText.value = "";
};
function beginHistory() {
if (history) {
history.begin();
epoch.value++;
}
}
function commitHistory() {
if (history && project.value) {
history.commit(project.value);
epoch.value++;
}
}
function undo() {
if (history?.canUndo) {
project.value = history.undo();
epoch.value++;
}
}
function redo() {
if (history?.canRedo) {
project.value = history.redo();
epoch.value++;
}
}
function globalKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === "z") {
e.preventDefault();
e.shiftKey ? redo() : undo();
} else if (mod && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
} else if (mod && e.key.toLowerCase() === "s") {
e.preventDefault();
void save();
}
}
async function initialize() {
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
project.value = p;
history = new ProjectHistory(p);
epoch.value++;
}
onMounted(() => {
void initialize();
window.addEventListener("keydown", globalKey);
});
onUnmounted(() => window.removeEventListener("keydown", globalKey));
async function readFile(file: File, mode: "kle" | "project") {
try {
const raw = JSON.parse(await file.text()) as unknown;
let p: ProjectFile;
if (mode === "kle") p = await createProjectFromKle(raw, { name: file.name });
else {
const r = await loadProject(JSON.stringify(raw));
if (!r.project) throw Error(r.errors.map((x) => x.message).join("; "));
p = r.project;
errorText.value = r.errors.map((x) => x.message).join("; ");
}
project.value = p;
history = new ProjectHistory(p);
epoch.value++;
} catch (e) {
errorText.value = String(e);
}
}
function download(name: string, text: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
async function save() {
if (!project.value) return;
const r = await saveProject(project.value, true);
if (r.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, r.json);
else errorText.value = r.errors.map((x) => x.message).join("; ");
}
function exportSelected() {
if (project.value)
download(
"layout.kle.json",
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
);
}
</script>
<template>
<main v-if="project" class="app-shell" :class="{ locked: mismatch }">
<header class="toolbar">
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
><input
ref="importInput"
hidden
type="file"
accept=".json,application/json"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) void readFile(f, 'kle');
(e.target as HTMLInputElement).value = '';
}
"
/><button @click="openInput?.click()">プロジェクトを開く</button
><input
ref="openInput"
hidden
type="file"
accept=".json,application/json"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) void readFile(f, 'project');
(e.target as HTMLInputElement).value = '';
}
"
/><button @click="save">プロジェクトを保存</button
><button @click="exportSelected">KLE JSONを出力</button
><button :disabled="!canUndo" @click="undo">Undo</button
><button :disabled="!canRedo" @click="redo">Redo</button
><span v-if="errorText" class="error">{{ errorText }}</span>
</header>
<nav class="tabs">
<button
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
:key="t"
:class="{ active: activeTab === t }"
@click="activeTab = t"
>
{{ t }}
</button>
</nav>
<PhyEditor
v-if="activeTab === 'PHY'"
:project="project"
:dispatch="dispatch"
:mismatch="mismatch"
:begin-history="beginHistory"
:commit-history="commitHistory"
@error="errorText = $event"
/>
<section v-else class="other-panel">
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
v-else-if="activeTab === 'ELC'"
:project="project"
:dispatch="dispatch"
/><MapPanel
v-else-if="activeTab === 'MAP'"
:project="project"
:dispatch="dispatch"
:editable="!mismatch"
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
</section>
<aside class="validation pane">
<h2>検証結果</h2>
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
保存できない状態です。blocksSave の問題を解消してください。
</p>
<div class="issues">
<section v-for="s in severity" :key="s">
<h3>
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
</h3>
<div v-for="(issue, i) in issues.filter((x) => x.severity === s)" :key="i" class="issue">
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
</div>
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
</section>
</div>
</aside>
</main>
</template>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
14px system-ui,
sans-serif;
color: #20242b;
background: #edf0f4;
}
.app-shell {
height: 100vh;
display: grid;
grid-template-rows: 52px 42px minmax(300px, 1fr) 180px;
gap: 8px;
padding: 8px;
}
.toolbar,
.tabs,
.pane {
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 7px;
}
.toolbar strong {
margin-right: auto;
}
.tabs {
display: flex;
gap: 4px;
padding: 4px;
}
.tabs button.active {
background: #dce9ff;
}
.other-panel {
min-height: 0;
}
.validation {
overflow: hidden;
padding: 10px;
}
.validation h2 {
font-size: 16px;
margin: 0 0 8px;
}
.issues {
display: flex;
gap: 20px;
overflow: auto;
}
.issues section {
min-width: 240px;
}
.issues h3 {
font-size: 13px;
}
.issue {
margin: 3px 0;
background: #f4f6f9;
padding: 5px;
}
.issue small {
display: block;
color: #667;
}
.muted {
color: #778;
}
.warning,
.error {
color: #9a4c00;
}
.error {
max-width: 240px;
overflow: hidden;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
// 選択構成の平坦な配置を読み取り専用で描く。IDX・ELC・MAP・Generatorのパネルで共有する。
import { computed } from "vue";
import { flattenKeys } from "../../domain/geometry";
import type { FlatKey, PhyFile } from "../../domain/phy";
const props = withDefaults(
defineProps<{
phy: PhyFile;
selection: Record<string, string>;
/** キーの中央に出す文字列。省略時は先頭のラベル */
keyText?: (keyId: string) => string;
/** キーに付けるCSSクラス(状態の色分け) */
keyClass?: (keyId: string) => string | undefined;
selectedIds?: string[];
unit?: number;
}>(),
{ selectedIds: () => [], unit: 48 },
);
const emit = defineEmits<{ keyClick: [keyId: string, event: MouseEvent] }>();
const keys = computed<FlatKey[]>(() => flattenKeys(props.phy, props.selection));
// 回転を含めた四隅から表示範囲を求める
const viewBox = computed(() => {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const k of keys.value) {
const r = (k.rotation * Math.PI) / 180,
c = Math.cos(r),
s = Math.sin(r);
const { w, h } = k.key.size;
for (const [lx, ly] of [
[0, 0],
[w, 0],
[0, h],
[w, h],
] as const) {
const x = k.x + lx * c - ly * s,
y = k.y + lx * s + ly * c;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (!isFinite(minX)) return "0 0 4 2";
const pad = 0.25;
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
});
const width = computed(() => {
const parts = viewBox.value.split(" ").map(Number);
return (parts[2] ?? 4) * props.unit;
});
function text(k: FlatKey): string {
return props.keyText ? props.keyText(k.key.id) : (k.key.labels?.find((l) => l) ?? "");
}
</script>
<template>
<svg class="keyboard-view" :viewBox="viewBox" :width="width" data-testid="keyboard-view">
<g
v-for="k in keys"
:key="k.key.id"
:transform="`translate(${k.x},${k.y}) rotate(${k.rotation})`"
:class="[
'kv-key',
keyClass?.(k.key.id),
{ selected: selectedIds.includes(k.key.id), decal: k.key.decal, ghost: k.key.ghost },
]"
:data-key-id="k.key.id"
@click="emit('keyClick', k.key.id, $event)"
>
<rect x="0.03" y="0.03" :width="k.key.size.w - 0.06" :height="k.key.size.h - 0.06" rx="0.08" />
<rect
v-if="k.key.size.w2 !== undefined || k.key.size.h2 !== undefined"
:x="(k.key.size.x2 ?? 0) + 0.03"
:y="(k.key.size.y2 ?? 0) + 0.03"
:width="(k.key.size.w2 ?? k.key.size.w) - 0.06"
:height="(k.key.size.h2 ?? k.key.size.h) - 0.06"
rx="0.08"
/>
<text :x="k.key.size.w / 2" :y="k.key.size.h / 2" text-anchor="middle" dominant-baseline="central">
{{ text(k) }}
</text>
</g>
</svg>
</template>
<style scoped>
.keyboard-view {
max-width: 100%;
height: auto;
user-select: none;
}
.kv-key {
cursor: pointer;
}
.kv-key rect {
fill: #f3f4f6;
stroke: #6b7280;
stroke-width: 0.02;
}
.kv-key text {
font-size: 0.22px;
fill: #111827;
pointer-events: none;
}
.kv-key.selected rect {
stroke: #2563eb;
stroke-width: 0.06;
}
.kv-key.decal rect {
stroke-dasharray: 0.06 0.04;
fill: none;
}
.kv-key.ghost {
opacity: 0.45;
}
.kv-key.warn rect {
fill: #fde68a;
}
.kv-key.error rect {
fill: #fecaca;
}
.kv-key.ok rect {
fill: #d1fae5;
}
.kv-key.muted rect {
fill: #e5e7eb;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
/** Small 4x4 starter layout, kept in src/ui rather than the test fixtures. */
export const defaultLayout: unknown[] = Array.from({ length: 4 }, () => ["", "", "", ""]);
+564
View File
@@ -0,0 +1,564 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { flattenKeys } from "../../domain/geometry";
import {
mcuPinCandidates,
removeKeyWiring,
setKeyWiring,
setProvider,
removeProvider,
createLedChain,
setLedOrder,
} from "../../domain/elc";
import type { ContactWiring, McuProvider, AnalogMuxProvider, PinRef } from "../../domain/elc";
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import { validateProject } from "../../domain/validate";
import { wireMatrixKeys } from "../../domain/elcMatrix";
import type { Dispatch } from "../types";
import KeyboardView from "../common/KeyboardView.vue";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
const variants = computed(() => ({
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
}));
const keys = computed(() =>
flattenKeys(props.project.phy, variants.value)
.filter((x) => !x.key.decal)
.map((x) => x.key),
);
const selectedKeyId = ref("");
const selectedKey = computed(() => keys.value.find((x) => x.id === selectedKeyId.value));
const wired = computed(() => new Map(props.project.elc.keys.map((x) => [x.key, x])));
const collisionIds = computed(
() =>
new Set(
validateProject(props.project)
.filter((x) => x.code === "wiring-collision")
.flatMap((x) => x.ids ?? []),
),
);
const unwired = computed(() => keys.value.filter((k) => !wired.value.has(k.id)).map((k) => k.id));
const wiring = ref({ a: "", b: "", diode: "" as "" | "a-to-b" | "b-to-a" });
watch(selectedKeyId, (id) => {
const current = props.project.elc.keys.find((x) => x.key === id);
wiring.value =
current?.detect === "contact"
? { a: current.terminals.a, b: current.terminals.b, diode: current.diode ?? "" }
: { a: "", b: "", diode: "" };
});
const providerId = ref("MCU"),
providerPart = ref("RP2040"),
providerError = ref("");
const providerCandidates = computed(() => {
const p = { id: providerId.value, kind: "mcu" as const, part: providerPart.value };
return mcuPinCandidates(p).map((name) => `${p.id}.${name}`);
});
const pinRefs = computed(() => [
"VCC",
"GND",
...props.project.elc.providers.flatMap((p) =>
p.kind === "mcu"
? mcuPinCandidates(p).map((pin) => `${p.id}.${pin}`)
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
),
]);
const providerRows = computed(() => props.project.elc.providers);
const mux = ref({ id: "MUX", channels: 8, select: "", common: "", enable: "" });
const chainId = ref("LED"),
chainData = ref(""),
selectedChainId = ref("");
const batchKeyIds = ref<string[]>([]),
batchRows = ref<string[]>([]),
batchCols = ref<string[]>([]);
const batchRow = ref(""),
batchCol = ref(""),
batchDiode = ref<"" | "a-to-b" | "b-to-a">("a-to-b");
const batchPreviewOpen = ref(false),
batchError = ref("");
const chains = computed(() => props.project.elc.ledChains ?? []);
const nodes = computed(() => {
const result: Array<{ id: string; label: string }> = [];
const visit = (ns: Node[]) =>
ns.forEach((n) => {
if (n.type === "key" && n.perKeyLED) result.push({ id: n.id, label: `${n.id} · per-key LED` });
else if (n.type === "component" && n.partGroup === "@led")
result.push({ id: n.id, label: `${n.id} · @led` });
else if (n.type === "cluster") visit(n.children);
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
});
visit(props.project.phy.root);
return result;
});
const chain = computed(() => chains.value.find((x) => x.id === selectedChainId.value));
const chainSelection = ref<string[]>([]);
watch(selectedChainId, () => {
chainSelection.value = [];
});
function clickKey(id: string) {
selectedKeyId.value = id;
if (chain.value && nodes.value.some((n) => n.id === id) && !chain.value.order.includes(id))
setOrder([...chain.value.order, id]);
}
function keyClass(id: string) {
return collisionIds.value.has(id) ? "error" : !wired.value.has(id) ? "warn" : "ok";
}
function commitWiring() {
if (!selectedKeyId.value || !wiring.value.a || !wiring.value.b) return;
const next: ContactWiring = {
key: selectedKeyId.value,
detect: "contact",
terminals: { a: wiring.value.a, b: wiring.value.b },
...(wiring.value.diode ? { diode: wiring.value.diode } : {}),
};
props.dispatch("接点配線", (p) => ({ ...p, elc: setKeyWiring(p.elc, next) }));
}
function removeWiring() {
if (selectedKeyId.value)
props.dispatch("配線解除", (p) => ({ ...p, elc: removeKeyWiring(p.elc, selectedKeyId.value) }));
}
function addMcu() {
providerError.value = "";
if (!providerId.value || providerId.value.includes(".")) {
providerError.value = "IDは空欄にできず、'.'を含められません";
return;
}
const p: McuProvider = { id: providerId.value, kind: "mcu", part: providerPart.value };
props.dispatch("MCU追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
}
function editProvider(id: string) {
const p = props.project.elc.providers.find((x) => x.id === id);
if (p?.kind === "mcu") {
providerId.value = p.id;
providerPart.value = p.part;
}
}
function addMux() {
if (!mux.value.id || mux.value.id.includes(".")) {
providerError.value = "IDは空欄にできず、'.'を含められません";
return;
}
const p: AnalogMuxProvider = {
id: mux.value.id,
kind: "analog-mux",
channels: Number(mux.value.channels),
select: mux.value.select
.split(",")
.map((x) => x.trim())
.filter(Boolean),
common: mux.value.common,
...(mux.value.enable ? { enable: mux.value.enable } : {}),
};
props.dispatch("analog-mux追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
}
function removeProviderUi(id: string) {
props.dispatch("提供者削除", (p) => ({ ...p, elc: removeProvider(p.elc, id) }));
}
function makeChain() {
if (!chainId.value || !chainData.value) return;
try {
props.dispatch("LEDチェーン作成", (p) => ({
...p,
elc: createLedChain(p.elc, chainId.value, chainData.value),
}));
selectedChainId.value = chainId.value;
} catch (e) {
providerError.value = String(e);
}
}
function setOrder(order: string[]) {
if (chain.value)
props.dispatch("LED順序", (p) => ({ ...p, elc: setLedOrder(p.elc, chain.value!.id, order) }));
}
function moveLed(index: number, direction: number) {
const order = [...(chain.value?.order ?? [])];
const to = index + direction;
if (to < 0 || to >= order.length) return;
[order[index], order[to]] = [order[to]!, order[index]!];
setOrder(order);
}
function removeLed(index: number) {
const order = [...(chain.value?.order ?? [])];
order.splice(index, 1);
setOrder(order);
}
function toggleBatchKey(id: string) {
batchKeyIds.value = batchKeyIds.value.includes(id)
? batchKeyIds.value.filter((x) => x !== id)
: [...batchKeyIds.value, id];
}
function moveBatchPin(kind: "row" | "col", index: number, delta: number) {
const list = kind === "row" ? batchRows.value : batchCols.value;
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= list.length) return;
[list[index], list[nextIndex]] = [list[nextIndex]!, list[index]!];
}
function batchWire() {
if (!batchKeyIds.value.length || !batchRows.value.length || !batchCols.value.length) return;
// Matrix assignment follows the visible PHY key order, regardless of click order.
const orderedKeys = keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id));
try {
props.dispatch("一括配線", (p) => ({
...p,
elc: wireMatrixKeys(
p.elc,
orderedKeys,
batchRows.value,
batchCols.value,
batchDiode.value || undefined,
),
}));
batchError.value = "";
batchPreviewOpen.value = false;
} catch (e) {
batchError.value = e instanceof Error ? e.message : String(e);
}
}
const batchOrderedKeys = computed(() =>
keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id)),
);
const batchCapacityError = computed(
() => batchOrderedKeys.value.length > batchRows.value.length * batchCols.value.length,
);
const batchPreview = computed(() =>
batchOrderedKeys.value.map((key, i) => ({
key,
row: batchRows.value[Math.floor(i / batchCols.value.length)],
col: batchCols.value[i % batchCols.value.length],
overwrite: wired.value.has(key),
})),
);
const ledCandidate = ref("");
function addLedCandidate() {
if (
chain.value &&
nodes.value.some((n) => n.id === ledCandidate.value) &&
!chain.value.order.includes(ledCandidate.value)
)
setOrder([...chain.value.order, ledCandidate.value]);
}
</script>
<template>
<section class="elc-panel">
<h2>Wiring Editor</h2>
<div class="elc-grid">
<section class="box">
<h3>ピン提供者</h3>
<ul>
<li v-for="p in providerRows" :key="p.id">
<button @click="editProvider(p.id)">
{{ p.id }} · {{ p.kind }} · {{ p.kind === "mcu" ? p.part : `${p.channels} channels` }}
</button>
<button @click="removeProviderUi(p.id)">削除</button>
<div v-if="p.kind === 'analog-mux'">
select {{ p.select.join(", ") }} / common {{ p.common
}}<span v-if="p.enable"> / enable {{ p.enable }}</span> (データ保持のみ)
</div>
</li>
</ul>
<label>ID <input v-model="providerId" data-testid="provider-id" /></label
><label
>MCU品番 <input v-model="providerPart" list="mcu-parts" /><datalist id="mcu-parts">
<option>RP2040</option>
</datalist></label
><button @click="addMcu">MCUを追加・更新</button>
<p v-if="providerCandidates.length">候補: {{ providerCandidates.join(", ") }}</p>
<p v-else>未知の品番はピン名を自由入力します。</p>
<hr />
<h4>analog-mux (保持のみ)</h4>
<label>ID <input v-model="mux.id" /></label
><label>channels <input type="number" min="1" v-model.number="mux.channels" /></label
><label>select (カンマ区切り) <input v-model="mux.select" /></label
><label>common <input v-model="mux.common" /></label
><label>enable <input v-model="mux.enable" /></label
><button @click="addMux">analog-muxを追加・更新</button>
<p v-if="providerError" class="error-text">{{ providerError }}</p>
</section>
<section class="box wiring-box">
<h3>選択構成のキー配線</h3>
<p>
未配線: <b data-testid="unwired-count">{{ unwired.length }}</b> / {{ keys.length }}
</p>
<KeyboardView
:phy="project.phy"
:selection="variants"
:selected-ids="selectedKeyId ? [selectedKeyId] : []"
:key-class="keyClass"
:key-text="
(id) => {
const w = wired.get(id);
return w?.detect === 'contact'
? `${w.terminals.a.split('.').at(-1)}/${w.terminals.b.split('.').at(-1)}`
: w
? 'analog'
: id.slice(-3);
}
"
@key-click="(id) => clickKey(id)"
/>
<p>
色: <span class="warn-text">黄 未配線</span> · <span class="error-text">赤 衝突</span> · 緑 配線済み
</p>
<div v-if="selectedKey" class="edit-form">
<h4>{{ selectedKey.id }} · 接点端子</h4>
<label
>a
<select v-model="wiring.a">
<option value="">選択…</option>
<option v-for="pin in pinRefs" :key="`a${pin}`" :value="pin">{{ pin }}</option></select
><input v-model="wiring.a" aria-label="a端子自由入力" placeholder="自由入力: provider.pin"
/></label>
<label
>b
<select v-model="wiring.b">
<option value="">選択…</option>
<option v-for="pin in pinRefs" :key="`b${pin}`" :value="pin">{{ pin }}</option></select
><input v-model="wiring.b" aria-label="b端子自由入力" placeholder="自由入力: provider.pin"
/></label>
<label
>ダイオード
<select v-model="wiring.diode">
<option value="">なし</option>
<option value="a-to-b">a-to-b</option>
<option value="b-to-a">b-to-a</option>
</select></label
><button @click="commitWiring">配線を保存</button><button @click="removeWiring">配線解除</button>
</div>
<p v-else>キーをクリックして端子を編集します。</p>
<h4>未配線・衝突</h4>
<p>未配線 ({{ unwired.length }}): {{ unwired.join(", ") || "なし" }}</p>
<ul>
<li
v-for="issue in validateProject(project).filter((x) => x.code === 'wiring-collision')"
:key="issue.ids?.join('-')"
>
{{ issue.code }}: {{ issue.ids?.join(" ↔ ") }}
</li>
</ul>
<hr />
<h4>一括配線</h4>
<p>
キーを選び、row/colピンの順序を指定して配線します。選択キーにはPHY表示順でrowを進め、各row内でcolを進めます。
</p>
<KeyboardView
:phy="project.phy"
:selection="variants"
:selected-ids="batchKeyIds"
:key-text="(id) => id.slice(-3)"
@key-click="(id) => toggleBatchKey(id)"
/>
<p>選択キー: {{ batchKeyIds.length }}</p>
<div class="batch-controls">
<label
>rowピン<select v-model="batchRow" data-testid="batch-row-select">
<option value="">選択…</option>
<option
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
:key="`br-${pin}`"
:value="pin"
>
{{ pin }}
</option>
</select></label
>
<button
@click="
if (batchRow && !batchRows.includes(batchRow)) batchRows.push(batchRow);
batchRow = '';
"
>
rowへ追加
</button>
<ol>
<li v-for="(pin, i) in batchRows" :key="`br-${i}-${pin}`">
{{ i }}: {{ pin }} <button @click="moveBatchPin('row', i, -1)">↑</button
><button @click="moveBatchPin('row', i, 1)">↓</button
><button @click="batchRows.splice(i, 1)">除去</button>
</li>
</ol>
<label
>colピン<select v-model="batchCol" data-testid="batch-col-select">
<option value="">選択…</option>
<option
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
:key="`bc-${pin}`"
:value="pin"
>
{{ pin }}
</option>
</select></label
>
<button
@click="
if (batchCol && !batchCols.includes(batchCol)) batchCols.push(batchCol);
batchCol = '';
"
>
colへ追加
</button>
<ol>
<li v-for="(pin, i) in batchCols" :key="`bc-${i}-${pin}`">
{{ i }}: {{ pin }} <button @click="moveBatchPin('col', i, -1)">↑</button
><button @click="moveBatchPin('col', i, 1)">↓</button
><button @click="batchCols.splice(i, 1)">除去</button>
</li>
</ol>
<label
>ダイオード<select v-model="batchDiode">
<option value="">なし</option>
<option value="a-to-b">a-to-b</option>
<option value="b-to-a">b-to-a</option>
</select></label
>
<button
data-testid="batch-wire"
:disabled="!batchKeyIds.length || !batchRows.length || !batchCols.length"
@click="
batchPreviewOpen = true;
batchError = '';
"
>
割当プレビュー
</button>
<p v-if="batchCapacityError" role="alert" data-testid="batch-capacity-error">
容量不足: {{ batchOrderedKeys.length }} keys / {{ batchRows.length * batchCols.length }} positions
</p>
<p v-if="batchError" role="alert">{{ batchError }}</p>
<section v-if="batchPreviewOpen" data-testid="batch-preview">
<table>
<thead>
<tr>
<th>キー</th>
<th>rowピン</th>
<th>colピン</th>
<th>既存配線</th>
</tr>
</thead>
<tbody>
<tr v-for="item in batchPreview" :key="item.key">
<td>{{ item.key }}</td>
<td>{{ item.row }}</td>
<td>{{ item.col }}</td>
<td>{{ item.overwrite ? "上書き" : "新規" }}</td>
</tr>
</tbody>
</table>
<button data-testid="batch-confirm" :disabled="batchCapacityError" @click="batchWire">
この内容で確定
</button>
<button @click="batchPreviewOpen = false">キャンセル</button>
</section>
</div>
</section>
<section class="box">
<h3>LEDチェーン</h3>
<label>チェーンID <input v-model="chainId" /></label
><label>データピン <input v-model="chainData" list="pin-options" placeholder="provider.pin" /></label
><datalist id="pin-options">
<option v-for="pin in pinRefs" :key="pin">{{ pin }}</option></datalist
><button @click="makeChain">作成</button>
<label
>編集するチェーン
<select v-model="selectedChainId">
<option value="">選択…</option>
<option v-for="c in chains" :key="c.id" :value="c.id">{{ c.id }}</option>
</select></label
>
<p>対象は全バリアントのper-key LEDキーと@led部品ノードです。</p>
<label
>追加するLED
<select v-model="ledCandidate" data-testid="led-candidate">
<option value="">選択…</option>
<option v-for="n in nodes" :key="n.id" :value="n.id">{{ n.label }}</option>
</select></label
>
<button data-testid="led-add" :disabled="!ledCandidate || !chain" @click="addLedCandidate">
チェーン末尾へ追加
</button>
<KeyboardView
v-if="chain"
:phy="project.phy"
:selection="variants"
:selected-ids="chain.order"
:key-text="(id) => String(chain!.order.indexOf(id) + 1 || '')"
@key-click="
(id) => {
if (nodes.some((n) => n.id === id) && !chain!.order.includes(id))
setOrder([...chain!.order, id]);
}
"
/>
<ol>
<li v-for="(id, i) in chain?.order ?? []" :key="`${id}-${i}`">
{{ i }} · {{ id }} <button @click="moveLed(i, -1)">↑</button
><button @click="moveLed(i, 1)">↓</button><button @click="removeLed(i)">除去</button>
</li>
</ol>
</section>
</div>
</section>
</template>
<style scoped>
.elc-panel {
height: 100%;
overflow: auto;
padding: 12px;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.elc-grid {
display: grid;
grid-template-columns: minmax(230px, 1fr) minmax(320px, 1.5fr) minmax(230px, 1fr);
gap: 10px;
}
.box {
padding: 10px;
border: 1px solid #d2d7df;
border-radius: 5px;
overflow: auto;
}
.box label {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0;
}
.box input,
.box select {
max-width: 170px;
}
.wiring-box {
min-width: 0;
}
.edit-form {
border-top: 1px solid #ddd;
padding-top: 6px;
}
.edit-form label {
flex-wrap: wrap;
}
.batch-controls {
padding: 8px;
background: #f6f8fa;
border-radius: 4px;
}
.batch-controls ol {
margin-top: 4px;
}
.warn-text {
color: #985c00;
}
.error-text {
color: #b42318;
}
.warn :deep(rect) {
fill: #fde68a;
}
.error :deep(rect) {
fill: #fecaca;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
import type { Node, VariantGroupNode } from "../../domain/phy";
import { flattenKeys } from "../../domain/geometry";
import { mcuPinCandidates } from "../../domain/elc";
import { generateMockTarget } from "../../domain/generator";
import type { Dispatch } from "../types";
import KeyboardView from "../common/KeyboardView.vue";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
const selection = computed(() => ({
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
}));
const groups = computed(() => {
const all: VariantGroupNode[] = [];
const visit = (ns: Node[]) =>
ns.forEach((n) => {
if (n.type === "cluster") visit(n.children);
else if (n.type === "variantGroup") {
all.push(n);
n.children.forEach((v) => visit(v.children));
}
});
visit(props.project.phy.root);
return all;
});
const pins = computed(() => [
...new Set(
props.project.elc.providers.flatMap((p) =>
p.kind === "mcu"
? mcuPinCandidates(p).map((x) => `${p.id}.${x}`)
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
),
),
]);
const rowAdd = ref(""),
colAdd = ref("");
const result = ref<ReturnType<typeof generateMockTarget> | null>(null);
watch(
() => props.project,
() => {
result.value = null;
},
);
const rows = computed(() => props.project.output.matrix?.rowPins ?? []),
cols = computed(() => props.project.output.matrix?.colPins ?? []);
const flatKeys = computed(() => flattenKeys(props.project.phy, selection.value).filter((x) => !x.key.decal));
const mappingById = computed(() => new Map((result.value?.target?.mapping ?? []).map((m) => [m.keyId, m])));
function setVariant(groupId: string, variantId: string) {
props.dispatch("出力構成", (p) => ({
...p,
output: { ...p.output, variantSelection: { ...selection.value, [groupId]: variantId } },
}));
result.value = null;
}
function setPins(kind: "rowPins" | "colPins", value: string[]) {
props.dispatch(kind === "rowPins" ? "rowピン順序" : "colピン順序", (p) => ({
...p,
output: {
...p.output,
matrix: {
...p.output.matrix,
kind: "matrix",
rowPins: [...(p.output.matrix?.rowPins ?? [])],
colPins: [...(p.output.matrix?.colPins ?? [])],
[kind]: value,
},
},
}));
result.value = null;
}
function addPin(kind: "rowPins" | "colPins", pin: string) {
if (!pin) return;
const current = kind === "rowPins" ? rows.value : cols.value;
if (current.includes(pin)) return;
setPins(kind, [...current, pin]);
if (kind === "rowPins") rowAdd.value = "";
else colAdd.value = "";
}
function movePin(kind: "rowPins" | "colPins", index: number, d: number) {
const next = [...(kind === "rowPins" ? rows.value : cols.value)],
to = index + d;
if (to < 0 || to >= next.length) return;
[next[index], next[to]] = [next[to]!, next[index]!];
setPins(kind, next);
}
function removePin(kind: "rowPins" | "colPins", index: number) {
const next = [...(kind === "rowPins" ? rows.value : cols.value)];
next.splice(index, 1);
setPins(kind, next);
}
function generate() {
result.value = generateMockTarget(props.project);
}
function download() {
if (!result.value?.target) return;
const blob = new Blob([JSON.stringify(result.value.target, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mock-target.json";
a.click();
URL.revokeObjectURL(url);
}
function keyText(id: string) {
const m = mappingById.value.get(id);
return m ? `${m.number}\nr${m.row} c${m.col}` : id.slice(-3);
}
function keyClass(id: string) {
return mappingById.value.has(id) ? "ok" : "muted";
}
</script>
<template>
<section class="generator-panel">
<h2>Generator · プレビュー</h2>
<p class="notice">
これは検証用の <code>mock-target.json</code> です。QMKにそのまま渡せる生成物ではありません。
</p>
<div class="settings">
<section class="box">
<h3>出力構成</h3>
<label v-for="g in groups" :key="g.id"
>{{ g.name }}
<select
:value="selection[g.id] ?? g.default"
@change="setVariant(g.id, ($event.target as HTMLSelectElement).value)"
>
<option v-for="v in g.children" :key="v.id" :value="v.id">{{ v.name }}</option>
</select></label
>
<p v-if="!groups.length">バリアントグループなし</p>
</section>
<section class="box">
<h3>Matrix row / col</h3>
<p>ELCで使うピン候補から順序を設定します。番号はリスト順です。</p>
<h4>rowPins</h4>
<ol>
<li v-for="(pin, i) in rows" :key="`${pin}-${i}`">
{{ i }}: {{ pin }} <button @click="movePin('rowPins', i, -1)">↑</button
><button @click="movePin('rowPins', i, 1)">↓</button
><button @click="removePin('rowPins', i)">削除</button>
</li>
</ol>
<select v-model="rowAdd" data-testid="row-pin-select">
<option value="">ピンを選択…</option>
<option v-for="pin in pins" :key="`r${pin}`">{{ pin }}</option></select
><button @click="addPin('rowPins', rowAdd)">row追加</button>
<h4>colPins</h4>
<ol>
<li v-for="(pin, i) in cols" :key="`${pin}-${i}`">
{{ i }}: {{ pin }} <button @click="movePin('colPins', i, -1)">↑</button
><button @click="movePin('colPins', i, 1)">↓</button
><button @click="removePin('colPins', i)">削除</button>
</li>
</ol>
<select v-model="colAdd" data-testid="col-pin-select">
<option value="">ピンを選択…</option>
<option v-for="pin in pins" :key="`c${pin}`">{{ pin }}</option></select
><button @click="addPin('colPins', colAdd)">col追加</button>
<p v-if="!pins.length">ELCのピン提供者を先に追加してください。</p>
</section>
<section class="box preview">
<h3>生成結果</h3>
<button data-testid="generate-button" @click="generate">生成</button
><span v-if="result?.target" data-testid="mapping-count"> {{ result.target.mapping.length }}行</span>
<div v-if="result && !result.target" class="errors">
<h4>生成を止めた理由 ({{ result.errors.length }})</h4>
<ul>
<li v-for="(e, i) in result.errors" :key="`${e.code}-${i}`">
<b>{{ e.code }}</b> {{ e.message }} <small>{{ e.ids?.join(", ") }}</small>
</li>
</ul>
</div>
<template v-if="result?.target"
><button data-testid="download-target" @click="download">mock-target.jsonをダウンロード</button>
<table>
<thead>
<tr>
<th>出力番号</th>
<th>keyId</th>
<th>row</th>
<th>col</th>
<th>ラベル</th>
</tr>
</thead>
<tbody>
<tr v-for="m in result.target.mapping" :key="m.keyId">
<td>{{ m.number }}</td>
<td>{{ m.keyId }}</td>
<td>{{ m.row }}</td>
<td>{{ m.col }}</td>
<td>{{ m.label }}</td>
</tr>
</tbody>
</table>
<h4>レイヤー配列</h4>
<section v-for="layer in result.target.keymaps" :key="layer.name">
<b>{{ layer.name }}</b
><code class="array">{{ JSON.stringify(layer.keycodes) }}</code>
</section>
</template>
</section>
</div>
<section class="layout-box">
<h3>選択構成 · 番号 / row,col</h3>
<KeyboardView :phy="project.phy" :selection="selection" :key-text="keyText" :key-class="keyClass" />
</section>
</section>
</template>
<style scoped>
.generator-panel {
height: 100%;
overflow: auto;
padding: 12px;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.notice {
padding: 8px;
background: #fff4d6;
}
.settings {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(380px, 1.5fr);
gap: 10px;
}
.box,
.layout-box {
padding: 10px;
border: 1px solid #d2d7df;
border-radius: 5px;
overflow: auto;
}
.box label {
display: flex;
gap: 8px;
align-items: center;
margin: 8px 0;
}
.box h3,
.layout-box h3 {
margin-top: 0;
}
.preview table {
border-collapse: collapse;
width: 100%;
margin-top: 8px;
}
.preview th,
.preview td {
border: 1px solid #d8dde5;
padding: 3px 5px;
text-align: left;
}
.errors {
color: #9b1c1c;
}
.array {
display: block;
overflow-wrap: anywhere;
margin: 5px 0 10px;
}
.layout-box {
margin-top: 10px;
}
.layout-box :deep(.kv-key text) {
font-size: 0.16px;
white-space: pre;
}
</style>
+254
View File
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { flattenKeys } from "../../domain/geometry";
import type { Node, VariantGroupNode } from "../../domain/phy";
import { appendKeys, excludeKey, reorderKeys, unregisteredKeys } from "../../domain/idx";
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
import type { Dispatch } from "../types";
import KeyboardView from "../common/KeyboardView.vue";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
const selectedKey = ref("");
const selection = computed(() => ({
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
}));
const groups = computed(() => {
const out: VariantGroupNode[] = [];
const walk = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children);
if (n.type === "variantGroup") {
out.push(n);
n.children.forEach((v) => walk(v.children));
}
});
walk(props.project.phy.root);
return out;
});
type Entry = { id: string; label: string; path: string; pathIds: string[] };
const entries = computed<Entry[]>(() => {
const out: Entry[] = [];
const walk = (nodes: Node[], path: string[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children, [...path, n.name || n.id]);
else if (n.type === "key") {
if (!n.decal)
out.push({
id: n.id,
label: n.labels?.find(Boolean) || "(ラベルなし)",
path: path.join(" / ") || "ルート",
pathIds: path,
});
} else if (n.type === "variantGroup") {
n.children.forEach((v) => walk(v.children, [...path, `${n.name} / ${v.name}`]));
}
});
walk(props.project.phy.root, []);
return out;
});
const byId = computed(() => new Map(entries.value.map((e) => [e.id, e])));
const unregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
const displayKeys = computed(() =>
flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal),
);
const outputNumbers = computed(() => {
const active = new Set(displayKeys.value.map((k) => k.key.id));
return new Map(props.project.idx.order.filter((id) => active.has(id)).map((id, index) => [id, index]));
});
const rowEntries = computed(() =>
props.project.idx.order.map(
(id) => byId.value.get(id) ?? { id, label: "(PHYに存在しないキー)", path: "不明", pathIds: [] },
),
);
function setVariant(groupId: string, variantId: string) {
props.dispatch("IDX出力構成変更", (p) => ({
...p,
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [groupId]: variantId } },
}));
}
function register(id: string) {
props.dispatch("IDXキー登録", (p) => ({ ...p, idx: appendKeys(p.idx, [id]) }));
}
function exclude(id: string) {
props.dispatch("IDXキー除外", (p) => ({ ...p, idx: excludeKey(p.idx, id) }));
}
function move(id: string, delta: number) {
const i = props.project.idx.order.indexOf(id),
j = i + delta;
if (j < 0 || j >= props.project.idx.order.length) return;
const order = [...props.project.idx.order];
[order[i], order[j]] = [order[j]!, order[i]!];
props.dispatch("IDX順序変更", (p) => ({ ...p, idx: reorderKeys(p.idx, order) }));
}
function registerAll() {
props.dispatch("IDX未登録キーを登録", (p) => ({
...p,
idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)),
}));
}
</script>
<template>
<section class="idx-panel">
<h2>Key Indexer</h2>
<div class="idx-layout">
<div class="idx-list">
<div class="idx-head">
<h3>IDX order</h3>
<button data-testid="idx-register-all" :disabled="!unregistered.size" @click="registerAll">
未登録をすべて登録
</button>
</div>
<p>選択構成での出力番号をプレビューします。</p>
<ol>
<li
v-for="(entry, i) in rowEntries"
:key="entry.id"
:data-testid="`idx-row-${entry.id}`"
:class="{ chosen: selectedKey === entry.id }"
>
<span class="idx-number" :aria-label="`保存順 ${i}`">保存順 {{ i }}</span
><b>{{ entry.id }}</b
><span>{{ entry.label }}</span
><small>{{ entry.path }}</small
><span>{{
outputNumbers.has(entry.id) ? `出力 ${outputNumbers.get(entry.id)}` : "出力対象外"
}}</span>
<button :aria-label="`${entry.id}を上へ`" :disabled="i === 0" @click="move(entry.id, -1)">
↑
</button>
<button
:aria-label="`${entry.id}を下へ`"
:disabled="i === rowEntries.length - 1"
@click="move(entry.id, 1)"
>
↓
</button>
<button :aria-label="`${entry.id}を除外`" @click="exclude(entry.id)">除外</button>
</li>
</ol>
<h3>全バリアントのキー</h3>
<ul class="idx-all-keys">
<li
v-for="entry in entries"
:key="entry.id"
:data-testid="`idx-key-${entry.id}`"
:class="{ chosen: selectedKey === entry.id, unregistered: unregistered.has(entry.id) }"
>
<b>{{ entry.id }}</b> · {{ entry.label }} <small>— {{ entry.path }}</small>
<strong v-if="unregistered.has(entry.id)">未登録</strong>
<span v-else-if="outputNumbers.has(entry.id)" class="idx-preview"
>出力 {{ outputNumbers.get(entry.id) }}</span
>
<span v-else class="idx-preview muted">選択構成外</span>
<button v-if="unregistered.has(entry.id)" @click="register(entry.id)">登録</button>
</li>
</ul>
</div>
<aside class="idx-preview-pane">
<h3>出力構成</h3>
<label v-for="group in groups" :key="group.id"
>{{ group.name }}
<select
:aria-label="`${group.name} 出力バリアント`"
:value="selection[group.id] ?? group.default"
@change="setVariant(group.id, ($event.target as HTMLSelectElement).value)"
>
<option v-for="variant in group.children" :key="variant.id" :value="variant.id">
{{ variant.name }}
</option>
</select>
</label>
<KeyboardView
:phy="project.phy"
:selection="selection"
:selected-ids="selectedKey ? [selectedKey] : []"
:key-text="(id) => (outputNumbers.has(id) ? String(outputNumbers.get(id)) : '')"
@key-click="(id) => (selectedKey = id)"
/>
</aside>
</div>
</section>
</template>
<style scoped>
.idx-panel {
height: 100%;
overflow: auto;
padding: 10px;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.idx-layout {
display: grid;
grid-template-columns: minmax(440px, 1.2fr) minmax(320px, 1fr);
gap: 18px;
}
.idx-head {
display: flex;
align-items: center;
gap: 12px;
}
.idx-panel h2 {
margin: 0 0 10px;
}
.idx-panel h3 {
margin: 12px 0 6px;
}
.idx-panel ol,
.idx-all-keys {
padding-left: 24px;
}
.idx-panel li {
padding: 5px;
margin: 3px 0;
}
.idx-panel ol li {
display: grid;
grid-template-columns: 86px minmax(95px, auto) 1fr 1fr auto auto auto auto;
gap: 5px;
align-items: center;
}
.idx-panel small {
color: #586273;
}
.idx-number {
font-weight: bold;
}
.idx-all-keys {
list-style: none;
padding: 0;
}
.idx-all-keys li {
border-bottom: 1px solid #e3e6eb;
}
.idx-panel .chosen {
background: #dce9ff;
}
.idx-all-keys .unregistered {
background: #fff1d6;
}
.idx-all-keys strong {
color: #9a4c00;
margin: 0 8px;
}
.idx-preview-pane {
position: sticky;
top: 0;
align-self: start;
}
.idx-preview-pane label {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.idx-preview {
font-size: 12px;
color: #1769d2;
}
.muted {
color: #778;
}
</style>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { flattenKeys } from "../../domain/geometry";
import {
assignKey,
deleteLayer,
isEditableKeycode,
parseKeycode,
reorderLayer,
renameLayer,
unassignKey,
unassignedKeys,
addLayer,
} from "../../domain/map";
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
import { getKeycodeLabel } from "../../domain/keycodeLabels";
import { qmkBasicKeycodes } from "../../domain/qmkBasicKeycodes";
import type { Dispatch } from "../types";
import KeyboardView from "../common/KeyboardView.vue";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch; editable?: boolean }>();
const selectedLayer = ref("Base"),
selectedKey = ref(""),
draft = ref(""),
newLayerName = ref(""),
renameValue = ref(""),
error = ref("");
const selection = computed(() => ({
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
}));
const keys = computed(() => flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal));
const layer = computed(
() => props.project.map.layers.find((x) => x.name === selectedLayer.value) ?? props.project.map.layers[0],
);
const keyLabels = computed(
() => new Map(keys.value.map((x) => [x.key.id, x.key.labels?.find(Boolean) || x.key.id])),
);
const assignments = computed(() => new Map((layer.value?.keymap ?? []).map((a) => [a.keyId, a.keycode])));
const selectedCode = computed(() => assignments.value.get(selectedKey.value) ?? "");
const selectedCodeEditable = computed(() => !selectedCode.value || isEditableKeycode(selectedCode.value));
const missing = computed(() =>
unassignedKeys(
props.project.map,
layer.value?.name ?? "",
keys.value.map((x) => x.key.id),
),
);
const candidates = computed(() => {
const keycodes = qmkBasicKeycodes
.filter((entry) => entry.group === "basic" || entry.group === "modifiers" || entry.group === "internal")
.map((entry) => entry.key);
const modifierFunctions = ["LCTL", "LSFT", "LALT", "LGUI", "RCTL", "RSFT", "RALT", "RGUI"];
const out = [...keycodes];
for (const l of props.project.map.layers)
for (const code of ["MO", "TG", "TO", "TT", "DF", "OSL"]) out.push(`${code}(${l.name})`);
for (const l of props.project.map.layers) for (const kc of keycodes) out.push(`LT(${l.name}, ${kc})`);
for (const mod of modifierFunctions) for (const kc of keycodes) out.push(`${mod}(${kc})`);
return [...new Set(out)];
});
function fail(e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
function pickLayer(name: string) {
selectedLayer.value = name;
selectedKey.value = "";
error.value = "";
}
function chooseKey(id: string) {
selectedKey.value = id;
draft.value = assignments.value.get(id) ?? "";
error.value = "";
}
function saveAssignment() {
if (props.editable === false || !selectedKey.value || !layer.value || !selectedCodeEditable.value) return;
if (draft.value.trim() === selectedCode.value) return;
try {
props.dispatch("MAPキー割り当て", (p) => ({
...p,
map: assignKey(p.map, layer.value!.name, selectedKey.value, draft.value.trim()),
}));
error.value = "";
} catch (e) {
fail(e);
}
}
function add() {
if (props.editable === false) return;
if (!newLayerName.value.trim()) {
error.value = "レイヤー名を入力してください。";
return;
}
try {
props.dispatch("MAPレイヤー追加", (p) => ({ ...p, map: addLayer(p.map, newLayerName.value.trim()) }));
selectedLayer.value = newLayerName.value.trim();
newLayerName.value = "";
error.value = "";
} catch (e) {
fail(e);
}
}
function rename() {
if (props.editable === false) return;
if (!layer.value) return;
if (!renameValue.value.trim()) {
error.value = "新しいレイヤー名を入力してください。";
return;
}
try {
props.dispatch("MAPレイヤー改名", (p) => ({
...p,
map: renameLayer(p.map, layer.value!.name, renameValue.value.trim()),
}));
selectedLayer.value = renameValue.value.trim();
renameValue.value = "";
error.value = "";
} catch (e) {
fail(e);
}
}
function remove() {
if (props.editable === false) return;
if (!layer.value) return;
try {
props.dispatch("MAPレイヤー削除", (p) => ({ ...p, map: deleteLayer(p.map, layer.value!.name) }));
selectedLayer.value = props.project.map.layers[0]?.name ?? "";
error.value = "";
} catch (e) {
fail(e);
}
}
function move(delta: number) {
if (props.editable === false) return;
if (!layer.value) return;
const i = props.project.map.layers.findIndex((x) => x.name === layer.value!.name),
j = i + delta;
if (j < 0 || j >= props.project.map.layers.length) return;
props.dispatch("MAPレイヤー順序変更", (p) => ({ ...p, map: reorderLayer(p.map, layer.value!.name, j) }));
}
function unassign() {
if (props.editable === false || !layer.value || !selectedKey.value || !selectedCodeEditable.value) return;
props.dispatch("MAP割り当て解除", (p) => ({
...p,
map: unassignKey(p.map, layer.value!.name, selectedKey.value),
}));
draft.value = "";
error.value = "";
}
function keyText(id: string) {
const code = assignments.value.get(id);
if (!code) return "";
const parsed = parseKeycode(code);
const label = parsed.kind === "basic" ? getKeycodeLabel(code) : undefined;
return label ? `${code}\n${label}` : code;
}
function keyClass(id: string) {
const code = assignments.value.get(id);
if (!code) return "warn";
return isEditableKeycode(code) ? undefined : "muted";
}
</script>
<template>
<section class="map-panel">
<h2>Keymap Designer</h2>
<div class="map-columns">
<aside class="map-layers">
<h3>レイヤー</h3>
<div class="layer-tabs" role="tablist">
<button
v-for="(item, i) in project.map.layers"
:key="item.name"
role="tab"
:aria-selected="selectedLayer === item.name"
:class="{ active: selectedLayer === item.name }"
@click="pickLayer(item.name)"
>
{{ item.name }}
</button>
</div>
<label
>追加する名前
<input
v-model="newLayerName"
aria-label="追加するレイヤー名"
:disabled="editable === false" /></label
><button data-testid="map-add-layer" :disabled="editable === false" @click="add">追加</button>
<label
>新しい名前
<input
v-model="renameValue"
aria-label="新しい名前"
:placeholder="layer?.name"
:disabled="editable === false" /></label
><button data-testid="map-rename-layer" :disabled="editable === false" @click="rename">改名</button>
<div>
<button
aria-label="レイヤーを上へ"
:disabled="editable === false || !layer || project.map.layers[0]?.name === layer.name"
@click="move(-1)"
>
↑</button
><button
aria-label="レイヤーを下へ"
:disabled="editable === false || !layer || project.map.layers.at(-1)?.name === layer.name"
@click="move(1)"
>
↓</button
><button data-testid="map-delete-layer" :disabled="editable === false" @click="remove">削除</button>
</div>
<p v-if="error" role="alert" class="map-error">{{ error }}</p>
</aside>
<main class="map-editor">
<h3>{{ layer?.name }} · キーマップ</h3>
<KeyboardView
:phy="project.phy"
:selection="selection"
:selected-ids="selectedKey ? [selectedKey] : []"
:key-text="keyText"
:key-class="keyClass"
@key-click="(id) => chooseKey(id)"
/>
<div v-if="selectedKey" class="map-assignment">
<strong>{{ keyLabels.get(selectedKey) }} · {{ selectedKey }}</strong>
<label
>キーコード
<input
v-model="draft"
list="map-keycode-candidates"
aria-label="キーコード"
@keydown.enter.prevent="saveAssignment"
:disabled="editable === false || !selectedCodeEditable"
/></label>
<datalist id="map-keycode-candidates">
<option v-for="value in candidates" :key="value" :value="value" />
</datalist>
<button @click="saveAssignment" :disabled="editable === false || !selectedCodeEditable">
割り当て</button
><button @click="unassign" :disabled="editable === false || !selectedCodeEditable">
割り当て解除
</button>
<p v-if="selectedCode && !selectedCodeEditable" role="status">
未知のキーコードは保持し、表示のみです。
</p>
<p v-if="draft && !isEditableKeycode(draft.trim())" class="map-error">
このキーコードは編集対象外です。
</p>
</div>
<section class="map-missing">
<h3>未割り当て · {{ missing.length }} keys</h3>
<ul>
<li v-for="id in missing" :key="id">
<button @click="chooseKey(id)">{{ keyLabels.get(id) }} · {{ id }}</button>
</li>
</ul>
</section>
</main>
</div>
</section>
</template>
<style scoped>
.map-panel {
height: 100%;
overflow: auto;
padding: 10px;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.map-panel h2 {
margin: 0 0 10px;
}
.map-columns {
display: grid;
grid-template-columns: 260px minmax(400px, 1fr);
gap: 16px;
}
.map-layers {
border-right: 1px solid #d5dae2;
padding-right: 12px;
}
.map-layers h3,
.map-editor h3 {
margin: 8px 0;
}
.map-layers label,
.map-assignment label {
display: flex;
gap: 6px;
margin: 9px 0;
align-items: center;
}
.map-layers input {
min-width: 0;
width: 120px;
}
.layer-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0;
}
.layer-tabs button.active {
background: #dce9ff;
}
.map-editor {
min-width: 0;
}
.map-assignment {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
padding: 8px;
background: #f5f7fa;
}
.map-assignment input {
width: 240px;
}
.map-missing ul {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 0;
list-style: none;
}
.map-error {
color: #a21d16;
background: #fff0ef;
padding: 6px;
}
.map-panel :deep(.keyboard-view) {
max-height: 48vh;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import { localBounds } from "./logic";
defineOptions({ name: "NodeSvg" });
const props = defineProps<{
nodes: Node[];
selected: string[];
variants: Record<string, string>;
cut: string[];
dim: boolean;
zoom: number;
angle: number;
longCandidate?: string | null;
}>();
const emit = defineEmits<{
down: [e: PointerEvent, n: Node, el: SVGGElement];
badge: [g: Node, e: PointerEvent];
focus: [id: string];
}>();
function bounds(n: Node) {
return n.type === "cluster"
? localBounds(n.children, props.variants)
: n.type === "variantGroup"
? localBounds(
n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [],
props.variants,
)
: { x: 0, y: 0, width: 0, height: 0 };
}
function badgeBox(n: Node) {
const b = bounds(n);
const size = 26 / props.zoom;
return {
x: b.x + b.width + 4 / props.zoom,
y: b.y - size - 4 / props.zoom,
size,
angle: -(props.angle + n.transform.rotation),
};
}
</script>
<template>
<g
v-for="n in nodes"
:key="n.id"
:data-node-type="n.type"
:data-node-id="n.id"
:transform="`translate(${n.transform.x} ${n.transform.y}) rotate(${n.transform.rotation})`"
:class="{ dim: dim, cut: cut.includes(n.id) }"
@dblclick.stop="emit('focus', n.id)"
>
<g
v-if="n.type === 'key'"
class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect
:width="n.size.w"
:height="n.size.h"
rx=".06"
class="key-face"
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }" /><text
class="key-label"
x=".08"
y=".24"
>{{ n.labels?.find(Boolean) }}</text
><rect
v-if="selected.includes(n.id)"
:width="n.size.w"
:height="n.size.h"
class="sel"
:class="{ candidate: longCandidate === n.id }"
/></g>
<g
v-else-if="n.type === 'component'"
class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect
v-if="n.partGroup === '@led'"
x="-.14"
y="-.14"
width=".28"
height=".28"
transform="rotate(45)"
class="part led"
:class="{ chosen: selected.includes(n.id) }"
/><g v-else-if="n.partGroup === '@encoder'" class="part" :class="{ chosen: selected.includes(n.id) }"
><circle r=".16" /><path d="M -.1 0 H .1 M 0 -.1 V .1" /></g
><path
v-else-if="n.partGroup === '@screw'"
d="M -.14 -.14 L .14 .14 M .14 -.14 L -.14 .14"
class="part screw"
:class="{ chosen: selected.includes(n.id) }"
/><rect
v-else-if="n.partGroup === '@oled'"
x="-.2"
y="-.12"
width=".4"
height=".24"
rx=".025"
class="part oled"
:class="{ chosen: selected.includes(n.id) }"
/><circle v-else r=".14" class="part" :class="{ chosen: selected.includes(n.id) }" /><text
x=".2"
y=".04"
class="part-label"
>{{ n.partGroup || "part" }}</text
></g
>
<g
v-else-if="n.type === 'cluster'"
class="group"
@pointerdown.self="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><NodeSvg
:nodes="n.children"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
>
<g v-else class="group"
><NodeSvg
v-for="v in n.children.filter((v) => v.id === (variants[n.id] || n.default))"
:key="v.id"
:nodes="v.children"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><g
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
class="badge"
@click.stop="emit('badge', n, $event)"
><rect
:x="badgeBox(n).x"
:y="badgeBox(n).y"
:width="badgeBox(n).size"
:height="badgeBox(n).size"
rx="3"
@click.stop="emit('badge', n, $event)"
/><text
:x="badgeBox(n).x + 3 / zoom"
:y="badgeBox(n).y + 16 / zoom"
:style="{ fontSize: `${12 / zoom}px` }"
>{{ n.children.find((v) => v.id === (variants[n.id] || n.default))?.name
}}{{ n.default === (variants[n.id] || n.default) ? " ✓" : "" }}</text
></g
></g
>
</g>
</template>
<style scoped>
.node {
cursor: grab;
}
.key-face {
fill: #f8cc78;
stroke: #5b4a2b;
stroke-width: 0.03;
}
.key-face.ghost {
opacity: 0.4;
}
.key-face.decal {
fill: #fff;
stroke-dasharray: 0.08 0.06;
}
.key-label,
.part-label,
.group-label {
font-size: 0.14px;
pointer-events: none;
}
.part {
fill: #b4d5ef;
stroke: #315d7a;
stroke-width: 0.03;
}
.part.chosen {
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.part.screw {
fill: none;
}
.part path {
fill: none;
stroke: #315d7a;
stroke-width: 0.03;
}
.sel,
.group-sel {
fill: none;
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.group-sel {
stroke-dasharray: 5 3;
}
.candidate {
stroke: #e87912;
stroke-width: 2.5px;
}
.group {
cursor: default;
}
.dim {
opacity: 0.25;
}
.cut {
opacity: 0.3;
}
.group-label {
fill: #222;
}
.badge {
cursor: pointer;
pointer-events: all;
}
.badge rect {
fill: #fff3c2;
stroke: #8a691b;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
.badge text {
font-size: 12px;
fill: #302709;
pointer-events: none;
}
</style>
+587
View File
@@ -0,0 +1,587 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import type { Dispatch } from "../types";
import { reframe } from "../../domain/phyCommands";
import { listDeleteReferences } from "../../domain/deleteCascade";
import PhyTree from "./PhyTree.vue";
import NodeSvg from "./NodeSvg.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
import { usePhyKeyboard } from "./usePhyKeyboard";
import { usePhyCommands } from "./usePhyCommands";
import { usePhyCanvasView } from "./usePhyCanvasView";
import { usePhyTreeModel } from "./usePhyTreeModel";
const props = defineProps<{
project: ProjectFile;
dispatch: Dispatch;
mismatch: boolean;
beginHistory: () => void;
commitHistory: () => void;
}>();
const emit = defineEmits<{ error: [text: string] }>();
const selected = ref<string[]>([]),
variants = ref<Record<string, string>>({}),
focus = ref<string | null>(null),
grid = ref<number>(0.25),
zoom = ref(52),
pan = ref({ x: 50, y: 50 }),
pane = ref<HTMLElement>(),
svg = ref<SVGSVGElement>(),
treeOpen = ref(true),
showHelp = ref(false),
popover = ref<string | null>(null),
cut = ref<string[]>([]),
confirm = ref<string | null>(null),
confirmIds = ref<string[]>([]),
confirmRefs = ref<ReturnType<typeof listDeleteReferences>>([]),
confirmPos = ref({ left: 12, top: 44 }),
popoverPos = ref({ left: 12, top: 44 }),
size = ref({ w: 900, h: 600 }),
longHint = ref("");
watch(
() => props.project,
(p) => {
for (const g of structuralNodes(p.phy.root))
if (g.type === "variantGroup" && !variants.value[g.id]) variants.value[g.id] = g.default;
},
{ immediate: true },
);
watch(
() => props.project.projectId,
async () => {
selected.value = [];
focus.value = null;
variants.value = {};
await nextTick();
fit();
},
);
const {
allNodes,
node,
parentId,
isAncestor,
selectedContainer,
groupForVariant,
parentLabel,
pressTargets,
} = usePhyTreeModel(() => props.project, focus);
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
project: () => props.project,
pane,
svg,
size,
zoom,
pan,
selected,
variants,
node,
});
function setSel(id: string, add = false) {
selected.value = normalizeSelection(
props.project.phy,
add
? selected.value.includes(id)
? selected.value.filter((x) => x !== id)
: [...selected.value, id]
: [id],
);
pane.value?.focus();
}
const canvasView = usePhyCanvasView({
project: () => props.project,
size,
pan,
zoom,
focus,
variants,
grid,
selected,
node,
parentId,
parentLabel,
selectedContainer,
});
const {
keys,
groupsAll,
outsideNodes,
gridLines,
variantGroups,
focusGridLines,
moveTargets,
parentMatrix: focusParentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode: prop,
} = canvasView;
function focusIn(id: string) {
focus.value = id;
selected.value = [id];
}
onMounted(() => {
window.addEventListener("keydown", keyHandler);
window.addEventListener("keyup", spaceUp);
window.addEventListener("paste", pasteEvent);
window.addEventListener("copy", copyEvent);
window.addEventListener("cut", copyEvent);
fit();
});
onUnmounted(() => {
window.removeEventListener("keydown", keyHandler);
window.removeEventListener("keyup", spaceUp);
window.removeEventListener("paste", pasteEvent);
window.removeEventListener("copy", copyEvent);
window.removeEventListener("cut", copyEvent);
});
function dispatch(label: string, fn: (p: ProjectFile) => ProjectFile) {
try {
props.dispatch(label, fn);
} catch (e) {
emit("error", String(e));
}
}
function update(id: string, fn: (n: Node) => void, label = "プロパティ変更") {
dispatch(label, (p) => {
const q = structuredClone(p);
const n = allNodes(q.phy.root).find((x) => x.id === id);
if (n) fn(n);
return q;
});
}
const commands = usePhyCommands({
project: () => props.project,
selected,
variants,
focus,
grid,
zoom,
pan,
size,
svg,
pane,
popover,
confirm,
confirmIds,
confirmRefs,
confirmPos,
node,
allNodes,
parentId,
selectedContainer,
groupForVariant,
variantGroups: () => variantGroups.value,
dispatch,
emitError: (message) => emit("error", message),
});
const {
parentMatrix,
nodesInContainer,
directChildren,
tabItem,
chooseVariant,
keyAdd,
partAdd,
makeCluster,
ungroup,
variantGroup,
duplicate,
moveSelectionTo,
askDelete,
deleteConfirmed,
setDefault,
addVariant: addVariantToGroup,
} = commands;
const { copyEvent, pasteEvent } = usePhyClipboard({
project: () => props.project,
mismatch: () => props.mismatch,
selected,
cut,
grid,
node,
selectedContainer,
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
parentMatrix,
dispatch,
setSelected: (ids) => (selected.value = ids),
});
const priorChild = new Map<string, string>();
function visibleIds(): Set<string> {
if (!focus.value) return new Set(allNodes().map((n) => n.id));
return visibleNodeIds(node(focus.value), variants.value);
}
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
const pointer = usePhyCanvasPointer({
project: () => props.project,
svg,
pane,
pan,
zoom,
grid,
selected,
focus,
rangeBox,
allNodes,
keys: () => keys.value,
localPoint,
visibleIds,
mismatch: () => props.mismatch,
isAncestor,
parentLabel,
pressTargets,
setSelection: setSel,
beginHistory: props.beginHistory,
commitHistory: props.commitHistory,
dispatch,
});
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
const keyHandler = usePhyKeyboard({
project: () => props.project,
mismatch: () => props.mismatch,
pane,
selected,
focus,
grid,
zoom,
showHelp,
popover,
confirm,
spaceHeld,
variants,
priorChild,
node,
allNodes,
parentId,
selectedContainer,
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
nodesInContainer,
directChildren,
tabItem,
dispatch,
fit,
fitSelected,
focusIn,
makeCluster,
ungroup,
duplicate,
keyAdd,
askDelete,
deleteConfirmed,
chooseVariant,
});
function overlayPoint(clientX: number, clientY: number, width = 380, height = 240) {
const r = pane.value?.getBoundingClientRect();
return {
left: Math.max(4, Math.min(clientX - (r?.left ?? 0), (r?.width ?? width) - width - 4)),
top: Math.max(4, Math.min(clientY - (r?.top ?? 0), (r?.height ?? height) - height - 4)),
};
}
function openBadge(g: Node, e: PointerEvent | MouseEvent) {
if (focus.value && !visibleIds().has(g.id)) return;
popover.value = g.id;
const r = (e.currentTarget as Element).getBoundingClientRect();
const paneRect = pane.value?.getBoundingClientRect();
const left = r.right + 210 < (paneRect?.right ?? Infinity) ? r.left : r.left - 210;
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
popoverPos.value = overlayPoint(left, top, 210, 150);
}
function onFocus(_id: string) {
if (
pointer.priorSelection().length === 1 &&
["cluster", "variantGroup"].includes(node(pointer.priorSelection()[0]!)?.type || "")
)
focusIn(pointer.priorSelection()[0]!);
}
</script>
<template>
<section class="workspace" :class="{ locked: mismatch }">
<aside v-if="treeOpen" class="tree pane">
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
</aside>
<section ref="pane" class="canvas-pane pane" tabindex="0">
<PhyToolbar
:mismatch="mismatch"
:selected-count="selected.length"
:grid="grid"
:move-targets="moveTargets"
:focus="focus"
:crumbs="focus ? focusCrumbs(focus) : []"
@key-add="keyAdd"
@part-add="partAdd"
@cluster="makeCluster"
@ungroup="ungroup"
@variant-group="variantGroup"
@move-to="moveSelectionTo"
@duplicate="duplicate"
@delete="(event) => askDelete(undefined, event)"
@grid="grid = nextGrid(grid, 1)"
@fit="fit"
@fit-selected="fitSelected"
@zoom="(direction) => (zoom *= direction > 0 ? 1.15 : 1 / 1.15)"
@tree="treeOpen = !treeOpen"
@help="showHelp = true"
@focus="focus = $event"
/>
<div
v-if="longLabel"
class="long-label"
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
>
{{ longLabel }}
</div>
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
<svg
ref="svg"
class="canvas"
:viewBox="`0 0 ${size.w} ${size.h}`"
@wheel.prevent="wheel"
@pointerdown="canvasDown"
>
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
<path
v-if="!focus"
v-for="(line, i) in gridLines"
:key="i"
:d="line.d"
:class="{ major: line.major }"
/>
<NodeSvg
v-if="focus && outsideNodes.length"
:nodes="outsideNodes"
:selected="[]"
:variants="variants"
:cut="cut"
:dim="true"
:zoom="zoom"
:angle="0"
/>
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
<g
:transform="`translate(${node(focus)!.transform.x} ${node(focus)!.transform.y}) rotate(${node(focus)!.transform.rotation})`"
>
<path v-for="(line, i) in focusGridLines" :key="i" :d="line.d" :class="{ major: line.major }" />
</g>
<NodeSvg
:nodes="[node(focus)!]"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="focusAngle(focus)"
@down="down"
@badge="openBadge"
@focus="onFocus"
/>
</g>
<NodeSvg
v-else
:nodes="project.phy.root"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="0"
@down="down"
@badge="openBadge"
@focus="onFocus"
/>
</g>
<rect
v-if="rangeBox"
class="range-box"
:x="rangeBox.x"
:y="rangeBox.y"
:width="rangeBox.width"
:height="rangeBox.height"
/>
</svg>
<div
v-if="confirm"
class="confirm-float"
:style="{ left: confirmPos.left + 'px', top: confirmPos.top + 'px' }"
>
<b>削除の確認</b>
<p>参照 {{ confirmRefs.length }}件</p>
<ul>
<li v-for="(r, i) in confirmRefs" :key="i">{{ r.layer }} · {{ r.id }} · {{ r.location }}</li>
</ul>
<button @click="deleteConfirmed">削除</button><button @click="confirm = null">取消</button>
</div>
<div
v-if="popover"
class="popover"
:style="{ left: popoverPos.left + 'px', top: popoverPos.top + 'px' }"
>
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
{{ v.name }} {{ g.default === v.id ? "✓" : "" }}
</button></template
>
</div>
<div v-if="showHelp" class="overlay" @click.self="showHelp = false">
<article>
<button @click="showHelp = false">閉じる</button>
<h2>ショートカット</h2>
<p>Esc 選択解除・階層を戻る · Alt+↑/↓ 親・子 · Tab/Shift+Tab 兄弟 · Ctrl+A 同じ親を選択</p>
<p>矢印 移動 · Shift+矢印 サイズ · PgUp/PgDn 回転 · R/Shift+R キーの向き</p>
<p>
Ctrl+G クラスター · Ctrl+Shift+G 解除 · Ctrl+C/X/V コピー・カット・貼り付け · Ctrl+D 複製 · Ins
追加 · Delete 削除
</p>
<p>
Shift+1 全体表示 · Shift+2 選択表示 · +/- ズーム · G/Shift+G 刻み · V/Shift+V バリアント · Enter
フォーカス · F2 プロパティ
</p>
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
</article>
</div>
</section>
<PhyProperties
:selected-count="selected.length"
:selected-id="selected[0] ?? null"
:item="prop ?? null"
:mismatch="mismatch"
@update="update"
@apply-frame="(id, frame) => dispatch('座標枠変更', (p) => ({ ...p, phy: reframe(p.phy, id, frame) }))"
@set-default="(groupId, variantId) => setDefault(groupId, variantId)"
@change-variant="chooseVariant"
@delete-node="(id) => askDelete(id)"
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
/>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.canvas-pane {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
background: #fbfcfe;
touch-action: none;
}
.canvas path {
stroke: #e6eaf0;
stroke-width: 0.015;
fill: none;
}
.canvas path.major {
stroke: #ccd3de;
stroke-width: 0.03;
}
.range-box {
fill: #4b8de822;
stroke: #1769d2;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.properties fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 110px;
margin-left: auto;
}
.popover,
.confirm-float,
.overlay {
position: absolute;
z-index: 5;
background: #fff;
padding: 12px;
border: 1px solid #99a3b2;
box-shadow: 0 4px 15px #0003;
}
.confirm-float {
position: absolute;
z-index: 6;
width: min(380px, calc(100% - 16px));
max-height: min(240px, calc(100% - 16px));
overflow: auto;
font-size: 14px;
color: #20242a;
}
.confirm-float ul {
max-height: 120px;
overflow: auto;
padding-left: 22px;
}
.overlay {
inset: 20%;
}
.tree-list {
list-style: none;
padding-left: 10px;
}
.confirm-float rect {
fill: #fff2dc;
stroke: #db9d39;
}
.confirm-float foreignObject div {
font: 1.5px sans-serif;
}
.confirm-float ul {
max-height: 1px;
overflow: auto;
}
.workspace.locked .properties {
pointer-events: none;
opacity: 0.55;
}
.warning {
color: #9a4c00;
}
.long-label {
position: fixed;
z-index: 3;
left: 0;
top: 0;
transform: translate(10px, 10px);
pointer-events: none;
background: #fff3d8;
border: 1px solid #c87d13;
padding: 3px 7px;
border-radius: 4px;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
item: Node | null;
mismatch: boolean;
}>();
const emit = defineEmits<{
update: [id: string, change: (node: Node) => void, label?: string];
applyFrame: [id: string, frame: Transform];
setDefault: [groupId: string, variantId: string];
changeVariant: [group: Node, variantId: string];
deleteNode: [id: string];
addVariant: [groupId: string, name: string];
}>();
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
const frameX = ref(0);
const frameY = ref(0);
const frameR = ref(0);
</script>
<template>
<aside class="properties pane">
<fieldset :disabled="mismatch">
<p v-if="selectedCount > 1">{{ selectedCount }}件を選択中</p>
<template v-else-if="item">
<h2>{{ item.type }}</h2>
<label
>X
<input
type="number"
step="any"
:value="item.transform.x"
@change="
emit(
'update',
item.id,
(n) => (n.transform.x = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<label
>Y
<input
type="number"
step="any"
:value="item.transform.y"
@change="
emit(
'update',
item.id,
(n) => (n.transform.y = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<label
>Rotation
<input
type="number"
step="any"
:value="item.transform.rotation"
@change="
emit(
'update',
item.id,
(n) => (n.transform.rotation = Number(($event.target as HTMLInputElement).value)),
)
"
/></label>
<template v-if="item.type === 'key'">
<label
>Width
<input
type="number"
step="any"
:value="item.size.w"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.size.w = Number(($event.target as HTMLInputElement).value);
})
"
/></label>
<label
>Height
<input
type="number"
step="any"
:value="item.size.h"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.size.h = Number(($event.target as HTMLInputElement).value);
})
"
/></label>
<label
>Label
<input
:value="item.labels?.[0]"
@change="
emit('update', item.id, (n) => {
if (n.type === 'key') n.labels = [($event.target as HTMLInputElement).value];
})
"
/></label>
</template>
<template v-if="item.type === 'cluster'">
<label
>名前
<input
:value="item.name"
@change="
emit('update', item.id, (n) => {
if (n.type === 'cluster') n.name = ($event.target as HTMLInputElement).value;
})
"
/></label>
<h3>座標枠変更</h3>
<label>原点X <input type="number" v-model.number="frameX" /></label>
<label>原点Y <input type="number" v-model.number="frameY" /></label>
<label>角度 <input type="number" v-model.number="frameR" /></label>
<button @click="emit('applyFrame', item.id, { x: frameX, y: frameY, rotation: frameR })">
適用
</button>
</template>
<template v-if="group">
<label
>名前
<input
:value="group.name"
@change="
emit('update', group.id, (n) => {
if (n.type === 'variantGroup') n.name = ($event.target as HTMLInputElement).value;
})
"
/></label>
<div v-for="v in group.children" :key="v.id">
{{ v.name }} {{ v.id === group.default ? "✓" : "" }}
<button @click="emit('setDefault', group.id, v.id)">既定</button>
<button @click="emit('changeVariant', group, v.id)">表示</button>
<button :disabled="group.children.length <= 2" @click="emit('deleteNode', v.id)">削除</button>
</div>
<button @click="emit('addVariant', group.id, 'Option ' + (group.children.length + 1))">追加</button>
</template>
</template>
<p v-else-if="selectedCount === 0">ノードを選択してください</p>
</fieldset>
</aside>
</template>
<style scoped>
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
.properties fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 110px;
margin-left: auto;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
defineProps<{
mismatch: boolean;
selectedCount: number;
grid: number;
moveTargets: { id: string; label: string }[];
focus: string | null;
crumbs: { id: string | null; label: string }[];
}>();
const emit = defineEmits<{
keyAdd: [];
partAdd: [group: string];
cluster: [];
ungroup: [];
variantGroup: [];
moveTo: [id: string];
duplicate: [];
delete: [event: MouseEvent];
grid: [];
fit: [];
fitSelected: [];
zoom: [direction: 1 | -1];
tree: [];
help: [];
focus: [id: string | null];
}>();
function addPart(e: Event) {
const select = e.target as HTMLSelectElement;
emit("partAdd", select.value);
select.value = "";
}
</script>
<template>
<div class="tools">
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
+⌨
</button>
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
<option value="">部品を足す</option>
<option>@led</option>
<option>@encoder</option>
<option>@screw</option>
<option>@oled</option>
<option>その他</option>
</select>
<button
title="クラスターにする"
aria-label="クラスターにする"
:disabled="mismatch || !selectedCount"
@click="emit('cluster')"
>
▱
</button>
<button
title="クラスターを解除"
aria-label="クラスターを解除"
:disabled="mismatch"
@click="emit('ungroup')"
>
⊟
</button>
<button
title="選択からバリアントグループ"
aria-label="選択からバリアントグループ"
:disabled="mismatch || !selectedCount"
@click="emit('variantGroup')"
>
◇
</button>
<select
aria-label="移し先…"
:disabled="mismatch || !selectedCount"
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
>
<option value="">移し先…</option>
<option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.label }}</option>
</select>
<button title="複製" aria-label="複製" :disabled="mismatch || !selectedCount" @click="emit('duplicate')">
⧉
</button>
<button
title="削除"
aria-label="削除"
:disabled="mismatch || !selectedCount"
@click="emit('delete', $event)"
>
⌫
</button>
<button @click="emit('grid')">Grid {{ grid }}U</button>
<button title="全体表示" aria-label="全体表示" @click="emit('fit')">⛶</button>
<button title="選択範囲にズーム" aria-label="選択範囲にズーム" @click="emit('fitSelected')">⌕</button>
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
<span v-if="focus">
<template v-for="(crumb, i) in crumbs" :key="i">
<button @click="emit('focus', crumb.id)">{{ i ? " › " : "" }}{{ crumb.label }}</button>
</template>
</span>
</div>
</template>
<style scoped>
.tools {
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.tools button,
.tools select {
font-size: 12px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
defineOptions({ name: "PhyTree" });
const props = defineProps<{ nodes: Node[]; selected: string[] }>();
const emit = defineEmits<{ select: [id: string, add: boolean] }>();
const structures = computed(() =>
props.nodes.filter((n) => n.type === "cluster" || n.type === "variantGroup"),
);
const leaves = computed(() => props.nodes.filter((n) => n.type === "key" || n.type === "component"));
function countLeaves(nodes: Node[]): number {
return nodes.reduce(
(count, n) =>
count +
(n.type === "key" || n.type === "component"
? 1
: n.type === "cluster"
? countLeaves(n.children)
: n.type === "variantGroup"
? n.children.reduce((sum, v) => sum + countLeaves(v.children), 0)
: 0),
0,
);
}
</script>
<template>
<ul class="tree-list">
<li v-for="n in structures" :key="n.id">
<button
class="tree-node"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ n.type === "cluster" ? "クラスター" : n.name }}
<small>{{ n.type === "cluster" ? `${countLeaves(n.children)}件` : `${n.children.length}件` }}</small>
</button>
<PhyTree
v-if="n.type === 'cluster'"
:nodes="n.children"
:selected="selected"
@select="(id, add) => emit('select', id, add)"
/>
<div v-else class="variants">
<div v-for="v in n.children" :key="v.id">
<button
class="tree-node variant"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ v.name }}
<small>{{ n.default === v.id ? "既定 · " : "" }}{{ countLeaves(v.children) }}件</small>
</button>
<details>
<summary>キー・部品 {{ countLeaves(v.children) }}件</summary>
<PhyTree
:nodes="v.children"
:selected="selected"
@select="(id, add) => emit('select', id, add)"
/>
</details>
</div>
</div>
</li>
<li v-if="leaves.length">
<details>
<summary>キー・部品 {{ leaves.length }}件</summary>
<ul class="tree-list">
<li v-for="n in leaves" :key="n.id">
<button
class="tree-node leaf"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ n.type === "key" ? "キー" : "部品" }}
<small>{{ n.type === "key" ? n.labels?.find(Boolean) : n.partGroup }}</small>
</button>
</li>
</ul>
</details>
</li>
</ul>
</template>
<style scoped>
.tree-list {
list-style: none;
padding-left: 12px;
margin: 3px 0;
}
.tree-node {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 4px;
white-space: nowrap;
}
.leaf {
font-size: 12px;
}
.chosen {
background: #dce9ff;
}
summary {
font-size: 11px;
color: #667;
cursor: pointer;
}
.variants {
padding-left: 4px;
}
</style>
+236
View File
@@ -0,0 +1,236 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
const parent = new Map<string, string | null>(),
nodes = new Map<string, Node>();
const walk = (items: Node[], p: string | null) =>
items.forEach((n) => {
parent.set(n.id, p);
nodes.set(n.id, n);
if (n.type === "cluster") walk(n.children, n.id);
if (n.type === "variantGroup")
n.children.forEach((v) => {
parent.set(v.id, n.id);
walk(v.children, v.id);
});
});
walk(file.root, null);
const out: string[] = [];
for (const id of ids) {
if (!nodes.has(id) || out.includes(id)) continue;
const ancestors = new Set<string>();
let p = parent.get(id) ?? null;
while (p) {
ancestors.add(p);
p = parent.get(p) ?? null;
}
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
out.splice(0, out.length, ...keep, id);
}
return out;
}
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
let p = parent.get(candidate) ?? null;
while (p) {
if (p === ancestor) return true;
p = parent.get(p) ?? null;
}
return false;
}
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
const list = [keyId, ...ancestorsNearestFirst];
if (focusId) {
const i = list.indexOf(focusId);
if (i >= 0) list.splice(i);
}
return list;
}
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
const sorted = [...items].sort((a, b) => a.y - b.y),
rows: T[][] = [];
for (const item of sorted) {
const row = rows.at(-1);
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
else row.push(item);
}
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
}
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
export function treeIndex(roots: Node[]) {
const nodes = new Map<string, Node>();
const parents = new Map<string, string | null>();
const containers = new Map<string, string | null>();
const variants = new Map<string, string>();
const visit = (items: Node[], parent: string | null, container: string | null) => {
for (const n of items) {
nodes.set(n.id, n);
parents.set(n.id, parent);
containers.set(n.id, container);
if (n.type === "cluster") visit(n.children, n.id, n.id);
if (n.type === "variantGroup") {
for (const v of n.children) {
variants.set(v.id, n.id);
visit(v.children, n.id, v.id);
}
}
}
};
visit(roots, null, null);
return {
nodes,
parents,
containers,
variants,
selectableParent(id: string) {
return parents.get(id) ?? null;
},
insertionContainer(id: string) {
return containers.get(id) ?? null;
},
groupForVariant(id: string) {
return variants.get(id) ?? null;
},
};
}
export function treeNodes(roots: Node[]): Node[] {
return [...treeIndex(roots).nodes.values()];
}
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
return treeNodes(roots).filter(
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
node.type === "cluster" || node.type === "variantGroup",
);
}
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
const result: Node[] = [];
for (const node of nodes) {
if (node.id === focusedId) continue;
if (node.type === "cluster") {
const children = removeFocusedSubtree(node.children, focusedId);
result.push({ ...node, children });
} else if (node.type === "variantGroup") {
result.push({
...node,
children: node.children.map((variant) => ({
...variant,
children: removeFocusedSubtree(variant.children, focusedId),
})),
});
} else {
result.push(node);
}
}
return result;
}
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
const result = new Set<string>();
const visit = (node: Node) => {
result.add(node.id);
if (node.type === "cluster") node.children.forEach(visit);
if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
variant?.children.forEach(visit);
}
};
if (root) visit(root);
return result;
}
export function selectableParent(roots: Node[], id: string): string | null {
return treeIndex(roots).selectableParent(id);
}
export function insertionContainer(
roots: Node[],
parentId: string | null,
shownVariants: Record<string, string> = {},
): string | null {
if (parentId === null) return null;
const index = treeIndex(roots);
const node = index.nodes.get(parentId);
if (node?.type === "cluster") return node.id;
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
return index.variants.get(parentId) === undefined ? null : parentId;
}
export function snap(value: number, step: number): number {
return Math.round(value / step) * step;
}
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
export function nextGrid(step: number, direction: 1 | -1): number {
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
}
export function resolveKey(
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): string | null {
const mod = e.ctrlKey || e.metaKey;
if (e.code === "Digit1" && e.shiftKey) return "fit";
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
if (mod && e.key.toLowerCase() === "y") return "redo";
if (mod && e.key.toLowerCase() === "s") return "save";
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
return e.key;
}
export type Box = { x: number; y: number; width: number; height: number };
export function obbAabb(points: { x: number; y: number }[]): Box {
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
const xs = points.map((p) => p.x),
ys = points.map((p) => p.y),
x = Math.min(...xs),
y = Math.min(...ys);
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
return selectedParents.length ? selectedParents[0]! : focus;
}
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
const points: { x: number; y: number }[] = [];
const visit = (items: Node[], parent: Affine) =>
items.forEach((n) => {
const w = compose(parent, matrix(n.transform));
if (n.type === "key") {
const corners = [
{ x: 0, y: 0 },
{ x: n.size.w, y: 0 },
{ x: n.size.w, y: n.size.h },
{ x: 0, y: n.size.h },
];
if (
n.size.x2 !== undefined ||
n.size.y2 !== undefined ||
n.size.w2 !== undefined ||
n.size.h2 !== undefined
) {
const x = n.size.x2 ?? 0,
y = n.size.y2 ?? 0,
ww = n.size.w2 ?? n.size.w,
hh = n.size.h2 ?? n.size.h;
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
}
points.push(...corners.map((p) => apply(w, p)));
} else if (n.type === "component") {
const c = apply(w, { x: 0, y: 0 });
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
} else if (n.type === "cluster") visit(n.children, w);
else {
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
if (v) visit(v.children, w);
}
});
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
return obbAabb(points);
}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
const local = compose(inverse(parentWorld), sourceWorld);
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
}
+349
View File
@@ -0,0 +1,349 @@
import { ref, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { apply, worldTransform } from "../../domain/geometry";
import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic";
type Point = { x: number; y: number };
type Box = Point & { width: number; height: number };
type Options = {
project: () => ProjectFile;
svg: Ref<SVGSVGElement | undefined>;
pane: Ref<HTMLElement | undefined>;
pan: Ref<Point>;
zoom: Ref<number>;
grid: Ref<number>;
selected: Ref<string[]>;
focus: Ref<string | null>;
rangeBox: Ref<Box | null>;
allNodes: () => Node[];
keys: () => { key: KeyNode }[];
localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point;
visibleIds: () => Set<string>;
mismatch: () => boolean;
isAncestor: (candidate: string, id: string) => boolean;
parentLabel: (id: string | null) => string;
pressTargets: (id: string) => string[];
setSelection: (id: string, add?: boolean) => void;
beginHistory: () => void;
commitHistory: () => void;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
};
export function usePhyCanvasPointer(options: Options) {
const longLabel = ref("");
const longLabelPos = ref({ x: 0, y: 0 });
const spaceHeld = ref(false);
const holdCandidates = ref<string[]>([]);
const holdIndex = ref(0);
let preClickSelection: string[] = [];
let preClickTarget: string | null = null;
let preClickAt = 0;
let holdTimer: number | undefined;
let holdCycle: number | undefined;
let holdShift = false;
let holdActivated = false;
let panDrag: { start: Point; origin: Point } | null = null;
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
let dragState: {
id: string;
start: Point;
base: ProjectFile;
ids: string[];
shift: boolean;
parents: Map<string, SVGGElement>;
starts: Map<string, Point>;
moved: boolean;
} | null = null;
function priorSelection() {
return preClickSelection;
}
function parentElementFor(id: string, fallback?: SVGGElement) {
const elements = options.svg.value?.querySelectorAll(`[data-node-id="${id}"]`);
return (
(elements?.item((elements.length || 1) - 1)?.parentElement as SVGGElement | undefined) ||
(fallback?.parentElement?.parentElement as SVGGElement | undefined) ||
options.svg.value!
);
}
function beginEmptyPointer(event: PointerEvent, point?: Point) {
const svg = options.svg.value;
const start = point ?? (svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 });
rangeDrag = { start, shift: event.shiftKey, moved: false };
options.rangeBox.value = { ...start, width: 0, height: 0 };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
}
function canvasDown(event: PointerEvent) {
const svg = options.svg.value;
if (event.target !== svg || !svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (event.button === 1 || spaceHeld.value) {
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
beginEmptyPointer(event, point);
}
function updateRange(event: PointerEvent) {
if (!rangeDrag || !options.svg.value) return;
const point = options.localPoint(options.svg.value, event.clientX, event.clientY);
const start = rangeDrag.start;
rangeDrag.moved = Math.hypot(point.x - start.x, point.y - start.y) >= 4;
options.rangeBox.value = {
x: Math.min(start.x, point.x),
y: Math.min(start.y, point.y),
width: Math.abs(point.x - start.x),
height: Math.abs(point.y - start.y),
};
}
function down(event: PointerEvent, node: Node, _element: SVGGElement) {
const svg = options.svg.value;
if (event.button === 1 || spaceHeld.value) {
const point = svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 };
panDrag = { start: point, origin: { ...options.pan.value } };
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
svg?.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
if (event.altKey) return;
options.pane.value?.focus();
if (options.focus.value && !options.visibleIds().has(node.id)) {
beginEmptyPointer(event);
return;
}
if (options.mismatch()) {
options.setSelection(node.id, event.shiftKey);
return;
}
if (preClickTarget !== node.id || Date.now() - preClickAt > 600) {
preClickSelection = [...options.selected.value];
}
preClickTarget = node.id;
preClickAt = Date.now();
holdActivated = false;
dragState = {
id: node.id,
start: { x: event.clientX, y: event.clientY },
base: options.project(),
ids: [...options.selected.value],
shift: event.shiftKey,
parents: new Map(),
starts: new Map(),
moved: false,
};
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
if (node.type === "key") {
holdCandidates.value = options.pressTargets(node.id);
holdIndex.value = 0;
holdShift = event.shiftKey;
holdTimer = window.setTimeout(() => {
if (holdCandidates.value.length <= 1) return;
holdActivated = true;
holdIndex.value = 1;
longLabelPos.value = { x: event.clientX, y: event.clientY };
longLabel.value = `${options.parentLabel(holdCandidates.value[1]!)} 2/${holdCandidates.value.length}`;
holdCycle = window.setInterval(() => {
holdIndex.value = (holdIndex.value + 1) % holdCandidates.value.length;
const id = holdCandidates.value[holdIndex.value]!;
const label = holdIndex.value === 0 ? "キー" : options.parentLabel(id);
longLabel.value = `${label} ${holdIndex.value + 1}/${holdCandidates.value.length}`;
}, 450);
}, 500);
}
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
}
function move(event: PointerEvent) {
const svg = options.svg.value;
if (!svg) return;
const point = options.localPoint(svg, event.clientX, event.clientY);
if (panDrag) {
options.pan.value = {
x: panDrag.origin.x + point.x - panDrag.start.x,
y: panDrag.origin.y + point.y - panDrag.start.y,
};
return;
}
if (rangeDrag) {
updateRange(event);
return;
}
if (!dragState) return;
const state = dragState;
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
if (holdTimer && !longLabel.value) {
window.clearTimeout(holdTimer);
holdTimer = undefined;
}
if (longLabel.value) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...preClickSelection, id] : [id],
);
state.ids = [id];
state.base = options.project();
state.parents = new Map([[id, parentElementFor(id)]]);
state.starts = new Map([
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
]);
options.beginHistory();
if (holdCycle) window.clearInterval(holdCycle);
longLabel.value = "";
} else if (!state.moved) {
const includesAncestor = preClickSelection.some(
(id) => id === state.id || options.isAncestor(id, state.id),
);
const ids = includesAncestor ? preClickSelection : [state.id];
if (!includesAncestor) options.selected.value = [state.id];
state.ids = ids;
state.base = options.project();
state.parents = new Map(ids.map((id) => [id, parentElementFor(id)]));
state.starts = new Map(
[...state.parents].map(([id, parent]) => [
id,
options.localPoint(parent, state.start.x, state.start.y),
]),
);
options.beginHistory();
}
state.moved = true;
let phy = state.base.phy;
for (const id of state.ids) {
const parent = state.parents.get(id);
const start = state.starts.get(id);
if (!parent || !start) continue;
const current = options.localPoint(parent, event.clientX, event.clientY);
phy = moveNode(
phy,
id,
snap(current.x - start.x, options.grid.value),
snap(current.y - start.y, options.grid.value),
);
}
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
}
function up() {
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", up, true);
if (panDrag) {
panDrag = null;
return;
}
if (rangeDrag) {
if (rangeDrag.moved && options.rangeBox.value) {
const box = options.rangeBox.value;
const worldBox = {
x: (box.x - options.pan.value.x) / options.zoom.value,
y: (box.y - options.pan.value.y) / options.zoom.value,
width: box.width / options.zoom.value,
height: box.height / options.zoom.value,
};
const scope = options.visibleIds();
const hits: string[] = [];
for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id);
const corners = [
{ x: 0, y: 0 },
{ x: item.key.size.w, y: 0 },
{ x: item.key.size.w, y: item.key.size.h },
{ x: 0, y: item.key.size.h },
].map((point) => apply(transform, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
if (
intersects(worldBox, {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
})
) {
hits.push(item.key.id);
}
}
for (const node of options
.allNodes()
.filter((item) => item.type === "component" && scope.has(item.id))) {
const transform = worldTransform(options.project().phy, node.id);
if (
intersects(worldBox, { x: transform.e - 0.16, y: transform.f - 0.16, width: 0.32, height: 0.32 })
) {
hits.push(node.id);
}
}
options.selected.value = normalizeSelection(
options.project().phy,
rangeDrag.shift ? [...options.selected.value, ...hits] : hits,
);
} else {
options.selected.value = [];
}
rangeDrag = null;
options.rangeBox.value = null;
return;
}
if (holdTimer) window.clearTimeout(holdTimer);
if (holdCycle) window.clearInterval(holdCycle);
if (longLabel.value && holdCandidates.value[holdIndex.value]) {
const id = holdCandidates.value[holdIndex.value]!;
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...options.selected.value, id] : [id],
);
}
longLabel.value = "";
const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory();
else if (dragState && !holdActivated) {
const id = dragState.id;
const next = dragState.shift
? options.selected.value.includes(id)
? options.selected.value.filter((selectedId) => selectedId !== id)
: [...options.selected.value, id]
: [id];
options.selected.value = normalizeSelection(options.project().phy, next);
}
if (holdActivated || moved) preClickTarget = null;
dragState = null;
}
function spaceUp(event: KeyboardEvent) {
if (event.code === "Space") spaceHeld.value = false;
}
return {
canvasDown,
down,
move,
up,
spaceUp,
longLabel,
longLabelPos,
spaceHeld,
holdCandidates,
holdIndex,
priorSelection,
};
}
function intersects(a: Box, b: Box) {
return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
}
+177
View File
@@ -0,0 +1,177 @@
import { computed, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node, VariantGroupNode } from "../../domain/phy";
import {
apply,
compose,
flattenKeys,
inverse,
matrix,
worldTransform,
type Affine,
} from "../../domain/geometry";
import { removeFocusedSubtree, structuralNodes } from "./logic";
type Options = {
project: () => ProjectFile;
size: Ref<{ w: number; h: number }>;
pan: Ref<{ x: number; y: number }>;
zoom: Ref<number>;
focus: Ref<string | null>;
variants: Ref<Record<string, string>>;
grid: Ref<number>;
selected: Ref<string[]>;
node: (id: string) => Node | undefined;
parentId: (id: string) => string | null;
parentLabel: (id: string | null) => string;
selectedContainer: (id: string) => string | null;
};
export function usePhyCanvasView(options: Options) {
const keys = computed(() => flattenKeys(options.project().phy, options.variants.value));
const groupsAll = computed(() => structuralNodes(options.project().phy.root));
const outsideNodes = computed(() =>
options.focus.value ? removeFocusedSubtree(options.project().phy.root, options.focus.value) : [],
);
const gridLines = computed(() => {
const step = options.grid.value;
const xMin = Math.floor(-options.pan.value.x / options.zoom.value / step);
const xMax = Math.ceil((options.size.value.w - options.pan.value.x) / options.zoom.value / step);
const yMin = Math.floor(-options.pan.value.y / options.zoom.value / step);
const yMax = Math.ceil((options.size.value.h - options.pan.value.y) / options.zoom.value / step);
const lines: { d: string; major: boolean }[] = [];
for (let index = xMin; index <= xMax; index++) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (let index = yMin; index <= yMax; index++) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const variantGroups = computed(() =>
groupsAll.value.filter((node): node is VariantGroupNode => node.type === "variantGroup"),
);
const focusGridLines = computed(() => {
const focusId = options.focus.value;
if (!focusId || !options.node(focusId)) return [];
const screen = compose(
{
a: options.zoom.value,
b: 0,
c: 0,
d: options.zoom.value,
e: options.pan.value.x,
f: options.pan.value.y,
},
worldTransform(options.project().phy, focusId),
);
const toLocal = inverse(screen);
const corners = [
{ x: 0, y: 0 },
{ x: options.size.value.w, y: 0 },
{ x: options.size.value.w, y: options.size.value.h },
{ x: 0, y: options.size.value.h },
].map((point) => apply(toLocal, point));
const xs = corners.map((point) => point.x);
const ys = corners.map((point) => point.y);
const step = options.grid.value;
const lines: { d: string; major: boolean }[] = [];
for (
let index = Math.floor(Math.min(...xs) / step);
index <= Math.ceil(Math.max(...xs) / step);
index++
) {
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
for (
let index = Math.floor(Math.min(...ys) / step);
index <= Math.ceil(Math.max(...ys) / step);
index++
) {
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
}
return lines;
});
const moveTargets = computed(() => {
const excluded = new Set<string>();
const add = (node: Node) => {
excluded.add(node.id);
if (node.type === "cluster") node.children.forEach(add);
if (node.type === "variantGroup") {
node.children.forEach((variant) => {
excluded.add(variant.id);
variant.children.forEach(add);
});
}
};
options.selected.value.forEach((id) => {
const node = options.node(id);
if (node) add(node);
});
return [
{ id: "__root__", label: "root" },
...groupsAll.value
.filter((node) => node.type === "cluster" && !excluded.has(node.id))
.map((node) => ({ id: node.id, label: node.name || "クラスター" })),
...variantGroups.value.flatMap((group) =>
group.children
.filter((variant) => !excluded.has(variant.id))
.map((variant) => ({ id: variant.id, label: `${group.name} / ${variant.name}` })),
),
];
});
function parentMatrix(id: string): Affine {
const node = options.node(id);
return node
? compose(worldTransform(options.project().phy, id), inverse(matrix(node.transform)))
: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
}
function focusAngle(id: string) {
const transform = parentMatrix(id);
return (Math.atan2(transform.b, transform.a) * 180) / Math.PI;
}
function affineText(transform: Affine) {
return `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`;
}
function focusCrumbs(id: string): { id: string | null; label: string }[] {
const ids = [id];
let parent = options.parentId(id);
while (parent) {
ids.unshift(parent);
parent = options.parentId(parent);
}
return [
{ id: null, label: "root" },
...ids.map((item) => ({
id:
groupsAll.value.find(
(node) => node.type === "variantGroup" && node.children.some((variant) => variant.id === item),
)?.id || item,
label: options.parentLabel(item),
})),
];
}
const propertyNode = computed(() =>
options.selected.value.length === 1 ? options.node(options.selected.value[0]!) : null,
);
return {
keys,
groupsAll,
outsideNodes,
gridLines,
variantGroups,
focusGridLines,
moveTargets,
parentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode,
};
}
+103
View File
@@ -0,0 +1,103 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { cloneNodesWithNewIds, insertNodes, reparent, reparentToVariant } from "../../domain/phyCommands";
import { worldTransform, type Affine } from "../../domain/geometry";
import { importKle } from "../../io/kleImport";
import { treeIndex, pasteLocalTransform } from "./logic";
type ClipboardNode = { node: Node; world: Affine };
type Options = {
project: () => ProjectFile;
mismatch: () => boolean;
selected: Ref<string[]>;
cut: Ref<string[]>;
grid: Ref<number>;
node: (id: string) => Node | undefined;
selectedContainer: (id: string) => string | null;
insertionParent: () => string | null;
parentMatrix: (project: ProjectFile, parent: string | null) => Affine;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
setSelected: (ids: string[]) => void;
};
export function usePhyClipboard(options: Options) {
let clipboard: ClipboardNode[] = [];
let marker = "";
function copyEvent(event: ClipboardEvent) {
if (!options.selected.value.length || !event.clipboardData) return;
const project = options.project();
clipboard = options.selected.value.map((id) => ({
node: structuredClone(options.node(id)!),
world: worldTransform(project.phy, id),
}));
marker = `beyond-kle-clipboard:${crypto.randomUUID()}`;
event.clipboardData.setData("text/plain", marker);
if (event.type === "cut") options.cut.value = [...options.selected.value];
event.preventDefault();
}
function pasteEvent(event: ClipboardEvent) {
if (options.mismatch()) return;
const text = event.clipboardData?.getData("text/plain") || "";
if (text === marker && clipboard.length) {
event.preventDefault();
const parent = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
if (options.cut.value.length) {
const ids = [...options.cut.value];
options.dispatch("カット貼り付け", (project) => ({
...ids.reduce((next, id) => {
const isVariant = parent !== null && treeIndex(next.phy.root).groupForVariant(parent) !== null;
return {
...next,
phy: isVariant ? reparentToVariant(next.phy, id, parent!) : reparent(next.phy, id, parent),
};
}, project),
}));
options.setSelected(ids);
options.cut.value = [];
} else {
let ids: string[] = [];
options.dispatch("貼り付け", (project) => {
const copies = cloneNodesWithNewIds(clipboard.map((item) => item.node));
const parentWorld = options.parentMatrix(project, parent);
copies.forEach((node, index) => {
node.transform = pasteLocalTransform(clipboard[index]!.world, parentWorld, options.grid.value);
ids.push(node.id);
});
return { ...project, phy: insertNodes(project.phy, parent, copies) };
});
options.setSelected(ids);
}
return;
}
try {
const { phy, keyOrder } = importKle(JSON.parse(text));
if (!event.clipboardData) return;
event.preventDefault();
let ids: string[] = [];
options.dispatch("KLE貼り付け", (project) => {
const nodes = phy.root
.filter((node): node is KeyNode => node.type === "key")
.map((key) => ({ ...key, profile: project.phy.profiles[0]?.id || key.profile }));
ids = nodes.map((node) => node.id);
const parent = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
return {
...project,
phy: insertNodes(project.phy, parent, nodes),
idx: { ...project.idx, order: [...project.idx.order, ...keyOrder] },
};
});
options.setSelected(ids);
} catch {
// 外部クリップボードがKLE JSONでなければ無視する。
}
}
return { copyEvent, pasteEvent };
}
+373
View File
@@ -0,0 +1,373 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
import {
addComponentToParent,
addVariant as addVariantCommand,
addKeyToParent,
clusterNodes,
cloneNodesWithNewIds,
createVariantGroup,
insertNodes,
reparent,
reparentToVariant,
setDefaultVariant,
uncluster,
} from "../../domain/phyCommands";
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
type Options = {
project: () => ProjectFile;
selected: Ref<string[]>;
variants: Ref<Record<string, string>>;
focus: Ref<string | null>;
grid: Ref<number>;
zoom: Ref<number>;
pan: Ref<{ x: number; y: number }>;
size: Ref<{ w: number; h: number }>;
svg: Ref<SVGSVGElement | undefined>;
pane: Ref<HTMLElement | undefined>;
popover: Ref<string | null>;
confirm: Ref<string | null>;
confirmIds: Ref<string[]>;
confirmRefs: Ref<ReturnType<typeof listDeleteReferences>>;
confirmPos: Ref<{ left: number; top: number }>;
node: (id: string) => Node | undefined;
allNodes: (roots?: Node[]) => Node[];
parentId: (id: string) => string | null;
selectedContainer: (id: string) => string | null;
groupForVariant: (id: string) => string | null;
variantGroups: () => VariantGroupNode[];
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
emitError: (message: string) => void;
};
export function usePhyCommands(options: Options) {
function parentMatrix(project: ProjectFile, parent: string | null): Affine {
if (!parent) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
if (treeIndex(project.phy.root).nodes.get(parent)?.type === "cluster")
return worldTransform(project.phy, parent);
const groupId = treeIndex(project.phy.root).groupForVariant(parent);
if (groupId) return worldTransform(project.phy, groupId);
throw new Error(`Unknown parent: ${parent}`);
}
function nodesInContainer(container: string | null): Node[] {
if (container === null) return options.project().phy.root;
const node = options.node(container);
if (node?.type === "cluster") return node.children;
if (node?.type === "variantGroup") {
const variant = node.children.find(
(item) => item.id === (options.variants.value[node.id] || node.default),
);
return variant?.children ?? [];
}
const groupId = options.groupForVariant(container);
const group = groupId ? options.node(groupId) : null;
if (group?.type === "variantGroup")
return group.children.find((variant) => variant.id === container)?.children ?? [];
return [];
}
function directChildren(id: string): Node[] {
const node = options.node(id);
if (node?.type === "cluster") return node.children;
if (node?.type === "variantGroup") {
const variant = node.children.find(
(item) => item.id === (options.variants.value[node.id] || node.default),
);
return variant?.children ?? [];
}
return [];
}
function tabItem(node: Node) {
if (node.type === "key") {
const center = apply(matrix(node.transform), { x: node.size.w / 2, y: node.size.h / 2 });
return { id: node.id, x: center.x, y: center.y };
}
if (node.type === "component") return { id: node.id, x: node.transform.x, y: node.transform.y };
const bounds =
node.type === "cluster"
? localBounds(node.children, options.variants.value)
: localBounds(directChildren(node.id), options.variants.value);
const center = apply(matrix(node.transform), {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
});
return { id: node.id, x: center.x, y: center.y };
}
function centerInParent(parent: string | null) {
const world = {
x: (options.size.value.w / 2 - options.pan.value.x) / options.zoom.value,
y: (options.size.value.h / 2 - options.pan.value.y) / options.zoom.value,
};
return apply(inverse(parentMatrix(options.project(), parent)), world);
}
function chooseVariant(group: Node, id: string) {
if (group.type !== "variantGroup") return;
options.variants.value = { ...options.variants.value, [group.id]: id };
const valid = new Set(
group.children.find((variant) => variant.id === id)?.children.map((item) => item.id) || [],
);
if (options.selected.value.some((selectedId) => !valid.has(selectedId)))
options.selected.value = [group.id];
options.popover.value = null;
}
function keyAdd() {
const chosen = options.node(options.selected.value.at(-1) || "");
const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent();
const center = centerInParent(parent);
const x =
Math.round(
(chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) /
options.grid.value,
) * options.grid.value;
const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value;
let id = "";
options.dispatch("キー追加", (project) => {
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
id = options.allNodes(phy.root).at(-1)?.id || "";
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
});
if (id) options.selected.value = [id];
}
function insertionParent() {
const focusId = options.focus.value;
if (!focusId) return null;
const focused = options.node(focusId);
if (focused?.type === "cluster") return focused.id;
if (focused?.type === "variantGroup") return options.variants.value[focused.id] ?? focused.default;
return null;
}
function partAdd(group: string) {
const selected = options.node(options.selected.value.at(-1) || "");
const parent = selected ? options.selectedContainer(selected.id) : insertionParent();
const center = centerInParent(parent);
const x =
Math.round(
(selected ? selected.transform.x + (selected.type === "key" ? selected.size.w : 1) : center.x) /
options.grid.value,
) * options.grid.value;
const y =
Math.round((selected ? selected.transform.y : center.y) / options.grid.value) * options.grid.value;
let id = "";
options.dispatch("部品追加", (project) => {
const phy = addComponentToParent(
project.phy,
parent,
{ x, y, rotation: 0 },
group === "その他" ? undefined : group,
);
id = options.allNodes(phy.root).at(-1)?.id || "";
return { ...project, phy };
});
if (id) options.selected.value = [id];
}
function makeCluster() {
try {
let id = "";
options.dispatch("クラスター化", (project) => {
const phy = clusterNodes(project.phy, options.selected.value);
id =
options
.allNodes(phy.root)
.find(
(node) =>
node.type === "cluster" &&
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
)?.id || "";
return { ...project, phy };
});
if (id) options.selected.value = [id];
} catch (error) {
options.emitError(String(error));
}
}
function ungroup() {
const ids = options.selected.value.filter((id) => options.node(id)?.type === "cluster");
if (ids.length)
options.dispatch("クラスター解除", (project) => ({
...project,
phy: ids.reduce((phy, id) => uncluster(phy, id), project.phy),
}));
options.selected.value = [];
}
function variantGroup() {
if (!options.selected.value.length) return;
let id = "";
options.dispatch("バリアントグループ作成", (project) => {
let phy = createVariantGroup(project.phy, "Variant Group", { x: 0, y: 0, rotation: 0 }, [
{ name: "Option A" },
{ name: "Option B" },
]);
const group = options
.allNodes(phy.root)
.find(
(node) =>
node.type === "variantGroup" &&
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
);
if (group?.type !== "variantGroup") return { ...project, phy };
id = group.id;
for (const child of options.selected.value) phy = reparentToVariant(phy, child, group.children[0]!.id);
return {
...project,
phy,
output: {
...project.output,
variantSelection: { ...project.output.variantSelection, [group.id]: group.default },
},
};
});
if (id) options.selected.value = [id];
}
function duplicate() {
if (!options.selected.value.length) return;
const originals = options.selected.value
.map((id) => options.node(id))
.filter((node): node is Node => !!node);
const parent = options.selectedContainer(options.selected.value[0]!);
const worlds = originals.map((node) => worldTransform(options.project().phy, node.id));
let ids: string[] = [];
options.dispatch("複製", (project) => {
const copies = cloneNodesWithNewIds(originals);
const parentWorld = parentMatrix(project, parent);
copies.forEach((node, index) => {
node.transform = pasteLocalTransform(worlds[index]!, parentWorld, options.grid.value);
ids.push(node.id);
});
return { ...project, phy: insertNodes(project.phy, parent, copies) };
});
options.selected.value = ids;
}
function moveSelectionTo(target: string) {
const destination = target === "__root__" ? null : target;
try {
options.dispatch("reparent", (project) => ({
...project,
phy: options.selected.value.reduce(
(phy, id) =>
destination && options.groupForVariant(destination)
? reparentToVariant(phy, id, destination)
: reparent(phy, id, destination),
project.phy,
),
}));
} catch (error) {
options.emitError(String(error));
}
}
function askDelete(id = options.selected.value[0], event?: MouseEvent) {
const ids = id ? (options.selected.value.includes(id) ? options.selected.value : [id]) : [];
if (!ids.length) return;
options.confirmIds.value = ids;
options.confirm.value = id!;
options.confirmRefs.value = ids.flatMap((selectedId) =>
listDeleteReferences(options.project(), selectedId),
);
if (event) {
const rect = (event.currentTarget as Element).getBoundingClientRect();
const pane = options.pane.value?.getBoundingClientRect();
const left = rect.right + 380 < (pane?.right ?? Infinity) ? rect.left : rect.left - 380;
const top = rect.bottom + 240 < (pane?.bottom ?? Infinity) ? rect.bottom + 4 : rect.top - 240;
options.confirmPos.value = overlayPoint(left, top, 380, 240);
} else {
const rects = ids.flatMap((selectedId) =>
[...(options.svg.value?.querySelectorAll(`[data-node-id="${selectedId}"]`) ?? [])].map((element) =>
element.getBoundingClientRect(),
),
);
if (rects.length) {
const right = Math.max(...rects.map((rect) => rect.right));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
const pane = options.pane.value?.getBoundingClientRect();
const left = right + 388 < (pane?.right ?? Infinity) ? right + 8 : right - 388;
const top = bottom + 248 < (pane?.bottom ?? Infinity) ? bottom + 8 : bottom - 248;
options.confirmPos.value = overlayPoint(left, top, 380, 240);
}
}
}
function overlayPoint(clientX: number, clientY: number, width: number, height: number) {
const rect = options.pane.value?.getBoundingClientRect();
return {
left: Math.max(4, Math.min(clientX - (rect?.left ?? 0), (rect?.width ?? width) - width - 4)),
top: Math.max(4, Math.min(clientY - (rect?.top ?? 0), (rect?.height ?? height) - height - 4)),
};
}
function deleteConfirmed() {
if (!options.confirm.value) return;
const ids = [...options.confirmIds.value];
const shownGroups = ids
.map(
(id) =>
options
.variantGroups()
.find(
(group) =>
group.children.some((variant) => variant.id === id) &&
options.variants.value[group.id] === id,
)?.id,
)
.filter((id): id is string => !!id);
options.dispatch("削除", (project) => ids.reduce((next, id) => deleteNodeCascade(next, id), project));
const next = { ...options.variants.value };
for (const id of shownGroups) {
const group = options.variantGroups().find((item) => item.id === id);
if (group) next[id] = group.default;
else delete next[id];
}
options.variants.value = next;
options.selected.value = [];
options.confirm.value = null;
options.confirmIds.value = [];
}
function setDefault(groupId: string, variantId: string) {
options.dispatch("既定変更", (project) => ({
...project,
phy: setDefaultVariant(project.phy, groupId, variantId),
}));
}
function addVariant(groupId: string, name: string) {
options.dispatch("バリアント追加", (project) => ({
...project,
phy: addVariantCommand(project.phy, groupId, name),
}));
}
return {
parentMatrix,
nodesInContainer,
directChildren,
tabItem,
chooseVariant,
keyAdd,
partAdd,
makeCluster,
ungroup,
variantGroup,
duplicate,
moveSelectionTo,
askDelete,
deleteConfirmed,
setDefault,
addVariant,
};
}
+253
View File
@@ -0,0 +1,253 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node } from "../../domain/phy";
import { moveNode, resizeKey, rotateNode } from "../../domain/phyCommands";
import { nextGrid, resolveKey, tabOrder } from "./logic";
type Options = {
project: () => ProjectFile;
mismatch: () => boolean;
pane: Ref<HTMLElement | undefined>;
selected: Ref<string[]>;
focus: Ref<string | null>;
grid: Ref<number>;
zoom: Ref<number>;
showHelp: Ref<boolean>;
popover: Ref<string | null>;
confirm: Ref<string | null>;
spaceHeld: Ref<boolean>;
variants: Ref<Record<string, string>>;
priorChild: Map<string, string>;
node: (id: string) => Node | undefined;
allNodes: (roots?: Node[]) => Node[];
parentId: (id: string) => string | null;
selectedContainer: (id: string) => string | null;
insertionParent: () => string | null;
nodesInContainer: (container: string | null) => Node[];
directChildren: (id: string) => Node[];
tabItem: (node: Node) => { id: string; x: number; y: number };
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
fit: () => void;
fitSelected: () => void;
focusIn: (id: string) => void;
makeCluster: () => void;
ungroup: () => void;
duplicate: () => void;
keyAdd: () => void;
askDelete: () => void;
deleteConfirmed: () => void;
chooseVariant: (group: Node, id: string) => void;
};
export function usePhyKeyboard(options: Options) {
return function keyHandler(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (
!options.pane.value?.contains(document.activeElement) ||
target?.closest("input,select,textarea,[contenteditable=true]")
) {
return;
}
if (event.code === "Space") {
options.spaceHeld.value = true;
event.preventDefault();
return;
}
const action = resolveKey(event);
const control = event.ctrlKey || event.metaKey;
if (["undo", "redo", "save"].includes(action || "")) return;
if (action === "?" || event.key === "?") {
options.showHelp.value = true;
event.preventDefault();
return;
}
if (event.key === "Escape") {
if (options.showHelp.value || options.popover.value || options.confirm.value) {
options.showHelp.value = false;
options.popover.value = null;
options.confirm.value = null;
} else if (options.selected.value.length) {
options.selected.value = [];
} else if (options.focus.value) {
options.focus.value = options.parentId(options.focus.value);
}
event.preventDefault();
return;
}
if (event.key === "Enter" && options.confirm.value) {
options.deleteConfirmed();
event.preventDefault();
return;
}
if (
event.key === "Enter" &&
options.selected.value.length === 1 &&
["cluster", "variantGroup"].includes(options.node(options.selected.value[0]!)?.type || "")
) {
options.focusIn(options.selected.value[0]!);
return;
}
if (action === "fit") {
options.fit();
return;
}
if (action === "selection-fit") {
options.fitSelected();
return;
}
if (options.mismatch()) return;
if (action === "group" && control) {
event.preventDefault();
options.makeCluster();
return;
}
if (action === "ungroup" && control) {
event.preventDefault();
options.ungroup();
return;
}
if (action === "d" && control) {
event.preventDefault();
options.duplicate();
return;
}
if (action === "a" && control) {
event.preventDefault();
const container = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
options.selected.value = options.nodesInContainer(container).map((node) => node.id);
return;
}
if (event.key === "Insert") {
event.preventDefault();
options.keyAdd();
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
options.askDelete();
return;
}
if (event.key === "g" || event.key === "G") {
options.grid.value = nextGrid(options.grid.value, event.shiftKey ? -1 : 1);
return;
}
if (event.key === "+" || event.key === "=") {
options.zoom.value *= 1.15;
return;
}
if (event.key === "-") {
options.zoom.value /= 1.15;
return;
}
if (event.key === "Tab") {
const container = options.selected.value.length
? options.selectedContainer(options.selected.value[0]!)
: options.insertionParent();
const siblings = options.nodesInContainer(container);
const ordered = tabOrder(siblings.map(options.tabItem));
const index = ordered.findIndex((node) => node.id === options.selected.value[0]);
const next = (index + (event.shiftKey ? -1 : 1) + ordered.length) % ordered.length;
if (ordered[next]) options.selected.value = [ordered[next]!.id];
event.preventDefault();
return;
}
if (event.altKey && event.key === "ArrowUp") {
const id = options.selected.value[0];
const parent = id ? options.parentId(id) : null;
if (id && parent) {
options.priorChild.set(parent, id);
options.selected.value = [parent];
}
event.preventDefault();
return;
}
if (event.altKey && event.key === "ArrowDown") {
const id = options.selected.value[0];
const children = id ? options.directChildren(id) : [];
const back = id ? options.priorChild.get(id) : undefined;
const next = children.some((child) => child.id === back)
? back
: tabOrder(children.map(options.tabItem))[0]?.id;
if (next) options.selected.value = [next];
event.preventDefault();
return;
}
if (event.key.startsWith("Arrow")) {
const ids = options.selected.value;
if (!ids.length) return;
event.preventDefault();
let phy = options.project().phy;
for (const id of ids) {
const node = options.node(id);
if (!node) continue;
if (event.shiftKey) {
if (node.type !== "key") continue;
const size = { ...node.size };
if (event.key === "ArrowRight") size.w = Math.max(0.25, size.w + 0.25);
if (event.key === "ArrowDown") size.h = Math.max(0.25, size.h + 0.25);
if (event.key === "ArrowLeft") size.w = Math.max(0.25, size.w - 0.25);
if (event.key === "ArrowUp") size.h = Math.max(0.25, size.h - 0.25);
phy = resizeKey(phy, id, size);
} else {
const delta =
event.key === "ArrowLeft"
? [-options.grid.value, 0]
: event.key === "ArrowRight"
? [options.grid.value, 0]
: event.key === "ArrowUp"
? [0, -options.grid.value]
: [0, options.grid.value];
phy = moveNode(phy, id, delta[0]!, delta[1]!);
}
}
options.dispatch(event.shiftKey ? "サイズ" : "移動", (project) => ({ ...project, phy }));
return;
}
if (event.key === "PageUp" || event.key === "PageDown") {
const ids = options.selected.value;
if (ids.length) {
const delta = event.key === "PageUp" ? 15 : -15;
options.dispatch("回転", (project) => ({
...project,
phy: ids.reduce((phy, id) => rotateNode(phy, id, delta), project.phy),
}));
}
return;
}
if (event.key.toLowerCase() === "r") {
const ids = options.selected.value.filter((id) => options.node(id)?.type === "key");
if (ids.length) {
const delta = event.shiftKey ? -90 : 90;
options.dispatch("向き変更", (project) => {
const copy = structuredClone(project);
for (const id of ids) {
const node = options.allNodes(copy.phy.root).find((item) => item.id === id);
if (node?.type === "key") {
node.orientation = (((node.orientation || 0) + delta + 360) % 360) as KeyNode["orientation"];
}
}
return copy;
});
}
return;
}
if (event.key.toLowerCase() === "v" && options.selected.value.length === 1) {
const group = options.node(options.selected.value[0]!);
if (group?.type === "variantGroup") {
const index = group.children.findIndex(
(variant) => variant.id === (options.variants.value[group.id] || group.default),
);
const variant =
group.children[(index + (event.shiftKey ? -1 : 1) + group.children.length) % group.children.length];
if (variant) options.chooseVariant(group, variant.id);
}
return;
}
if (event.key === "F2") {
(document.querySelector(".properties input") as HTMLInputElement | null)?.focus();
event.preventDefault();
}
};
}
+76
View File
@@ -0,0 +1,76 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import { longPressColumn, treeIndex, treeNodes } from "./logic";
export function usePhyTreeModel(project: () => ProjectFile, focus: Ref<string | null>) {
function allNodes(nodes = project().phy.root): Node[] {
return treeNodes(nodes);
}
function node(id: string): Node | undefined {
return treeIndex(project().phy.root).nodes.get(id);
}
function parentId(id: string): string | null {
return treeIndex(project().phy.root).selectableParent(id);
}
function isAncestor(candidate: string, id: string): boolean {
let parent = parentId(id);
while (parent) {
if (parent === candidate) return true;
parent = parentId(parent);
}
return false;
}
function selectedContainer(id: string): string | null {
return treeIndex(project().phy.root).insertionContainer(id);
}
function groupForVariant(id: string): string | null {
return treeIndex(project().phy.root).groupForVariant(id);
}
function parentLabel(id: string | null): string {
if (!id) return "root";
const item = node(id);
if (item?.type === "variantGroup") return item.name;
if (item?.type === "cluster") return item.name || "クラスター";
const variant = allNodes()
.flatMap((item) => (item.type === "variantGroup" ? item.children : []))
.find((item) => item.id === id);
return variant?.name || id;
}
function pressTargets(id: string): string[] {
const ancestors: string[] = [];
let parent = parentId(id);
while (parent) {
const group = allNodes().find(
(item) => item.type === "variantGroup" && item.children.some((variant) => variant.id === parent),
);
if (group) {
ancestors.push(group.id);
parent = parentId(group.id);
} else {
ancestors.push(parent);
parent = parentId(parent);
}
if (ancestors.at(-1) === focus.value) break;
}
return longPressColumn(id, ancestors, focus.value || undefined);
}
return {
allNodes,
node,
parentId,
isAncestor,
selectedContainer,
groupForVariant,
parentLabel,
pressTargets,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { onMounted, onUnmounted, type Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { Node } from "../../domain/phy";
import { fitViewport, zoomAround } from "./viewport";
import { localBounds } from "./logic";
type Options = {
project: () => ProjectFile;
pane: Ref<HTMLElement | undefined>;
svg: Ref<SVGSVGElement | undefined>;
size: Ref<{ w: number; h: number }>;
zoom: Ref<number>;
pan: Ref<{ x: number; y: number }>;
selected: Ref<string[]>;
variants: Ref<Record<string, string>>;
node: (id: string) => Node | undefined;
};
export function usePhyViewport(options: Options) {
let observer: ResizeObserver | null = null;
function fit() {
const bounds = localBounds(options.project().phy.root, options.variants.value);
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
function fitSelected() {
const nodes = options.selected.value
.map((id) => options.node(id))
.filter((node): node is Node => node !== undefined);
if (!nodes.length) return;
const bounds = localBounds(nodes, options.variants.value);
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
function localPoint(element: SVGSVGElement | SVGGElement, x: number, y: number) {
const transform = element.getScreenCTM();
if (!transform) return { x: 0, y: 0 };
const point = new DOMPoint(x, y).matrixTransform(transform.inverse());
return { x: point.x, y: point.y };
}
function wheel(event: WheelEvent) {
const svg = options.svg.value;
if (!svg) return;
const point = localPoint(svg, event.clientX, event.clientY);
const viewport = zoomAround(
point,
{ zoom: options.zoom.value, pan: options.pan.value },
Math.exp(-event.deltaY * 0.001),
);
options.zoom.value = viewport.zoom;
options.pan.value = viewport.pan;
}
onMounted(() => {
if (!options.pane.value) return;
observer = new ResizeObserver((entries) => {
const rect = entries[0]!.contentRect;
options.size.value = { w: rect.width, h: rect.height };
fit();
});
observer.observe(options.pane.value);
fit();
});
onUnmounted(() => observer?.disconnect());
return { fit, fitSelected, localPoint, wheel };
}
+46
View File
@@ -0,0 +1,46 @@
export type Viewport = { zoom: number; pan: { x: number; y: number } };
export type ViewBounds = { x: number; y: number; width: number; height: number };
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
export function fitViewport(
bounds: ViewBounds,
width: number,
height: number,
padding = 40,
minZoom = 8,
maxZoom = 160,
): Viewport {
const zoom = Math.max(
minZoom,
Math.min(
maxZoom,
Math.min(
(width - 2 * padding) / Math.max(bounds.width, 1),
(height - 2 * padding) / Math.max(bounds.height, 1),
),
),
);
return {
zoom,
pan: {
x: (width - (2 * bounds.x + bounds.width) * zoom) / 2,
y: (height - (2 * bounds.y + bounds.height) * zoom) / 2,
},
};
}
/** 指定した画面座標の下にある点を保ったまま倍率を変える。 */
export function zoomAround(
point: { x: number; y: number },
viewport: Viewport,
factor: number,
minZoom = 5,
maxZoom = 200,
): Viewport {
const zoom = Math.max(minZoom, Math.min(maxZoom, viewport.zoom * factor));
const world = {
x: (point.x - viewport.pan.x) / viewport.zoom,
y: (point.y - viewport.pan.y) / viewport.zoom,
};
return { zoom, pan: { x: point.x - world.x * zoom, y: point.y - world.y * zoom } };
}
+2
View File
@@ -0,0 +1,2 @@
import type { ProjectFile } from "../domain/project";
export type Dispatch = (label: string, update: (project: ProjectFile) => ProjectFile) => void;
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { createProjectFromKle } from "../src/domain/project";
import { saveProject, loadProject } from "../src/io/projectFile";
import { validateSchema } from "../src/io/validateSchema";
import { appendKeys, unregisteredKeys } from "../src/domain/idx";
import { assignKey, isEditableKeycode, parseKeycode, renameLayer, deleteLayer } from "../src/domain/map";
import { getKeycodeLabel } from "../src/domain/keycodeLabels";
import { qmkBasicKeycodes } from "../src/domain/qmkBasicKeycodes";
import { generateMockTarget } from "../src/domain/generator";
import { deleteNodeCascade } from "../src/domain/deleteCascade";
import { ProjectHistory } from "../src/domain/history";
import { validateProject } from "../src/domain/validate";
import { mcuPinCandidates } from "../src/domain/elc";
const kle = JSON.parse(
readFileSync(new URL("../../samples/kle/macropad-4x4.json", import.meta.url), "utf8"),
) as unknown;
const gen = (() => {
let i = 0;
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
})();
describe("domain project flow", () => {
it("supports QMK basic keycode names and aliases while preserving entered spelling", async () => {
const editable = [
"KC_ENT",
"KC_ENTER",
"KC_ESCAPE",
"KC_ESC",
"KC_P0",
"KC_KP_0",
"KC_LCTL",
"KC_LEFT_CTRL",
"KC_INT1",
"KC_LNG1",
];
for (const code of editable) expect(isEditableKeycode(code), code).toBe(true);
const formalNames = qmkBasicKeycodes.filter((entry) => entry.group !== "internal");
expect(formalNames.length).toBeGreaterThanOrEqual(100);
const p = await createProjectFromKle(kle, { idGenerator: gen });
const keyId = p.idx.order[0]!;
for (const code of editable) {
p.map = assignKey(p.map, "Base", keyId, code);
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
}
p.map = assignKey(p.map, "Base", keyId, "LCTL(KC_ENT)");
expect(isEditableKeycode("LCTL(KC_ENT)")).toBe(true);
p.map.layers.push({ name: "Nav", keymap: [] });
p.map = assignKey(p.map, "Base", keyId, "LT(Nav, KC_LNG1)");
expect(isEditableKeycode("LT(Nav, KC_LNG1)")).toBe(true);
p.map = assignKey(p.map, "Base", keyId, "KC_P0");
const roundTrip = await loadProject((await saveProject(p)).json!);
expect(roundTrip.project!.map.layers[0]!.keymap[0]!.keycode).toBe("KC_P0");
for (const code of ["KC_NO", "XXXXXXX", "KC_TRANSPARENT", "KC_TRNS", "_______"]) {
expect(isEditableKeycode(code), code).toBe(true);
p.map = assignKey(p.map, "Base", keyId, code);
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
}
for (const code of ["KC_FOO", "QK_BOOT"]) {
expect(parseKeycode(code).kind).toBe("unknown");
expect(isEditableKeycode(code)).toBe(false);
expect(() => assignKey(p.map, "Base", keyId, code)).toThrow();
}
p.map.layers[0]!.keymap[0]!.keycode = "KC_FOO";
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe("KC_FOO");
expect(getKeycodeLabel("KC_A")).toBe("A");
expect(getKeycodeLabel("KC_ENTER")).toBe("Enter");
expect(getKeycodeLabel("KC_ENT")).toBe("Enter");
expect(getKeycodeLabel("KC_ESCAPE")).toBe("Escape");
expect(getKeycodeLabel("KC_ESC")).toBe("Escape");
});
it("imports KLE, saves, reloads and validates schemas", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
const saved = await saveProject(p);
expect(saved.errors.some((issue) => issue.severity === "blocksSave")).toBe(false);
const loaded = await loadProject(saved.json!);
expect(loaded.errors.filter((x) => x.severity === "blocksSave")).toEqual([]);
expect(JSON.stringify(loaded.project)).toBe(JSON.stringify(p));
for (const l of ["phy", "idx", "elc", "map"] as const) expect(validateSchema(l, p[l])).toEqual([]);
});
it("preserves unknown data and IDX order", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
p.extra = { x: 1 };
(p.phy as typeof p.phy & { extraPhy: boolean }).extraPhy = true;
p.idx.extraIdx = true;
p.elc.extraElc = true;
p.map.extraMap = true;
const q = (await loadProject((await saveProject(p)).json!)).project!;
expect(q.extra).toEqual({ x: 1 });
expect((q.phy as typeof q.phy & { extraPhy: boolean }).extraPhy).toBe(true);
expect(q.idx.extraIdx).toBe(true);
expect(q.elc.extraElc).toBe(true);
expect(q.map.extraMap).toBe(true);
expect(unregisteredKeys(q.phy, appendKeys(q.idx, []))).toEqual([]);
});
it("supports MAP layer reference changes, duplicate checks and safe parsing", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
p.map.layers.push({ name: "Nav", keymap: [] });
p.map = assignKey(p.map, "Base", p.idx.order[0]!, "MO(Nav)");
p.map = assignKey(p.map, "Base", p.idx.order[1]!, "LT(Nav, KC_SPC)");
p.map = renameLayer(p.map, "Nav", "Fn");
expect(p.map.layers[0]!.keymap.map((x) => x.keycode)).toEqual(["MO(Fn)", "LT(Fn, KC_SPC)"]);
expect(parseKeycode("MO(Fn)").layer).toBe("Fn");
expect(() => deleteLayer(p.map, "Fn")).toThrow();
p.map.layers[0]!.keymap.push({ ...p.map.layers[0]!.keymap[0]! });
expect(validateProject(p).some((x) => x.code === "duplicate-map-key")).toBe(true);
});
it("generates ordered matrix/keymap previews and cascades deletion", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
const ids = p.idx.order;
p.output.matrix = {
rowPins: ["m.GP0", "m.GP1", "m.GP2", "m.GP3"],
colPins: ["m.GP4", "m.GP5", "m.GP6", "m.GP7"],
};
p.elc.providers.push({ id: "m", kind: "mcu", part: "RP2040" });
p.elc.ledChains = [{ id: "chain", data: "m.GP8", order: [ids[0]!, ids[1]!] }];
p.map.layers[0]!.keymap = ids.map((keyId, i) => ({ keyId, keycode: `KC_${i % 10}` }));
p.elc.keys = ids.map((key, i) => ({
key,
detect: "contact",
terminals: { a: `m.GP${Math.floor(i / 4)}`, b: `m.GP${4 + (i % 4)}` },
diode: "b-to-a",
}));
const out = generateMockTarget(p);
expect(out.errors).toEqual([]);
expect(out.target?.idx).toEqual(ids);
expect(out.target?.matrix.rows.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
expect(out.target?.matrix.cols.slice(0, 5)).toEqual([0, 1, 2, 3, 0]);
expect(out.target?.keymaps[0]!.keycodes).toHaveLength(16);
const q = deleteNodeCascade(p, ids[0]!);
expect(q.idx.order).not.toContain(ids[0]);
expect(q.elc.keys.some((x) => x.key === ids[0])).toBe(false);
expect(q.elc.ledChains?.[0]?.order).not.toContain(ids[0]);
expect(q.map.layers[0]!.keymap.some((x) => x.keyId === ids[0])).toBe(false);
});
it("groups history edits", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen }),
h = new ProjectHistory(p);
const q = structuredClone(p);
q.phy.metadata.name = "drag";
h.begin();
h.push(q);
h.replaceTop(q);
h.commit(q);
expect(h.canUndo).toBe(true);
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
expect(h.redo().phy.metadata.name).toBe("drag");
});
it("imports the VIA geometry sample", async () => {
const via = JSON.parse(
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
) as unknown;
const p = await createProjectFromKle(via, { idGenerator: gen });
expect(validateSchema("phy", p.phy)).toEqual([]);
});
it("detects T-25 collisions and permits reversed diode pairs and exclusive variants", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
const ids = p.idx.order.slice(0, 2);
p.phy.root = p.phy.root.filter((n) => n.type === "key" && ids.includes(n.id));
p.idx.order = ids;
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
p.elc.keys = [
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
];
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
p.elc.keys = [
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "b-to-a" },
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" }, diode: "b-to-a" },
];
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
p.elc.keys = [
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
];
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
p.elc.keys = [
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "a-to-b" },
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
];
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
const a = p.phy.root[0]!,
b = p.phy.root[1]!;
p.phy.root = [
{
type: "variantGroup",
id: "vg",
name: "Choice",
transform: { x: 0, y: 0, rotation: 0 },
default: "va",
children: [
{ type: "variant", id: "va", name: "A", children: [a] },
{ type: "variant", id: "vb", name: "B", children: [b] },
],
},
];
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
expect(mcuPinCandidates(p.elc.providers[0] as { id: string; kind: "mcu"; part: string })).toHaveLength(
30,
);
});
it("filters variant output without rewriting stored IDX order", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen });
const ids = p.idx.order.slice(0, 2);
p.phy.root = [
{
type: "variantGroup",
id: "vgx",
name: "Choice",
transform: { x: 0, y: 0, rotation: 0 },
default: "vax",
children: [
{
type: "variant",
id: "vax",
name: "A",
children: [
{
type: "key",
id: ids[0]!,
profile: "default",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
},
],
},
{
type: "variant",
id: "vbx",
name: "B",
children: [
{
type: "key",
id: ids[1]!,
profile: "default",
transform: { x: 1, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
},
],
},
],
},
];
p.output.variantSelection = { vgx: "vax" };
p.idx.order = ids;
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
p.output.matrix = { rowPins: ["m.GP0"], colPins: ["m.GP1"] };
p.elc.keys = ids.map((key) => ({
key,
detect: "contact",
terminals: { a: "m.GP0", b: "m.GP1" },
diode: "b-to-a",
}));
p.map.layers[0]!.keymap = ids.map((keyId) => ({ keyId, keycode: "KC_A" }));
let g = generateMockTarget(p);
expect(g.target?.idx).toEqual([ids[0]]);
p.output.variantSelection = { vgx: "vbx" };
g = generateMockTarget(p);
expect(g.target?.idx).toEqual([ids[1]]);
expect(p.idx.order).toEqual(ids);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { createElc } from "../src/domain/elc";
import { wireMatrixKeys } from "../src/domain/elcMatrix";
describe("wireMatrixKeys", () => {
it("assigns row-major pins and diode without mutating source", () => {
const source = createElc({ source: "test", checksum: "" });
const next = wireMatrixKeys(
source,
["k1", "k2", "k3"],
["MCU.GP0", "MCU.GP1"],
["MCU.GP2", "MCU.GP3"],
"a-to-b",
);
expect(next.keys.map((w) => w.detect === "contact" && [w.terminals.a, w.terminals.b, w.diode])).toEqual([
["MCU.GP0", "MCU.GP2", "a-to-b"],
["MCU.GP0", "MCU.GP3", "a-to-b"],
["MCU.GP1", "MCU.GP2", "a-to-b"],
]);
expect(source.keys).toEqual([]);
});
it("rejects an undersized matrix", () => {
expect(() =>
wireMatrixKeys(createElc({ source: "test", checksum: "" }), ["a", "b"], ["R"], ["C"]),
).toThrow();
});
it("preserves extension fields when updating existing wiring", () => {
const source = {
...createElc({ source: "test", checksum: "" }),
keys: [
{
key: "k1",
detect: "contact" as const,
terminals: { a: "OLD.R", b: "OLD.C" },
extension: "keep",
},
],
};
const next = wireMatrixKeys(source, ["k1"], ["R"], ["C"]);
expect(next.keys[0]).toMatchObject({ extension: "keep", terminals: { a: "R", b: "C" } });
});
});
File diff suppressed because it is too large Load Diff
+374
View File
@@ -0,0 +1,374 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import type { KeyNode, PhyFile } from "../src/domain/phy";
import { flattenKeys } from "../src/domain/geometry";
import { clusterNodes, reframe, reparent } from "../src/domain/phyCommands";
import { importKle } from "../src/io/kleImport";
import { exportKle } from "../src/io/kleExport";
import { createIdx } from "../src/domain/idx";
type OracleKey = {
x: number;
y: number;
width: number;
height: number;
x2: number;
y2: number;
width2: number;
height2: number;
rotation_angle: number;
rotation_x: number;
rotation_y: number;
labels: (string | null)[];
textColor: (string | null)[];
textSize: (number | null)[];
default: { textColor: string; textSize: number };
color: string;
profile: string;
nub: boolean;
stepped: boolean;
decal: boolean;
ghost: boolean;
sm: string;
sb: string;
st: string;
};
type Case = { input: unknown[]; meta: Record<string, unknown>; keys: OracleKey[] };
const oracle = JSON.parse(
readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"),
) as Record<string, Case>;
// Independent expected lookup derived from the frozen serial.js output fixture.
const labelMap: readonly (readonly (number | null)[])[] = [
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
[null, null, null, null, 0, null, null, null, null, null, 4, null],
];
const uuid = (() => {
let i = 0;
return () => `12345678-90ab-4cde-8f01-${String(++i).padStart(12, "0")}`;
})();
const angleRad = (degrees: number) => (degrees * Math.PI) / 180;
const near = (actual: number, expected: number, tolerance = 1e-9) =>
expect(Math.abs(actual - expected), `${actual} differs from ${expected}`).toBeLessThanOrEqual(tolerance);
function canonicalLabels(labels: (string | null)[]): string[] {
const result = labels.map((label) => label ?? "");
while (result.at(-1) === "") result.pop();
return result;
}
function oracleOrigin(k: OracleKey): { x: number; y: number } {
const r = angleRad(k.rotation_angle),
dx = k.x - k.rotation_x,
dy = k.y - k.rotation_y;
return {
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
};
}
function expectedCorners(k: OracleKey, second = false): { x: number; y: number }[] {
const r = angleRad(k.rotation_angle),
ox = k.x + (second ? k.x2 : 0),
oy = k.y + (second ? k.y2 : 0);
const w = second ? k.width2 : k.width,
h = second ? k.height2 : k.height;
return [
[0, 0],
[w, 0],
[w, h],
[0, h],
].map(([x, y]) => {
const px = ox + x!,
py = oy + y!,
dx = px - k.rotation_x,
dy = py - k.rotation_y;
return {
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
};
});
}
function keyCorners(k: KeyNode, second = false): { x: number; y: number }[] {
const r = angleRad(k.transform.rotation),
ox = second ? (k.size.x2 ?? 0) : 0,
oy = second ? (k.size.y2 ?? 0) : 0;
const w = second ? (k.size.w2 ?? k.size.w) : k.size.w,
h = second ? (k.size.h2 ?? k.size.h) : k.size.h;
return [
[0, 0],
[w, 0],
[w, h],
[0, h],
].map(([x, y]) => ({
x: k.transform.x + Math.cos(r) * (ox + x!) - Math.sin(r) * (oy + y!),
y: k.transform.y + Math.sin(r) * (ox + x!) + Math.cos(r) * (oy + y!),
}));
}
function expectedStyleArrays(k: OracleKey, key: KeyNode): void {
const compat = key.kleCompatibility ?? {},
a = typeof compat.a === "number" ? compat.a : 4;
const slots = labelMap[a] ?? labelMap[4]!;
const t = typeof compat.t === "string" ? compat.t.split("\n") : [];
expect(compat.c).toBe(k.color);
expect(compat.p).toBe(k.profile);
expect(compat.sm).toBe(k.sm);
expect(compat.sb).toBe(k.sb);
expect(compat.st).toBe(k.st);
expect(compat.l === true).toBe(k.stepped);
expect(compat.n === true).toBe(k.nub);
expect(key.decal === true).toBe(k.decal);
expect(key.ghost === true).toBe(k.ghost);
expect(compat.f).toBe(k.default.textSize);
const defaultTextColor = t[0] ?? "#000000";
expect(k.default.textColor).toBe(defaultTextColor);
const expectedColors = Array<string | null>(12).fill(null),
expectedSizes = Array<number | null>(12).fill(null);
slots.forEach((source, slot) => {
const hasLabel = (key.labels?.[slot] ?? "") !== "";
if (hasLabel && source !== null && source > 0 && source < t.length) expectedColors[slot] = t[source]!;
if (hasLabel && source !== null && source >= 0) {
if (Array.isArray(compat.fa) && source < compat.fa.length && typeof compat.fa[source] === "number")
expectedSizes[slot] = compat.fa[source] as number;
else if (source > 0 && typeof compat.f2 === "number") expectedSizes[slot] = compat.f2;
}
});
const oracleColors = Array.from({ length: 12 }, (_, i) => k.textColor[i] ?? null);
const oracleSizes = Array.from({ length: 12 }, (_, i) => k.textSize[i] ?? null);
expect(expectedColors).toEqual(oracleColors);
expect(expectedSizes).toEqual(oracleSizes);
for (let i = 0; i < 12; i++) {
expect(expectedColors[i]).toBe(oracleColors[i]);
expect(expectedSizes[i]).toBe(oracleSizes[i]);
}
}
function verify(phy: PhyFile, expected: Case, tolerance = 1e-9): void {
const keys = flattenKeys(phy).map((x) => x.key);
expect(keys).toHaveLength(expected.keys.length);
expect(phy.kleCompatibility).toEqual(expected.meta);
const unmatched = [...keys];
for (const want of expected.keys) {
const origin = oracleOrigin(want),
labels = canonicalLabels(want.labels);
const index = unmatched.findIndex(
(candidate) =>
JSON.stringify(candidate.labels ?? []) === JSON.stringify(labels) &&
Math.abs(candidate.transform.x - origin.x) < 1e-6 &&
Math.abs(candidate.transform.y - origin.y) < 1e-6,
);
expect(
index,
`missing oracle key labels=${JSON.stringify(labels)} at ${origin.x},${origin.y}`,
).toBeGreaterThanOrEqual(0);
const actual = unmatched.splice(index, 1)[0]!;
near(actual.transform.x, origin.x, tolerance);
near(actual.transform.y, origin.y, tolerance);
near(actual.transform.rotation, want.rotation_angle, tolerance);
expect(actual.labels ?? []).toEqual(labels);
expect(actual.size.w).toBe(want.width);
expect(actual.size.h).toBe(want.height);
expect(actual.size.x2 ?? 0).toBe(want.x2);
expect(actual.size.y2 ?? 0).toBe(want.y2);
expect(actual.size.w2 ?? actual.size.w).toBe(want.width2);
expect(actual.size.h2 ?? actual.size.h).toBe(want.height2);
const actualMain = keyCorners(actual),
expectedMain = expectedCorners(want);
const actualSecond = keyCorners(actual, true),
expectedSecond = expectedCorners(want, true);
[...actualMain, ...actualSecond].forEach((point, i) => {
const target = [...expectedMain, ...expectedSecond][i]!;
near(point.x, target.x, tolerance);
near(point.y, target.y, tolerance);
});
expectedStyleArrays(want, actual);
}
}
describe("KLE importer against frozen serial.js oracle", () => {
for (const [name, testCase] of Object.entries(oracle)) {
it(`${name}: import and export/import match oracle`, () => {
const first = importKle(testCase.input, { idGenerator: uuid });
verify(first.phy, testCase);
const eligible = first.phy.root.filter((node): node is KeyNode => node.type === "key" && !node.decal);
expect(first.keyOrder).toEqual(eligible.map((key) => key.id));
const second = importKle(exportKle(first.phy), { idGenerator: uuid });
verify(second.phy, testCase, 1e-6);
});
}
});
describe("PHY regression cases from Sol review", () => {
const base = (root: PhyFile["root"]): PhyFile => ({
version: "0.1.0",
metadata: {},
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
root,
});
it("reparent rejects self and descendants regardless of moved node kind", () => {
const phy = base([
{
type: "cluster",
id: "parent",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "cluster",
id: "child",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{ type: "cluster", id: "grandchild", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
],
},
],
},
{
type: "variantGroup",
id: "group",
name: "G",
transform: { x: 0, y: 0, rotation: 0 },
default: "v",
children: [
{
type: "variant",
id: "v",
name: "V",
children: [
{ type: "cluster", id: "inside", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
],
},
{ type: "variant", id: "v2", name: "V2", children: [] },
],
},
]);
expect(() => reparent(phy, "parent", "parent")).toThrow();
expect(() => reparent(phy, "parent", "child")).toThrow();
expect(() => reparent(phy, "child", "child")).toThrow();
expect(() => reparent(phy, "child", "grandchild")).toThrow();
expect(() => reparent(phy, "group", "inside")).toThrow();
});
it("cluster frame starts at the rotated shape bounds, including second rectangles", () => {
const phy = base([
{
type: "key",
id: "wide",
profile: "default",
transform: { x: 2, y: 2, rotation: 90 },
size: { w: 2, h: 1, x2: -1, y2: -0.5, w2: 1, h2: 1 },
},
{
type: "key",
id: "other",
profile: "default",
transform: { x: 5, y: 2, rotation: 90 },
size: { w: 1, h: 1 },
},
]);
const grouped = clusterNodes(
phy,
["wide", "other"],
"block",
() => "12345678-90ab-4cde-8f01-000000000001",
);
const cluster = grouped.root[0]!;
expect(cluster.type).toBe("cluster");
if (cluster.type !== "cluster") throw new Error("Expected cluster");
// In the cluster's 90-degree coordinate frame the second rectangle extends left/up.
near(cluster.transform.rotation, 90);
near(cluster.transform.x, 5);
near(cluster.transform.y, 1);
});
it("flattening propagates raw ancestor rotations without normalizing", () => {
const phy = base([
{
type: "cluster",
id: "a",
transform: { x: 0, y: 0, rotation: 450 },
children: [
{
type: "cluster",
id: "b",
transform: { x: 0, y: 0, rotation: 400 },
children: [
{
type: "key",
id: "k",
profile: "default",
transform: { x: 0, y: 0, rotation: -20 },
size: { w: 1, h: 1 },
},
],
},
],
},
]);
expect(flattenKeys(phy)[0]!.rotation).toBe(830);
});
it("cluster, uncluster, reparent, and reframe retain raw world angles", () => {
let phy = base([
{
type: "cluster",
id: "outer",
transform: { x: 1, y: 2, rotation: 400 },
children: [
{
type: "key",
id: "a",
profile: "default",
transform: { x: 0, y: 0, rotation: 50 },
size: { w: 1, h: 1 },
},
{
type: "key",
id: "b",
profile: "default",
transform: { x: 2, y: 0, rotation: 50 },
size: { w: 1, h: 1 },
},
],
},
{ type: "cluster", id: "target", transform: { x: -1, y: 4, rotation: 720 }, children: [] },
]);
const rotations = () =>
Object.fromEntries(flattenKeys(phy).map(({ key, rotation }) => [key.id, rotation]));
expect(rotations()).toEqual({ a: 450, b: 450 });
phy = clusterNodes(phy, ["a", "b"], "nested", uuid);
expect(rotations()).toEqual({ a: 450, b: 450 });
const outer = phy.root[0]!;
if (outer.type !== "cluster") throw new Error("Expected outer cluster");
const nested = outer.children[0]!;
if (nested.type !== "cluster") throw new Error("Expected nested cluster");
phy = reframe(phy, nested.id, { x: 3, y: -2, rotation: 810 });
expect(rotations()).toEqual({ a: 450, b: 450 });
phy = reparent(phy, "a", "target");
expect(rotations()).toEqual({ a: 450, b: 450 });
phy = clusterNodes(phy, ["b"], "single", uuid);
expect(rotations()).toEqual({ a: 450, b: 450 });
});
it("T-18 initial order filters decal and retains ghost", () => {
const phy = base([
{
type: "key",
id: "decal",
profile: "default",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
decal: true,
},
{
type: "key",
id: "ghost",
profile: "default",
transform: { x: 1, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
ghost: true,
},
]);
const imported = importKle([[{ d: true }, "D", { g: true }, "G"]], { idGenerator: uuid });
expect(imported.keyOrder).toHaveLength(1);
const idx = createIdx(phy, ["decal", "ghost"], { source: "x", checksum: "sha256-jcs:x" });
expect(idx.order).toEqual(["ghost"]);
});
});
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { PhyFile, KeyNode } from "../src/domain/phy";
import { flattenKeys } from "../src/domain/geometry";
import { importKle } from "../src/io/kleImport";
import { exportKle } from "../src/io/kleExport";
import { canonicalize, sha256Jcs } from "../src/io/json";
import {
addVariant,
clusterNodes,
createVariantGroup,
deleteVariant,
reframe,
reparent,
uncluster,
} from "../src/domain/phyCommands";
const gen = (() => {
let i = 0;
return () => {
const s = String(++i).padStart(12, "0");
return `${s.slice(0, 8)}-${s.slice(8)}-4000-8000-000000000000`;
};
})();
function sample(path: string): unknown {
return JSON.parse(readFileSync(resolve(process.cwd(), "../samples/kle", path), "utf8")) as unknown;
}
function byShape(p: PhyFile) {
return flattenKeys(p)
.map((k) => ({
x: k.x,
y: k.y,
r: k.rotation,
w: k.key.size.w,
h: k.key.size.h,
labels: k.key.labels,
compat: k.key.kleCompatibility,
}))
.sort(
(a, b) =>
Math.round(a.r * 1e6) - Math.round(b.r * 1e6) ||
Math.round(a.y * 1e6) - Math.round(b.y * 1e6) ||
Math.round(a.x * 1e6) - Math.round(b.x * 1e6),
);
}
describe("KLE round trip", () => {
for (const file of ["macropad-4x4.json", "cr0wn60.json"])
it(file, () => {
const a = importKle(sample(file), { idGenerator: gen }).phy;
const b = importKle(exportKle(a), { idGenerator: gen }).phy;
const aa = byShape(a),
bb = byShape(b);
expect(bb).toHaveLength(aa.length);
for (let i = 0; i < aa.length; i++) {
const x = aa[i]!,
y = bb[i]!;
expect(
Math.abs(x.x - y.x),
`${file} item ${i}: x ${x.x} vs ${y.x}; y=${x.y}/${y.y}; rot=${x.r}/${y.r}; labels=${JSON.stringify(x.labels)}/${JSON.stringify(y.labels)}`,
).toBeLessThanOrEqual(1e-6);
expect(Math.abs(x.y - y.y), `${file} item ${i}: y ${x.y} vs ${y.y}`).toBeLessThanOrEqual(1e-6);
expect(Math.abs(x.r - y.r)).toBeLessThanOrEqual(1e-6);
expect(x.w).toBe(y.w);
expect(x.h).toBe(y.h);
expect(x.labels).toEqual(y.labels);
expect(x.compat).toEqual(y.compat);
}
});
it("rotated KLE cursor, mid-row rotation and pivot changes", () => {
const { phy } = importKle([[{ r: 90, rx: 1, ry: 1 }, "A", "B", { r: 180, rx: 2, ry: 1 }, "C"]], {
idGenerator: gen,
});
const k = phy.root as KeyNode[];
expect(k.map((x) => [x.transform.x, x.transform.y, x.transform.rotation])).toEqual([
[1, 1, 90],
[1, 2, 90],
[2, 1, 180],
]);
const corners = (n: KeyNode) =>
[
[0, 0],
[n.size.w, 0],
[n.size.w, n.size.h],
[0, n.size.h],
].map(([x, y]) => {
const a = (n.transform.rotation * Math.PI) / 180;
return [
n.transform.x + Math.cos(a) * x! - Math.sin(a) * y!,
n.transform.y + Math.sin(a) * x! + Math.cos(a) * y!,
];
});
const check = (actual: number[][], expected: number[][]) =>
actual.forEach((p, i) =>
p.forEach((v, j) => expect(Math.abs(v - expected[i]![j]!)).toBeLessThanOrEqual(1e-12)),
);
check(corners(k[0]!), [
[1, 1],
[1, 2],
[0, 2],
[0, 1],
]);
check(corners(k[2]!), [
[2, 1],
[1, 1],
[1, 0],
[2, 0],
]);
});
});
function fixture(): PhyFile {
return {
version: "0.1.0",
metadata: {},
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
root: [
{
type: "cluster",
id: "outer",
name: "Outer",
transform: { x: 4, y: 2, rotation: 30 },
children: [
{
type: "cluster",
id: "inner",
name: "Inner",
transform: { x: 1, y: 2, rotation: 20 },
children: [
{
type: "key",
id: "a",
profile: "default",
transform: { x: 0.2, y: 0.4, rotation: 15 },
size: { w: 1, h: 1 },
},
{
type: "key",
id: "b",
profile: "default",
transform: { x: 1.4, y: -0.2, rotation: -5 },
size: { w: 1, h: 1 },
},
],
},
],
},
{
type: "cluster",
id: "other",
name: "Other",
transform: { x: -3, y: 6, rotation: -12 },
children: [],
},
],
};
}
const pose = (p: PhyFile) =>
Object.fromEntries(flattenKeys(p).map((k) => [k.key.id, [k.x, k.y, k.rotation]]));
const closePose = (a: Record<string, number[]>, b: Record<string, number[]>) => {
for (const id of Object.keys(a)) {
a[id]!.forEach((v, i) =>
expect(Math.abs(v - b[id]![i]!), `pose ${id} component ${i}: ${v} vs ${b[id]![i]}`).toBeLessThanOrEqual(
1e-9,
),
);
}
};
describe("PHY commands and variants", () => {
it("preserves nested rotated key world placement through cluster, uncluster, reparent, reframe", () => {
let p = fixture();
const before = pose(p);
p = clusterNodes(p, ["a", "b"], "picked", gen);
closePose(before, pose(p));
const outer = p.root[0]!;
if (outer.type !== "cluster") throw Error();
const inner = outer.children[0]!;
if (inner.type !== "cluster") throw Error();
const created = inner.children[0]!;
if (created.type !== "cluster") throw Error();
p = uncluster(p, created.id);
closePose(before, pose(p));
p = reparent(p, "a", "other");
closePose(before, pose(p));
p = reframe(p, "outer", { x: 8, y: -4, rotation: -20 });
closePose(before, pose(p));
});
it("flattens variant selection, exports only the selected branch, and applies deletion rules", () => {
let p: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
root: [],
};
p = createVariantGroup(
p,
"Layout",
{ x: 2, y: 0, rotation: 0 },
[
{
name: "ANSI",
children: [
{
type: "key",
id: "ansi",
profile: "default",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
labels: ["Enter"],
},
],
},
{
name: "ISO",
children: [
{
type: "key",
id: "iso",
profile: "default",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
labels: ["Enter"],
},
],
},
],
gen,
);
const group = p.root[0]!;
if (group.type !== "variantGroup") throw Error();
expect(flattenKeys(p).map((x) => x.key.id)).toEqual(["ansi"]);
const choice = group.children[1]!.id;
expect(flattenKeys(p, { [group.id]: choice }).map((x) => x.key.id)).toEqual(["iso"]);
const out = JSON.stringify(exportKle(p, { [group.id]: choice }));
expect(out).not.toContain('"ansi"');
expect(() => deleteVariant(p, group.id, group.children[0]!.id)).toThrow();
p = addVariant(p, group.id, "JIS", gen);
const grown = p.root[0]!;
if (grown.type !== "variantGroup") throw Error();
p = deleteVariant(p, group.id, grown.default);
const reduced = p.root[0]!;
if (reduced.type !== "variantGroup") throw Error();
expect(reduced.default).toBe(reduced.children[0]!.id);
});
});
describe("JCS", () => {
it("sorts keys and uses ECMAScript number formatting", () => {
expect(canonicalize({ z: 1, a: 1e-6, b: 1e21, c: -0 })).toBe('{"a":0.000001,"b":1e+21,"c":0,"z":1}');
expect(canonicalize([333333333.33333329, 1e30, 4.5, 2e-3, 1e-27])).toBe(
"[333333333.3333333,1e+30,4.5,0.002,1e-27]",
);
});
it("hashes canonical UTF-8 with SHA-256", async () => {
expect(await sha256Jcs({ a: 1 })).toBe(
"sha256-jcs:015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862",
);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { PhyFile } from "../src/domain/phy";
import {
addComponentToParent,
addKeyToParent,
cloneNodesWithNewIds,
duplicateNode,
insertNodes,
} from "../src/domain/phyCommands";
const ids = (() => {
let n = 0;
return () => `0000000${++n}-0000-4000-8000-000000000000`;
})();
const file: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [{ id: "p", name: "P", unitSize: { x: 1, y: 1 } }],
root: [
{ type: "cluster", id: "cl-parent", name: "P", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
],
};
describe("PHY parent insertion and duplication", () => {
it("adds keys and components to root, clusters, or variants", () => {
let p = addKeyToParent(file, "cl-parent", { x: 2, y: 3, rotation: 0 }, { w: 1, h: 1 }, "p", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[0]?.type,
).toBe("key");
p = addComponentToParent(p, "cl-parent", { x: 0, y: 0, rotation: 0 }, "@led", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[1]?.type,
).toBe("component");
});
it("inserts a copied node batch into a selected container", () => {
const node = {
type: "key" as const,
id: "key-source",
profile: "p",
transform: { x: 1, y: 1, rotation: 0 },
size: { w: 1, h: 1 },
};
const p = insertNodes(file, "cl-parent", [node]);
expect(
p.root[0]!.type === "cluster" && (p.root[0]! as import("../src/domain/phy").ClusterNode).children,
).toHaveLength(1);
});
it("renews IDs and remaps a duplicated group's internal default", () => {
const base: PhyFile = {
...file,
root: [
{
type: "variantGroup",
id: "vg-source",
name: "VG",
transform: { x: 0, y: 0, rotation: 0 },
default: "v-one",
children: [
{ type: "variant", id: "v-one", name: "One", children: [] },
{ type: "variant", id: "v-two", name: "Two", children: [] },
],
},
],
};
const q = duplicateNode(base, "vg-source", ids);
const copy = q.root[1];
expect(copy?.type).toBe("variantGroup");
if (copy?.type === "variantGroup") {
expect(copy.id).not.toBe("vg-source");
expect(copy.default).not.toBe("v-one");
expect(copy.children.map((v) => v.id)).not.toContain("v-one");
expect(copy.default).toBe(copy.children[0]!.id);
}
});
it("remaps defaults for variant groups nested inside a copied cluster", () => {
const nested: PhyFile["root"][number] = {
type: "cluster",
id: "outer",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "variantGroup",
id: "vg-inner",
name: "Inner",
transform: { x: 1, y: 2, rotation: 0 },
default: "v-inner-a",
children: [
{ type: "variant", id: "v-inner-a", name: "A", children: [] },
{ type: "variant", id: "v-inner-b", name: "B", children: [] },
],
},
],
};
const copy = cloneNodesWithNewIds([nested], ids)[0];
expect(copy?.type).toBe("cluster");
if (copy?.type === "cluster") {
const group = copy.children[0];
expect(group?.type).toBe("variantGroup");
if (group?.type === "variantGroup") {
expect(group.default).toBe(group.children[0]!.id);
expect(group.default).not.toBe("v-inner-a");
}
}
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
GRID_STEPS,
localBounds,
insertionContainer,
longPressColumn,
nextGrid,
normalizeSelection,
obbAabb,
removeFocusedSubtree,
pasteLocalTransform,
pasteParent,
resolveKey,
snap,
tabOrder,
treeIndex,
visibleNodeIds,
} from "../src/ui/phy/logic";
import { fitViewport, zoomAround } from "../src/ui/phy/viewport";
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
const phy: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [],
root: [
{
type: "cluster",
id: "c",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{ type: "key", id: "k", profile: "p", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 } },
],
},
],
};
describe("PHY pure interaction rules", () => {
it("removes ancestors and descendants from mixed selections", () => {
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
const outside = removeFocusedSubtree(phy.root, "k");
expect(treeIndex(outside).nodes.has("c")).toBe(true);
expect(treeIndex(outside).nodes.has("k")).toBe(false);
expect(outside[0]?.type === "cluster" ? outside[0].children : []).toEqual([]);
});
it("cycles long press candidates and stops before the focus parent", () => {
expect(longPressColumn("k", ["c", "root"], "c")).toEqual(["k"]);
expect(longPressColumn("k", ["c", "root"])).toEqual(["k", "c", "root"]);
});
it("orders rows with a half unit tolerance", () => {
expect(
tabOrder([
{ id: "b", x: 2, y: 0.4 },
{ id: "a", x: 1, y: 0 },
{ id: "c", x: 0, y: 1 },
]).map((x) => x.id),
).toEqual(["a", "b", "c"]);
});
it("computes bounds and paste target", () => {
expect(
obbAabb([
{ x: 2, y: 3 },
{ x: -1, y: 5 },
]),
).toEqual({ x: -1, y: 3, width: 3, height: 2 });
expect(pasteParent([], "c")).toBe("c");
expect(pasteParent(["p"], null)).toBe("p");
expect(pasteParent([null], "c")).toBeNull();
});
it("computes child bounds in parent axes and converts paste world position", () => {
const group: PhyNode = {
type: "cluster",
id: "g",
transform: { x: 10, y: 20, rotation: 90 },
children: [
{
type: "key",
id: "key2",
profile: "p",
transform: { x: 2, y: 3, rotation: 0 },
size: { w: 1, h: 2 },
},
],
};
expect(localBounds(group.type === "cluster" ? group.children : [])).toEqual({
x: 2,
y: 3,
width: 1,
height: 2,
});
expect(
pasteLocalTransform(
{ a: 0, b: 1, c: -1, d: 0, e: 10, f: 20 },
{ a: 0, b: 1, c: -1, d: 0, e: 8, f: 18 },
0.25,
),
).toEqual({ x: 2.25, y: -1.75, rotation: 0 });
});
it("distinguishes selectable parents from variant insertion containers without ID prefixes", () => {
const roots: PhyNode[] = [
{
type: "variantGroup",
id: "group-any-id",
name: "Layout",
transform: { x: 0, y: 0, rotation: 0 },
default: "option-any-id",
children: [
{
type: "variant",
id: "option-any-id",
name: "Main",
children: [
{
type: "key",
id: "key-any-id",
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
},
],
},
],
},
];
const index = treeIndex(roots);
expect(index.selectableParent("key-any-id")).toBe("group-any-id");
expect(index.insertionContainer("key-any-id")).toBe("option-any-id");
expect(index.groupForVariant("option-any-id")).toBe("group-any-id");
expect(insertionContainer(roots, "group-any-id", { "group-any-id": "option-any-id" })).toBe(
"option-any-id",
);
expect(visibleNodeIds(roots[0], {}).has("key-any-id")).toBe(true);
const outside = removeFocusedSubtree(roots, "key-any-id");
expect(treeIndex(outside).nodes.has("key-any-id")).toBe(false);
expect(treeIndex(outside).nodes.has("group-any-id")).toBe(true);
});
it("snaps without changing off-grid direct numeric values and cycles steps", () => {
expect(snap(0.37, 0.25)).toBe(0.25);
expect(nextGrid(0.25, 1)).toBe(0.125);
expect(GRID_STEPS).toHaveLength(4);
});
it("resolves keyboard layout independent digit shortcuts and modifiers", () => {
expect(
resolveKey({ key: "@", code: "Digit1", shiftKey: true, ctrlKey: false, metaKey: false, altKey: false }),
).toBe("fit");
expect(
resolveKey({ key: "z", code: "KeyZ", shiftKey: true, ctrlKey: true, metaKey: false, altKey: false }),
).toBe("redo");
});
it("fits bounds centrally with padding and zooms around the cursor", () => {
const fitted = fitViewport({ x: 10, y: 20, width: 4, height: 2 }, 400, 300, 20);
expect(fitted.zoom).toBe(90);
expect(fitted.pan.x + (10 + 2) * fitted.zoom).toBeCloseTo(200);
expect(fitted.pan.y + (20 + 1) * fitted.zoom).toBeCloseTo(150);
const cursor = { x: 140, y: 160 },
world = { x: (cursor.x - fitted.pan.x) / fitted.zoom, y: (cursor.y - fitted.pan.y) / fitted.zoom },
zoomed = zoomAround(cursor, fitted, 1.5);
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
});
});

Some files were not shown because too many files have changed in this diff Show More