Compare commits

..
Author SHA1 Message Date
ewdernaandClaude Opus 5.5 cafd4dd2c4 journal: 二乗マトリクスの出典を正式タイトルと参照先に分けて書く
略記していたタイトルを各記事の正式タイトルに直し、著者・掲載先・日付と URL を分けた。
ikeji 2019 の記事名は「キーボードの回路」ではなく「キーボードのマトリクス方式の分類」だった。

Journal: journal/2026-10-29-square-matrix.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRs1RXxQe5ZgZFoSsXppV5
2026-10-29 08:54:33 +09:00
ewdernaandClaude Opus 5.5 c250efd8ae journal: 二乗マトリクスの一般知識とファームウェアの対応を調べる
ELC Editorの要検討(二乗マトリクスの操作)の前提として整理した。
実用されるのは改良二乗マトリクスで、追加ダイオードはピンごとに決まった位置に付くのでELCで持たず、Generatorで扱う。
QMK(rows/colsに同じピン+matrix_mask)とZMK(charlieplexドライバ)ではrow/colの向きが逆になり、これもGeneratorで扱う。

Journal: journal/2026-10-29-square-matrix.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRs1RYYjdQpozrhQMNBY3G
2026-10-29 08:41:11 +09:00
ewdernaandClaude Opus 5.5 754b1d5d85 provisional: ELC Editorの倍マトリクスの操作を合意事項に移す
倍マトリクスモードはELCに保存しない配線補助の設定とし、プロジェクトのエディタ状態に残してGeneratorの出力設定の初期値に使う。
なぞるときは排他でないキーとの重なりで向きを自動反転し、モード切り替えでは既存の配線を書き換えない。
向きの操作は「相方と交換」(ピンの組単位)と「このキーだけ反転」の2つ、表示は a→b の三角形とする。
二乗マトリクスとの統合は両方の検討後に回し、Generatorの検査の穴は範囲外として記録する。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRs1RXP4ghZKFWMLPBQ3d8
2026-10-29 08:08:31 +09:00
ewdernaandClaude Opus 5.5 afd9af1914 mock: PHY Editorで異形キーを描き・編集し・追加できるようにする(T-32)
- domain/keyShape.ts に、キーの矩形・外接矩形・和の外形の計算と、異形キーのプリセット表(今はISO Enterだけ)を置いた
- キャンバスと選択枠は、2つ目の矩形を持つキーを和の外形のpathで描く。範囲選択と囲み矩形も両方の矩形で取る
- プロパティ欄に「異形キー」のチェックと、2つ目の矩形の欄(X2・Y2・Width2・Height2)を置いた
- ツールバーに「異形キーを足す」selectを置いた。1Uと同じ規則の位置に、外接矩形の左上を合わせて足す
- 検証: vue-tsc 0件、Vitest 106件(うち新規5件)、Playwright PHY 31件(うち新規1件)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
2026-10-26 08:55:20 +09:00
ewdernaandClaude Opus 5.5 3749f267b5 provisional: PHY Editorで異形キーを描き・編集し・追加する方法を仮に決める(T-32)
- 2つの矩形の和の外形を1つの輪郭で描き、選択枠も同じ外形にする
- プロパティ欄は「異形キー」のチェックで2つ目の矩形の欄(X2・Y2・Width2・Height2)を出し入れする
- 1Uの「キーを足す」は残し、隣に「異形キーを足す」selectを置く。候補はdomainのプリセット表から出し、今はISO Enterだけ
- 追加位置は1Uと同じ規則で、外接矩形の左上を合わせる

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
2026-10-26 08:54:46 +09:00
ewdernaandClaude Opus 5.5 c180642fa8 mock: IDX Editorを作り直す(T-31)
provisional/idx-editor-feedback.md の合意事項と T-31 の仮決定に沿って、IdxPanel を捨てて ui/idx/ に一から書き直した。
並びのリストは置かず、左にバリアントのツリー、中央のキャンバスでドラッグして並べ替え、右にプリセット・取り込み・未登録の登録を置いた。

- order の操作(隠れたキーのくっつき先、挿入、入れ替え、連番の並べ直し)を domain/idxOrder.ts に、
  走査・半身の分割・行の割り当てのずらし・マトリクス順を domain/idxScan.ts に、純粋関数として置いた。行と列の判定は rows.ts をそのまま使う
- QMK keyboard.json・VIA定義・KLE JSON の取り込みは、位置と回転で表示中のキーに照合する(io/layoutImport.ts、domain/idxImport.ts)
- 回転したキーでも出力番号を正立させる
- Codex の設定ファイルが壊れていて起動できなかったため、実装は Claude が直接行った
- 検証: vue-tsc 0件、Vitest 101件(うち新規17件)、Playwright 48件(IDX 8件を書き直し)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
2026-10-25 10:59:44 +09:00
ewdernaandClaude Opus 5.5 2321c068f9 provisional: IDX Editorの作り直しの範囲と、所感が定めていない操作の細部を仮に決める(T-31)
- IdxPanel を捨てて ui/idx/ に作り直す。order の操作は domain/ の純粋関数にし、rows.ts は変えずに使う
- 表示する構成はエディタ内の状態にし、output.variantSelection を書き換えない
- ドロップ先のキーの幅で挿入・入れ替えを分け、隠れたキーのくっつき先と、連番の並べ直しでの動き方を決める
- 半身の分け方、向きの指定、プレビューでのずらし方、取り込みの照合方法を決める

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
2026-10-25 10:44:17 +09:00
ewdernaandClaude Opus 5.5 0838487f4a mock: 変化のないスナップショットをUndo履歴に積まない
数値欄の change がフォーカスを外したときにもう一度届くと、同じ値の変更が履歴に2回積まれ、
Undo 1回で戻らなかった。グループ編集の commit と同じく、直前と同じなら積まない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
2026-10-23 22:18:50 +09:00
ewdernaandClaude Opus 5.5 785daa23c2 mock: スタビ位置は、どのサイズも何も選ばない状態から始める(T-30 単位8)
候補が1つのサイズも既定で選ばない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
2026-10-23 22:18:50 +09:00
ewdernaandClaude Opus 5.5 eacf8a1581 mock: スイッチ・スタビの十字をトグルで出し、スタビ候補を選べるようにする(T-30 単位8)
スタビ候補はキーの長辺の長さで引き、キーごとに複数選べる。選択は ProjectFile.ui.phy.stabilizers に持つ。
プロパティ欄に switchOffset の入力欄を置く。異形キーは候補なし。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
2026-10-23 22:13:07 +09:00
ewdernaandClaude Opus 5.5 2c388e3bdb decisions: T-30 単位8のスタビ候補を幅ごとに決め、選択はuiに持つ
Deskthority wiki の実測と、プレート・基板の生成ツールの値で裏取りした。
6U・6.25Uは候補が複数あるため、キーごとに複数選べるようにし、初期は未選択とする。
switchOffset は2U以上でも必要になる(6U offset、6.25U G80)が、候補とは連動させず手で入れる。
異形キーは候補を出す仕組みだけを入れ、候補は置かない。

Journal: journal/2026-10-23-stabilizer-positions.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
2026-10-23 22:05:39 +09:00
ewdernaandClaude Opus 5.5 b383fcf844 mock: 長押しにループ方式とリスト方式を入れ、ツールバーで切り替える(T-30 単位7b)
どちらも押した位置に円形インジケータを、少し離して選択先のリストを出す。
間隔は600msで始まり1000msごとに進む(500ms/450msは早いという所感への対応)。
リスト方式は、ポインタがリストに入ると巡回を止め、離したときに指している行で確定する。
リストへ向かう動きをドラッグと取り違えないよう、リスト方式では長押し後のドラッグをしない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShoEXYqi8PWTjB9E86Ltot
2026-10-23 21:07:04 +09:00
ewdernaandClaude Opus 5.5 37ed6d5186 mock: 「移し先…」セレクトの幅を固定する
閉じたセレクトの幅は最長の候補名で決まるため、キーを足すなどで候補が変わると
ツールバーが折り返し、キャンバスが縮んで全体表示がやり直されていた。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShoEXYqi8PWTjB9E86Ltot
2026-10-23 21:04:16 +09:00
ewdernaandClaude Opus 5.5 a4eabe3691 mock: キャンバスの viewBox を、ツールバーを除いた SVG の大きさに合わせる
ResizeObserver がツールバーを含むペインを見ていたため、viewBox の高さが SVG の実寸より
ツールバーの分だけ大きく、描画が縮んでいた。ツールバーが折り返すとずれが広がる。SVG 自身を見る。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShoEXYqi8PWTjB9E86Ltot
2026-10-23 20:59:42 +09:00
ewdernaandClaude Opus 5.5 b314309e8c mock: 長押しの処理を usePhyLongPress に切り出す(T-30 単位7a)
候補・巡回のタイマー・表示ラベルを usePhyLongPress に移し、
usePhyCanvasPointer はドラッグ・クリックとの接続だけを持つ。挙動は変えない。
7b で2方式を入れる土台にする。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKxNtqjjPyttUfT1TgC7Qt
2026-10-23 08:36:09 +09:00
ewdernaandClaude Opus 5.5 50e672768b mock: バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る(T-30 単位6)
クラスターを選んだときと同じ挙動にそろえる。これまではグループの右隣(親の中)に足していた。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
2026-10-23 08:23:44 +09:00
ewdernaandClaude Opus 5.5 e5678d79e6 mock: クラスターを選んでキーを足すと、そのクラスターに新しい行を作る(T-30 単位6)
これまではクラスターを1つのノードとして扱い、その右隣(親の中)に足していた。
クラスターの中に新しい行を作り、その左端に置く。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
2026-10-23 08:18:22 +09:00
ewdernaandClaude Opus 5.5 f64d632136 mock: キー・部品を足したあと、足したノードを選ぶ
ツリーの最後のノードを新しいノードとみなしていたため、クラスター内に足すと
ツリーでそれより後ろにある別のキーを選んでいた。追加前後のIDの差で特定する。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
2026-10-23 08:09:24 +09:00
ewdernaandClaude Opus 5.5 9254d0649b mock: キー追加を、選択中のキーの行の右端、または新しい行の左端に置く(T-30 単位6)
行の判定は idx-editor-feedback.md の規則(そろっている軸は座標でまとめ、
ずれている軸は隣とのつながりで1対1にたどる)を domain/rows.ts に実装し、
IDX Editor でも使えるよう列の判定も合わせて置いた。
何も選んでいないときは、コンテナの全キーの下に新しい行を作り、その左端に置く。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
2026-10-23 07:54:53 +09:00
ewdernaandClaude Opus 5.5 0697e7a3b4 mock: バリアントのバッジをノードより後に描き、キーの下に隠れないようにする
バッジは NodeSvg の再帰の中で各バリアントグループの <g> に描いていたため、
後から描かれる兄弟のキーに隠れていた。選択の描画層の後に PhyBadgeLayer を置き、
位置は純粋関数 variantBadges でワールド座標に直して正立で描く。
フォーカス中、フォーカス外のバッジは薄く描き、クリックを受けない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 14:24:49 +09:00
ewdernaandClaude Opus 5.5 812e2edfc2 mock: 選択中のクラスターにだけ原点の丸を出す(T-30 単位5)
単位1の選択の描画層に、選択中のクラスターとバリアントグループの原点の丸を描く。
長押しの候補になっているだけのクラスターには出さない。丸の大きさは画面上で固定する。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 09:42:32 +09:00
ewdernaandClaude Opus 5.5 e50722948d mock: キーを画面の端へ寄せると、キャンバスを自動でスクロールする(T-30 単位4)
ノードのドラッグ中にポインタがSVGの端から32px以内か外にあると、
requestAnimationFrame で pan を動かし続け、DOM更新後の最後のポインタ位置で
ドラッグ位置を計算し直す。速度は端に近いほど速く、上限480px/秒(autoPanVelocity)。
範囲選択とパンのドラッグは対象外。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 09:39:19 +09:00
ewdernaandClaude Opus 5.5 38954e9b36 mock: バリアントの既定の印・バッジ・作成先を直す(T-30 単位3)
ポップオーバーの既定の印を「既定」の札にする。バッジには名前を書かず、
表示中のバリアント名はツールチップで示す(はみ出しをなくす。T-30の着手前の決定を反映)。
バリアントグループは選択の入れ物(クラスター・バリアント)の中に作る。
そのため domain に createVariantGroupInParent を足し、variant のIDを渡すと
例外になっていた parentNodes を直した。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 09:19:06 +09:00
ewdernaandClaude Opus 5.5 fe1b048ea4 mock: 階層パネルを閉じたら、キャンバスが空いた幅を埋める(T-30 単位2)
閉じたときも3列の grid-template-columns のままで、キャンバスが1列目に詰まっていた。
閉じている間は2列(キャンバス・プロパティ欄)に切り替える。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 09:13:34 +09:00
ewdernaandClaude Opus 5.5 e92708dc12 mock: 選択枠を太くし、最前面の描画層に移す(T-30 単位1)
選択枠と長押し候補の枠を NodeSvg の再帰から外し、ノードの描画より後に描く
PhySelectionLayer に移した。枠はワールド変換とローカル矩形から純粋関数
selectionFrames で求め、画面上3.5pxで描く。部品の選択表示は形状上に残す。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDkA8qMQ3Uf1eY3Ar19vjy
2026-10-21 08:42:14 +09:00
ewdernaandClaude Opus 5.5 d110320cb8 provisional: PHY Editorの所感(作り直し1回目)への対応の順番と単位を決める
T-29のコードを作り直さず、所感を8単位(長押しは切り出しと機能追加の2つに分ける)に分けて順に直す。
選択の描画層を最初に作り、クラスター原点とスイッチ位置のオーバーレイをその層に載せる。
キー追加の位置・長押しの2方式・スタビライザー位置は、着手前に決める点を書き出した。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPACY39UbXMTH3vztB8eW
2026-10-19 08:38:23 +09:00
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
91 changed files with 8253 additions and 1734 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
## 注意
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
+7
View File
@@ -0,0 +1,7 @@
{
"[markdown]": {
"editor.detectIndentation": false,
"editor.tabSize": 4,
"editor.insertSpaces": true
}
}
+24 -24
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とは寿命が異なる。
仕様書側の書式:
@@ -128,8 +128,8 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
- Markdownをコミットする前に、変更したファイルへ `npx markdownlint-cli2 --fix <files>` をかける。
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
規則は `.markdownlint.jsonc`。変更していないファイルの指摘は直さない。
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
規則は `.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の取り込み
+22 -22
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,17 +80,17 @@
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「配線トポロジを区分として持たない」
- 未決事項: 各方式で出力器が決められる走査方法と、ユーザーの追加指定が必要になる範囲。
接点マトリクスではダイオードの向き、directでは電源レールの参照から役割を判断できる範囲を確認する。
接点マトリクスではダイオードの向き、directでは電源レールの参照から役割を判断できる範囲を確認する。
- 保留できる理由: 走査方向の決定・追加指定の責任は出力器側と決まり、ELCの配線保存はそのまま進められる。
- 再開条件: 各方式のファームウェア出力に着手するとき。
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
全方式で走査手順が一意に決まることの証明は求めない。
全方式で走査手順が一意に決まることの証明は求めない。
### ELC-P-4 GPIOに直結したLEDの表し方
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「LEDの識別番号」
- 未決事項: インジケータなど、チェーンに入らずマイコンのピンで直接点灯するLEDをELCにどう持つか。
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
- 保留できる理由: チェーン方式(WS2812系)のLEDとキーの配線は、直結LEDと独立に扱える。
- 再開条件: インジケータLEDの出力に対応するとき、またはLED配線の編集画面の仕様を作るとき。
- 完了条件: 実在の基板で直結LEDの配線を確認し、ELCでの表し方と、ファームウェア出力に必要な情報を定める。
@@ -101,12 +101,12 @@
- 関連仕様: [IDXレイヤー](../IDXレイヤー.md)「レイアウトバリアント」
- 未決事項: 採用済みの方式で既存コミュニティレイアウトの要求順を再現できる範囲の実測。
同じPHYキーIDを使う共通キー同士の要求順がレイアウト間で逆転する場合は、単一の `order` からの取捨では表せないが、その実例の有無は未確認。
同じPHYキーIDを使う共通キー同士の要求順がレイアウト間で逆転する場合は、単一の `order` からの取捨では表せないが、その実例の有無は未確認。
- 保留できる理由: 全バリアントのキーを別々の要素として並べ、選択後に番号を詰める仕様は決定済み。
既存レイアウト全般との互換性を前提にせず実装できる。
既存レイアウト全般との互換性を前提にせず実装できる。
- 再開条件: 既存コミュニティレイアウトとの互換出力を検証するとき。
- 完了条件: 実データのキーを共通キーとバリアント別のPHYキーに対応づけ、全体の `order` から各レイアウトの要求順を取捨で再現できるか確認する。
再現できないものが残る場合は、対応範囲と扱いを決める。
再現できないものが残る場合は、対応範囲と扱いを決める。
## MAP
@@ -114,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で読み書きする場合は必要なコマンドを確認する。
@@ -125,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プロファイルの指定方法と置き場所をユーザーが決める。
@@ -134,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のデータが初期化されることを実機で確認する。
@@ -162,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「概要」)が、申告方式ならモデルを増やさずに扱える。
@@ -0,0 +1,85 @@
# スタビライザー位置とswitchOffsetの裏取り(T-30 単位8)
## 結論
- 6U以上の幅の中には、スタビライザー位置が何通りもあるものがある。6U・6.25U・7Uがそう。
6.5U と 8〜10U は、出典にある範囲では1通り。
- switchOffset が要るのは2U未満のキーに限らない。6U offset・6.25U(Cherry G80のWindowsキー付き)・BAE(ANSI互換)では
スイッチがキーの中心から外れる。逆に、2U未満でよくあるステップドCaps Lockは、KLEの表し方のおかげで offset 0 になる。
- ISO Enterは「(w,h)矩形の中心にスイッチ、2U間隔のスタビを縦向きに置く」とすれば、出典にある生成ツールの扱いと合う。
実物の寸法の出典はまだ見つけていない。
- BAEは、スイッチとスタビの配置が型によって違う。決まった形はない。
座標はすべてキーのローカル座標で、(w,h)矩形の中心を原点にした値(U、1U = 19.05mm)。
PHYの `switchOffset` と同じ基準になる。スタビは中心から±の位置に2つ置く。
## 候補表(Cherry MX系、横向き。縦長のキーは長辺の向きに読み替える)
| 幅 | スタビ(中心から) | switchOffset.x | 使っているキーボード・出典 |
| --- | --- | --- | --- |
| 2〜2.75U | ±11.938mm(±0.6267U) | 0 | kad・kiswitch・ai03 で一致 |
| 3U | ±1U | 0 | DT(Deskthority wiki、以下同じ)、kad、kiswitch |
| 4U | ±1.5U | 0 | DT、kad(ai03 は3U以上をすべて±1Uにしている) |
| 4.5U | ±34.671mm(±1.82U) | 0 | DT、kad |
| 5.5U | ±2.25U | 0 | DT、kad |
| 6U offset | ±2.5U(芯々95mm) | +0.5U | DT: Cherry G80-1800/11900/11908/11800。kad、kiswitch `6uOffset` |
| 6U 中央A | ±2U | 0 | DT: Razer BlackWidow、TG3 BL-82 |
| 6U 中央B | ±2.5U(47.625mm) | 0 | kiswitch の 6U スタビ、kad の6U値。フォーラム: FC660C、Topre |
| 6.25U 標準 | ±50mm(±2.6247U) | 0 | DT: Filco、Ducky、Poker、Das 3 ほか。kad・kiswitch・ai03 で一致 |
| 6.25U G80 | ±50mm | +0.625U(11.9mm) | DT: Cherry G80-1000/3000(Windowsキー付き)、Das 2。キーキャップに中央のマウントがない |
| 6.25U 40mm | ±40mm(±2.1U) | 0 | DT: Leopold FC200、SteelSeries 6GV2/7G、Zowie Celeritas |
| 6.25U 42.5mm | ±42.5mm | 0 | DT: Leopold FC500、Keycool 87(2012年より前のモデル) |
| 6.5U | ±2.75U(52.38mm) | 0 | DT: Corsair K60〜K95、QuickFire TK、G710+。kad |
| 7U | ±3U | 0 | DT: Cherry G80-1000/1800/3000(Windowsキーなし)ほか。kad・kiswitch・ai03 で一致 |
| 8U / 9U / 9.75U / 10U | ±3.5U | 0 | DT(9U以上も8Uと同じ間隔)。kiswitch は8U〜10Uに同じフットプリントを使う |
候補表から外したもの(実物の位置が出典から決まらない):
- 6U CMStorm QuickFire Ultimate: 「43.5mm, 49mm」「芯々98mm」とだけ書かれていて、軸がどれだけずれているか読み取れない
- 7U Compaq MX-11800: 軸から見てスタビが左3U・右2.25U。キーキャップの中でどう対称になっているかは書かれていない。
スタビがキーの中心に対して対称なら±2.625U、switchOffset +0.375U になり、6.25U と同じ100mmのワイヤーになるが、これは推測
- 7U Propac: 「55mm apart」とあるだけ
## 異形キー
KLEのプリセット(`keys.json`)と、PHYの `size` の主矩形(w,h):
| キー | KLE | スイッチ | スタビ |
| --- | --- | --- | --- |
| ISO Enter | w1.25 h2, w2 1.5 h2 1, x 0.25, x2 -0.25 | 主矩形の中心(0,0) | 縦向きに±0.6267U。kad は h>w なら自動で縦にする。ai03 は長辺を幅として扱うが、向きは利用者が `_rs` で回す |
| BAE (a) ANSI互換 | w1.5 h2, w2 2.25 h2 1, x 0.75, x2 -0.75, y2 1 | 下段2.25Uの中心 = (-0.375, +0.5) | (-0.375±0.6267, +0.5) の横向き。GMK CYL BAE: 「ANSIの2.25U Enterのスイッチとスタビの位置、1.5UのPipeキーの位置にマウントがある」 |
| BAE (b) 旧Cherry | 同上 | 不明 | 検索の要約には「上段1.5Uを中心にISO風のスタビ、補助のMXスイッチ」とあったが、原文は確認できていない |
| ステップドCaps Lock | w1.25 w2 1.75 l | 主矩形 = 高い側の1.25Uの中心。よくある軸の位置(左寄り)と一致し、offset 0 | なし |
| ステップドCaps Lock(軸が中央) | 同上 | 1.75Uの中心 = +0.25U | なし。数は少ない(Leopoldのセットにある) |
| Center-stepped | w1 w2 1.5, x 0.25, x2 -0.25 l | 主矩形の中心 | なし |
KLEのステップドキーは、`w` が高い側、`w2` が全幅(kb.js の `stepped` の処理)。
kad も主矩形の中心にスイッチを置く(`AltWidth > Width` のときは外形の計算だけを広げる)。
## switchOffset が0でないケース
- 2U以上: 6U offset (+0.5U)、6.25U G80 (+0.625U)、BAE(a) (-0.375, +0.5)、おそらく7U Compaq
- 2U未満: 軸が中央にあるステップドCaps Lock (+0.25U) だけ。よくある左寄りの軸は0
- 「2U未満のみ」という想定は当たらない
## ツールどうしの食い違い(参考)
- ai03 は6Uで、スイッチをKLEのキーの中心に置いたまま、スタビを+38.1/-57.15mmと左右非対称に置く。
kad はスタビをキーの中心に対して±47.5mmで対称に置き、スイッチを+0.5Uずらす。DTの実測(マウントは左から0.5/3.5/5.5U)と合うのは kad
- ai03 は3U〜5.5Uをすべて±19.05mmにしている。DT・kad とは4U以上で合わない
- kad は2Uの値を11.9mmに丸めている。kiswitch・ai03 は11.938mm
## 出典
- Deskthority wiki "Space bar dimensions"(`?action=raw` で原文を確認): <https://deskthority.net/wiki/Space_bar_dimensions>
- swill/kad `key.go` の `GetCherryStabOffset`、`Draw`: <https://github.com/swill/kad>
- perigoso/keyswitch-kicad-library `KiSwitch/switch.py` の `StabilizerCherryMX.LU_TABLE`、`KiSwitch/keycap.py`:
<https://github.com/perigoso/keyswitch-kicad-library>
- ai03-2725/another-keyboard-builder `plategen.py` の `generate_stabs`: <https://github.com/ai03-2725/another-keyboard-builder>
- KLE `keys.json`(異形キーのプリセット)、`kb.js` の `stepped`: <https://github.com/ijprest/keyboard-layout-editor>
- GMK CYL BAE addon: <https://geon.works/products/gmk-cyl-bae-addon-kits>
- ステップドCaps Lockの軸の位置(フォーラム): <https://deskthority.net/viewtopic.php?p=112099>、
<https://deskthority.net/keyboards-f2/keycap-cherry-mx-stepped-capslock-t3794.html>
- 6Uの中央軸とTopreの値(フォーラム): <https://www.keebtalk.com/t/topre-6u-stabilizer-spacing-spec/17047>
Alpsのスタビ(kad `GetAlpsStabOffset`: 1.75U 11.938、2〜2.75U 14.096、6.25U 41.859、6.5U 45.3mm。DT: 7U ±55mm)は、モックにスイッチの種別がないので扱っていない。
+92
View File
@@ -0,0 +1,92 @@
# 二乗マトリクスの一般知識(ELC Editorの要検討の前提)
## 結論
- 二乗マトリクス: N本のピンがどれも駆動側にも受信側にもなる。キーは異なる2ピンの順序付きの組 (駆動, 受信) に1つ。
上限 N(N−1) キー。対角 (i, i) は使えない。考案は ikeji 氏(2019)。英語では Square / Round-robin / Charlieplex matrix。
- 実用されているのは改良二乗マトリクス(各ピンに追加ダイオード1つ)。素の二乗マトリクスは主要MCUでゴーストが出る。
- 通常・倍マトリクスは、二乗マトリクスの部分集合とみなせる(使うピンの組を row→col に限ったもの、その両向き)。
- 追加ダイオードはELCで持たない。マトリクス方式に依存し、位置が決定的なのでGeneratorで扱える(ユーザー判断)。
- QMKとZMKで row/col の向きが逆になる問題はGeneratorで扱う(ユーザー判断)。
- GND直結で対角を使う変形(N² キー)は標準対応しない。Directとの組み合わせで表せるかは、仕様が固まってから確認する(ユーザー判断)。
## 方式の比較(ikeji 2022)
| 方式 | 正常経路のダイオード数 | ゴースト経路のダイオード数 | 成立条件 |
| --- | --- | --- | --- |
| 倍 | 1 | 3 | Vf < Vth < 3Vf |
| 二乗 | 1 | 2 | Vf < Vth < 2Vf |
| 改良二乗 | 2 | 4 | 2Vf < Vth < 4Vf |
同じピン数Nでのキー数: 通常 N²/4、倍 N²/2、二乗 N(N−1)(N=18 で 81 / 162 / 306)。
## ファームウェア
### QMK
- 標準の二乗マトリクス対応はない。ただし標準機能の組み合わせで実現できる:
- `matrix_pins.rows` と `cols` に同じピンを同じ順で並べる。
- `config.h` に `#define MATRIX_MASKED` を置き、`<keyboard>.c` の `matrix_mask` で対角のビットを0にする。
- 対角のマスクは改良版かどうかに関係なく必須。Lowに駆動したrowピンを同じピンのcolとして読むので、常に押された扱いになる
(gachiham: マスクしないとPCに認識されなくなった。STM32/RP2040)。
- `matrix_mask` は使わない位置も0にできる。対角はその一例。
- 実例 ewderna/cr0wn/cr0wn60(`Dev/KBDs/qmk_firmware/keyboards/ewderna/cr0wn/cr0wn60`):
RP2040、9ピン(GP8 GP9 GP10 GP11 GP19 GP21 GP20 GP12 GP18)を rows/cols の両方に列挙、`diode_direction: COL2ROW`。
`matrix_mask` は row0〜6 で対角と未使用の位置を0にし、row7 は一部だけ1、row8 は全部0。
- 対応関係: row = Lowに駆動するピン、col = 読むピン。電流は col → row に流れる。
### ZMK `zmk,kscan-gpio-charlieplex`
- 専用ドライバ。`gpios` にピンを並べるだけで、row/colの区別はない。
- `interrupt-gpios` なしで n本 → n(n−1)キー(ポーリング)。ありで (n−1)(n−2)キー(割り込み、省電力)。
- 割り込みピンは `gpios` に含めない別のピン(ソース上、セルは `gpios` だけから作る)。
- 待機中は全セルを出力1にし、割り込みピンのレベルを待つ。割り込みピンの配線はドキュメントに書かれていない。
- 低電力改良二乗マトリクス(Cheena: 1ピンを復帰用にして (N−1)(N−2))と同じ構成と思われるが、回路は追えていない。
- キーの位置は matrix transform の `RC(row, col)` で書き、row = 駆動ピン、col = 受信ピン。対角は transform から除くよう明記されている。
- ドキュメントは `GPIO_ACTIVE_HIGH` を指定するよう求めている。この場合は入力プルダウン・High駆動で、電流は row → col に流れる。
- QMK COL2ROW と逆向き。同じ基板では row/col が転置される。
- ソース上は `GPIO_ACTIVE_LOW` ならプルアップ・Low駆動になり、QMKと同じ向きにできる。割り込みと併用できるかは未確認。
- 待ち時間の設定: `CONFIG_ZMK_KSCAN_CHARLIEPLEX_WAIT_BEFORE_INPUTS` / `_WAIT_BETWEEN_OUTPUTS`。
## 改良二乗マトリクスの追加ダイオードの位置
記事の図は読めなかった。以下は経路の段数から組み立てたもので、ユーザーの認識(各ピンに追加ダイオード)と合う。
- ピン i を、GPIOにつながる線 G_i と、ダイオード D_i(アノードが G_i 側)を挟んだ線 S_i に分ける。
- キー (駆動 a, 受信 b) は、S_b → G_a の向きのダイオードを付ける(QMK COL2ROW の場合)。
- 正常経路: G_b →D_b→ S_b →キー→ G_a で2段。
- ゴースト経路: G_c →D_c→ S_c →キー→ G_b →D_b→ S_b →キー→ G_a で4段。中継するピンでも D_b を通らされる。
- 回路図を格子に描くと D_i は対角 (i, i) の位置に来る。gachiham の「[0,0] はダイオードを入れているだけなのでスイッチとして使えない」と合う。
## 実測値(ikeji 2022)
| MCU | Vth | Vf | 2Vf | 3Vf | 4Vf |
| --- | --- | --- | --- | --- | --- |
| ATmega32u4 | 1.45〜1.56V | 0.513V | 1.034V | 1.495V | 1.963V |
| RP2040 | 1.26〜1.36V | 0.474V | 0.949V | 1.418V | 1.862V |
- 素の二乗マトリクスは両MCUとも Vth > 2Vf で、ゴーストがLowと読まれる。改良版は両MCUとも成立する。
- ATmega32u4 では倍マトリクスの条件(Vth < 3Vf)が際どい。著者は測定条件(5V弱の電源、非選択線の電流)を疑っている。
- MCUとダイオードの相性は Vf と Vth の比で決まる。
## 出典
- ikeji 2019: ikeji「キーボードのマトリクス方式の分類」ikejiのblog、2019-12-14
- 参照先: <https://blog.ikejima.org/make/keyboard/2019/12/14/keyboard-circuit.html>
- 二乗マトリクスの初出。ゴーストはダイオード2つ分なので部品の組み合わせに注意、1キー2ダイオードが要るかも、と書いている。
- ikeji 2022: ikeji「変則マトリクスキーボード設計」ikejiのblog、2022-12-07
- 参照先: <https://blog.ikejima.org/make/keyboard/2022/12/07/atypical-matrix-design.html>
- gachiham: gachiham「QMK 0.19.2で改良二乗マトリクスのキーボードファームウェアを作っていく話」gachiham's blog、2022-12-17
- 参照先: <https://gachiham.hatenablog.com/entry/2022/12/17/000000>
- 9ピン72キーから63キー。rows/colsに同じピンを並べ、MATRIX_MASKEDを使う。
- Cheena: Cheena「自作キーボード初心者のためのでかいキーボード設計法」note、2025-05-26(2025-09 追記あり)
- 参照先: <https://note.com/cheena/n/n846846c3efce>
- 低電力改良二乗マトリクス。
- ZMK kscan: 「Keyboard Scan Configuration」ZMK Firmware ドキュメント
- 参照先: <https://zmk.dev/docs/config/kscan>
- ZMK ドライバ: zmkfirmware/zmk のソース `app/module/drivers/kscan/kscan_gpio_charlieplex.c`(main ブランチ)
- 参照先: <https://raw.githubusercontent.com/zmkfirmware/zmk/main/app/module/drivers/kscan/kscan_gpio_charlieplex.c>
## 捨てた記述
- 二乗マトリクスを e3w2q 氏の発案とした記憶は誤り。初出は ikeji 2019。
+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. 製造・組み立て段階
+115 -30
View File
@@ -1,44 +1,129 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
const canvasKeys = (page: Page) => page.locator('[data-testid="idx-canvas"] .key');
const numberOf = (page: Page, i: number) => canvasKeys(page).nth(i).getByTestId("idx-number");
/** Drags key `from` onto key `to`, releasing at `fraction` of the target's width. */
async function dragKey(page: Page, from: number, to: number, fraction: number) {
const a = (await canvasKeys(page).nth(from).locator("rect").boundingBox())!;
const b = (await canvasKeys(page).nth(to).locator("rect").boundingBox())!;
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2, { steps: 3 });
await page.mouse.move(b.x + b.width * fraction, b.y + b.height / 2, { steps: 5 });
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(canvasKeys(page)).toHaveCount(16);
});
test("register unindexed key, reorder its output number, and undo", async ({ page }) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
await page.getByRole("button", { name: "キー追加" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
const unregisteredRow = page.locator(".idx-all-keys li.unregistered").first();
const keyId = (await unregisteredRow.locator("b").innerText()).trim();
await unregisteredRow.getByRole("button", { name: "登録" }).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");
test("drops at the edge of a key insert before or after it, and undo restores", async ({ page }) => {
await dragKey(page, 0, 3, 0.9);
await expect(numberOf(page, 0)).toHaveText("3");
await expect(numberOf(page, 3)).toHaveText("2");
await expect(numberOf(page, 1)).toHaveText("0");
await dragKey(page, 0, 1, 0.1);
await expect(numberOf(page, 0)).toHaveText("0");
await page.getByRole("button", { name: "Undo" }).click();
await expect(previewKey).toHaveText("16");
await expect(numberOf(page, 0)).toHaveText("3");
await page.getByRole("button", { name: "Undo" }).click();
await expect(numberOf(page, 0)).toHaveText("0");
await expect(numberOf(page, 3)).toHaveText("3");
});
test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({
test("a drop in the middle of a key swaps the two and leaves the others", async ({ page }) => {
await dragKey(page, 0, 5, 0.5);
await expect(numberOf(page, 0)).toHaveText("5");
await expect(numberOf(page, 5)).toHaveText("0");
await expect(numberOf(page, 1)).toHaveText("1");
await expect(numberOf(page, 4)).toHaveText("4");
});
test("a key added in PHY is registered at the end and can be dragged to the front", async ({ page }) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
await page.getByRole("button", { name: "キーを足す" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(numberOf(page, 16)).toHaveText("16");
await dragKey(page, 16, 0, 0.1);
await expect(numberOf(page, 16)).toHaveText("0");
await expect(numberOf(page, 0)).toHaveText("1");
});
test("the variant tree switches the shown configuration without touching other keys' order", async ({
page,
}) => {
await page.getByRole("button", { name: "PHY", exact: true }).click();
const treeKeys = page.locator(".tree-node").filter({ hasText: "キー ·" });
const firstId = await treeKeys.nth(0).getAttribute("data-tree-id");
const secondId = await treeKeys.nth(1).getAttribute("data-tree-id");
await treeKeys.nth(0).click();
await treeKeys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
const keys = page.locator('[data-node-type="key"]');
const firstId = await keys.nth(0).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("Layout 出力バリアント").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);
const optionA = page.locator(".tree").getByRole("button", { name: /Option A/ });
const optionB = page.locator(".tree").getByRole("button", { name: /Option B/ });
await expect(optionA).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(1);
await optionB.click();
await expect(optionB).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(0);
await expect(canvasKeys(page)).toHaveCount(14);
await expect(numberOf(page, 0)).toHaveText("0");
});
test("a column-major preset previews, applies as one undo step", async ({ page }) => {
await page.getByLabel("走査").selectOption({ label: "列優先" });
await page.getByRole("button", { name: "プレビュー" }).click();
await expect(numberOf(page, 4)).toHaveText("1");
await expect(page.locator(".scan-line")).toHaveCount(4);
await page.getByRole("button", { name: "適用" }).click();
await expect(numberOf(page, 4)).toHaveText("1");
await expect(numberOf(page, 1)).toHaveText("4");
await page.getByRole("button", { name: "Undo" }).click();
await expect(numberOf(page, 1)).toHaveText("1");
});
test("the preview shifts a picked column by one row and Esc discards it", async ({ page }) => {
await page.getByRole("button", { name: "プレビュー" }).click();
await canvasKeys(page).nth(0).click();
await page.keyboard.press("ArrowDown");
// column 0 now belongs one row lower: row 0 is keys 1..3
await expect(numberOf(page, 1)).toHaveText("0");
await expect(numberOf(page, 0)).toHaveText("3");
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "プレビュー" })).toBeVisible();
await expect(numberOf(page, 0)).toHaveText("0");
});
test("a range selection renumbers from its first key's position", async ({ page }) => {
const k2 = (await canvasKeys(page).nth(2).locator("rect").boundingBox())!;
const k15 = (await canvasKeys(page).nth(15).locator("rect").boundingBox())!;
await page.mouse.move(k2.x + 5, k2.y - 20);
await page.mouse.down();
await page.mouse.move(k15.x + k15.width + 20, k15.y + k15.height + 20, { steps: 6 });
await page.mouse.up();
await expect(page.getByText("選択範囲(8)")).toBeVisible();
await page.getByText("選択範囲(8)").click();
await page.getByLabel("走査").selectOption({ label: "列優先" });
await page.getByRole("button", { name: "プレビュー" }).click();
await page.getByRole("button", { name: "適用" }).click();
// 0, 1, then the right half column by column, then the rest of the left half
await expect(numberOf(page, 2)).toHaveText("2");
await expect(numberOf(page, 6)).toHaveText("3");
await expect(numberOf(page, 3)).toHaveText("6");
await expect(numberOf(page, 4)).toHaveText("10");
});
test("importing a QMK layout copies its key order onto matching keys", async ({ page }) => {
const layout = [0, 1, 2, 3].flatMap((x) => [0, 1, 2, 3].map((y) => ({ matrix: [y, x], x, y })));
await page.getByTestId("idx-import-file").setInputFiles({
name: "keyboard.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify({ layouts: { LAYOUT: { layout } } })),
});
await expect(page.getByTestId("idx-import-result")).toContainText("16件を照合");
await expect(numberOf(page, 4)).toHaveText("1");
await expect(numberOf(page, 1)).toHaveText("4");
});
+737 -197
View File
@@ -7,225 +7,765 @@ test.beforeEach(async ({ page }) => {
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
});
test("drag snaps to quarter units and undo/redo", async ({ page }) => {
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("階層パネルを閉じると、キャンバスが空いた幅を埋める", async ({ page }) => {
const canvas = page.locator(".canvas-pane");
const properties = page.locator(".properties");
const before = (await canvas.boundingBox())!;
await page.getByRole("button", { name: "階層" }).click();
await expect(page.locator(".tree")).toHaveCount(0);
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeGreaterThan(before.width + 200);
const after = (await canvas.boundingBox())!;
const side = (await properties.boundingBox())!;
const workspace = (await page.locator(".workspace").boundingBox())!;
expect(Math.abs(after.x - workspace.x)).toBeLessThan(1);
expect(Math.abs(side.x + side.width - (workspace.x + workspace.width))).toBeLessThan(1);
});
test("選択レイヤーはキーより前面にあり、回転とフォーカスに追従する", async ({ page }) => {
test.setTimeout(60_000);
const keys = page.locator('[data-node-type="key"]');
const leftId = await keys.first().getAttribute("data-node-id");
await keys.first().click();
const order = await page.locator("svg.canvas").evaluate((svg, id) => {
const face = svg.querySelector(`[data-node-id="${id}"]`)!;
const layer = svg.querySelector(`[data-sel-id="${id}"]`)!;
return [
face.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING,
layer.parentElement?.classList.contains("selection-layer"),
];
}, leftId);
expect(order).toEqual([4, true]);
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
await page.getByLabel("Rotation").fill("35");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = cluster.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await key.click();
const aligned = async () =>
page.evaluate((keyId) => {
const face = document.querySelector(`[data-node-id="${keyId}"] .key-face`)!.getBoundingClientRect();
const outline = document.querySelector(`[data-sel-id="${keyId}"]`)!.getBoundingClientRect();
return [
Math.abs(face.x - outline.x),
Math.abs(face.y - outline.y),
Math.abs(face.width - outline.width),
Math.abs(face.height - outline.height),
];
}, id);
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
await page.locator(".canvas-pane").focus();
await page.keyboard.press("Enter");
const focused = page.locator(`[data-node-id="${id}"]`).last();
await focused.click();
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
});
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();
if (!box) throw new Error("Key has no bounds");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
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 + 2, { steps: 3 });
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
await page.mouse.up();
const moved = await key.getAttribute("transform");
expect(moved).not.toBe(before);
const x = Number(moved?.match(/translate\(([-\d.]+)/)?.[1]);
expect(x / 0.25).toBeCloseTo(Math.round(x / 0.25), 6);
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", moved!);
await expect(key).toHaveAttribute("transform", after!);
expect(errors).toEqual([]);
});
test("dragging inside a rotated cluster tracks pointer movement", async ({ page }) => {
const treeNodes = page.locator("[data-tree-id]");
await treeNodes.nth(0).click();
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスター化" }).click();
const cluster = page.locator('[data-node-type="cluster"]').first();
test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const before = await key.getAttribute("transform");
const keyBox = (await key.boundingBox())!;
const canvasBox = (await page.locator("svg.canvas").boundingBox())!;
const y = keyBox.y + keyBox.height / 2;
await page.mouse.move(keyBox.x + keyBox.width / 2, y);
await page.mouse.down();
await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 });
const atEdge = await key.getAttribute("transform");
await page.waitForTimeout(700);
expect(await key.getAttribute("transform")).not.toBe(atEdge);
await page.mouse.up();
const released = await key.getAttribute("transform");
await page.waitForTimeout(250);
expect(await key.getAttribute("transform")).toBe(released);
await page.getByRole("button", { name: "Undo" }).click();
await expect(key).toHaveAttribute("transform", before!);
});
test("選択中のクラスターにだけ原点の丸を出す", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
const corner = (await keys.nth(0).locator(".key-face").boundingBox())!;
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await keys.nth(5).click();
await expect(page.locator(".origin")).toHaveCount(0);
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
const origin = page.locator(`[data-origin-id="${clusterId}"]`);
await expect(origin).toHaveCount(1);
const box = (await origin.boundingBox())!;
expect(Math.abs(box.x + box.width / 2 - corner.x)).toBeLessThan(2);
expect(Math.abs(box.y + box.height / 2 - corner.y)).toBeLessThan(2);
await keys.nth(5).click();
await expect(page.locator(".origin")).toHaveCount(0);
});
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();
const rotation = page.getByLabel("Rotation");
await rotation.fill("30");
await rotation.dispatchEvent("change");
await page.getByLabel("Rotation").fill("90");
await page.getByLabel("Rotation").dispatchEvent("change");
const key = page.locator('[data-node-type="key"]').first();
const box = await key.boundingBox();
if (!box) throw new Error("Key bounds missing");
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const before = { ...start };
const localBefore = await key.getAttribute("transform");
const matrix = await key.evaluate((el) => {
const m = (el.parentElement as unknown as SVGGElement).getScreenCTM()!;
return [m.a, m.b, m.c, m.d] as const;
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(".hold-row.active")).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-sel-id="${innerId}"].candidate`)).toBeVisible({
timeout: 1800,
});
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 32, start.y, { steps: 4 });
await page.mouse.up();
const after = await key.boundingBox();
if (!after) throw new Error("Key bounds missing");
const screenDx = after.x + after.width / 2 - before.x;
const localAfter = await key.getAttribute("transform");
const parse = (value: string | null) => {
const match = value?.match(/translate\(([-\d.]+),([-\d.]+)\)/);
return { x: Number(match?.[1]), y: Number(match?.[2]) };
};
const delta = {
x: parse(localAfter).x - parse(localBefore).x,
y: parse(localAfter).y - parse(localBefore).y,
};
expect(Math.abs(screenDx - (matrix[0] * delta.x + matrix[2] * delta.y))).toBeLessThan(4);
expect(
Math.abs(after.y + after.height / 2 - before.y - (matrix[1] * delta.x + matrix[3] * delta.y)),
).toBeLessThan(4);
expect(Math.abs(screenDx - 32)).toBeLessThan(15);
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("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => {
const positions = async (): Promise<Record<string, [number, number]>> =>
page.locator('[data-node-type="key"]').evaluateAll((els) =>
Object.fromEntries(
els.map((el) => {
const r = el.getBoundingClientRect();
return [el.getAttribute("data-node-id"), [r.x + r.width / 2, r.y + r.height / 2]];
}),
),
) as Promise<Record<string, [number, number]>>;
const before = await positions();
const treeNodes = page.locator("[data-tree-id]");
const targetId = await page.locator('[data-node-type="key"]').first().getAttribute("data-node-id");
await treeNodes.nth(0).click();
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "クラスター化" }).click();
const clustered = await positions();
for (const [id, point] of Object.entries(before)) {
expect(clustered[id]?.[0]).toBeCloseTo(point[0], 0);
expect(clustered[id]?.[1]).toBeCloseTo(point[1], 0);
}
const target = page.locator(`[data-node-id="${targetId}"]`).first();
const box = await target.boundingBox();
if (!box) throw new Error("Target key bounds 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 + 24, box.y + box.height / 2, { steps: 3 });
await page.mouse.up();
const after = await positions();
for (const id of Object.keys(before)) {
if (id === targetId) continue;
expect(after[id]?.[0]).toBeCloseTo(before[id]![0]!, 0);
expect(after[id]?.[1]).toBeCloseTo(before[id]![1]!, 0);
}
});
test("variant deletion uses the reference review and removes IDX references", async ({ page }) => {
const initialKey = page.locator('[data-node-type="key"]').first();
const keyId = await initialKey.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${keyId}"]`).click();
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
const controls = page.locator(".canvas-head label").last();
await controls.locator("button").first().click(); // Add a third Variant so deletion can proceed.
await controls.locator("button").last().click(); // Request deletion of the selected/default Variant.
await expect(page.locator(".confirm-box")).toContainText("削除される参照");
await expect(page.locator(".confirm-box")).toContainText("idx");
await page.getByRole("button", { name: "参照と一緒に削除" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${keyId}`)).toHaveCount(0);
});
test("multi-key rotation is one undo step", async ({ page }) => {
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
test.setTimeout(60_000);
const keys = page.locator('[data-node-type="key"]');
const ids = [
await keys.nth(0).getAttribute("data-node-id"),
await keys.nth(1).getAttribute("data-node-id"),
];
const before = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
const tree = page.locator("[data-tree-id]");
await tree.nth(0).click();
await tree.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択を+15°回転" }).click();
const after = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
expect(after).not.toEqual(before);
await page.getByRole("button", { name: "Undo" }).click();
const undone = await Promise.all(
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
);
expect(undone).toEqual(before);
await keys.first().click();
const outline = page.locator(".selection-layer .sel").first();
await expect(outline).toBeVisible();
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("3.5px");
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 = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
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("unsupported versions are rejected; save-blocked projects stay open and mismatches block panel edits", async ({
test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", async ({
page,
}) => {
const first = page.locator('[data-node-type="key"]').first();
const firstId = await first.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${firstId}"]`).click();
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
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 fs = await import("node:fs/promises");
const original = JSON.parse(await fs.readFile(filepath, "utf8"));
const load = async (value: unknown, name: string) => {
const chooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
await (
await chooserPromise
).setFiles({
name,
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(value)),
});
};
await load({ ...original, version: "0.2.0" }, "unsupported.bkle.json");
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
await expect(page.locator(".error")).toContainText("Unsupported ProjectFile version");
const duplicate = structuredClone(original);
duplicate.idx.order[0] = "missing-key-reference";
await load(duplicate, "missing-reference.bkle.json");
await expect(page.getByTestId("save-blocked")).toBeVisible();
const mismatch = structuredClone(original);
mismatch.phy.profiles.push({ ...mismatch.phy.profiles[0], id: "other", unitSize: { x: 2, y: 2 } });
await load(mismatch, "unit-mismatch.bkle.json");
await expect(page.getByText("unitSize が揃っていないPHYは編集できません。", { exact: true })).toBeVisible();
await page.getByLabel("Layout").selectOption({ label: "Option B" });
await expect(page.locator('[data-node-type="key"]')).toHaveCount(15);
await page.getByRole("button", { name: "MAP", exact: true }).click();
await expect(page.getByLabel("追加するレイヤー名")).toBeDisabled();
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
await expect(page.getByRole("tab", { name: "Blocked" })).toHaveCount(0);
});
test("project download can be loaded back", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const box = await key.boundingBox();
if (!box) throw new Error("Key bounds 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 + 16, box.y + box.height / 2, { steps: 2 });
await page.mouse.up();
const savedTransform = await key.getAttribute("transform");
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 chooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
const chooser = await chooserPromise;
await chooser.setFiles(filepath);
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
await expect(page.locator('[data-node-type="key"]').first()).toHaveAttribute("transform", savedTransform!);
await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible();
});
test("KLE import creates sixty visible keys", async ({ page }) => {
const kle = JSON.parse(await readFile(path.resolve(process.cwd(), "../samples/kle/cr0wn60.json"), "utf8"));
const chooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: "cr0wn60.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(kle)),
const cluster = await makeCluster(page);
const key = cluster.locator('[data-node-type="key"]').first();
await key.click();
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
const group = cluster.locator('[data-node-type="variantGroup"]');
await expect(group).toHaveCount(1);
const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
await expect(badge.locator("text")).toHaveText("⇄");
await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)");
// バッジはキーより後に描き、隣のキーの下に隠れない
const onTop = await badge.evaluate((element) => {
const keys = [...document.querySelectorAll('[data-node-type="key"]')];
const box = element.getBoundingClientRect();
const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
return [
keys.every((key) => key.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),
element.contains(hit),
];
});
expect(onTop).toEqual([true, true]);
await badge.locator("rect").click();
const popover = page.locator(".popover");
await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定");
await expect(popover.getByRole("button", { name: /Option B/ }).locator(".default-tag")).toHaveCount(0);
await popover.getByRole("button", { name: /Option B/ }).click();
await expect(badge.locator("title")).toHaveText("Variant Group: Option B");
});
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.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0);
await expect(page.getByTestId("idx-register-all")).toBeDisabled();
});
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(".badge-layer:not(.dim) .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(
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
).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(".hold-row.active")).toContainText("Cluster", { timeout: 1800 });
await expect(
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
).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("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
const cluster = await makeCluster(page);
await page.getByRole("button", { name: "長押し: ループ" }).click();
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
const key = cluster.locator('[data-node-type="key"]').first();
const keyId = await key.getAttribute("data-node-id");
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(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
await expect(page.locator(".hold-row.active")).toHaveText("キー");
await expect(page.locator(".hold-ring")).toHaveCount(0);
await page.waitForTimeout(1200);
await expect(page.locator(".hold-row.active")).toHaveText("キー");
await page.mouse.up();
await expect(page.locator(".hold-list")).toHaveCount(0);
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
// リストへ動かしてもキーはドラッグされない
expect(await key.boundingBox()).toEqual(b);
});
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const keys = cluster.locator('[data-node-type="key"]');
const before = await keys.nth(1).boundingBox();
const b = (await keys.first().boundingBox())!;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
await page.mouse.down();
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
await expect(page.locator(".hold-list")).toHaveCount(0);
await page.mouse.up();
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
// クラスターごと動くので、押していないキーも動く
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
});
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");
});
test("クラスター内でキーを足すと、足したキーを選ぶ", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterKeys = cluster.locator('[data-node-type="key"]');
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
await page.getByText("キー・部品 2件", { exact: true }).click();
await page.locator(`[data-tree-id="${before[0]}"]`).first().click();
await page.getByRole("button", { name: "キーを足す" }).click();
await expect(clusterKeys).toHaveCount(3);
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
const added = after.find((id) => !before.includes(id));
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
await expect(page.locator("[data-sel-id]")).toHaveCount(1);
});
test("クラスターを選んでキーを足すと、そのクラスターに新しい行を作る", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
const clusterKeys = cluster.locator('[data-node-type="key"]');
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
await page.getByRole("button", { name: "キーを足す" }).click();
await expect(clusterKeys).toHaveCount(3);
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
const added = after.find((id) => !before.includes(id));
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
// The cluster holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
const unit = right!.x - left!.x;
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
});
test("バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る", async ({ page }) => {
await keysSelectVariantGroup(page);
const group = page.locator('[data-node-type="variantGroup"]').first();
const groupId = await group.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${groupId}"]`).first().click();
const groupKeys = group.locator('[data-node-type="key"]');
const before = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
await page.getByRole("button", { name: "キーを足す" }).click();
await expect(groupKeys).toHaveCount(before.length + 1);
const after = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
const added = after.find((id) => !before.includes(id));
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
// The variant holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
const unit = right!.x - left!.x;
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
});
test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに合う", async ({ page }) => {
const canvas = page.locator("svg.canvas");
const box = (await canvas.boundingBox())!;
const [, , w, h] = (await canvas.getAttribute("viewBox"))!.split(" ").map(Number);
expect(Math.abs(w! - box.width)).toBeLessThan(1);
expect(Math.abs(h! - box.height)).toBeLessThan(1);
});
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await expect(page.locator(".mount-layer")).toHaveCount(0);
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
const mark = page.locator(`[data-mount-id="${id}"]`);
await expect(mark.locator(".switch")).toHaveCount(1);
await expect(mark.locator(".stab")).toHaveCount(0);
await key.click();
await expect(page.locator(".stabilizers")).toContainText("候補なし");
await page.getByLabel("Width").fill("6.25");
await page.getByLabel("Width").blur();
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
await page.getByLabel("±50mm").check();
await page.getByLabel("±40mm").check();
await expect(mark.locator(".stab")).toHaveCount(4);
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
const before = await switchX();
await page.getByLabel("switchOffset X").fill("0.625");
await page.getByLabel("switchOffset X").dispatchEvent("change");
await expect.poll(switchX).toBeGreaterThan(before + 10);
await expect(mark.locator(".stab")).toHaveCount(4);
await page.getByRole("button", { name: "Undo" }).click();
await page.getByRole("button", { name: "Undo" }).click();
await expect(mark.locator(".stab")).toHaveCount(2);
await expect(page.getByLabel("±40mm")).not.toBeChecked();
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
await expect(page.locator(".mount-layer")).toHaveCount(0);
});
test("ISO Enterを足すと外形で描き、プロパティの異形キーで2つ目の矩形を出し入れできる", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.first().click();
const select = page.getByRole("combobox", { name: "異形キーを足す" });
await select.selectOption("ISO Enter");
await expect(select).toHaveValue("");
await expect(keys).toHaveCount(17);
const added = keys.last();
const addedId = await added.getAttribute("data-node-id");
await expect(added.locator("path.key-face")).toHaveCount(1);
await expect(page.locator(`path[data-sel-id="${addedId}"]`)).toHaveCount(1);
const irregular = page.getByLabel("異形キー", { exact: true });
await expect(irregular).toBeChecked();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("-0.25");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.5");
await page.getByLabel("Width2", { exact: true }).fill("2");
await page.getByLabel("Width2", { exact: true }).press("Enter");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("2");
await irregular.uncheck();
await expect(added.locator("rect.key-face")).toHaveAttribute("width", "1.25");
await expect(page.getByLabel("X2", { exact: true })).toHaveCount(0);
await irregular.check();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("0");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.25");
});
+2 -1
View File
@@ -10,12 +10,13 @@ export class ProjectHistory {
) {
this.items = [structuredClone(initial)];
}
/** Adds one undoable snapshot unless a grouped edit is active. */
/** Adds one undoable snapshot unless a grouped edit is active or nothing changed. */
push(value: ProjectFile): void {
if (this.pending) {
this.replaceTop(value);
return;
}
if (JSON.stringify(value) === JSON.stringify(this.items[this.cursor])) return;
this.add(value);
}
/** Starts grouping subsequent pushes into a single operation. T-17. */
+39
View File
@@ -0,0 +1,39 @@
import type { FlatKey } from "./phy";
/** World position of a key origin and its rotation, as read from an existing definition. T-31. */
export type Placement = { x: number; y: number; rotation: number };
const TOLERANCE = 0.01;
const angle = (deg: number) => ((deg % 360) + 360) % 360;
function sameRotation(a: number, b: number): boolean {
const d = Math.abs(angle(a) - angle(b));
return Math.min(d, 360 - d) <= TOLERANCE;
}
/** Matches placements one-to-one to the shown keys by position and rotation, in placement order. */
export function matchPlacements(
placements: readonly Placement[],
keys: readonly FlatKey[],
): { ids: string[]; unmatched: number } {
const used = new Set<string>();
const ids: string[] = [];
for (const p of placements) {
let best: FlatKey | undefined,
distance = Infinity;
for (const k of keys) {
if (used.has(k.key.id) || !sameRotation(p.rotation, k.rotation)) continue;
const dx = Math.abs(p.x - k.x),
dy = Math.abs(p.y - k.y);
if (dx > TOLERANCE || dy > TOLERANCE || dx + dy >= distance) continue;
best = k;
distance = dx + dy;
}
if (best) {
used.add(best.key.id);
ids.push(best.key.id);
}
}
return { ids, unmatched: placements.length - ids.length };
}
+110
View File
@@ -0,0 +1,110 @@
import type { Node, PhyFile } from "./phy";
/** Keys shown in the edited configuration and the VariantGroups each key sits under. T-31. */
export type OrderContext = {
visible: ReadonlySet<string>;
groups: ReadonlyMap<string, readonly string[]>;
};
/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */
export function keyVariantGroups(phy: PhyFile): Map<string, string[]> {
const out = new Map<string, string[]>();
const visit = (nodes: Node[], groups: string[]) =>
nodes.forEach((n) => {
if (n.type === "key") out.set(n.id, groups);
else if (n.type === "cluster") visit(n.children, groups);
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children, [...groups, n.id]));
});
visit(phy.root, []);
return out;
}
function sharesGroup(ctx: OrderContext, a: string, b: string): boolean {
const mine = ctx.groups.get(a) ?? [];
return (ctx.groups.get(b) ?? []).some((g) => mine.includes(g));
}
/**
* Splits `order` into one segment per visible key, each holding the hidden keys attached to it.
* A hidden key attaches to the nearer visible key that shares a VariantGroup with it,
* preferring the one before it; once a hidden key attaches forward, the rest of that run does too.
* Concatenating the segments gives `order` back. Without visible keys there are no segments.
*/
export function orderSegments(order: readonly string[], ctx: OrderContext): string[][] {
const at = order.flatMap((id, i) => (ctx.visible.has(id) ? [i] : []));
if (!at.length) return [];
const segments = at.map((i) => [order[i]!]);
segments[0]!.unshift(...order.slice(0, at[0]));
segments.at(-1)!.push(...order.slice(at.at(-1)! + 1));
for (let k = 0; k + 1 < at.length; k++) {
const prev = order[at[k]!]!,
next = order[at[k + 1]!]!,
run = order.slice(at[k]! + 1, at[k + 1]);
let split = run.findIndex((h) => sharesGroup(ctx, h, next) && !sharesGroup(ctx, h, prev));
if (split < 0) split = run.length;
segments[k]!.push(...run.slice(0, split));
segments[k + 1]!.unshift(...run.slice(split));
}
return segments;
}
/**
* Moves `keyId` to gap `gap` (0..V) between the visible keys left after taking it out.
* Hidden keys that were attached to it stay where they are. Unregistered keys are registered there.
*/
export function insertAtGap(
order: readonly string[],
ctx: OrderContext,
keyId: string,
gap: number,
): string[] {
const rest = order.filter((id) => id !== keyId);
const segments = orderSegments(rest, ctx);
if (gap >= segments.length) return [...rest, keyId];
return [...segments.slice(0, gap).flat(), keyId, ...segments.slice(gap).flat()];
}
/** Moves `keyId` right before (`after` false) or right after the visible key `target`. */
export function insertNextTo(
order: readonly string[],
ctx: OrderContext,
keyId: string,
target: string,
after: boolean,
): string[] {
if (keyId === target) return [...order];
const visibleRest = order.filter((id) => id !== keyId && ctx.visible.has(id));
const index = visibleRest.indexOf(target);
if (index < 0) throw new Error(`Not a visible registered key: ${target}`);
return insertAtGap(order, ctx, keyId, index + (after ? 1 : 0));
}
/** Swaps two registered keys. Every other key keeps its position. */
export function swapKeys(order: readonly string[], a: string, b: string): string[] {
const i = order.indexOf(a),
j = order.indexOf(b);
if (i < 0 || j < 0) throw new Error("Both keys must be registered");
const out = [...order];
[out[i], out[j]] = [out[j]!, out[i]!];
return out;
}
/**
* Places the visible keys of `sequence` consecutively, starting where its first registered key was
* among the other visible keys. Other visible keys keep their relative order, hidden keys move with
* the visible key they are attached to, and unregistered keys in `sequence` are registered. T-31.
*/
export function renumber(order: readonly string[], ctx: OrderContext, sequence: readonly string[]): string[] {
const registered = new Set(order);
const seq = [...new Set(sequence)].filter((id) => ctx.visible.has(id) || !registered.has(id));
const inSeq = new Set(seq);
const anchor = seq.find((id) => registered.has(id));
if (!anchor) return [...order, ...seq];
const segments = orderSegments(order, ctx);
const byKey = new Map(segments.map((s) => [s.find((id) => ctx.visible.has(id))!, s]));
const others = segments.filter((s) => !s.some((id) => inSeq.has(id)));
const anchorAt = order.indexOf(anchor);
const gap = others.filter((s) => order.indexOf(s.find((id) => ctx.visible.has(id))!) < anchorAt).length;
const block = seq.flatMap((id) => byKey.get(id) ?? [id]);
return [...others.slice(0, gap).flat(), ...block, ...others.slice(gap).flat()];
}
+158
View File
@@ -0,0 +1,158 @@
import type { FlatKey } from "./phy";
import type { ProjectFile } from "./project";
import { detectColumns, detectRows, type KeyRect } from "./rows";
/** Gaps narrower than this do not separate the two halves. T-31. */
const MIN_HALF_GAP = 0.5;
export type ScanKind = "rows" | "columns" | "halves" | "alternating";
export type Direction = "normal" | "reversed";
export type ScanOptions = {
kind: ScanKind;
/** Whole-board direction for `rows` and `columns`. */
direction: Direction;
/** Per-half directions for `halves` and `alternating`. */
left: Direction;
right: Direction;
/** Shift of the line assignment per cross line index (columns for row scans, rows for `columns`). */
offsets: ReadonlyMap<number, number>;
};
export type ScanResult = {
sequence: string[];
/** Lines the scan walks, for the preview: rows, or columns for `columns`. */
lines: string[][];
/** Lines that can be shifted with `offsets`, in index order. */
crossLines: string[][];
/** x of the split between the halves, for scans that use halves. */
boundary?: number;
};
/** Axis-aligned bounding box of a key in world coordinates; rotated keys are judged by it. T-31. */
export function keyRect(k: FlatKey): KeyRect {
const r = (k.rotation * Math.PI) / 180,
c = Math.cos(r),
s = Math.sin(r);
const { w, h } = k.key.size;
const xs = [0, w * c, -h * s, w * c - h * s].map((dx) => k.x + dx);
const ys = [0, w * s, h * c, w * s + h * c].map((dy) => k.y + dy);
const x = Math.min(...xs),
y = Math.min(...ys);
return { id: k.key.id, x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
/**
* Splits keys into left and right halves at the widest gap between detected columns.
* When no gap reaches MIN_HALF_GAP, splits at the middle of the keys' x range by key center.
*/
export function splitHalves(rects: KeyRect[]): { left: Set<string>; right: Set<string>; boundary: number } {
const columns = detectColumns(rects);
const byId = new Map(rects.map((r) => [r.id, r]));
const span = columns.map((col) => {
const rs = col.map((id) => byId.get(id)!);
return { min: Math.min(...rs.map((r) => r.x)), max: Math.max(...rs.map((r) => r.x + r.w)) };
});
let best = -1,
width = -Infinity;
for (let i = 0; i + 1 < span.length; i++) {
const gap = span[i + 1]!.min - span[i]!.max;
if (gap > width) [best, width] = [i, gap];
}
if (best >= 0 && width >= MIN_HALF_GAP) {
const left = new Set(columns.slice(0, best + 1).flat());
return {
left,
right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))),
boundary: (span[best]!.max + span[best + 1]!.min) / 2,
};
}
const boundary = rects.length
? (Math.min(...rects.map((r) => r.x)) + Math.max(...rects.map((r) => r.x + r.w))) / 2
: 0;
const left = new Set(rects.filter((r) => r.x + r.w / 2 < boundary).map((r) => r.id));
return { left, right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), boundary };
}
/**
* Detects rows (or columns), then moves every key of cross line `i` by `offsets.get(i)` lines.
* Rows are rebuilt top to bottom and sorted by x; columns left to right and sorted by y.
*/
export function assignLines(
rects: KeyRect[],
kind: "row" | "column",
offsets: ReadonlyMap<number, number>,
): { lines: string[][]; crossLines: string[][] } {
const base = kind === "row" ? detectRows(rects) : detectColumns(rects);
const crossLines = kind === "row" ? detectColumns(rects) : detectRows(rects);
const line = new Map(base.flatMap((ids, i) => ids.map((id) => [id, i] as const)));
crossLines.forEach((ids, i) => ids.forEach((id) => line.set(id, line.get(id)! + (offsets.get(i) ?? 0))));
const byId = new Map(rects.map((r) => [r.id, r]));
const along = (id: string) => (kind === "row" ? byId.get(id)!.x : byId.get(id)!.y);
const indexes = [...new Set(line.values())].sort((a, b) => a - b);
const lines = indexes.map((i) =>
[...line]
.filter(([, at]) => at === i)
.map(([id]) => id)
.sort((a, b) => along(a) - along(b)),
);
return { lines, crossLines };
}
const directed = <T>(items: T[], d: Direction) => (d === "reversed" ? [...items].reverse() : items);
/** Builds the key sequence of a preset scan over the target keys. T-31. */
export function scanKeys(rects: KeyRect[], options: ScanOptions): ScanResult {
if (options.kind === "columns") {
const { lines, crossLines } = assignLines(rects, "column", options.offsets);
return { sequence: directed(lines, options.direction).flat(), lines, crossLines };
}
const { lines, crossLines } = assignLines(rects, "row", options.offsets);
if (options.kind === "rows")
return { sequence: lines.flatMap((row) => directed(row, options.direction)), lines, crossLines };
const { left, boundary } = splitHalves(rects);
const leftPart = (row: string[]) =>
directed(
row.filter((id) => left.has(id)),
options.left,
);
const rightPart = (row: string[]) =>
directed(
row.filter((id) => !left.has(id)),
options.right,
);
const sequence =
options.kind === "halves"
? [...lines.flatMap(leftPart), ...lines.flatMap(rightPart)]
: lines.flatMap((row) => [...leftPart(row), ...rightPart(row)]);
return { sequence, lines, crossLines, boundary };
}
/**
* Finds each contact key's (row, col) from the matrix pins, by the same rule as the Generator.
* Returns undefined when the project has no matrix settings. T-19.
*/
export function matrixPositions(p: ProjectFile): Map<string, { row: number; col: number }> | undefined {
const matrix = p.output.matrix;
if (!matrix) return undefined;
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
const out = new Map<string, { row: number; col: number }>();
for (const w of p.elc.keys) {
if (w.detect !== "contact") continue;
const { a, b } = w.terminals;
if (rowIndex.has(a) && colIndex.has(b)) out.set(w.key, { row: rowIndex.get(a)!, col: colIndex.get(b)! });
else if (rowIndex.has(b) && colIndex.has(a))
out.set(w.key, { row: rowIndex.get(b)!, col: colIndex.get(a)! });
}
return out;
}
/** Sorts keys by row then col. Keys without a matrix position keep their input order at the end. */
export function matrixSequence(
ids: readonly string[],
positions: ReadonlyMap<string, { row: number; col: number }>,
) {
const placed = ids.filter((id) => positions.has(id));
const rest = ids.filter((id) => !positions.has(id));
const key = (id: string) => positions.get(id)!;
return [...placed.sort((a, b) => key(a).row - key(b).row || key(a).col - key(b).col), ...rest];
}
+89
View File
@@ -0,0 +1,89 @@
import type { Point, Size } from "./phy";
export type Rect = { x: number; y: number; w: number; h: number };
/** ISO Enter, BAE and stepped keys carry a second rectangle (KLE's x2/y2/w2/h2). */
export function hasSecondRect(size: Size): boolean {
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
}
/** The key's rectangles in key-local coordinates. Missing second-rectangle fields take KLE's defaults (0, 0, w, h). */
export function keyRects(size: Size): Rect[] {
const main = { x: 0, y: 0, w: size.w, h: size.h };
if (!hasSecondRect(size)) return [main];
return [main, { x: size.x2 ?? 0, y: size.y2 ?? 0, w: size.w2 ?? size.w, h: size.h2 ?? size.h }];
}
/** Corners of every rectangle of the key, for bounding boxes. */
export function keyCorners(size: Size): Point[] {
return keyRects(size).flatMap((r) => [
{ x: r.x, y: r.y },
{ x: r.x + r.w, y: r.y },
{ x: r.x + r.w, y: r.y + r.h },
{ x: r.x, y: r.y + r.h },
]);
}
/** Top-left of the key's local bounding box. */
export function keyOrigin(size: Size): Point {
const corners = keyCorners(size);
return { x: Math.min(...corners.map((p) => p.x)), y: Math.min(...corners.map((p) => p.y)) };
}
/**
* Outline of the union of the key's rectangles, as closed loops running clockwise on screen (y down).
* The plane is cut into cells along every rectangle edge; boundary edges of filled cells are chained into loops.
*/
export function keyOutline(size: Size): Point[][] {
const rects = keyRects(size).filter((r) => r.w > 0 && r.h > 0);
const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b);
const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b);
const filled = (i: number, j: number) => {
if (i < 0 || j < 0 || i >= xs.length - 1 || j >= ys.length - 1) return false;
const cx = (xs[i]! + xs[i + 1]!) / 2,
cy = (ys[j]! + ys[j + 1]!) / 2;
return rects.some((r) => cx > r.x && cx < r.x + r.w && cy > r.y && cy < r.y + r.h);
};
const key = (p: Point) => `${p.x},${p.y}`;
const edges = new Map<string, Point[]>();
const edge = (from: Point, to: Point) => edges.set(key(from), [...(edges.get(key(from)) ?? []), to]);
for (let i = 0; i < xs.length - 1; i++)
for (let j = 0; j < ys.length - 1; j++) {
if (!filled(i, j)) continue;
const [x0, x1, y0, y1] = [xs[i]!, xs[i + 1]!, ys[j]!, ys[j + 1]!];
if (!filled(i, j - 1)) edge({ x: x0, y: y0 }, { x: x1, y: y0 });
if (!filled(i + 1, j)) edge({ x: x1, y: y0 }, { x: x1, y: y1 });
if (!filled(i, j + 1)) edge({ x: x1, y: y1 }, { x: x0, y: y1 });
if (!filled(i - 1, j)) edge({ x: x0, y: y1 }, { x: x0, y: y0 });
}
const loops: Point[][] = [];
for (const start of edges.keys()) {
while (edges.get(start)?.length) {
const loop: Point[] = [];
let at = start;
for (let next = edges.get(at)?.pop(); next; next = edges.get(at)?.pop()) {
loop.push(next);
at = key(next);
if (at === start) break;
}
loops.push(dropCollinear(loop));
}
}
return loops;
}
function dropCollinear(loop: Point[]): Point[] {
return loop.filter((p, i) => {
const prev = loop[(i + loop.length - 1) % loop.length]!,
next = loop[(i + 1) % loop.length]!;
return !((prev.x === p.x && p.x === next.x) || (prev.y === p.y && p.y === next.y));
});
}
/** A key shape the editor adds in one step. provisional/decisions.md T-32. */
export type KeyPreset = { id: string; label: string; size: Size };
/** Non-rectangular keys offered by「異形キーを足す」. Sizes follow KLE's presets (keys.json). */
export const IRREGULAR_KEY_PRESETS: KeyPreset[] = [
{ id: "iso-enter", label: "ISO Enter", size: { w: 1.25, h: 2, x2: -0.25, w2: 1.5, h2: 1 } },
];
+98 -33
View File
@@ -1,6 +1,7 @@
import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy";
import type { ClusterNode, KeyNode, Node, PhyFile, Size, Transform, Variant, VariantGroupNode } from "./phy";
import { apply, compose, inverse, matrix } from "./geometry";
import { createNodeId } from "./ids";
import { keyCorners } from "./keyShape";
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
@@ -50,7 +51,7 @@ function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
export function addKey(
f: PhyFile,
at: Transform,
size: { w: number; h: number },
size: Size,
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
@@ -69,6 +70,59 @@ export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?
});
return o;
}
/** 指定コンテナへキーを追加する。 */
export function addKeyToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
size: Size,
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;
// variantのIDは containers に子の入れ物として載るので、find ではノードとして引けない
const n = containers(f)
.get(parentId)
?.nodes.find((x) => x.id === 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),
@@ -222,25 +276,7 @@ function containsNode(n: Node, id: string): boolean {
}
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));
return keyCorners(n.size).map((point) => apply(worldMatrix, point));
}
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
if (n.type === "cluster")
@@ -347,6 +383,17 @@ export function createVariantGroup(
at: Transform,
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
gen?: () => string,
): PhyFile {
return createVariantGroupInParent(f, null, name, at, variants, gen);
}
/** 指定コンテナへVariantGroupを作成する。T-11。 */
export function createVariantGroupInParent(
f: PhyFile,
parentId: string | null,
name: string,
at: Transform,
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
gen?: () => string,
): PhyFile {
const o = mutable(f),
children = variants.map((x) => ({
@@ -355,7 +402,7 @@ export function createVariantGroup(
name: x.name,
children: structuredClone(x.children ?? []),
}));
o.root.push({
parentNodes(o, parentId).push({
type: "variantGroup",
id: nodeId("variantGroup", gen),
name,
@@ -407,20 +454,38 @@ export function deleteNode(f: PhyFile, id: string): PhyFile {
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
const o = mutable(f),
n = find(o, id),
copy = structuredClone(n);
const renew = (x: Node) => {
x.id = nodeId(x.type, gen);
if (x.type === "cluster") x.children.forEach(renew);
if (x.type === "variantGroup")
for (const v of x.children) {
v.id = nodeId("variant", gen);
v.children.forEach(renew);
}
};
renew(copy);
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;
}
+105
View File
@@ -0,0 +1,105 @@
/** Axis-aligned key rectangle in one coordinate frame, in U. */
export type KeyRect = { id: string; x: number; y: number; w: number; h: number };
const EPS = 1e-3;
/** Keys farther apart than this across adjacent lines are not linked. */
const MAX_LINK = 1;
type Point = { id: string; along: number; across: number };
/**
* Detects visual rows, top to bottom, each listing key ids left to right.
* The aligned axis is grouped by coordinate and the staggered axis is traced through
* adjacent lines (idx-editor-feedback.md). Keys are placed by their top-left corner,
* so tall keys belong to their top row and wide keys to their left column.
*/
export function detectRows(keys: KeyRect[]): string[][] {
return detectLines(keys, "row");
}
/** Detects visual columns, left to right, each listing key ids top to bottom. */
export function detectColumns(keys: KeyRect[]): string[][] {
return detectLines(keys, "column");
}
function detectLines(keys: KeyRect[], kind: "row" | "column"): string[][] {
const points: Point[] = keys.map((k) =>
kind === "row" ? { id: k.id, along: k.x, across: k.y } : { id: k.id, along: k.y, across: k.x },
);
const rowsAligned = groupBy(keys, (k) => k.y).length <= groupBy(keys, (k) => k.x).length;
return (kind === "row") === rowsAligned ? groupLines(points) : traceLines(points);
}
/** Clusters values within EPS, ordered by value. */
function groupBy<T>(items: T[], value: (item: T) => number): T[][] {
const sorted = [...items].sort((a, b) => value(a) - value(b));
const groups: T[][] = [];
for (const item of sorted) {
const last = groups.at(-1);
if (last && value(item) - value(last.at(-1)!) <= EPS) last.push(item);
else groups.push([item]);
}
return groups;
}
function groupLines(points: Point[]): string[][] {
return groupBy(points, (p) => p.across).map((line) => sortAlong(line).map((p) => p.id));
}
/** Links each pair of adjacent cross lines one-to-one by nearest `across`, then joins the links. */
function traceLines(points: Point[]): string[][] {
const cross = groupBy(points, (p) => p.along);
const parent = new Map(points.map((p) => [p.id, p.id]));
const find = (id: string): string => {
const up = parent.get(id)!;
if (up === id) return id;
const root = find(up);
parent.set(id, root);
return root;
};
for (let i = 0; i + 1 < cross.length; i++) {
const pairs = cross[i]!.flatMap((a) =>
cross[i + 1]!.map((b) => ({ a, b, d: Math.abs(a.across - b.across) })),
)
.filter((pair) => pair.d < MAX_LINK)
.sort((p, q) => p.d - q.d);
const used = new Set<string>();
for (const { a, b } of pairs) {
if (used.has(a.id) || used.has(b.id)) continue;
used.add(a.id);
used.add(b.id);
parent.set(find(b.id), find(a.id));
}
}
const lines = new Map<string, Point[]>();
for (const p of points) lines.set(find(p.id), [...(lines.get(find(p.id)) ?? []), p]);
return [...lines.values()]
.map(sortAlong)
.sort((a, b) => mean(a) - mean(b) || a[0]!.along - b[0]!.along)
.map((line) => line.map((p) => p.id));
}
function sortAlong(line: Point[]): Point[] {
return [...line].sort((a, b) => a.along - b.along);
}
function mean(line: Point[]): number {
return line.reduce((sum, p) => sum + p.across, 0) / line.length;
}
/** Position right after the last key of the row containing `keyId`. */
export function rowEndPosition(keys: KeyRect[], keyId: string): { x: number; y: number } | undefined {
const row = detectRows(keys).find((line) => line.includes(keyId));
if (!row) return undefined;
const byId = new Map(keys.map((k) => [k.id, k]));
const last = row.map((id) => byId.get(id)!).reduce((a, b) => (b.x + b.w > a.x + a.w ? b : a));
return { x: last.x + last.w, y: last.y };
}
/** Leftmost position of a new row below every key. */
export function newRowPosition(keys: KeyRect[]): { x: number; y: number } | undefined {
if (!keys.length) return undefined;
return {
x: Math.min(...keys.map((k) => k.x)),
y: Math.max(...keys.map((k) => k.y + k.h)),
};
}
+66
View File
@@ -0,0 +1,66 @@
import type { Point, Size } from "./phy";
import { hasSecondRect } from "./keyShape";
/** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */
export type StabilizerCandidate = { id: string; label: string; offset: number };
const MM = 1 / 19.05;
/** Cherry MX spacings by key length. provisional/decisions.md「単位8のスタビ候補」. */
const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
{
lengths: [2, 2.25, 2.5, 2.75],
candidates: [{ id: "11.938mm", label: "±11.938mm", offset: 11.938 * MM }],
},
{ lengths: [3], candidates: [{ id: "1u", label: "±1U", offset: 1 }] },
{ lengths: [4], candidates: [{ id: "1.5u", label: "±1.5U", offset: 1.5 }] },
{ lengths: [4.5], candidates: [{ id: "34.671mm", label: "±34.671mm", offset: 34.671 * MM }] },
{ lengths: [5.5], candidates: [{ id: "2.25u", label: "±2.25U", offset: 2.25 }] },
{
lengths: [6],
candidates: [
{ id: "2.5u", label: "±2.5U", offset: 2.5 },
{ id: "2u", label: "±2U", offset: 2 },
],
},
{
lengths: [6.25],
candidates: [
{ id: "50mm", label: "±50mm", offset: 50 * MM },
{ id: "40mm", label: "±40mm", offset: 40 * MM },
{ id: "42.5mm", label: "±42.5mm", offset: 42.5 * MM },
],
},
{ lengths: [6.5], candidates: [{ id: "2.75u", label: "±2.75U", offset: 2.75 }] },
{ lengths: [7], candidates: [{ id: "3u", label: "±3U", offset: 3 }] },
{ lengths: [8, 9, 9.75, 10], candidates: [{ id: "3.5u", label: "±3.5U", offset: 3.5 }] },
];
const EPS = 1e-9;
export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
// Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet.
if (hasSecondRect(size)) return [];
const length = Math.max(size.w, size.h);
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
}
/** Mount points in key-local coordinates, around the (w, h) rectangle's center, not the switch. */
export function stabilizerPoints(size: Size, ids: string[]): Point[] {
const cx = size.w / 2,
cy = size.h / 2,
vertical = size.h > size.w;
return stabilizerCandidates(size)
.filter((c) => ids.includes(c.id))
.flatMap((c) =>
vertical
? [
{ x: cx, y: cy - c.offset },
{ x: cx, y: cy + c.offset },
]
: [
{ x: cx - c.offset, y: cy },
{ x: cx + c.offset, y: cy },
],
);
}
export function switchPoint(key: { size: Size; switchOffset?: Point }): Point {
return { x: key.size.w / 2 + (key.switchOffset?.x ?? 0), y: key.size.h / 2 + (key.switchOffset?.y ?? 0) };
}
+54
View File
@@ -0,0 +1,54 @@
import { flattenKeys } from "../domain/geometry";
import type { Placement } from "../domain/idxImport";
import { importKle } from "./kleImport";
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
const num = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback);
/** Result of reading an existing definition. `layouts` lists the choices when one must be picked. */
export type LayoutRead = { placements: Placement[]; layouts?: string[]; layout?: string };
/** QMK keyboard.json/info.json layout names, or undefined when the input is not a QMK definition. */
export function qmkLayoutNames(input: unknown): string[] | undefined {
if (!isObj(input) || !isObj(input.layouts) || Array.isArray(input.layouts.keymap)) return undefined;
const names = Object.entries(input.layouts)
.filter(([, v]) => isObj(v) && Array.isArray(v.layout))
.map(([name]) => name);
return names.length ? names : undefined;
}
/**
* Reads key placements, in definition order, from QMK `layouts.<NAME>.layout[]`, a VIA definition's
* `layouts.keymap`, or KLE JSON. KLE and VIA go through importKle so coordinates match the project.
* QMK r/rx/ry are read with the KLE meaning. Without `layout`, a QMK file with several layouts
* returns only the names. T-31.
*/
export function readLayoutPlacements(input: unknown, layout?: string): LayoutRead {
const names = qmkLayoutNames(input);
if (names) {
const name = layout ?? (names.length === 1 ? names[0] : undefined);
if (!name) return { placements: [], layouts: names };
const entries = ((input as { layouts: Obj }).layouts[name] as { layout: unknown[] }).layout;
return { placements: entries.filter(isObj).map(qmkPlacement), layouts: names, layout: name };
}
const { phy, keyOrder } = importKle(input);
const flat = new Map(flattenKeys(phy).map((k) => [k.key.id, k]));
const placements = keyOrder.flatMap((id) => {
const k = flat.get(id);
return k && !k.key.decal ? [{ x: k.x, y: k.y, rotation: k.rotation }] : [];
});
return { placements };
}
function qmkPlacement(e: Obj): Placement {
const x = num(e.x),
y = num(e.y),
r = num(e.r),
rx = num(e.rx),
ry = num(e.ry);
const a = (r * Math.PI) / 180,
c = Math.cos(a),
s = Math.sin(a);
return { x: rx + (x - rx) * c - (y - ry) * s, y: ry + (x - rx) * s + (y - ry) * c, rotation: r };
}
+99 -696
View File
@@ -2,198 +2,111 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
import { createProjectFromKle } from "../domain/project";
import type { ProjectFile } from "../domain/project";
import type { Node, KeyNode, Transform, VariantGroupNode } from "../domain/phy";
import { flattenKeys } from "../domain/geometry";
import {
addKey,
addComponent,
moveNode,
resizeKey,
rotateNode,
clusterNodes,
uncluster,
reparent,
reparentToVariant,
duplicateNode,
createVariantGroup,
addVariant,
setDefaultVariant,
reframe,
} from "../domain/phyCommands";
import { ProjectHistory } from "../domain/history";
import { validateProject } from "../domain/validate";
import { listDeleteReferences, deleteNodeCascade } from "../domain/deleteCascade";
import { saveProject, loadProject } from "../io/projectFile";
import { exportKle } from "../io/kleExport";
import { defaultLayout } from "./defaultLayout";
import type { Dispatch } from "./types";
import PhyTree from "./phy/PhyTree.vue";
import PhyCanvas from "./phy/PhyCanvas.vue";
import { parentPoint, snappedDelta } from "./phy/dragMath";
import IdxPanel from "./panels/IdxPanel.vue";
import PhyEditor from "./phy/PhyEditor.vue";
import IdxEditor from "./idx/IdxEditor.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);
const activeTab = ref("PHY"),
selection = ref<string[]>([]),
variants = ref<Record<string, string>>({}),
zoom = ref(1);
const errorText = ref(""),
pendingDelete = ref<{
id: string;
refs: ReturnType<typeof listDeleteReferences>;
} | null>(null);
const project = shallowRef<ProjectFile | null>(null),
activeTab = ref("PHY"),
errorText = ref("");
const importInput = ref<HTMLInputElement>(),
openInput = ref<HTMLInputElement>();
let history: ProjectHistory | null = null;
const historyEpoch = ref(0);
let drag: {
id: string;
parent: SVGGElement;
start: { x: number; y: number };
origin: Transform;
base: ProjectFile;
} | 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) : []));
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
severity = ["blocksSave", "blocksOutput", "warning"] as const;
const canUndo = computed(() => {
historyEpoch.value;
epoch.value;
return !!history?.canUndo;
}),
canRedo = computed(() => {
historyEpoch.value;
epoch.value;
return !!history?.canRedo;
});
const severity = ["blocksSave", "blocksOutput", "warning"] as const;
const selectedNode = computed(() =>
project.value && selection.value.length === 1
? findNode(project.value.phy.root, selection.value[0]!)
: null,
);
const keyCount = computed(() => (project.value ? flattenKeys(project.value.phy, variants.value).length : 0));
const variantGroups = computed(() => {
const groups: VariantGroupNode[] = [];
if (project.value)
visit(project.value.phy.root, (n) => {
if (n.type === "variantGroup") groups.push(n);
});
return groups;
});
const dispatch: Dispatch = (label, update) => {
if (!project.value) return;
if (mismatch.value) return;
if (!project.value || mismatch.value) return;
const next = update(project.value);
project.value = next;
if (history) history.push(next);
historyEpoch.value++;
history?.push(next);
epoch.value++;
errorText.value = "";
};
function findNode(nodes: Node[], id: string): Node | null {
for (const n of nodes) {
if (n.id === id) return n;
if (n.type === "cluster") {
const x = findNode(n.children, id);
if (x) return x;
}
if (n.type === "variantGroup")
for (const v of n.children) {
if (v.id === id) return n;
const x = findNode(v.children, id);
if (x) return x;
}
}
return null;
}
function visit(nodes: Node[], fn: (n: Node) => void) {
for (const n of nodes) {
fn(n);
if (n.type === "cluster") visit(n.children, fn);
if (n.type === "variantGroup") for (const v of n.children) visit(v.children, fn);
function beginHistory() {
if (history) {
history.begin();
epoch.value++;
}
}
function parents() {
const out: { id: string; label: string }[] = [{ id: "", label: "root" }];
if (project.value)
visit(project.value.phy.root, (n) => {
if (n.type === "cluster") out.push({ id: n.id, label: `クラスター ${n.name || n.id}` });
if (n.type === "variantGroup")
for (const v of n.children) out.push({ id: v.id, label: `${n.name} / ${v.name}` });
});
return out;
}
function setSelected(id: string, additive: boolean) {
selection.value = additive
? selection.value.includes(id)
? selection.value.filter((x) => x !== id)
: [...selection.value, id]
: [id];
const n = project.value ? findNode(project.value.phy.root, id) : null;
if (n?.type === "cluster")
promptValue.value = { x: n.transform.x, y: n.transform.y, r: n.transform.rotation };
}
async function initialize() {
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
project.value = p;
history = new ProjectHistory(p);
historyEpoch.value++;
variants.value = { ...p.output.variantSelection };
}
onMounted(() => {
void initialize();
window.addEventListener("keydown", keyHandler);
});
onUnmounted(() => window.removeEventListener("keydown", keyHandler));
function keyHandler(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
e.shiftKey ? redo() : undo();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
function commitHistory() {
if (history && project.value) {
history.commit(project.value);
epoch.value++;
}
}
function undo() {
if (history?.canUndo) {
project.value = history.undo();
historyEpoch.value++;
variants.value = { ...project.value.output.variantSelection };
selection.value = selection.value.filter(
(id) => !!project.value && !!findNode(project.value.phy.root, id),
);
epoch.value++;
}
}
function redo() {
if (history?.canRedo) {
project.value = history.redo();
historyEpoch.value++;
variants.value = { ...project.value.output.variantSelection };
epoch.value++;
}
}
async function readFile(file: File, mode: "project" | "kle") {
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 json = JSON.parse(await file.text()) as unknown;
if (mode === "kle") {
const p = await createProjectFromKle(json, { name: file.name });
project.value = p;
history = new ProjectHistory(p);
variants.value = { ...p.output.variantSelection };
selection.value = [];
} else {
const result = await loadProject(JSON.stringify(json));
if (!result.project) throw Error(result.errors.map((x) => x.message).join("; "));
project.value = result.project;
history = new ProjectHistory(result.project);
variants.value = { ...result.project.output.variantSelection };
selection.value = [];
errorText.value = result.errors.map((x) => x.message).join("; ");
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("; ");
}
historyEpoch.value++;
project.value = p;
history = new ProjectHistory(p);
epoch.value++;
} catch (e) {
errorText.value = String(e);
}
@@ -206,215 +119,21 @@ function download(name: string, text: string) {
URL.revokeObjectURL(a.href);
}
async function save() {
if (project.value) {
const result = await saveProject(project.value, true);
if (result.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, result.json);
else errorText.value = result.errors.map((x) => x.message).join("; ");
}
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, variants.value), null, 2));
}
function nodeDown(event: PointerEvent, id: string, parent: SVGGElement) {
if (mismatch.value || !project.value) return;
const node = findNode(project.value.phy.root, id);
if (!node) return;
event.preventDefault();
const p = parentPoint(parent, event.clientX, event.clientY);
history?.begin();
historyEpoch.value++;
drag = { id, parent, start: p, origin: { ...node.transform }, base: project.value };
window.addEventListener("pointermove", movePointer);
window.addEventListener("pointerup", endPointer, { once: true });
}
function movePointer(e: PointerEvent) {
if (!drag) return;
const p = parentPoint(drag.parent, e.clientX, e.clientY),
delta = snappedDelta(drag.start, p);
const next = moveNode(drag.base.phy, drag.id, delta.x, delta.y);
project.value = { ...drag.base, phy: next };
history?.replaceTop(project.value);
}
function endPointer() {
window.removeEventListener("pointermove", movePointer);
if (project.value) history?.commit(project.value);
historyEpoch.value++;
drag = null;
}
function keyAdd() {
if (!project.value) return;
dispatch("キー追加", (p) => ({ ...p, phy: addKey(p.phy, { x: 0, y: 0, rotation: 0 }, { w: 1, h: 1 }) }));
const n = project.value.phy.root.at(-1);
if (n) selection.value = [n.id];
}
function componentAdd(group: string) {
if (!project.value) return;
dispatch("部品追加", (p) => ({
...p,
phy: addComponent(p.phy, { x: 0, y: 0, rotation: 0 }, group || undefined),
}));
const n = project.value.phy.root.at(-1);
if (n) selection.value = [n.id];
}
function updateTransform(field: keyof Transform, value: number) {
if (mismatch.value || !project.value || selection.value.length !== 1 || !selectedNode.value) return;
const id = selectedNode.value.id;
dispatch("変換変更", (p) => {
const phy = structuredClone(p.phy),
n = findNode(phy.root, id);
if (n) n.transform[field] = value;
return { ...p, phy };
});
}
function increment(field: keyof Transform, step: number) {
if (!selectedNode.value) return;
const n = selectedNode.value;
updateTransform(field, n.transform[field] + step);
}
function updateSize(field: "w" | "h", value: number) {
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
const id = selectedNode.value.id;
dispatch("キーサイズ変更", (p) => ({
...p,
phy: resizeKey(p.phy, id, { ...(findNode(p.phy.root, id) as KeyNode).size, [field]: value }),
}));
}
function updateLabel(value: string) {
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
const id = selectedNode.value.id;
dispatch("ラベル変更", (p) => {
const phy = structuredClone(p.phy),
n = findNode(phy.root, id);
if (n && n.type === "key") n.labels = [value];
return { ...p, phy };
});
}
function rotateSelected() {
if (mismatch.value || !project.value) return;
const ids = [...selection.value].filter((id) => findNode(project.value!.phy.root, id));
if (!ids.length) return;
dispatch("回転", (p) => ({ ...p, phy: ids.reduce((phy, id) => rotateNode(phy, id, 15), p.phy) }));
}
function makeCluster() {
if (!project.value || selection.value.length < 1) return;
try {
dispatch("クラスター化", (p) => ({ ...p, phy: clusterNodes(p.phy, selection.value) }));
const n = project.value.phy.root.find((n) => n.type === "cluster");
if (n) selection.value = [n.id];
} catch (e) {
errorText.value = String(e);
}
}
function ungroup() {
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
const id = selectedNode.value.id;
dispatch("クラスター解除", (p) => ({ ...p, phy: uncluster(p.phy, id) }));
selection.value = [];
}
function duplicate() {
if (!project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
dispatch("複製", (p) => ({ ...p, phy: duplicateNode(p.phy, id) }));
}
function reparentTo(target: string) {
if (mismatch.value || !project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
try {
dispatch("reparent", (p) => ({
...p,
phy: target.startsWith("v-")
? reparentToVariant(p.phy, id, target)
: reparent(p.phy, id, target || null),
}));
} catch (e) {
errorText.value = String(e);
}
}
function createVariants() {
if (!project.value || selection.value.length === 0) return;
const first = findNode(project.value.phy.root, selection.value[0]!);
if (!first) return;
try {
dispatch("VariantGroup作成", (p) => {
let phy = createVariantGroup(p.phy, "Layout", { ...first.transform }, [
{ name: "Option A" },
{ name: "Option B" },
]);
const vg = phy.root.at(-1);
if (vg?.type === "variantGroup") {
for (const id of selection.value) phy = reparentToVariant(phy, id, vg.children[0]!.id);
return {
...p,
phy,
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [vg.id]: vg.default } },
};
}
return { ...p, phy };
});
selection.value = [];
if (project.value) variants.value = { ...project.value.output.variantSelection };
} catch (e) {
errorText.value = String(e);
}
}
function addVariantUi(groupId: string) {
if (!project.value) return;
dispatch("Variant追加", (p) => ({
...p,
phy: addVariant(
p.phy,
groupId,
`Option ${String.fromCharCode(65 + (findNode(p.phy.root, groupId) as Node & { children: Node[] }).children.length)}`,
),
}));
}
function removeVariantUi(variantId: string) {
if (!project.value) return;
pendingDelete.value = { id: variantId, refs: listDeleteReferences(project.value, variantId) };
}
function changeDefault(groupId: string, id: string) {
if (!project.value) return;
dispatch("既定Variant変更", (p) => ({ ...p, phy: setDefaultVariant(p.phy, groupId, id) }));
}
function confirmDelete() {
if (!pendingDelete.value || !project.value) return;
const { id } = pendingDelete.value;
try {
dispatch("ノード削除", (p) => deleteNodeCascade(p, id));
if (project.value) variants.value = { ...project.value.output.variantSelection };
selection.value = [];
pendingDelete.value = null;
} catch (e) {
errorText.value = String(e);
}
}
function requestDelete() {
if (mismatch.value || !project.value || selection.value.length !== 1) return;
const id = selection.value[0]!;
pendingDelete.value = { id, refs: listDeleteReferences(project.value, id) };
}
function changeFrame() {
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
const id = selectedNode.value.id;
const x = Number(promptValue.value.x),
y = Number(promptValue.value.y),
rotation = Number(promptValue.value.r);
dispatch("座標枠変更", (p) => ({ ...p, phy: reframe(p.phy, id, { x, y, rotation }) }));
}
const promptValue = ref({ x: 0, y: 0, r: 0 });
function changeVariant(groupId: string, id: string) {
variants.value = { ...variants.value, [groupId]: id };
if (!mismatch.value)
dispatch("表示構成変更", (p) => ({
...p,
output: { ...p.output, variantSelection: { ...variants.value } },
}));
download(
"layout.kle.json",
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
);
}
</script>
<template>
<main class="app-shell" :class="{ locked: mismatch }" v-if="project">
<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
@@ -434,7 +153,7 @@ function changeVariant(groupId: string, id: string) {
ref="openInput"
hidden
type="file"
accept=".bkle.json,.json,application/json"
accept=".json,application/json"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
@@ -450,177 +169,37 @@ function changeVariant(groupId: string, id: string) {
</header>
<nav class="tabs">
<button
v-for="tab in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
:key="tab"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
:key="t"
:class="{ active: activeTab === t }"
@click="activeTab = t"
>
{{ tab }}
{{ t }}
</button>
</nav>
<section v-if="activeTab === 'PHY'" class="workspace">
<aside class="tree pane">
<h2>PHYの木</h2>
<div class="commands">
<button :disabled="mismatch" @click="keyAdd">キー追加</button
><select
id="part-group"
:disabled="mismatch"
@change="componentAdd(($event.target as HTMLSelectElement).value)"
>
<option value="">部品追加…</option>
<option>@led</option>
<option>@encoder</option>
<option>@screw</option>
<option>@oled</option>
<option>custom</option></select
><button :disabled="mismatch || selection.length === 0" @click="makeCluster">クラスター化</button
><button :disabled="mismatch || selectedNode?.type !== 'cluster'" @click="ungroup">解除</button
><button :disabled="mismatch || selection.length !== 1" @click="duplicate">複製</button
><button :disabled="mismatch || selection.length !== 1" @click="requestDelete">削除</button
><select
:disabled="mismatch || selection.length !== 1"
@change="reparentTo(($event.target as HTMLSelectElement).value)"
>
<option value="">reparent先…</option>
<option v-for="p in parents()" :key="p.id" :value="p.id">{{ p.label }}</option></select
><button :disabled="mismatch || selection.length === 0" @click="createVariants">
選択からVariantGroup
</button>
</div>
<PhyTree
:nodes="project.phy.root"
:selected="selection"
@select="(id, add) => setSelected(id, add)"
/>
<div v-if="pendingDelete" class="confirm-box">
<strong>削除の確認: {{ pendingDelete.id }}</strong>
<p>削除される参照 {{ pendingDelete.refs.length }} 件 (T-15)</p>
<ul>
<li v-for="(r, i) in pendingDelete.refs" :key="i">
{{ r.layer }} · {{ r.id }} · {{ r.location }}
</li>
</ul>
<button :disabled="mismatch" @click="confirmDelete">参照と一緒に削除</button
><button @click="pendingDelete = null">キャンセル</button>
</div>
</aside>
<section class="canvas-pane pane">
<div class="canvas-head">
<strong>レイアウト · {{ keyCount }} keys</strong
><label>Zoom <input type="range" min="0.5" max="2" step="0.1" v-model.number="zoom" /></label
><label v-for="n in variantGroups" :key="n.id"
>{{ n.name }}
<select
:value="variants[n.id] ?? n.default"
@change="changeVariant(n.id, ($event.target as HTMLSelectElement).value)"
>
<option v-for="v in n.children" :key="v.id" :value="v.id">{{ v.name }}</option></select
><button :disabled="mismatch" @click="addVariantUi(n.id)">+</button
><button :disabled="mismatch" @click="changeDefault(n.id, variants[n.id] ?? n.default)">
default</button
><button :disabled="mismatch" @click="removeVariantUi(variants[n.id] ?? n.default)">
−
</button></label
>
</div>
<p v-if="mismatch" class="warning">unitSize が揃っていないPHYは編集できません。</p>
<PhyCanvas
:nodes="project.phy.root"
:selected="selection"
:variants="variants"
:zoom="zoom"
@select="(id, add) => setSelected(id, add)"
@drag-start="nodeDown"
/>
</section>
<aside class="properties pane">
<h2>プロパティ</h2>
<template v-if="selectedNode"
><p>
<b>{{ selectedNode.type }}</b
><br /><small>{{ selectedNode.id }}</small>
</p>
<label
>X
<input
type="number"
step="any"
:value="selectedNode.transform.x"
@change="updateTransform('x', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('x', 0.25)">±.25U</button></label
><label
>Y
<input
type="number"
step="any"
:value="selectedNode.transform.y"
@change="updateTransform('y', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('y', 0.25)">±.25U</button></label
><label
>Rotation
<input
type="number"
step="any"
:value="selectedNode.transform.rotation"
@change="updateTransform('rotation', Number(($event.target as HTMLInputElement).value))"
/><button @click="increment('rotation', 15)">±15°</button></label
><button @click="rotateSelected">+15°回転</button
><template v-if="selectedNode.type === 'key'"
><label
>Width
<input
type="number"
step="any"
:value="selectedNode.size.w"
@change="updateSize('w', Number(($event.target as HTMLInputElement).value))" /></label
><label
>Height
<input
type="number"
step="any"
:value="selectedNode.size.h"
@change="updateSize('h', Number(($event.target as HTMLInputElement).value))" /></label
><label
>Label
<input
:value="selectedNode.labels?.filter(Boolean)[0]"
@change="updateLabel(($event.target as HTMLInputElement).value)" /></label></template
><template v-if="selectedNode.type === 'cluster'"
><h3>座標枠変更</h3>
<label>原点 X <input type="number" v-model.number="promptValue.x" /></label
><label>原点 Y <input type="number" v-model.number="promptValue.y" /></label
><label>角度 <input type="number" v-model.number="promptValue.r" /></label
><button @click="changeFrame">適用</button></template
></template
>
<template v-else-if="selection.length > 1">
<p>{{ selection.length }}件を選択中</p>
<button :disabled="mismatch" @click="rotateSelected">選択を+15°回転</button>
</template>
<p v-else>ノードを選択してください。Shift+クリックで複数選択。</p>
</aside>
</section>
<PhyEditor
v-if="activeTab === 'PHY'"
:project="project"
:dispatch="dispatch"
:mismatch="mismatch"
:begin-history="beginHistory"
:commit-history="commitHistory"
@error="errorText = $event"
/>
<IdxEditor
v-else-if="activeTab === 'IDX'"
:project="project"
:dispatch="dispatch"
@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
<ElcPanel v-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>
<div v-if="activeTab === 'PHY' && selectedNode" class="nudge-controls">
<button :disabled="mismatch" @click="increment('x', -0.25)">X −.25U</button
><button :disabled="mismatch" @click="increment('x', 0.25)">X +.25U</button
><button :disabled="mismatch" @click="increment('y', -0.25)">Y −.25U</button
><button :disabled="mismatch" @click="increment('y', 0.25)">Y +.25U</button
><button :disabled="mismatch" @click="increment('rotation', -15)">回転 −15°</button
><button :disabled="mismatch" @click="increment('rotation', 15)">回転 +15°</button>
</div>
<aside class="validation pane">
<h2>検証結果</h2>
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
@@ -631,21 +210,15 @@ function changeVariant(groupId: string, id: string) {
<h3>
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
</h3>
<button
v-for="(issue, i) in issues.filter((x) => x.severity === s)"
:key="i"
class="issue"
@click="issue.ids?.[0] && setSelected(issue.ids[0], false)"
>
<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>
</button>
</div>
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
</section>
</div>
</aside>
</main>
</template>
<style>
* {
box-sizing: border-box;
@@ -665,15 +238,10 @@ body {
gap: 8px;
padding: 8px;
}
.app-shell.locked .properties {
pointer-events: none;
opacity: 0.55;
}
.toolbar,
.tabs,
.pane,
.placeholder {
background: white;
.pane {
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
@@ -694,158 +262,17 @@ body {
.tabs button.active {
background: #dce9ff;
}
.workspace {
.other-panel {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.pane {
.validation {
overflow: hidden;
padding: 10px;
min-height: 0;
overflow: auto;
}
.tree h2,
.properties h2,
.validation h2 {
font-size: 16px;
margin: 0 0 8px;
}
.commands {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.commands button,
.commands select {
font-size: 12px;
}
.tree-list {
padding-left: 14px;
list-style: none;
margin: 3px 0;
}
.tree-node {
width: 100%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: transparent;
border: 0;
padding: 4px;
}
.tree-node small {
color: #778;
}
.tree-node.chosen {
background: #dce9ff;
}
.canvas-pane {
display: flex;
flex-direction: column;
}
.canvas-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.canvas-head label {
display: flex;
align-items: center;
gap: 4px;
}
.canvas {
width: 1152px;
height: 896px;
min-height: 240px;
flex: none;
background-color: #fbfcfe;
background-image:
linear-gradient(#e7ebf1 1px, transparent 1px), linear-gradient(90deg, #e7ebf1 1px, transparent 1px);
background-size: 64px 64px;
touch-action: none;
}
.phy-node {
cursor: grab;
}
.phy-node:active {
cursor: grabbing;
}
.key-face {
fill: #f8cc78;
stroke: #5b4a2b;
stroke-width: 0.025;
vector-effect: non-scaling-stroke;
}
.secondary {
fill: #ffe7ad;
}
.key-label {
font-size: 0.13px;
fill: #29251e;
pointer-events: none;
}
.ghost {
opacity: 0.4;
}
.decal .key-face {
fill: #fff;
stroke-dasharray: 0.08 0.06;
}
.chosen > .key-face,
.chosen > .part {
stroke: #1769d2;
stroke-width: 0.055;
}
.part {
fill: #b4d5ef;
stroke: #315d7a;
stroke-width: 0.03;
}
.part-label {
font-size: 0.12px;
fill: #333;
}
.selection-box {
fill: none;
stroke: #1769d2;
stroke-width: 0.025;
stroke-dasharray: 0.04;
}
.group-origin {
fill: #d33;
pointer-events: none;
}
.properties label {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
}
.properties input {
width: 105px;
margin-left: auto;
}
.nudge-controls {
position: fixed;
right: 270px;
bottom: 194px;
z-index: 4;
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: calc(100vw - 540px);
padding: 6px;
background: #fff;
border: 1px solid #adb8c8;
border-radius: 6px;
}
.validation {
grid-row: 4;
overflow: hidden;
}
.issues {
display: flex;
gap: 20px;
@@ -858,11 +285,7 @@ body {
font-size: 13px;
}
.issue {
display: block;
text-align: left;
width: 100%;
margin: 3px 0;
border: 0;
background: #f4f6f9;
padding: 5px;
}
@@ -881,24 +304,4 @@ body {
max-width: 240px;
overflow: hidden;
}
.confirm-box {
margin-top: 12px;
padding: 8px;
background: #fff2dc;
border: 1px solid #db9d39;
}
.confirm-box ul {
max-height: 90px;
overflow: auto;
padding-left: 18px;
}
.other-panel {
min-height: 0;
}
.placeholder {
padding: 20px;
}
.canvas-pane .warning {
margin: 4px 0;
}
</style>
+657
View File
@@ -0,0 +1,657 @@
<script setup lang="ts">
// IDX Editor。キャンバス上のドラッグとプリセットで order を並べ替える。T-31。
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import { defaultVariantSelection } from "../../domain/project";
import { flattenKeys } from "../../domain/geometry";
import type { FlatKey, Node } from "../../domain/phy";
import { appendKeys, unregisteredKeys } from "../../domain/idx";
import { keyVariantGroups, renumber, type OrderContext } from "../../domain/idxOrder";
import { keyRect, matrixPositions, matrixSequence, scanKeys, type ScanResult } from "../../domain/idxScan";
import { matchPlacements, type Placement } from "../../domain/idxImport";
import { fitViewport, zoomAround } from "../phy/viewport";
import type { Dispatch } from "../types";
import IdxTree from "./IdxTree.vue";
import IdxPresets from "./IdxPresets.vue";
import IdxImport from "./IdxImport.vue";
import { applyDrop, keyCenter, outputNumbers, type DropAction } from "./logic";
import { defaultPresetSettings, lineColors, type PresetSettings } from "./presets";
import { useIdxPointer } from "./useIdxPointer";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
const emit = defineEmits<{ error: [text: string] }>();
const variants = ref<Record<string, string>>({}),
selected = ref<string[]>([]),
zoom = ref(52),
pan = ref({ x: 50, y: 50 }),
size = ref({ w: 900, h: 600 }),
svg = ref<SVGSVGElement>(),
settings = ref<PresetSettings>(defaultPresetSettings()),
preview = ref<{ offsets: Map<number, number>; cross: number | null } | null>(null),
importMessage = ref("");
/** Displayed configuration: starts from output.variantSelection, never written back. T-31. */
function resetVariants() {
variants.value = {
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
};
}
resetVariants();
watch(
() => props.project.projectId,
() => {
resetVariants();
selected.value = [];
preview.value = null;
importMessage.value = "";
fit();
},
);
watch(
() => props.project.phy,
() => {
// groups made in PHY after opening get their default; removed keys leave the selection
variants.value = { ...defaultVariantSelection(props.project.phy), ...variants.value };
const shown = new Set(keys.value.map((k) => k.key.id));
selected.value = selected.value.filter((id) => shown.has(id));
},
);
const keys = computed<FlatKey[]>(() => {
try {
return flattenKeys(props.project.phy, variants.value).filter((k) => !k.key.decal);
} catch {
return flattenKeys(props.project.phy).filter((k) => !k.key.decal);
}
});
const keyById = computed(() => new Map(keys.value.map((k) => [k.key.id, k])));
const visible = computed(() => new Set(keys.value.map((k) => k.key.id)));
const registered = computed(() => new Set(props.project.idx.order));
const allUnregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
const ctx = computed<OrderContext>(() => ({
visible: visible.value,
groups: keyVariantGroups(props.project.phy),
}));
const positions = computed(() => matrixPositions(props.project));
// ---- preview of a preset ----
const targetKeys = computed(() =>
settings.value.target === "selection"
? keys.value.filter((k) => selected.value.includes(k.key.id))
: keys.value,
);
const scan = computed<ScanResult | null>(() => {
if (!preview.value) return null;
const target = targetKeys.value;
if (settings.value.kind === "matrix") {
const at = (id: string) => (registered.value.has(id) ? props.project.idx.order.indexOf(id) : Infinity);
const ids = target.map((k) => k.key.id).sort((a, b) => at(a) - at(b));
return { sequence: matrixSequence(ids, positions.value ?? new Map()), lines: [], crossLines: [] };
}
return scanKeys(target.map(keyRect), {
...settings.value,
kind: settings.value.kind,
offsets: preview.value.offsets,
});
});
const shownOrder = computed(() =>
scan.value ? renumber(props.project.idx.order, ctx.value, scan.value.sequence) : props.project.idx.order,
);
const numbers = computed(() => outputNumbers(shownOrder.value, visible.value));
const crossAxis = computed(() => (settings.value.kind === "columns" ? "row" : "column"));
const shiftHint = computed(() => {
if (!scan.value?.crossLines.length) return "";
if (preview.value?.cross == null)
return `キーをクリックして${crossAxis.value === "column" ? "列" : "行"}を選ぶと、${crossAxis.value === "column" ? "↑/↓で行" : "←/→で列"}の割り当てを1つずらせます。`;
return `選んだ${crossAxis.value === "column" ? "列" : "行"}: ${crossAxis.value === "column" ? "↑/↓" : "←/→"}でずらす(${preview.value.offsets.get(preview.value.cross) ?? 0})。Escで取り消し。`;
});
const previewCross = computed(() => {
const i = preview.value?.cross;
return new Set(i == null ? [] : (scan.value?.crossLines[i] ?? []));
});
watch(
settings,
() => {
if (preview.value) preview.value = { offsets: new Map(), cross: null };
},
{ deep: true },
);
watch(
() => [settings.value.target, selected.value.length] as const,
([target, count]) => {
if (target === "selection" && !count) settings.value = { ...settings.value, target: "all" };
},
);
function startPreview() {
preview.value = { offsets: new Map(), cross: null };
}
function applyPreview() {
if (!scan.value) return;
const sequence = scan.value.sequence;
run("IDXプリセット適用", (p) => ({
...p,
idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, sequence) },
}));
preview.value = null;
}
function shiftCross(delta: number) {
const p = preview.value;
if (!p || p.cross == null) return;
const offsets = new Map(p.offsets);
offsets.set(p.cross, (offsets.get(p.cross) ?? 0) + delta);
preview.value = { ...p, offsets };
}
function pickCross(id: string) {
const i = scan.value?.crossLines.findIndex((line) => line.includes(id)) ?? -1;
if (preview.value) preview.value = { ...preview.value, cross: i < 0 ? null : i };
}
function linePoints(ids: string[]): string {
return ids
.map((id) => keyById.value.get(id))
.filter((k): k is FlatKey => !!k)
.map((k) => {
const c = keyCenter(k);
return `${c.x},${c.y}`;
})
.join(" ");
}
// ---- editing ----
function run(label: string, update: (p: ProjectFile) => ProjectFile) {
try {
props.dispatch(label, update);
} catch (e) {
emit("error", String(e));
}
}
function drop(source: string, action: DropAction) {
run(action.kind === "swap" ? "IDX入れ替え" : "IDX挿入", (p) => ({
...p,
idx: { ...p.idx, order: applyDrop(p.idx.order, ctx.value, source, action) },
}));
}
function registerAll() {
run("IDX未登録キーを登録", (p) => ({ ...p, idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)) }));
}
function importPlacements(placements: Placement[], source: string) {
const { ids, unmatched } = matchPlacements(placements, keys.value);
if (ids.length)
run("IDX取り込み", (p) => ({ ...p, idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, ids) } }));
const missing = keys.value.length - ids.length;
importMessage.value =
`${source}: ${ids.length}件を照合して並べ直しました。` +
(unmatched ? `定義側の${unmatched}件は表示中のキーと一致しませんでした。` : "") +
(missing ? `表示中の${missing}件は定義になく、今の順序のまま残しました。` : "");
}
function selectVariant(groupId: string, variantId: string) {
variants.value = { ...variants.value, [groupId]: variantId };
const shown = new Set(keys.value.map((k) => k.key.id));
selected.value = selected.value.filter((id) => shown.has(id));
}
const { drag, band, spaceHeld, begin, move, end, cancel } = useIdxPointer({
svg,
zoom,
pan,
selected,
keys: () => keys.value,
registered: () => registered.value,
previewing: () => !!preview.value,
onDrop: drop,
onPreviewPick: pickCross,
});
const dropText = computed(() => {
const action = drag.value?.action;
if (!drag.value) return "";
if (!action) return "ここには置けません";
const n = numbers.value.get(action.target)!;
if (action.kind === "swap") return `${n} と入れ替え`;
return action.after
? `${n} と ${n + 1} の間に挿入`
: n === 0
? "先頭に挿入"
: `${n - 1} と ${n} の間に挿入`;
});
// ---- selection info ----
const info = computed(() => {
const id = selected.value.at(-1);
const k = id ? keyById.value.get(id) : undefined;
if (!k || !id) return null;
const names = new Map<string, string>();
const walk = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children);
if (n.type === "variantGroup")
n.children.forEach((v) => {
names.set(v.id, `${n.name} / ${v.name}`);
walk(v.children);
});
});
walk(props.project.phy.root);
const path = k.path.flatMap((p) => (names.has(p) ? [names.get(p)!] : []));
const saved = props.project.idx.order.indexOf(id);
return {
label: k.key.labels?.find(Boolean) || "(ラベルなし)",
id,
saved,
number: numbers.value.get(id),
path: path.join(" › ") || "共通キー",
};
});
// ---- viewport ----
function fit() {
const rects = keys.value.map(keyRect);
if (!rects.length) return;
const x = Math.min(...rects.map((r) => r.x)),
y = Math.min(...rects.map((r) => r.y));
const bounds = {
x,
y,
width: Math.max(...rects.map((r) => r.x + r.w)) - x,
height: Math.max(...rects.map((r) => r.y + r.h)) - y,
};
const v = fitViewport(bounds, size.value.w, size.value.h);
zoom.value = v.zoom;
pan.value = v.pan;
}
function wheel(event: WheelEvent) {
const el = svg.value;
if (!el) return;
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(el.getScreenCTM()!.inverse());
const v = zoomAround(local, { zoom: zoom.value, pan: pan.value }, Math.exp(-event.deltaY * 0.001));
zoom.value = v.zoom;
pan.value = v.pan;
}
const bounds = computed(() => {
const rects = keys.value.map(keyRect);
return rects.length
? {
top: Math.min(...rects.map((r) => r.y)) - 0.4,
bottom: Math.max(...rects.map((r) => r.y + r.h)) + 0.4,
}
: { top: 0, bottom: 1 };
});
function keydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
if (e.code === "Space") spaceHeld.value = true;
if (e.key === "Escape") {
if (drag.value) cancel();
else if (preview.value) preview.value = null;
else selected.value = [];
} else if (preview.value?.cross != null && e.key.startsWith("Arrow")) {
const vertical = crossAxis.value === "column";
const delta = {
ArrowUp: vertical ? -1 : 0,
ArrowDown: vertical ? 1 : 0,
ArrowLeft: vertical ? 0 : -1,
ArrowRight: vertical ? 0 : 1,
}[e.key];
if (delta) {
e.preventDefault();
shiftCross(delta);
}
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
selected.value = keys.value.map((k) => k.key.id);
} else if (e.key === "Enter" && preview.value) applyPreview();
}
function keyup(e: KeyboardEvent) {
if (e.code === "Space") spaceHeld.value = false;
}
let observer: ResizeObserver | null = null;
onMounted(() => {
window.addEventListener("keydown", keydown);
window.addEventListener("keyup", keyup);
if (svg.value) {
observer = new ResizeObserver((entries) => {
const rect = entries[0]!.contentRect;
size.value = { w: rect.width, h: rect.height };
fit();
});
observer.observe(svg.value);
}
fit();
});
onUnmounted(() => {
window.removeEventListener("keydown", keydown);
window.removeEventListener("keyup", keyup);
observer?.disconnect();
});
</script>
<template>
<section class="workspace">
<aside class="tree pane">
<h2>構成</h2>
<IdxTree
:nodes="project.phy.root"
:variants="variants"
:unregistered="allUnregistered"
@select="selectVariant"
/>
<p v-if="!Object.keys(variants).length" class="hint">バリアントグループはありません。</p>
</aside>
<section class="canvas-pane pane">
<div class="canvas-toolbar">
<button @click="fit">全体表示</button>
<span class="status" data-testid="idx-status">{{
drag
? dropText
: preview
? "プレビュー中(適用するまで保存されません)"
: "キーをドラッグして並べ替え。左右の端で挿入、中央で入れ替え"
}}</span>
</div>
<svg
ref="svg"
class="canvas"
:class="{ previewing: !!preview, panning: spaceHeld }"
data-testid="idx-canvas"
:viewBox="`0 0 ${size.w} ${size.h}`"
@wheel.prevent="wheel"
@pointerdown="begin($event)"
@pointermove="move"
@pointerup="end"
@pointercancel="cancel"
>
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
<g
v-for="k in keys"
:key="k.key.id"
class="key"
:data-key-id="k.key.id"
:transform="`translate(${k.x} ${k.y}) rotate(${k.rotation})`"
:class="{
chosen: selected.includes(k.key.id),
unregistered: !registered.has(k.key.id),
ghost: k.key.ghost,
dragging: drag?.id === k.key.id,
cross: previewCross.has(k.key.id),
}"
@pointerdown.stop="begin($event, k.key.id)"
>
<rect :width="k.key.size.w" :height="k.key.size.h" rx=".06" class="face" />
<text class="label" x=".08" y=".22">{{ k.key.labels?.find(Boolean) }}</text>
<!-- 番号は回転したキーでも正立させる -->
<text
class="number"
data-testid="idx-number"
:x="k.key.size.w / 2"
:y="k.key.size.h / 2 + 0.12"
:transform="`rotate(${-k.rotation} ${k.key.size.w / 2} ${k.key.size.h / 2})`"
>
{{ numbers.has(k.key.id) ? numbers.get(k.key.id) : "未登録" }}
</text>
</g>
<template v-if="scan">
<polyline
v-for="(line, i) in scan.lines"
:key="i"
class="scan-line"
:points="linePoints(line)"
:stroke="lineColors[i % lineColors.length]"
/>
<line
v-if="scan.boundary !== undefined"
class="boundary"
data-testid="idx-boundary"
:x1="scan.boundary"
:x2="scan.boundary"
:y1="bounds.top"
:y2="bounds.bottom"
/>
</template>
<g
v-if="drag?.action && keyById.get(drag.action.target)"
:transform="`translate(${keyById.get(drag.action.target)!.x} ${keyById.get(drag.action.target)!.y}) rotate(${keyById.get(drag.action.target)!.rotation})`"
class="drop-mark"
>
<rect
v-if="drag.action.kind === 'swap'"
class="swap-mark"
:width="keyById.get(drag.action.target)!.key.size.w"
:height="keyById.get(drag.action.target)!.key.size.h"
rx=".06"
/>
<line
v-else
class="insert-mark"
:x1="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
:x2="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
y1="-0.1"
:y2="keyById.get(drag.action.target)!.key.size.h + 0.1"
/>
</g>
<g v-if="drag" class="drag-ghost" :transform="`translate(${drag.point.x} ${drag.point.y})`">
<circle r=".28" />
<text y=".1">{{ numbers.get(drag.id) ?? "+" }}</text>
</g>
<rect
v-if="band"
class="range-box"
:x="Math.min(band.start.x, band.end.x)"
:y="Math.min(band.start.y, band.end.y)"
:width="Math.abs(band.end.x - band.start.x)"
:height="Math.abs(band.end.y - band.start.y)"
/>
</g>
</svg>
</section>
<aside class="side pane">
<IdxPresets
:settings="settings"
:selected-count="selected.length"
:matrix-available="!!positions"
:previewing="!!preview"
:shift-hint="shiftHint"
@update="settings = $event"
@preview="startPreview"
@apply="applyPreview"
@cancel="preview = null"
/>
<IdxImport :message="importMessage" @apply="importPlacements" @error="emit('error', $event)" />
<section class="register">
<h3>未登録のキー</h3>
<p class="hint">
PHYに足したキーは、ここで末尾に登録するか、キャンバスでドラッグして位置を決めます。
</p>
<button data-testid="idx-register-all" :disabled="!allUnregistered.size" @click="registerAll">
未登録をすべて末尾に登録({{ allUnregistered.size }})
</button>
</section>
<section v-if="info" class="info" data-testid="idx-info">
<h3>選択中のキー</h3>
<dl>
<dt>ラベル</dt>
<dd>{{ info.label }}</dd>
<dt>出力番号</dt>
<dd>{{ info.number ?? "未登録" }}</dd>
<dt>保存順</dt>
<dd>{{ info.saved < 0 ? "未登録" : info.saved }}</dd>
<dt>所属</dt>
<dd>{{ info.path }}</dd>
<dt>ID</dt>
<dd class="id">{{ info.id }}</dd>
</dl>
<p v-if="selected.length > 1" class="hint">ほか {{ selected.length - 1 }} 件を選択中</p>
</section>
</aside>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 220px minmax(300px, 1fr) 270px;
gap: 8px;
}
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
h2 {
font-size: 15px;
margin: 0 0 6px;
}
h3 {
font-size: 14px;
margin: 0 0 6px;
}
.hint {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.canvas-pane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.canvas-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 6px;
}
.status {
font-size: 12px;
color: #445;
}
.canvas {
width: 100%;
flex: 1;
min-height: 0;
background: #fbfcfe;
touch-action: none;
user-select: none;
}
.canvas.panning {
cursor: grab;
}
.key {
cursor: grab;
}
.previewing .key {
cursor: pointer;
}
.face {
fill: #f4f6f9;
stroke: #8d97a5;
stroke-width: 0.03;
}
.chosen .face {
fill: #dce9ff;
stroke: #1769d2;
stroke-width: 0.06;
}
.unregistered .face {
fill: #fff1d6;
stroke: #c07a12;
stroke-dasharray: 0.08 0.06;
}
.ghost .face {
fill-opacity: 0.5;
}
.dragging {
opacity: 0.45;
}
.cross .face {
stroke: #9b30d9;
stroke-width: 0.08;
}
.label {
font-size: 0.16px;
fill: #667;
pointer-events: none;
}
.number {
font-size: 0.34px;
font-weight: 600;
text-anchor: middle;
fill: #20242b;
pointer-events: none;
}
.unregistered .number {
font-size: 0.18px;
fill: #9a4c00;
}
.scan-line {
fill: none;
stroke-width: 3px;
stroke-opacity: 0.7;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.boundary {
stroke: #9a4c00;
stroke-width: 2px;
stroke-dasharray: 6 4;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.drop-mark {
pointer-events: none;
}
.insert-mark {
stroke: #d2142b;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.swap-mark {
fill: #d2142b22;
stroke: #d2142b;
stroke-width: 3px;
stroke-dasharray: 6 3;
vector-effect: non-scaling-stroke;
}
.drag-ghost {
pointer-events: none;
}
.drag-ghost circle {
fill: #1769d2;
}
.drag-ghost text {
font-size: 0.26px;
fill: #fff;
text-anchor: middle;
}
.range-box {
fill: #4b8de822;
stroke: #1769d2;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.register {
margin: 0 0 14px;
}
.info dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
margin: 0;
font-size: 13px;
}
.info dt {
color: #667;
}
.info dd {
margin: 0;
}
.info .id {
font-family: ui-monospace, monospace;
font-size: 11px;
word-break: break-all;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 既存の定義(QMK keyboard.json / VIA定義 / KLE JSON)からの取り込み。照合は親が表示中のキーで行う。T-31。
import { ref } from "vue";
import type { Placement } from "../../domain/idxImport";
import { readLayoutPlacements } from "../../io/layoutImport";
defineProps<{ message: string }>();
const emit = defineEmits<{ apply: [placements: Placement[], source: string]; error: [text: string] }>();
const input = ref<HTMLInputElement>();
const pending = ref<{ name: string; data: unknown; layouts: string[] } | null>(null);
const layout = ref("");
async function read(file: File) {
try {
const data = JSON.parse(await file.text()) as unknown;
const result = readLayoutPlacements(data);
if (result.layouts && !result.layout) {
pending.value = { name: file.name, data, layouts: result.layouts };
layout.value = result.layouts[0]!;
return;
}
pending.value = null;
emit("apply", result.placements, result.layout ? `${file.name} / ${result.layout}` : file.name);
} catch (e) {
emit("error", String(e));
}
}
function applyLayout() {
if (!pending.value) return;
const { placements } = readLayoutPlacements(pending.value.data, layout.value);
emit("apply", placements, `${pending.value.name} / ${layout.value}`);
pending.value = null;
}
</script>
<template>
<section class="import">
<h3>既存の定義から取り込む</h3>
<p class="hint">
QMK keyboard.json・VIA定義・KLE JSON のキー順を、位置が一致する表示中のキーに写します。
</p>
<button @click="input?.click()">ファイルを選ぶ…</button>
<input
ref="input"
hidden
type="file"
accept=".json,application/json"
data-testid="idx-import-file"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) void read(f);
(e.target as HTMLInputElement).value = '';
}
"
/>
<div v-if="pending" class="layouts">
<label
>レイアウト
<select v-model="layout" aria-label="取り込むレイアウト">
<option v-for="name in pending.layouts" :key="name" :value="name">{{ name }}</option>
</select>
</label>
<button @click="applyLayout">取り込む</button>
<button @click="pending = null">やめる</button>
</div>
<p v-if="message" class="result" data-testid="idx-import-result">{{ message }}</p>
</section>
</template>
<style scoped>
.import {
margin: 0 0 14px;
}
h3 {
font-size: 14px;
margin: 0 0 6px;
}
.hint,
.result {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.result {
color: #20242b;
}
.layouts {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 6px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
// プリセット(対象・走査・向き)の指定と、プレビュー・適用のボタン。T-31。
import type { PresetSettings } from "./presets";
const props = defineProps<{
settings: PresetSettings;
selectedCount: number;
matrixAvailable: boolean;
previewing: boolean;
shiftHint: string;
}>();
const emit = defineEmits<{
update: [settings: PresetSettings];
preview: [];
apply: [];
cancel: [];
}>();
const scans = [
["rows", "行優先"],
["columns", "列優先"],
["halves", "半身ごと(左手側が先)"],
["alternating", "行ごとに左右交互"],
["matrix", "マトリクス順(row → col)"],
] as const;
function set<K extends keyof PresetSettings>(key: K, value: PresetSettings[K]) {
emit("update", { ...props.settings, [key]: value });
}
</script>
<template>
<fieldset class="presets">
<legend>一括の並べ替え</legend>
<div class="row" role="radiogroup" aria-label="対象">
<label
><input
type="radio"
name="idx-target"
:checked="settings.target === 'all'"
@change="set('target', 'all')"
/>全体</label
>
<label
><input
type="radio"
name="idx-target"
:checked="settings.target === 'selection'"
:disabled="!selectedCount"
@change="set('target', 'selection')"
/>選択範囲({{ selectedCount }})</label
>
</div>
<label class="field"
>走査
<select
aria-label="走査"
:value="settings.kind"
@change="set('kind', ($event.target as HTMLSelectElement).value as PresetSettings['kind'])"
>
<option
v-for="[value, label] in scans"
:key="value"
:value="value"
:disabled="value === 'matrix' && !matrixAvailable"
>
{{ label }}
</option>
</select>
</label>
<p v-if="!matrixAvailable" class="hint">マトリクス順には、出力設定の rowPins / colPins が必要です。</p>
<label v-if="settings.kind === 'rows' || settings.kind === 'columns'" class="field"
>向き
<select
aria-label="向き"
:value="settings.direction"
@change="set('direction', ($event.target as HTMLSelectElement).value as PresetSettings['direction'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
<template v-else-if="settings.kind !== 'matrix'">
<label class="field"
>左手側
<select
aria-label="左手側の向き"
:value="settings.left"
@change="set('left', ($event.target as HTMLSelectElement).value as PresetSettings['left'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
<label class="field"
>右手側
<select
aria-label="右手側の向き"
:value="settings.right"
@change="set('right', ($event.target as HTMLSelectElement).value as PresetSettings['right'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
</template>
<div class="row">
<button v-if="!previewing" @click="emit('preview')">プレビュー</button>
<template v-else>
<button class="primary" @click="emit('apply')">適用</button>
<button @click="emit('cancel')">取り消し</button>
</template>
</div>
<p v-if="previewing && shiftHint" class="hint">{{ shiftHint }}</p>
</fieldset>
</template>
<style scoped>
.presets {
border: 0;
padding: 0;
margin: 0 0 14px;
min-width: 0;
}
legend {
font-weight: 600;
padding: 0;
margin-bottom: 6px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
}
.field {
display: flex;
gap: 6px;
align-items: center;
justify-content: space-between;
margin: 6px 0;
}
.field select {
max-width: 160px;
}
.hint {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.primary {
background: #dce9ff;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// VariantGroupとバリアントのツリー。クラスターは出さず、その中のグループだけを拾う。T-31。
import { computed } from "vue";
import type { Node, VariantGroupNode } from "../../domain/phy";
defineOptions({ name: "IdxTree" });
const props = defineProps<{
nodes: Node[];
variants: Record<string, string>;
unregistered: ReadonlySet<string>;
}>();
const emit = defineEmits<{ select: [groupId: string, variantId: string] }>();
function groupsIn(nodes: Node[]): VariantGroupNode[] {
return nodes.flatMap((n) =>
n.type === "cluster" ? groupsIn(n.children) : n.type === "variantGroup" ? [n] : [],
);
}
function keysIn(nodes: Node[]): string[] {
return nodes.flatMap((n) =>
n.type === "key"
? n.decal
? []
: [n.id]
: n.type === "cluster"
? keysIn(n.children)
: n.type === "variantGroup"
? n.children.flatMap((v) => keysIn(v.children))
: [],
);
}
const groups = computed(() => groupsIn(props.nodes));
const chosen = (g: VariantGroupNode) => props.variants[g.id] ?? g.default;
</script>
<template>
<ul v-if="groups.length" class="tree-list">
<li v-for="g in groups" :key="g.id">
<div class="tree-group" :data-tree-id="g.id">{{ g.name }}</div>
<ul class="tree-list">
<li v-for="v in g.children" :key="v.id">
<button
class="tree-node"
:data-tree-id="v.id"
:class="{ chosen: chosen(g) === v.id }"
:aria-pressed="chosen(g) === v.id"
@click="emit('select', g.id, v.id)"
>
{{ v.name }}
<small
>{{ g.default === v.id ? "既定 · " : "" }}{{ keysIn(v.children).length }}件<template
v-if="keysIn(v.children).some((id) => unregistered.has(id))"
>
· 未登録 {{ keysIn(v.children).filter((id) => unregistered.has(id)).length }}</template
></small
>
</button>
<IdxTree
:nodes="v.children"
:variants="variants"
:unregistered="unregistered"
@select="(group, variant) => emit('select', group, variant)"
/>
</li>
</ul>
</li>
</ul>
</template>
<style scoped>
.tree-list {
list-style: none;
padding-left: 12px;
margin: 3px 0;
}
.tree-group {
padding: 4px;
font-weight: 600;
white-space: nowrap;
}
.tree-node {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 4px;
white-space: nowrap;
}
.tree-node small {
color: #667;
}
.chosen {
background: #dce9ff;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
import type { FlatKey } from "../../domain/phy";
import { insertNextTo, swapKeys, type OrderContext } from "../../domain/idxOrder";
type Point = { x: number; y: number };
export type DropAction =
{ kind: "insert"; target: string; after: boolean } | { kind: "swap"; target: string };
/** World point in the key's own frame (origin at its top-left, x along its width). */
export function toKeyLocal(k: FlatKey, p: Point): Point {
const a = (-k.rotation * Math.PI) / 180,
dx = p.x - k.x,
dy = p.y - k.y;
return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) };
}
/** World center of a key. */
export function keyCenter(k: FlatKey): Point {
const a = (k.rotation * Math.PI) / 180,
hw = k.key.size.w / 2,
hh = k.key.size.h / 2;
return { x: k.x + hw * Math.cos(a) - hh * Math.sin(a), y: k.y + hw * Math.sin(a) + hh * Math.cos(a) };
}
/** Topmost key under a world point, with the point's position across the key width (0..1). */
export function hitKey(keys: readonly FlatKey[], p: Point): { id: string; fraction: number } | undefined {
for (let i = keys.length - 1; i >= 0; i--) {
const k = keys[i]!,
local = toKeyLocal(k, p);
if (local.x >= 0 && local.y >= 0 && local.x <= k.key.size.w && local.y <= k.key.size.h)
return { id: k.key.id, fraction: local.x / k.key.size.w };
}
return undefined;
}
/**
* Drop result of `source` at a hit: the outer quarters insert before or after the key, the middle
* swaps. Unregistered keys cannot swap, so their drop splits the key in halves. Unregistered targets
* and the source itself take no drop. T-31.
*/
export function dropAction(
source: string,
hit: { id: string; fraction: number } | undefined,
registered: ReadonlySet<string>,
): DropAction | undefined {
if (!hit || hit.id === source || !registered.has(hit.id)) return undefined;
if (!registered.has(source)) return { kind: "insert", target: hit.id, after: hit.fraction >= 0.5 };
if (hit.fraction < 0.25) return { kind: "insert", target: hit.id, after: false };
if (hit.fraction > 0.75) return { kind: "insert", target: hit.id, after: true };
return { kind: "swap", target: hit.id };
}
/** New order after dropping `source`. */
export function applyDrop(
order: readonly string[],
ctx: OrderContext,
source: string,
action: DropAction,
): string[] {
return action.kind === "swap"
? swapKeys(order, source, action.target)
: insertNextTo(order, ctx, source, action.target, action.after);
}
/** Output number of each shown key: its position after dropping keys outside the configuration. */
export function outputNumbers(order: readonly string[], visible: ReadonlySet<string>): Map<string, number> {
return new Map(order.filter((id) => visible.has(id)).map((id, i) => [id, i]));
}
+21
View File
@@ -0,0 +1,21 @@
import type { Direction, ScanKind } from "../../domain/idxScan";
/** Preset choices in the side pane. `matrix` sorts by ELC row → col instead of scanning. T-31. */
export type PresetSettings = {
target: "all" | "selection";
kind: ScanKind | "matrix";
direction: Direction;
left: Direction;
right: Direction;
};
export const defaultPresetSettings = (): PresetSettings => ({
target: "all",
kind: "rows",
direction: "normal",
left: "normal",
right: "normal",
});
/** Line colors of the preview, cycled by line index. */
export const lineColors = ["#1769d2", "#d2691e", "#2e8b57", "#9b30d9", "#c0392b", "#00838f"];
+108
View File
@@ -0,0 +1,108 @@
import { ref, type Ref } from "vue";
import type { FlatKey } from "../../domain/phy";
import { dropAction, hitKey, keyCenter, type DropAction } from "./logic";
type Point = { x: number; y: number };
type Options = {
svg: Ref<SVGSVGElement | undefined>;
zoom: Ref<number>;
pan: Ref<Point>;
selected: Ref<string[]>;
keys: () => FlatKey[];
registered: () => ReadonlySet<string>;
previewing: () => boolean;
onDrop: (source: string, action: DropAction) => void;
onPreviewPick: (id: string) => void;
};
/** Pointer travel (px) before a press on a key becomes a drag. */
const DRAG_START = 4;
/** Canvas pointer handling: click/Shift+click selection, dragging a key to reorder, range selection, pan. T-31. */
export function useIdxPointer(o: Options) {
const drag = ref<{ id: string; point: Point; action?: DropAction } | null>(null);
const band = ref<{ start: Point; end: Point } | null>(null);
const spaceHeld = ref(false);
let press: { id?: string; client: Point; add: boolean; moved: boolean; pan?: Point } | null = null;
function world(event: PointerEvent): Point {
const svg = o.svg.value!;
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM()!.inverse());
return { x: (local.x - o.pan.value.x) / o.zoom.value, y: (local.y - o.pan.value.y) / o.zoom.value };
}
function begin(event: PointerEvent, id?: string) {
const add = event.shiftKey || event.ctrlKey || event.metaKey;
const panning = event.button === 1 || spaceHeld.value;
if (event.button !== 0 && !panning) return;
event.preventDefault();
if (!panning && id && o.previewing()) {
o.onPreviewPick(id);
return;
}
o.svg.value?.setPointerCapture?.(event.pointerId);
press = {
id: panning ? undefined : id,
client: { x: event.clientX, y: event.clientY },
add,
moved: false,
};
if (panning) press.pan = { ...o.pan.value };
else if (!id && !o.previewing()) band.value = { start: world(event), end: world(event) };
}
function move(event: PointerEvent) {
if (!press) return;
const dx = event.clientX - press.client.x,
dy = event.clientY - press.client.y;
if (!press.moved && Math.hypot(dx, dy) < DRAG_START) return;
press.moved = true;
if (press.pan) {
// viewBox matches the SVG's pixel size, so client deltas are viewBox deltas
o.pan.value = { x: press.pan.x + dx, y: press.pan.y + dy };
return;
}
const point = world(event);
if (press.id) {
const action = dropAction(press.id, hitKey(o.keys(), point), o.registered());
drag.value = { id: press.id, point, action };
} else if (band.value) band.value = { ...band.value, end: point };
}
function end() {
if (!press) return;
const { id, add, moved } = press;
if (id && !moved) {
o.selected.value = add
? o.selected.value.includes(id)
? o.selected.value.filter((x) => x !== id)
: [...o.selected.value, id]
: [id];
} else if (id && drag.value?.action) o.onDrop(id, drag.value.action);
else if (!id && !press.pan) {
const b = band.value;
if (!moved || !b) {
if (!add) o.selected.value = [];
} else {
const [x0, x1] = [Math.min(b.start.x, b.end.x), Math.max(b.start.x, b.end.x)];
const [y0, y1] = [Math.min(b.start.y, b.end.y), Math.max(b.start.y, b.end.y)];
const inside = o
.keys()
.filter((k) => {
const c = keyCenter(k);
return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1;
})
.map((k) => k.key.id);
o.selected.value = add ? [...new Set([...o.selected.value, ...inside])] : inside;
}
}
cancel();
}
function cancel() {
press = null;
drag.value = null;
band.value = null;
}
return { drag, band, spaceHeld, begin, move, end, cancel };
}
-254
View File
@@ -1,254 +0,0 @@
<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>
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import { keyPath } from "./logic";
defineOptions({ name: "NodeSvg" });
defineProps<{
nodes: Node[];
selected: string[];
variants: Record<string, string>;
cut: string[];
dim: boolean;
}>();
const emit = defineEmits<{
down: [e: PointerEvent, n: Node, el: SVGGElement];
focus: [id: string];
}>();
</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)"
><path
v-if="keyPath(n.size)"
:d="keyPath(n.size)"
class="key-face"
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
/><rect
v-else
: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></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"
@down="(e, c, el) => emit('down', e, c, el)"
@focus="(id) => emit('focus', 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"
@down="(e, c, el) => emit('down', e, c, el)"
@focus="(id) => emit('focus', id)"
/></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;
}
.group {
cursor: default;
}
.dim {
opacity: 0.25;
}
.cut {
opacity: 0.3;
}
.group-label {
fill: #222;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
import type { Affine } from "../../domain/geometry";
import { variantBadges } from "./logic";
const props = withDefaults(
defineProps<{
nodes: Node[];
variants: Record<string, string>;
zoom: number;
dim?: boolean;
parent?: Affine;
}>(),
{ dim: false, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
);
const emit = defineEmits<{ badge: [g: Node, e: MouseEvent] }>();
// ノードより後に描き、隣のキーの下に隠れないようにする
const badges = computed(() => variantBadges(props.nodes, props.variants, props.zoom, props.parent));
/** バッジは名前を書かず、表示中のバリアント名はツールチップで示す。T-30。 */
function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
const shown = props.variants[n.id] || n.default;
const name = n.children.find((v) => v.id === shown)?.name ?? "";
return `${n.name}: ${name}${shown === n.default ? "(既定)" : ""}`;
}
</script>
<template>
<g class="badge-layer" :class="{ dim }">
<g
v-for="badge in badges"
:key="badge.group.id"
:data-badge-id="badge.group.id"
class="badge"
@click.stop="!dim && emit('badge', badge.group, $event)"
><rect :x="badge.x" :y="badge.y" :width="badge.size" :height="badge.size" :rx="3 / zoom" /><text
:x="badge.x + badge.size / 2"
:y="badge.y + badge.size / 2"
:style="{ fontSize: `${12 / zoom}px` }"
>⇄</text
><title>{{ badgeTitle(badge.group) }}</title></g
>
</g>
</template>
<style scoped>
.badge {
cursor: pointer;
pointer-events: all;
}
.dim {
opacity: 0.25;
}
.dim .badge {
cursor: default;
}
.badge rect {
fill: #fff3c2;
stroke: #8a691b;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
.badge text {
fill: #302709;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
}
</style>
-35
View File
@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import PhyNodeView from "./PhyNodeView.vue";
defineProps<{ nodes: Node[]; selected: string[]; variants: Record<string, string>; zoom: number }>();
const emit = defineEmits<{
select: [id: string, additive: boolean];
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
}>();
function visible(n: Node, selection: Record<string, string>) {
return n.type !== "variantGroup" || !!n.children.find((v) => v.id === (selection[n.id] ?? n.default));
}
</script>
<template>
<svg
class="canvas"
data-testid="phy-canvas"
:viewBox="`-64 -64 ${1152 / zoom} ${896 / zoom}`"
width="1152"
height="896"
preserveAspectRatio="xMinYMin meet"
>
<g class="layout" transform="scale(64)">
<g v-for="n in nodes" :key="n.id">
<PhyNodeView
:node="n"
:selected="selected"
:variants="variants"
:visible="visible(n, variants)"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/>
</g>
</g>
</svg>
</template>
+645
View File
@@ -0,0 +1,645 @@
<script setup lang="ts">
import { computed, 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 PhySelectionLayer from "./PhySelectionLayer.vue";
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
import PhyToolbar from "./PhyToolbar.vue";
import PhyProperties from "./PhyProperties.vue";
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
import PhyMountLayer from "./PhyMountLayer.vue";
import {
normalizeSelection,
nextGrid,
insertionContainer,
structuralNodes,
visibleNodeIds,
stabilizerSelections,
selectedStabilizers,
withStabilizers,
} from "./logic";
import { stabilizerCandidates } from "../../domain/stabilizers";
import { usePhyClipboard } from "./usePhyClipboard";
import { usePhyViewport } from "./usePhyViewport";
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
import { HOLD_DELAY, HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
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,
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,
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,
presetAdd,
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 holdMode = ref<HoldMode>("loop");
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,
holdMode,
setSelection: setSel,
beginHistory: props.beginHistory,
commitHistory: props.commitHistory,
dispatch,
});
const { canvasDown, down, spaceUp, hold, spaceHeld } = 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);
}
const showMounts = ref(false);
const stabilizerChoice = computed(() => {
const item = prop.value;
if (item?.type !== "key") return null;
return {
candidates: stabilizerCandidates(item.size),
selected: selectedStabilizers(stabilizerSelections(props.project), item),
};
});
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, 'tree-closed': !treeOpen }">
<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()"
@preset-add="presetAdd"
@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"
:hold-mode="holdMode"
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
:show-mounts="showMounts"
@mounts="showMounts = !showMounts"
/>
<PhyHoldOverlay
v-if="hold.open.value"
:mode="hold.mode.value"
:pos="hold.pos.value"
:names="hold.names.value"
:index="hold.index.value"
:step="hold.step.value"
:pointing="hold.pointing.value"
/>
<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"
/>
<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"
@down="down"
@focus="onFocus"
/>
</g>
<NodeSvg
v-else
:nodes="project.phy.root"
:selected="selected"
:variants="variants"
:cut="cut"
:dim="false"
@down="down"
@focus="onFocus"
/>
<PhyMountLayer
v-if="showMounts"
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:selections="stabilizerSelections(project)"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhySelectionLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:selected="selected"
:variants="variants"
:zoom="zoom"
:candidate="hold.open.value ? (hold.candidates.value[hold.index.value] ?? null) : null"
:parent="focus ? focusParentMatrix(focus) : undefined"
/>
<PhyBadgeLayer
v-if="focus && outsideNodes.length"
:nodes="outsideNodes"
:variants="variants"
:zoom="zoom"
:dim="true"
/>
<PhyBadgeLayer
:nodes="focus ? [node(focus)!] : project.phy.root"
:variants="variants"
:zoom="zoom"
:parent="focus ? focusParentMatrix(focus) : undefined"
@badge="openBadge"
/>
</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 }}<span v-if="g.default === v.id" class="default-tag">既定</span>
</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>
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
HOLD_INTERVAL
}}msごとに進み、離すと確定します。
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
</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)"
:stabilizers="stabilizerChoice"
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
/>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 260px minmax(300px, 1fr) 250px;
gap: 8px;
}
.workspace.tree-closed {
grid-template-columns: minmax(300px, 1fr) 250px;
}
.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;
}
.default-tag {
margin-left: 6px;
padding: 0 4px;
border: 1px solid #8a691b;
border-radius: 3px;
font-size: 11px;
color: #6b520f;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
defineProps<{
mode: HoldMode;
pos: { x: number; y: number };
names: string[];
index: number;
step: number;
pointing: boolean;
}>();
// 円の周長。インジケータは次の切り替えまでに一周する
const ring = 2 * Math.PI * 14;
</script>
<template>
<svg
v-if="!pointing"
:key="step"
class="hold-ring"
width="36"
height="36"
:style="{ left: pos.x - 18 + 'px', top: pos.y - 18 + 'px' }"
>
<circle cx="18" cy="18" r="14" class="track" />
<circle
cx="18"
cy="18"
r="14"
class="progress"
:stroke-dasharray="ring"
:style="{ '--ring': ring, animationDuration: HOLD_INTERVAL + 'ms' }"
/>
</svg>
<ol
class="hold-list"
:class="mode"
:data-hold-mode="mode"
:style="{ left: pos.x + 22 + 'px', top: pos.y + 10 + 'px' }"
>
<li
v-for="(name, i) in names"
:key="i"
class="hold-row"
:class="{ active: i === index }"
:data-hold-index="i"
>
{{ name }}
</li>
</ol>
</template>
<style scoped>
.hold-ring {
position: fixed;
z-index: 3;
pointer-events: none;
}
.track,
.progress {
fill: none;
stroke-width: 3;
}
.track {
stroke: rgb(200 125 19 / 25%);
}
.progress {
stroke: #c87d13;
transform: rotate(-90deg);
transform-origin: 18px 18px;
animation: hold-ring linear forwards;
}
@keyframes hold-ring {
from {
stroke-dashoffset: var(--ring);
}
to {
stroke-dashoffset: 0;
}
}
.hold-list {
position: fixed;
z-index: 3;
margin: 0;
padding: 3px 0;
list-style: none;
background: #fff3d8;
border: 1px solid #c87d13;
border-radius: 4px;
font-size: 13px;
user-select: none;
}
.hold-list.loop {
pointer-events: none;
}
.hold-row {
padding: 3px 10px;
white-space: nowrap;
}
.hold-row.active {
background: #c87d13;
color: #fff;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
import type { Affine } from "../../domain/geometry";
import { mountMarks, type StabilizerSelections } from "./logic";
const props = withDefaults(
defineProps<{
nodes: Node[];
variants: Record<string, string>;
selections: StabilizerSelections;
zoom: number;
parent?: Affine;
}>(),
{ parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
);
const marks = computed(() => mountMarks(props.nodes, props.variants, props.selections, props.parent));
// 十字の大きさは画面上で固定する。
const cross = (p: { x: number; y: number }) => {
const r = 6 / props.zoom;
return `M ${p.x - r} ${p.y} H ${p.x + r} M ${p.x} ${p.y - r} V ${p.y + r}`;
};
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
</script>
<template>
<g class="mount-layer" pointer-events="none">
<g v-for="mark in marks" :key="mark.id" :data-mount-id="mark.id" :transform="affineText(mark.transform)">
<path class="switch" :d="cross(mark.switchAt)" />
<path v-for="(p, i) in mark.stabs" :key="i" class="stab" :d="cross(p)" />
</g>
</g>
</template>
<style scoped>
path {
fill: none;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.switch {
stroke: #c2185b;
}
.stab {
stroke: #00897b;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
const props = defineProps<{
node: Node;
selected: string[];
variants: Record<string, string>;
visible: boolean;
}>();
const emit = defineEmits<{
select: [id: string, additive: boolean];
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
}>();
function children(n: Node): Node[] {
if (n.type === "cluster") return n.children;
if (n.type === "variantGroup")
return n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [];
return [];
}
function partShape(part?: string) {
return part === "@led"
? "led"
: part === "@encoder"
? "encoder"
: part === "@screw"
? "screw"
: part === "@oled"
? "oled"
: "other";
}
function pointer(event: PointerEvent) {
const parent = (event.currentTarget as SVGGElement).parentElement as unknown as SVGGElement;
emit("dragStart", event, props.node.id, parent);
}
</script>
<template>
<g
v-if="visible"
class="phy-node"
:class="[
{
chosen: selected.includes(node.id),
ghost: node.type === 'key' && node.ghost,
decal: node.type === 'key' && node.decal,
},
`node-${node.type}`,
]"
:data-node-id="node.id"
:data-node-type="node.type"
:transform="`translate(${node.transform.x},${node.transform.y}) rotate(${node.transform.rotation})`"
@pointerdown.stop="pointer"
@click.stop="emit('select', node.id, $event.shiftKey)"
>
<template v-if="node.type === 'key'"
><rect
class="key-face"
x="0.04"
y="0.04"
:width="Math.max(0.1, node.size.w - 0.08)"
:height="Math.max(0.1, node.size.h - 0.08)"
rx="0.08"
/><rect
v-if="node.size.w2 || node.size.h2 || node.size.x2 || node.size.y2"
class="key-face secondary"
:x="node.size.x2 || 0"
:y="node.size.y2 || 0"
:width="node.size.w2 || node.size.w"
:height="node.size.h2 || node.size.h"
rx="0.06"
/><text class="key-label" x="0.12" y="0.3">{{
node.labels?.filter(Boolean).slice(0, 2).join(" / ")
}}</text></template
>
<template v-else-if="node.type === 'component'"
><circle v-if="partShape(node.partGroup) === 'led'" class="part led" r="0.25" /><circle
v-else-if="partShape(node.partGroup) === 'screw'"
class="part screw"
r="0.18"
/><rect
v-else-if="partShape(node.partGroup) === 'oled'"
class="part oled"
x="-0.35"
y="-0.22"
width="0.7"
height="0.44"
/><g v-else-if="partShape(node.partGroup) === 'encoder'"
><circle class="part encoder" r="0.27" /><path d="M-.1 -.18 L.12 0 -.1 .18" /></g
><path v-else class="part" d="M0 -.22 L.22 0 0 .22 -.22 0 Z" /><text
class="part-label"
x="0.28"
y="0.05"
>{{ node.partGroup || "part" }}</text
></template
>
<g v-else-if="node.type === 'cluster'" class="cluster-content"
><PhyNodeView
v-for="c in node.children"
:key="c.id"
:node="c"
:selected="selected"
:variants="variants"
:visible="true"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/></g>
<g v-else-if="node.type === 'variantGroup'" class="variant-content"
><PhyNodeView
v-for="c in children(node)"
:key="c.id"
:node="c"
:selected="selected"
:variants="variants"
:visible="true"
@select="(id, additive) => emit('select', id, additive)"
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
/></g>
<circle v-if="node.type === 'cluster' || node.type === 'variantGroup'" class="group-origin" r="0.07" />
<rect
v-if="selected.includes(node.id) && node.type !== 'key'"
class="selection-box"
x="-0.12"
y="-0.12"
width="0.24"
height="0.24"
/>
</g>
</template>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Node, Transform } from "../../domain/phy";
import type { StabilizerCandidate } from "../../domain/stabilizers";
import { hasSecondRect } from "../../domain/keyShape";
const props = defineProps<{
selectedCount: number;
selectedId: string | null;
item: Node | null;
mismatch: boolean;
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
}>();
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];
stabilizers: [keyId: string, ids: string[]];
}>();
function setSwitchOffset(axis: "x" | "y", value: number) {
emit("update", props.item!.id, (n) => {
if (n.type !== "key") return;
const offset = { x: n.switchOffset?.x ?? 0, y: n.switchOffset?.y ?? 0, [axis]: value };
if (offset.x === 0 && offset.y === 0) delete n.switchOffset;
else n.switchOffset = offset;
});
}
// 異形キーは2つ目の矩形(x2, y2, w2, h2)を持つ。オンにしたときはKLEの既定(0, 0, w, h)から始める。T-32。
const irregular = computed(() => props.item?.type === "key" && hasSecondRect(props.item.size));
function setIrregular(on: boolean) {
emit("update", props.item!.id, (n) => {
if (n.type !== "key") return;
if (on) n.size = { ...n.size, x2: 0, y2: 0, w2: n.size.w, h2: n.size.h };
else n.size = { w: n.size.w, h: n.size.h };
});
}
function setSecondRect(field: "x2" | "y2" | "w2" | "h2", value: number) {
emit("update", props.item!.id, (n) => {
if (n.type === "key") n.size[field] = value;
});
}
function toggleStabilizer(id: string, on: boolean) {
const current = props.stabilizers?.selected ?? [];
emit(
"stabilizers",
props.item!.id,
on ? [...current.filter((x) => x !== id), id] : current.filter((x) => x !== id),
);
}
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 class="check"
><input
type="checkbox"
:checked="irregular"
@change="setIrregular(($event.target as HTMLInputElement).checked)"
/>異形キー</label
>
<template v-if="irregular && item.type === 'key'">
<label
>X2
<input
type="number"
step="any"
:value="item.size.x2 ?? 0"
@change="setSecondRect('x2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Y2
<input
type="number"
step="any"
:value="item.size.y2 ?? 0"
@change="setSecondRect('y2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Width2
<input
type="number"
step="any"
:value="item.size.w2 ?? item.size.w"
@change="setSecondRect('w2', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>Height2
<input
type="number"
step="any"
:value="item.size.h2 ?? item.size.h"
@change="setSecondRect('h2', Number(($event.target as HTMLInputElement).value))"
/></label>
</template>
<label
>switchOffset X
<input
type="number"
step="any"
:value="item.switchOffset?.x ?? 0"
@change="setSwitchOffset('x', Number(($event.target as HTMLInputElement).value))"
/></label>
<label
>switchOffset Y
<input
type="number"
step="any"
:value="item.switchOffset?.y ?? 0"
@change="setSwitchOffset('y', Number(($event.target as HTMLInputElement).value))"
/></label>
<div class="stabilizers">
スタビ
<span v-if="!stabilizers?.candidates.length" class="none">候補なし</span>
<label v-for="c in stabilizers?.candidates ?? []" :key="c.id" class="check"
><input
type="checkbox"
:checked="stabilizers!.selected.includes(c.id)"
@change="toggleStabilizer(c.id, ($event.target as HTMLInputElement).checked)"
/>{{ c.label }}</label
>
</div>
<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;
}
.properties label.check {
margin: 4px 0 4px 12px;
}
.properties label.check input {
width: auto;
margin-left: 0;
}
.stabilizers .none {
margin-left: 8px;
color: #6b7380;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Node } from "../../domain/phy";
import type { Affine } from "../../domain/geometry";
import { selectionFrames } from "./logic";
const props = withDefaults(
defineProps<{
nodes: Node[];
selected: string[];
variants: Record<string, string>;
zoom: number;
candidate?: string | null;
parent?: Affine;
}>(),
{ candidate: null, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
);
const frames = computed(() =>
selectionFrames(props.nodes, props.selected, props.variants, props.candidate, props.parent),
);
// 原点の丸は選択中のクラスター(VariantGroupを含む)にだけ出す。T-30。
const origins = computed(() => frames.value.filter((frame) => frame.selected && frame.type !== "key"));
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
</script>
<template>
<g class="selection-layer" pointer-events="none">
<template v-for="frame in frames" :key="frame.id">
<path
v-if="frame.path"
:data-sel-id="frame.id"
:class="['sel', { candidate: frame.candidate }]"
:transform="affineText(frame.transform)"
:d="frame.path"
/>
<rect
v-else
:data-sel-id="frame.id"
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
:transform="affineText(frame.transform)"
:x="frame.box.x"
:y="frame.box.y"
:width="frame.box.width"
:height="frame.box.height"
/>
</template>
<circle
v-for="frame in origins"
:key="`origin-${frame.id}`"
:data-origin-id="frame.id"
class="origin"
:cx="frame.transform.e"
:cy="frame.transform.f"
:r="5 / zoom"
/>
</g>
</template>
<style scoped>
.sel,
.group-sel {
fill: none;
stroke: #1769d2;
stroke-width: 3.5px;
vector-effect: non-scaling-stroke;
}
.group-sel {
stroke-dasharray: 5 3;
}
.candidate {
stroke: #e87912;
stroke-width: 3.5px;
}
.origin {
fill: #fff;
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
defineProps<{
mismatch: boolean;
selectedCount: number;
grid: number;
moveTargets: { id: string; label: string }[];
focus: string | null;
crumbs: { id: string | null; label: string }[];
holdMode: "loop" | "list";
showMounts: boolean;
}>();
const emit = defineEmits<{
keyAdd: [];
presetAdd: [presetId: string];
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];
holdMode: [];
mounts: [];
}>();
function addPreset(e: Event) {
const select = e.target as HTMLSelectElement;
emit("presetAdd", select.value);
select.value = "";
}
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="addPreset">
<option value="">異形キーを足す</option>
<option v-for="preset in IRREGULAR_KEY_PRESETS" :key="preset.id" :value="preset.id">
{{ preset.label }}
</option>
</select>
<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
class="move-to"
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="`長押しの方式: ${holdMode === 'loop' ? 'ループ' : 'リスト'}(押すと切り替え)`"
:aria-label="`長押し: ${holdMode === 'loop' ? 'ループ' : 'リスト'}`"
@click="emit('holdMode')"
>
{{ holdMode === "loop" ? "⟳" : "☰" }}
</button>
<button
title="スイッチ・スタビライザーの位置"
aria-label="スイッチ・スタビライザーの位置"
:aria-pressed="showMounts"
:class="{ on: showMounts }"
@click="emit('mounts')"
>
✛
</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;
}
.tools button.on {
background: #dbe8fb;
border-color: #1769d2;
}
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
.tools select.move-to {
width: 6em;
}
</style>
+99 -39
View File
@@ -1,54 +1,114 @@
<script setup lang="ts">
import type { Node, Variant } from "../../domain/phy";
type Item = Node | Variant;
defineProps<{ nodes: Item[]; selected: string[]; expanded?: boolean }>();
const emit = defineEmits<{ select: [id: string, additive: boolean] }>();
function kind(n: Item) {
switch (n.type) {
case "variantGroup":
return "VariantGroup";
case "cluster":
return "Cluster";
case "component":
return "部品";
case "key":
return "キー";
case "variant":
return "Variant";
}
}
function title(n: Item) {
switch (n.type) {
case "key":
return n.labels?.filter(Boolean)[0] || "無題キー";
case "component":
return n.partGroup || "部品";
case "variantGroup":
case "cluster":
case "variant":
return n.name || n.type;
}
}
function children(n: Item): Item[] {
return n.type === "cluster" || n.type === "variantGroup" || n.type === "variant" ? n.children : [];
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 nodes" :key="n.id">
<li v-for="n in structures" :key="n.id">
<button
class="tree-node"
:class="{ chosen: selected.includes(n.id) }"
:data-tree-id="n.id"
:class="{ chosen: selected.includes(n.id) }"
@click="emit('select', n.id, $event.shiftKey)"
>
{{ kind(n) }} · {{ title(n) }} <small>{{ n.id }}</small></button
><PhyTree
v-if="children(n).length"
:nodes="children(n)"
{{ 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, additive) => emit('select', id, additive)"
@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>
-13
View File
@@ -1,13 +0,0 @@
export type Point = { x: number; y: number };
export function snapFromStart(_start: number, delta: number, step = 0.25): number {
return Math.round(delta / step) * step;
}
export function parentPoint(parent: SVGGraphicsElement, clientX: number, clientY: number): Point {
const matrix = parent.getScreenCTM();
if (!matrix) throw new Error("SVG parent has no screen transform");
const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
return { x: point.x, y: point.y };
}
export function snappedDelta(start: Point, current: Point): Point {
return { x: snapFromStart(0, current.x - start.x), y: snapFromStart(0, current.y - start.y) };
}
+377
View File
@@ -0,0 +1,377 @@
import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
import type { ProjectFile } from "../../domain/project";
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
import { hasSecondRect, keyCorners, keyOutline } from "../../domain/keyShape";
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") {
points.push(...keyCorners(n.size).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);
}
/** SVG path of a non-rectangular key's outline; undefined for plain keys, which are drawn as rounded rects. */
export function keyPath(size: KeyNode["size"]): string | undefined {
if (!hasSecondRect(size)) return undefined;
return keyOutline(size)
.map((loop) => `M ${loop.map((p) => `${p.x} ${p.y}`).join(" L ")} Z`)
.join(" ");
}
export type SelectionFrame = {
id: string;
type: Node["type"];
transform: Affine;
box: Box;
/** Outline of a non-rectangular key, drawn instead of `box`. */
path?: string;
selected: boolean;
candidate: boolean;
};
/** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */
export function selectionFrames(
roots: Node[],
selected: string[],
variants: Record<string, string>,
candidate: string | null,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): SelectionFrame[] {
const frames: SelectionFrame[] = [];
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "key" && (selected.includes(node.id) || candidate === node.id)) {
frames.push({
id: node.id,
type: node.type,
transform,
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
path: keyPath(node.size),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
} else if (node.type === "cluster") {
if (selected.includes(node.id) || candidate === node.id)
frames.push({
id: node.id,
type: node.type,
transform,
box: localBounds(node.children, variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
visit(node.children, transform);
} else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
if (selected.includes(node.id) || candidate === node.id)
frames.push({
id: node.id,
type: node.type,
transform,
box: localBounds(variant?.children ?? [], variants),
selected: selected.includes(node.id),
candidate: candidate === node.id,
});
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return frames;
}
export type VariantBadge = {
group: Extract<Node, { type: "variantGroup" }>;
x: number;
y: number;
size: number;
};
/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */
export function variantBadges(
roots: Node[],
variants: Record<string, string>,
zoom: number,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): VariantBadge[] {
const badges: VariantBadge[] = [];
const size = 20 / zoom;
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "cluster") visit(node.children, transform);
else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] || node.default));
const box = localBounds(variant?.children ?? [], variants);
const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom });
badges.push({ group: node, x: corner.x, y: corner.y, size });
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return badges;
}
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で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 };
}
/** スタビ位置の選択は仕様の層に入れず、ProjectFile.ui.phy.stabilizers にキーIDごとに持つ。T-10/T-30。 */
export type StabilizerSelections = Record<string, string[]>;
export function stabilizerSelections(project: ProjectFile): StabilizerSelections {
const ui = project.ui as { phy?: { stabilizers?: StabilizerSelections } } | undefined;
return ui?.phy?.stabilizers ?? {};
}
/** 記録がなければ何も選ばない。サイズが変わって候補にない記録は捨てて読む。 */
export function selectedStabilizers(selections: StabilizerSelections, key: KeyNode): string[] {
const ids = selections[key.id] ?? [];
const known = new Set(stabilizerCandidates(key.size).map((c) => c.id));
return ids.filter((id) => known.has(id));
}
export function withStabilizers(project: ProjectFile, keyId: string, ids: string[]): ProjectFile {
const ui = (project.ui ?? {}) as Record<string, unknown>,
phy = (ui.phy ?? {}) as Record<string, unknown>;
return {
...project,
ui: { ...ui, phy: { ...phy, stabilizers: { ...stabilizerSelections(project), [keyId]: ids } } },
};
}
export type MountMark = {
id: string;
transform: Affine;
switchAt: { x: number; y: number };
stabs: { x: number; y: number }[];
};
/** 表示中のキーごとに、スイッチ位置とスタビ位置をキーのローカル座標で返す。 */
export function mountMarks(
roots: Node[],
variants: Record<string, string>,
selections: StabilizerSelections,
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
): MountMark[] {
const marks: MountMark[] = [];
const visit = (nodes: Node[], ancestor: Affine) => {
for (const node of nodes) {
const transform = compose(ancestor, matrix(node.transform));
if (node.type === "key") {
if (node.decal) continue;
marks.push({
id: node.id,
transform,
switchAt: switchPoint(node),
stabs: stabilizerPoints(node.size, selectedStabilizers(selections, node)),
});
} else if (node.type === "cluster") visit(node.children, transform);
else if (node.type === "variantGroup") {
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
if (variant) visit(variant.children, transform);
}
}
};
visit(roots, parent);
return marks;
}
+394
View File
@@ -0,0 +1,394 @@
import { nextTick, onUnmounted, 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 { keyCorners } from "../../domain/keyShape";
import { moveNode } from "../../domain/phyCommands";
import { normalizeSelection, snap } from "./logic";
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
import { autoPanVelocity } from "./viewport";
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[];
holdMode: Ref<HoldMode>;
setSelection: (id: string, add?: boolean) => void;
beginHistory: () => void;
commitHistory: () => void;
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
};
export function usePhyCanvasPointer(options: Options) {
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
const spaceHeld = ref(false);
let preClickSelection: string[] = [];
let preClickTarget: string | null = null;
let preClickAt = 0;
let lastPointer: Point | null = null;
let autoPanFrame: number | undefined;
let lastAutoPanAt = 0;
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 stopAutoPan() {
if (autoPanFrame !== undefined) window.cancelAnimationFrame(autoPanFrame);
autoPanFrame = undefined;
lastAutoPanAt = 0;
}
function updateDraggedPosition(clientX: number, clientY: number) {
const state = dragState;
if (!state?.moved) return;
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, clientX, 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 autoPanTick(time: number) {
autoPanFrame = undefined;
const svg = options.svg.value;
const pointer = lastPointer;
if (!svg || !pointer || !dragState?.moved || longPress.cycling()) return;
const rect = svg.getBoundingClientRect();
const velocity = autoPanVelocity(
{ x: pointer.x, y: pointer.y },
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
);
if (!velocity.x && !velocity.y) {
lastAutoPanAt = 0;
return;
}
const elapsed = lastAutoPanAt ? Math.min((time - lastAutoPanAt) / 1000, 0.05) : 0;
lastAutoPanAt = time;
const transform = svg.getScreenCTM();
if (elapsed && transform) {
options.pan.value = {
x: options.pan.value.x - (velocity.x * elapsed) / transform.a,
y: options.pan.value.y - (velocity.y * elapsed) / transform.d,
};
void nextTick(() => {
if (dragState?.moved && lastPointer) updateDraggedPosition(lastPointer.x, lastPointer.y);
});
}
autoPanFrame = window.requestAnimationFrame(autoPanTick);
}
function scheduleAutoPan() {
const svg = options.svg.value;
if (!svg || !lastPointer || !dragState?.moved || longPress.cycling()) {
stopAutoPan();
return;
}
const rect = svg.getBoundingClientRect();
const velocity = autoPanVelocity(
{ x: lastPointer.x, y: lastPointer.y },
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
);
if (!velocity.x && !velocity.y) {
stopAutoPan();
} else if (autoPanFrame === undefined) {
autoPanFrame = window.requestAnimationFrame(autoPanTick);
}
}
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();
longPress.reset();
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") longPress.start(event, node.id);
(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;
lastPointer = { x: event.clientX, y: event.clientY };
if (longPress.picking()) {
longPress.point(event.clientX, event.clientY);
return;
}
const state = dragState;
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
longPress.cancelPending();
if (longPress.cycling()) {
const { id, shift } = longPress.take();
options.selected.value = normalizeSelection(
options.project().phy,
shift ? [...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();
} 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;
updateDraggedPosition(event.clientX, event.clientY);
scheduleAutoPan();
}
function up() {
stopAutoPan();
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 = keyCorners(item.key.size).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;
}
const pick = longPress.finish();
if (pick) {
options.selected.value = normalizeSelection(
options.project().phy,
pick.shift ? [...options.selected.value, pick.id] : [pick.id],
);
}
const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory();
else if (dragState && !longPress.wasActivated()) {
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 (longPress.wasActivated() || moved) preClickTarget = null;
dragState = null;
lastPointer = null;
}
function spaceUp(event: KeyboardEvent) {
if (event.code === "Space") spaceHeld.value = false;
}
onUnmounted(stopAutoPan);
return {
canvasDown,
down,
move,
up,
spaceUp,
hold: {
open: longPress.open,
mode: longPress.mode,
pos: longPress.pos,
candidates: longPress.candidates,
names: longPress.names,
index: longPress.index,
step: longPress.step,
pointing: longPress.pointing,
},
spaceHeld,
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 };
}
+407
View File
@@ -0,0 +1,407 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
import {
addComponentToParent,
addVariant as addVariantCommand,
addKeyToParent,
clusterNodes,
cloneNodesWithNewIds,
createVariantGroupInParent,
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 { newRowPosition, rowEndPosition } from "../../domain/rows";
import { IRREGULAR_KEY_PRESETS, keyOrigin } from "../../domain/keyShape";
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;
}
/** The node id present in `after` but not in `before`; the tree order does not tell where it went. */
function addedId(before: ProjectFile["phy"], after: ProjectFile["phy"]): string {
const existing = new Set(options.allNodes(before.root).map((node) => node.id));
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
}
function keyAdd(size: Size = { w: 1, h: 1 }) {
const chosen = options.node(options.selected.value.at(-1) || "");
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
// like an empty selection inside the focus.
const parent =
chosen?.type === "cluster"
? chosen.id
: chosen?.type === "variantGroup"
? options.variants.value[chosen.id] || chosen.default
: chosen
? options.selectedContainer(chosen.id)
: insertionParent();
const keys = nodesInContainer(parent).flatMap((node) =>
node.type === "key" ? [{ id: node.id, x: node.transform.x, y: node.transform.y, ...node.size }] : [],
);
const snap = (value: number) => Math.round(value / options.grid.value) * options.grid.value;
const center = centerInParent(parent);
const { x, y } =
chosen?.type === "key"
? rowEndPosition(keys, chosen.id)!
: chosen?.type === "component"
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
// The key's bounding box, not its origin, goes to the found position (T-32).
const offset = keyOrigin(size);
let id = "";
options.dispatch("キー追加", (project) => {
const at = { x: x - offset.x, y: y - offset.y, rotation: 0 };
const phy = addKeyToParent(project.phy, parent, at, size);
id = addedId(project.phy, phy);
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
});
if (id) options.selected.value = [id];
}
function presetAdd(presetId: string) {
const preset = IRREGULAR_KEY_PRESETS.find((item) => item.id === presetId);
if (preset) keyAdd(preset.size);
}
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 = addedId(project.phy, phy);
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;
const parent = options.selectedContainer(options.selected.value[0]!);
let id = "";
options.dispatch("バリアントグループ作成", (project) => {
let phy = createVariantGroupInParent(
project.phy,
parent,
"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,
presetAdd,
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();
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { ref } from "vue";
export type HoldMode = "loop" | "list";
// feedback「長押しでの選択の切り替え」で 500ms / 450ms は早いとされたため延ばす。
export const HOLD_DELAY = 600;
export const HOLD_INTERVAL = 1000;
type Options = {
parentLabel: (id: string | null) => string;
pressTargets: (id: string) => string[];
mode: () => HoldMode;
};
export type LongPressPick = { id: string; shift: boolean };
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
// loop: 巡回だけで選び、長押し後に動かすと選んだ先をドラッグする。
// list: 巡回は同じだが、ポインタがリストに入ると巡回をやめ、指している行を離した時点で選ぶ。ドラッグはしない。
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
export function usePhyLongPress(options: Options) {
const open = ref(false);
const mode = ref<HoldMode>("loop");
const pos = ref({ x: 0, y: 0 });
const candidates = ref<string[]>([]);
const names = ref<string[]>([]);
const index = ref(0);
// 巡回が進むたびに増やし、インジケータのアニメーションをやり直させる
const step = ref(0);
// list 方式で、ポインタがリストに入ったあと
const pointing = ref(false);
let timer: number | undefined;
let cycle: number | undefined;
let shift = false;
let activated = false;
function reset() {
activated = false;
}
function stopCycle() {
if (cycle) window.clearInterval(cycle);
cycle = undefined;
}
function start(event: PointerEvent, keyId: string) {
candidates.value = options.pressTargets(keyId);
index.value = 0;
shift = event.shiftKey;
timer = window.setTimeout(() => {
timer = undefined;
if (candidates.value.length <= 1) return;
activated = true;
mode.value = options.mode();
names.value = candidates.value.map((id, i) => (i === 0 ? "キー" : options.parentLabel(id)));
index.value = 1;
step.value++;
pointing.value = false;
pos.value = { x: event.clientX, y: event.clientY };
open.value = true;
cycle = window.setInterval(() => {
index.value = (index.value + 1) % candidates.value.length;
step.value++;
}, HOLD_INTERVAL);
}, HOLD_DELAY);
}
function cycling() {
return open.value && mode.value === "loop";
}
function picking() {
return open.value && mode.value === "list";
}
// list 方式: ポインタの下にあるリストの行を指す。リストの外では直前の状態を保つ。
function point(clientX: number, clientY: number) {
const row = document
.elementsFromPoint(clientX, clientY)
.find((element) => element instanceof HTMLElement && element.dataset.holdIndex !== undefined);
if (!(row instanceof HTMLElement)) return;
stopCycle();
pointing.value = true;
index.value = Number(row.dataset.holdIndex);
}
function cancelPending() {
if (timer && !open.value) {
window.clearTimeout(timer);
timer = undefined;
}
}
function take(): LongPressPick {
const id = candidates.value[index.value]!;
stopCycle();
open.value = false;
return { id, shift };
}
function finish(): LongPressPick | null {
if (timer) window.clearTimeout(timer);
timer = undefined;
stopCycle();
const id = open.value ? candidates.value[index.value] : undefined;
open.value = false;
return id ? { id, shift } : null;
}
function wasActivated() {
return activated;
}
return {
open,
mode,
pos,
candidates,
names,
index,
step,
pointing,
reset,
start,
cycling,
picking,
point,
cancelPending,
take,
finish,
wasActivated,
};
}
+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;
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.svg.value) return;
// viewBox はSVG自身の大きさに合わせる。ペインにはツールバーも入り、折り返すと高さがずれる
observer = new ResizeObserver((entries) => {
const rect = entries[0]!.contentRect;
options.size.value = { w: rect.width, h: rect.height };
fit();
});
observer.observe(options.svg.value);
fit();
});
onUnmounted(() => observer?.disconnect());
return { fit, fitSelected, localPoint, wheel };
}
+66
View File
@@ -0,0 +1,66 @@
export type Viewport = { zoom: number; pan: { x: number; y: number } };
export type ViewBounds = { x: number; y: number; width: number; height: number };
/** SVG画面端に近いほど強く、端の外では上限に達する自動パン速度(px/秒)。 */
export function autoPanVelocity(
point: { x: number; y: number },
bounds: ViewBounds,
edge = 32,
maxSpeed = 480,
): { x: number; y: number } {
const axis = (position: number, start: number, length: number) => {
const fromStart = position - start;
const fromEnd = start + length - position;
if (fromStart < edge) return -maxSpeed * Math.min(1, (edge - fromStart) / edge);
if (fromEnd < edge) return maxSpeed * Math.min(1, (edge - fromEnd) / edge);
return 0;
};
return {
x: axis(point.x, bounds.x, bounds.width),
y: axis(point.y, bounds.y, bounds.height),
};
}
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
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 } };
}
+10
View File
@@ -149,6 +149,16 @@ describe("domain project flow", () => {
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
expect(h.redo().phy.metadata.name).toBe("drag");
});
it("does not add a history step for an unchanged snapshot", async () => {
const p = await createProjectFromKle(kle, { idGenerator: gen }),
h = new ProjectHistory(p);
const q = structuredClone(p);
q.phy.metadata.name = "edit";
h.push(q);
h.push(structuredClone(q));
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
expect(h.canUndo).toBe(false);
});
it("imports the VIA geometry sample", async () => {
const via = JSON.parse(
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
-12
View File
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath";
describe("PHY drag math", () => {
it("snaps from the operation start in quarter-unit increments", () => {
expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25);
expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5);
});
it("returns snapped local deltas", () => {
expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 });
});
});
+322
View File
@@ -0,0 +1,322 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import {
insertAtGap,
insertNextTo,
keyVariantGroups,
orderSegments,
renumber,
swapKeys,
} from "../src/domain/idxOrder";
import type { OrderContext } from "../src/domain/idxOrder";
import {
assignLines,
keyRect,
matrixSequence,
scanKeys,
splitHalves,
type ScanOptions,
} from "../src/domain/idxScan";
import { matchPlacements } from "../src/domain/idxImport";
import { readLayoutPlacements } from "../src/io/layoutImport";
import { importKle } from "../src/io/kleImport";
import { flattenKeys } from "../src/domain/geometry";
import type { KeyRect } from "../src/domain/rows";
import type { FlatKey, PhyFile } from "../src/domain/phy";
const sample = (path: string) =>
JSON.parse(readFileSync(new URL(`../../samples/${path}`, import.meta.url), "utf8")) as unknown;
const gen = () => {
let i = 0;
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
};
/** Context where `hidden` keys are in group g (with every `grouped` visible key) and the rest are common. */
const ctx = (visible: string[], grouped: string[] = []): OrderContext => ({
visible: new Set(visible),
groups: new Map(grouped.map((id) => [id, ["g"]])),
});
describe("IDX order editing", () => {
it("keeps the ISO Enter next to the ANSI Enter when inserting after it", () => {
// a = ANSI Enter (shown), h = ISO Enter (hidden), b = common key
const c = ctx(["a", "b", "x"], ["a", "h"]);
expect(orderSegments(["a", "h", "b"], c)).toEqual([["a", "h"], ["b"]]);
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "a", true)).toEqual(["a", "h", "x", "b"]);
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "b", false)).toEqual(["a", "h", "x", "b"]);
});
it("attaches hidden keys forward only to a key sharing their group", () => {
const c = ctx(["p", "q"], ["h1", "h2", "q"]);
expect(orderSegments(["p", "h1", "h2", "q"], c)).toEqual([["p"], ["h1", "h2", "q"]]);
// shares with neither: stays with the key before
expect(orderSegments(["p", "z", "q"], ctx(["p", "q"]))).toEqual([["p", "z"], ["q"]]);
// shares with both: stays with the key before
expect(orderSegments(["p", "h", "q"], ctx(["p", "q"], ["p", "h", "q"]))).toEqual([["p", "h"], ["q"]]);
// once a hidden key goes forward, the rest of the run follows to keep the order
const mixed = {
visible: new Set(["p", "q"]),
groups: new Map([
["h1", ["g"]],
["q", ["g"]],
["h2", ["k"]],
["p", ["k"]],
]),
};
expect(orderSegments(["p", "h1", "h2", "q"], mixed)).toEqual([["p"], ["h1", "h2", "q"]]);
// leading hidden keys attach to the first visible key, trailing ones to the last
expect(orderSegments(["z", "p", "q", "y"], ctx(["p", "q"]))).toEqual([
["z", "p"],
["q", "y"],
]);
expect(orderSegments(["z"], ctx([]))).toEqual([]);
});
it("moves a key between two others like a list move", () => {
const c = ctx(["k0", "k1", "k2", "k3", "k4", "k5"]);
const order = ["k0", "k1", "k2", "k3", "k4", "k5"];
// n = 4 dropped between i = 1 and i + 1 = 2: lands on 2, 2..3 shift back
expect(insertNextTo(order, c, "k4", "k1", true)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
expect(insertNextTo(order, c, "k4", "k2", false)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
// n = 1 dropped between i = 3 and 4: lands on 3, 2..3 shift forward
expect(insertNextTo(order, c, "k1", "k3", true)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
expect(insertNextTo(order, c, "k1", "k4", false)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
expect(insertNextTo(order, c, "k1", "k1", true)).toEqual(order);
expect(insertAtGap(order, c, "k0", 99)).toEqual(["k1", "k2", "k3", "k4", "k5", "k0"]);
});
it("leaves hidden keys in place when their visible key moves away", () => {
const c = ctx(["a", "b", "c"], ["a", "h"]);
expect(insertNextTo(["a", "h", "b", "c"], c, "a", "c", true)).toEqual(["h", "b", "c", "a"]);
});
it("registers an unregistered key at the drop position", () => {
const c = ctx(["a", "b", "new"]);
expect(insertNextTo(["a", "b"], c, "new", "b", false)).toEqual(["a", "new", "b"]);
});
it("swaps only the two keys", () => {
expect(swapKeys(["a", "h", "b", "c"], "a", "c")).toEqual(["c", "h", "b", "a"]);
expect(() => swapKeys(["a"], "a", "zz")).toThrow();
});
it("renumbers a sequence from the position of its first key, moving attached hidden keys along", () => {
const c = ctx(["a", "b", "c", "d"], ["b", "h"]);
const order = ["a", "b", "h", "c", "d"];
expect(renumber(order, c, ["d", "b"])).toEqual(["a", "c", "d", "b", "h"]);
expect(renumber(order, c, ["b", "a"])).toEqual(["b", "h", "a", "c", "d"]);
expect(renumber(order, c, ["c", "a"])).toEqual(["b", "h", "c", "a", "d"]);
// unregistered keys are registered inside the block; an all-new sequence goes to the end
expect(renumber(["a", "b"], ctx(["a", "b", "n"]), ["n", "b"])).toEqual(["a", "n", "b"]);
expect(renumber(["a"], ctx(["a", "n"]), ["n"])).toEqual(["a", "n"]);
});
it("never duplicates or drops keys", () => {
const c = ctx(["a", "b", "c", "d"], ["a", "h1", "c", "h2"]);
const order = ["h1", "a", "b", "h2", "c", "d", "z"];
const hiddenOf = (o: string[]) => o.filter((id) => !c.visible.has(id));
const moved = renumber(order, c, ["d", "c", "b", "a"]);
expect([...moved].sort()).toEqual([...order].sort());
// hidden keys travel with their visible key here
expect(moved).toEqual(["d", "z", "h2", "c", "b", "h1", "a"]);
for (const r of [
insertNextTo(order, c, "a", "d", true),
insertNextTo(order, c, "d", "a", false),
swapKeys(order, "b", "d"),
]) {
expect([...r].sort()).toEqual([...order].sort());
expect(hiddenOf(r)).toEqual(hiddenOf(order));
}
});
it("lists VariantGroup ancestors including nested groups", () => {
const { phy } = importKle(sample("kle/macropad-4x4.json"), { idGenerator: gen() });
const [a, b] = phy.root as Extract<PhyFile["root"][number], { type: "key" }>[];
const nested: PhyFile = {
...phy,
root: [
{
type: "variantGroup",
id: "outer",
name: "o",
default: "v1",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "variant",
id: "v1",
name: "1",
children: [
{
type: "variantGroup",
id: "inner",
name: "i",
default: "v2",
transform: { x: 0, y: 0, rotation: 0 },
children: [{ type: "variant", id: "v2", name: "2", children: [a!] }],
},
],
},
],
},
b!,
],
};
const groups = keyVariantGroups(nested);
expect(groups.get(a!.id)).toEqual(["outer", "inner"]);
expect(groups.get(b!.id)).toEqual([]);
});
});
const r = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
const options = (o: Partial<ScanOptions>): ScanOptions => ({
kind: "rows",
direction: "normal",
left: "normal",
right: "normal",
offsets: new Map(),
...o,
});
describe("IDX preset scans", () => {
// Two 2x2 halves with a 2U gap
const split = [r("l00", 0, 0), r("l01", 1, 0), r("l10", 0, 1), r("l11", 1, 1)].concat([
r("r00", 4, 0),
r("r01", 5, 0),
r("r10", 4, 1),
r("r11", 5, 1),
]);
it("scans rows, columns, halves and alternating halves", () => {
expect(scanKeys(split, options({})).sequence).toEqual([
"l00",
"l01",
"r00",
"r01",
"l10",
"l11",
"r10",
"r11",
]);
expect(scanKeys(split, options({ direction: "reversed" })).sequence.slice(0, 4)).toEqual([
"r01",
"r00",
"l01",
"l00",
]);
expect(scanKeys(split, options({ kind: "columns" })).sequence.slice(0, 4)).toEqual([
"l00",
"l10",
"l01",
"l11",
]);
expect(scanKeys(split, options({ kind: "columns", direction: "reversed" })).sequence.slice(0, 2)).toEqual(
["r01", "r11"],
);
const halves = scanKeys(split, options({ kind: "halves", right: "reversed" }));
expect(halves.sequence).toEqual(["l00", "l01", "l10", "l11", "r01", "r00", "r11", "r10"]);
expect(halves.boundary).toBe(3);
expect(scanKeys(split, options({ kind: "alternating" })).sequence).toEqual([
"l00",
"l01",
"r00",
"r01",
"l10",
"l11",
"r10",
"r11",
]);
});
it("splits a board without a gap at the middle of its x range", () => {
const halves = splitHalves([r("a", 0, 0), r("b", 1, 0), r("c", 2, 0), r("d", 3, 0, 2)]);
expect(halves.boundary).toBe(2.5);
expect([...halves.left]).toEqual(["a", "b"]);
});
it("shifts the row assignment of one column", () => {
// column-staggered: the outer column sits a full row lower and is detected one row off
const keys = [r("o0", 0, 1), r("o1", 0, 2), r("a0", 1, 0), r("a1", 1, 1), r("a2", 1, 2)];
const before = assignLines(keys, "row", new Map());
expect(before.lines).toEqual([["a0"], ["o0", "a1"], ["o1", "a2"]]);
const outer = before.crossLines.findIndex((col) => col.includes("o0"));
const after = assignLines(keys, "row", new Map([[outer, -1]]));
expect(after.lines).toEqual([["o0", "a0"], ["o1", "a1"], ["a2"]]);
});
it("uses the bounding box of rotated keys", () => {
const rotated = keyRect({
key: {
type: "key",
id: "k",
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 2, h: 1 },
},
x: 0,
y: 0,
rotation: 90,
path: [],
});
expect(rotated.x).toBeCloseTo(-1);
expect(rotated.w).toBeCloseTo(1);
expect(rotated.h).toBeCloseTo(2);
});
it("orders by matrix row then col and leaves unwired keys at the end", () => {
const positions = new Map([
["a", { row: 1, col: 0 }],
["b", { row: 0, col: 2 }],
["c", { row: 0, col: 1 }],
]);
expect(matrixSequence(["x", "a", "b", "y", "c"], positions)).toEqual(["c", "b", "a", "x", "y"]);
});
});
describe("IDX import from existing definitions", () => {
const shown = (phy: PhyFile): FlatKey[] => flattenKeys(phy).filter((k) => !k.key.decal);
it("matches every key of the KLE the project came from, in KLE order", () => {
for (const file of ["kle/macropad-4x4.json", "kle/cr0wn60.json"]) {
const { phy, keyOrder } = importKle(sample(file), { idGenerator: gen() });
const result = matchPlacements(readLayoutPlacements(sample(file)).placements, shown(phy));
expect(result.unmatched, file).toBe(0);
expect(result.ids).toEqual(keyOrder.filter((id) => shown(phy).some((k) => k.key.id === id)));
}
});
it("matches the VIA definition of cr0wn60 against its KLE", () => {
const { phy } = importKle(sample("kle/cr0wn60.json"), { idGenerator: gen() });
const keys = shown(phy);
const result = matchPlacements(readLayoutPlacements(sample("via/cr0wn60.json")).placements, keys);
expect(result.ids.length).toBe(keys.length);
expect(new Set(result.ids).size).toBe(result.ids.length);
});
it("reads QMK layouts, asks for a name when there are several, and applies rotation", () => {
const kle = [
[{ r: 30, rx: 2, ry: 1, x: 0.5 }, "A"],
[{ x: -1 }, "B"],
];
const { phy } = importKle(kle, { idGenerator: gen() });
const keys = shown(phy);
const qmk = {
layouts: {
LAYOUT_a: {
layout: [
{ matrix: [0, 1], x: 1, y: 2, r: 30, rx: 2, ry: 1 },
{ matrix: [0, 0], x: 2.5, y: 1, r: 30, rx: 2, ry: 1 },
],
},
LAYOUT_b: { layout: [{ matrix: [0, 0], x: 0, y: 0 }] },
},
};
expect(readLayoutPlacements(qmk)).toEqual({ placements: [], layouts: ["LAYOUT_a", "LAYOUT_b"] });
const read = readLayoutPlacements(qmk, "LAYOUT_a");
const result = matchPlacements(read.placements, keys);
expect(result.unmatched).toBe(0);
const label = (id: string) => keys.find((k) => k.key.id === id)!.key.labels?.find(Boolean);
expect(result.ids.map(label)).toEqual(["B", "A"]);
expect(matchPlacements(readLayoutPlacements(qmk, "LAYOUT_b").placements, keys).unmatched).toBe(1);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
IRREGULAR_KEY_PRESETS,
hasSecondRect,
keyOrigin,
keyOutline,
keyRects,
} from "../src/domain/keyShape";
import { keyPath } from "../src/ui/phy/logic";
const isoEnter = IRREGULAR_KEY_PRESETS.find((p) => p.id === "iso-enter")!.size;
const asSet = (loop: { x: number; y: number }[]) => loop.map((p) => `${p.x},${p.y}`).sort();
describe("key shape", () => {
it("fills missing second-rectangle fields with KLE's defaults", () => {
expect(hasSecondRect({ w: 1, h: 1 })).toBe(false);
expect(keyRects({ w: 1, h: 1 })).toEqual([{ x: 0, y: 0, w: 1, h: 1 }]);
expect(keyRects({ w: 1.25, h: 1, w2: 1.75 })).toEqual([
{ x: 0, y: 0, w: 1.25, h: 1 },
{ x: 0, y: 0, w: 1.75, h: 1 },
]);
});
it("traces the ISO Enter as one clockwise loop of six corners", () => {
const loops = keyOutline(isoEnter);
expect(loops).toHaveLength(1);
expect(asSet(loops[0]!)).toEqual(
asSet([
{ x: -0.25, y: 0 },
{ x: 1.25, y: 0 },
{ x: 1.25, y: 2 },
{ x: 0, y: 2 },
{ x: 0, y: 1 },
{ x: -0.25, y: 1 },
]),
);
// Shoelace area is positive for a clockwise loop on a y-down screen.
const loop = loops[0]!;
const area = loop.reduce((sum, p, i) => {
const q = loop[(i + 1) % loop.length]!;
return sum + (p.x * q.y - q.x * p.y) / 2;
}, 0);
expect(area).toBeCloseTo(1.25 * 2 + 0.25);
});
it("traces a rectangle-in-rectangle as the outer rectangle", () => {
expect(keyOutline({ w: 1, h: 1, w2: 2 }).map(asSet)).toEqual([
asSet([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
]);
});
it("puts the bounding box's top-left where the key is placed", () => {
expect(keyOrigin(isoEnter)).toEqual({ x: -0.25, y: 0 });
expect(keyOrigin({ w: 1, h: 1 })).toEqual({ x: 0, y: 0 });
});
it("draws plain keys as rects and irregular ones as a path", () => {
expect(keyPath({ w: 1, h: 1 })).toBeUndefined();
expect(keyPath(isoEnter)).toMatch(/^M .* Z$/);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { PhyFile } from "../src/domain/phy";
import {
addComponentToParent,
addKeyToParent,
cloneNodesWithNewIds,
createVariantGroupInParent,
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");
}
}
});
it("creates a variant group inside a cluster or a variant", () => {
const variants: Parameters<typeof createVariantGroupInParent>[4] = [{ name: "A" }, { name: "B" }];
let p = createVariantGroupInParent(file, "cl-parent", "G", { x: 0, y: 0, rotation: 0 }, variants, ids);
const parent = p.root[0] as import("../src/domain/phy").ClusterNode;
expect(p.root).toHaveLength(1);
expect(parent.children.map((n) => n.type)).toEqual(["variantGroup"]);
const group = parent.children[0] as import("../src/domain/phy").VariantGroupNode;
p = createVariantGroupInParent(
p,
group.children[1]!.id,
"Inner",
{ x: 0, y: 0, rotation: 0 },
variants,
ids,
);
const updated = (p.root[0] as import("../src/domain/phy").ClusterNode).children[0] as typeof group;
expect(updated.children[1]!.children.map((n) => n.type)).toEqual(["variantGroup"]);
});
});
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import {
GRID_STEPS,
localBounds,
insertionContainer,
longPressColumn,
nextGrid,
normalizeSelection,
obbAabb,
removeFocusedSubtree,
pasteLocalTransform,
pasteParent,
resolveKey,
snap,
selectionFrames,
variantBadges,
tabOrder,
treeIndex,
visibleNodeIds,
} from "../src/ui/phy/logic";
import { autoPanVelocity, 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("computes selected frame world transforms through nested rotations", () => {
const roots: PhyNode[] = [
{
type: "cluster",
id: "outer",
transform: { x: 10, y: 20, rotation: 90 },
children: [
{
type: "cluster",
id: "inner",
transform: { x: 2, y: 3, rotation: -90 },
children: [
{
type: "key",
id: "target",
profile: "p",
transform: { x: 4, y: 5, rotation: 30 },
size: { w: 2, h: 1 },
},
],
},
],
},
];
const frame = selectionFrames(roots, ["target"], {}, null)[0]!;
expect(frame.id).toBe("target");
expect(frame.transform.e).toBeCloseTo(11);
expect(frame.transform.f).toBeCloseTo(27);
expect(frame.transform.a).toBeCloseTo(Math.cos(Math.PI / 6));
expect(frame.transform.b).toBeCloseTo(Math.sin(Math.PI / 6));
expect(frame.selected).toBe(true);
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]);
});
it("places variant badges at the rotated group's top-right corner, upright", () => {
const key = (id: string): PhyNode => ({
type: "key",
id,
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 2, h: 1 },
});
const roots: PhyNode[] = [
{
type: "variantGroup",
id: "g",
name: "G",
transform: { x: 1, y: 1, rotation: 90 },
default: "a",
children: [
{ type: "variant", id: "a", name: "A", children: [key("ka")] },
{ type: "variant", id: "b", name: "B", children: [] },
],
},
];
const [badge] = variantBadges(roots, {}, 10);
// ローカル (2 + 0.4, -2 - 0.4) を90度回して (1, 1) だけ動かす
expect(badge!.group.id).toBe("g");
expect(badge!.x).toBeCloseTo(1 + 2.4);
expect(badge!.y).toBeCloseTo(1 + 2.4);
expect(badge!.size).toBeCloseTo(2);
});
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);
});
it("scales auto-pan speed near edges, outside the canvas, and at corners", () => {
const bounds = { x: 10, y: 20, width: 200, height: 100 };
expect(autoPanVelocity({ x: 110, y: 70 }, bounds)).toEqual({ x: 0, y: 0 });
expect(autoPanVelocity({ x: 194, y: 70 }, bounds)).toEqual({ x: 240, y: 0 });
expect(autoPanVelocity({ x: 210, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
expect(autoPanVelocity({ x: 220, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
expect(autoPanVelocity({ x: 210, y: 10 }, bounds)).toEqual({ x: 480, y: -480 });
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { detectColumns, detectRows, newRowPosition, rowEndPosition, type KeyRect } from "../src/domain/rows";
const key = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
describe("row detection", () => {
it("groups row-staggered keys by y and traces columns", () => {
const keys = [
key("tab", 0, 0, 1.5),
key("q", 1.5, 0),
key("w", 2.5, 0),
key("caps", 0, 1, 1.75),
key("a", 1.75, 1),
key("s", 2.75, 1),
key("space", 0.5, 2, 3),
];
expect(detectRows(keys)).toEqual([["tab", "q", "w"], ["caps", "a", "s"], ["space"]]);
expect(detectColumns(keys)[0]).toEqual(["tab", "caps", "space"]);
});
it("traces column-staggered rows through the nearest key of the next column", () => {
const offsets = [0.5, 0.25, 0, 0.25];
const keys = offsets.flatMap((dy, col) => [0, 1, 2].map((row) => key(`c${col}r${row}`, col, row + dy)));
expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => offsets.map((_, col) => `c${col}r${row}`)));
expect(detectColumns(keys)[2]).toEqual(["c2r0", "c2r1", "c2r2"]);
});
it("keeps rows apart when an outer column has fewer keys", () => {
const keys = [
key("p1", 0, 1.5),
key("p2", 0, 2.5),
...[0, 1, 2].map((row) => key(`r${row}`, 1, row + 1.25)),
...[0, 1, 2, 3].map((row) => key(`i${row}`, 2, row + 1)),
];
expect(detectRows(keys)).toEqual([["p1", "r0", "i0"], ["p2", "r1", "i1"], ["r2", "i2"], ["i3"]]);
});
it("does not link keys a full row apart", () => {
const keys = [key("a0", 0, 0), key("a1", 0, 1), key("b1", 1, 1.1), key("b2", 1, 2.1)];
expect(detectRows(keys)).toEqual([["a0"], ["a1", "b1"], ["b2"]]);
});
it("puts a tall key in its top row", () => {
const keys = [key("7", 0, 0), key("8", 1, 0), key("plus", 2, 0, 1, 2), key("4", 0, 1), key("5", 1, 1)];
expect(detectRows(keys)).toEqual([
["7", "8", "plus"],
["4", "5"],
]);
});
it("links the inner columns across the gap between halves", () => {
const columns: [string, number, number][] = [
["l", 0, 0.5],
["m", 1, 0.25],
["n", 4, 0.25],
["o", 5, 0.5],
];
const keys = columns.flatMap(([name, x, dy]) =>
[0, 1, 2].map((row) => key(`${name}${row}`, x, row + dy)),
);
expect(detectRows(keys)).toEqual([0, 1, 2].map((row) => columns.map(([name]) => `${name}${row}`)));
});
});
describe("key insertion position", () => {
const keys = [key("a", 0, 0), key("b", 1, 0, 2), key("c", 0, 1)];
it("places a new key after the right end of the selected key's row", () => {
expect(rowEndPosition(keys, "a")).toEqual({ x: 3, y: 0 });
expect(rowEndPosition(keys, "c")).toEqual({ x: 1, y: 1 });
expect(rowEndPosition(keys, "missing")).toBeUndefined();
});
it("starts a new row at the left below every key", () => {
expect(newRowPosition([...keys, key("tall", 3, 0.5, 1, 2)])).toEqual({ x: 0, y: 2.5 });
expect(newRowPosition([])).toBeUndefined();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../src/domain/stabilizers";
import { mountMarks, selectedStabilizers, stabilizerSelections, withStabilizers } from "../src/ui/phy/logic";
import type { KeyNode } from "../src/domain/phy";
import type { ProjectFile } from "../src/domain/project";
const key = (id: string, w: number, h = 1, extra: Partial<KeyNode> = {}): KeyNode => ({
type: "key",
id,
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w, h },
...extra,
});
const ids = (w: number, h = 1) => stabilizerCandidates({ w, h }).map((c) => c.id);
describe("stabilizer candidates", () => {
it("looks up candidates by the long side", () => {
expect(ids(1)).toEqual([]);
expect(ids(1.75)).toEqual([]);
expect(ids(2.25)).toEqual(["11.938mm"]);
expect(ids(1, 2)).toEqual(["11.938mm"]);
expect(ids(6)).toEqual(["2.5u", "2u"]);
expect(ids(6.25)).toEqual(["50mm", "40mm", "42.5mm"]);
expect(ids(10)).toEqual(["3.5u"]);
expect(ids(6.75)).toEqual([]);
});
it("gives no candidates to non-rectangular keys", () => {
expect(stabilizerCandidates({ w: 1.25, h: 2, w2: 1.5, h2: 1, x2: -0.25 })).toEqual([]);
expect(stabilizerCandidates({ w: 2, h: 1, w2: 2.25 })).toEqual([]);
});
it("places mounts around the rectangle center along the long axis", () => {
expect(stabilizerPoints({ w: 7, h: 1 }, ["3u"])).toEqual([
{ x: 0.5, y: 0.5 },
{ x: 6.5, y: 0.5 },
]);
const [top, bottom] = stabilizerPoints({ w: 1, h: 2 }, ["11.938mm"]);
expect(top!.x).toBe(0.5);
expect(bottom!.y - top!.y).toBeCloseTo(23.876 / 19.05, 12);
expect(stabilizerPoints({ w: 6, h: 1 }, ["2.5u", "2u"])).toHaveLength(4);
});
it("keeps stabilizers on the key center when the switch is offset", () => {
const k = key("k", 6, 1, { switchOffset: { x: 0.5, y: 0 } });
expect(switchPoint(k)).toEqual({ x: 3.5, y: 0.5 });
expect(stabilizerPoints(k.size, ["2.5u"]).map((p) => p.x)).toEqual([0.5, 5.5]);
});
});
describe("stabilizer selections in ProjectFile.ui", () => {
const project = { ui: { other: 1 } } as unknown as ProjectFile;
it("selects nothing by default and drops ids the size no longer offers", () => {
expect(selectedStabilizers({}, key("a", 2))).toEqual([]);
expect(selectedStabilizers({ a: ["11.938mm"] }, key("a", 2))).toEqual(["11.938mm"]);
expect(selectedStabilizers({ a: ["2.5u", "3u"] }, key("a", 6))).toEqual(["2.5u"]);
});
it("writes per key without touching other ui state", () => {
const next = withStabilizers(withStabilizers(project, "a", ["50mm"]), "b", []);
expect(stabilizerSelections(next)).toEqual({ a: ["50mm"], b: [] });
expect((next.ui as { other: number }).other).toBe(1);
expect(stabilizerSelections(project)).toEqual({});
});
it("lists marks for visible keys only, skipping decals", () => {
const marks = mountMarks(
[
key("a", 2),
key("d", 2, 1, { decal: true }),
{
type: "variantGroup",
id: "g",
name: "g",
default: "v1",
transform: { x: 0, y: 1, rotation: 0 },
children: [
{ type: "variant", id: "v1", name: "1", children: [key("b", 1)] },
{ type: "variant", id: "v2", name: "2", children: [key("c", 1)] },
],
},
],
{ g: "v2" },
{ a: ["11.938mm"] },
);
expect(marks.map((m) => m.id)).toEqual(["a", "c"]);
expect(marks[1]!.transform.f).toBe(1);
expect(marks[0]!.stabs).toHaveLength(2);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
モックの仮実装のために、仕様上の未決事項を仮に1つへ固定したもの。**仕様ではない。**
- メインドキュメント(リポジトリ直下の `*.md`)・backlog・journal の運用ルールの対象外。
メインドキュメントやbacklogからこのフォルダへリンクしない。
メインドキュメントやbacklogからこのフォルダへリンクしない。
- 仮決定は本決定を意味しない。推測の検証済み扱いにもしない(`[推測:P-n]` のタグと未確認事項はそのまま残る)。
- 本決定にする場合は、通常どおり `/decide` でメインドキュメントとjournalに記録し、ここの該当項目を消す。
- モックを終えたら、このフォルダごと削除する。経緯は履歴で追える。
+144 -6
View File
@@ -6,14 +6,16 @@
## A. モックの範囲と技術
### T-1 モックの目的
配置を編集しても割り当てがずれないこと、全バリアントを保持したまま選択した構成を出力・往復できることを、動くもので確かめる。
完成品のツールにはしない。
### T-2 対象範囲
ブラウザで動く1つのアプリ。1画面でタブを切り替える。
| ツール | 作る範囲 |
|---|---|
| --- | --- |
| PHY Editor | KLE JSONの新規取り込み、キー・部品の追加、移動・回転・サイズ編集、クラスター化・解除・reparent・数値入力のreframe、バリアントの編集・表示切替、KLE JSON出力 |
| Key Indexer | 全バリアントのキーの一覧、`order` への登録・除外・並べ替え、選択構成での出力番号のプレビュー |
| Wiring Editor | MCUの提供者と接点キーの端子のフォーム編集、配線の表示、未配線・衝突の表示、LEDチェーンの順序編集 |
@@ -26,18 +28,21 @@
後段(任意): 疑似デバイスとの往復(書き込み → 外部での変更を模擬 → 読み出し → 差分確認 → MAPへ取り込み)。着手する場合は T-24 に従う。
### T-3 作る順番
1. 回転したクラスター内のキーのドラッグと、保存・再読み込みの往復(描画方式の確認を兼ねる)
2. IDX・MAPとの連携
3. ELCと出力の対応表
4. (任意)疑似デバイスとの往復
### T-4 検証に使うデータ
`samples/kle/macropad-4x4.json`、`samples/kle/cr0wn60.json`(幾何のみ)、手作りの回転・入れ子バリアントのfixture。
実在の配線とVIAの適合は検証の対象にしない。
### T-5 技術選定 (implementation-plan P-2)
| 項目 | 仮決定 |
|---|---|
| --- | --- |
| 言語 | TypeScript(strict) |
| UI | Vue 3(Composition API) + Vite。Nuxtは使わない(SSR・ルーティングが不要) |
| 描画 | 素のSVG。PHYの階層を入れ子の `<g>` に対応させ、ドラッグは親要素の `getScreenCTM()` の逆行列でローカル座標へ戻す |
@@ -49,21 +54,130 @@
理由: 描画の変換がPHYの `Translate · Rotate` と同じ定義で、座標の逆変換をブラウザに任せられる。Vueは既存の経験を使える。
### T-6 リポジトリ構成 (implementation-plan P-3)
このリポジトリの `mock/` に単一のパッケージとして置く。
中は `domain/`(層のモデル・変換。Vue・DOMを参照しない)、`io/`(KLE・プロジェクトファイル)、`ui/` に分ける。
モノレポ化とパッケージの分割はモックの後に決める。
### T-7 KLE JSONの読み書き (implementation-plan P-1)
kle-serial2 を使わず、読み書きともに自作する。ijprest/keyboard-layout-editor・kle-serial・過去のkbd-editorのコードも取り込まない。
KLE本家の挙動の確認は、既存の検証ハーネスで本家の `serial.js` を動かして比べる(コードは写さない)。
理由: 出自の確認を待たずに進められ、後からライブラリへ差し替えられる。
### T-29 PHY Editorの作り直し
`ui/phy/` だけを作り直し、`domain/`・`io/` とそのテストは流用する。作り直したものもモックとして扱う。
入力は [phy-editor-feedback.md](phy-editor-feedback.md) と [phy-editor-keybindings.md](phy-editor-keybindings.md)。
理由: 所感はすべて画面の操作と表示に関するもので、データモデル側に作り直す理由がない。
### T-30 作り直し1回目の所感への対応
T-29 のコードを作り直さず、[phy-editor-feedback.md](phy-editor-feedback.md)「作り直し1回目(T-29)を触った所感」を下の単位で順に直す。
1単位1コミットとし、各単位で既存の Vitest・Playwright を通してから次へ進む。
理由: 所感はどれも局所的な修正か機能の追加で、T-29 の構成(composable・純粋モジュールの分離)のまま入る。
| 順 | 単位 | 内容 | 主な箇所 |
| --- | --- | --- | --- |
| 1 | 選択の描画層 | 選択枠を太くし、`NodeSvg` の再帰から外して最前面の層に描く。以後のオーバーレイ(5・8)もこの層に載せる | `NodeSvg.vue`、`PhyEditor.vue` |
| 2 | パネルの幅 | 階層パネルを閉じたら、キャンバスが空いた幅を埋める | `PhyEditor.vue` の `grid-template-columns` |
| 3 | バリアント | ポップオーバーの印を「既定」と分かる見た目にする。バッジには名前を書かず、表示中のバリアント名はツールチップで示す。クラスター内のキーから作ったバリアントグループは、そのクラスターの中に作る | `NodeSvg.vue`、`usePhyCommands.ts` |
| 4 | ドラッグ中の自動スクロール | キーを画面の端へ寄せると、キャンバスがスクロールする | `usePhyCanvasPointer.ts`、`usePhyCanvasView.ts` |
| 5 | クラスターの原点 | 選択中のクラスターにだけ、原点に丸を出す | 1の層 |
| 6 | キー追加の位置 | 選択中のキー(複数なら最後に選んだキー)の行の右端に追加する。何も選んでいなければ、同じコンテナの全キーの下に新しい行を作り、その左端に置く。クラスターを選んでいればその中に、バリアントグループを選んでいれば表示中のバリアントの中に、同じように新しい行を作る。行の判定は `domain/` に純粋関数として実装してテストし、のちにIDX Editorでも使う。規則は [idx-editor-feedback.md](idx-editor-feedback.md)「行と列の判定」 | `domain/rows.ts`、`usePhyCommands.ts` |
| 7a | 長押しの切り出し | 長押しの処理を composable に切り出す。挙動は変えない | `usePhyCanvasPointer.ts` |
| 7b | 長押しの2方式 | ループ方式(間隔を延ばし、円形インジケータと選択先のリストを付ける)と、長押しでリストを出して選ぶ方式を入れ、ツールバーで切り替えて比べる。間隔は、押してから600msで始まり1000msごとに進む。リスト方式は、押した位置から少し離してリストを出し、ポインタがリストに入ったら巡回を止め、離したときに指している行で確定する。リストに入らずに離せば、そのときの巡回先で確定する。ループ方式は長押しのあと動かすと選んだ先をドラッグし、リスト方式はドラッグしない | 7aの composable |
| 8 | スイッチ・スタビライザーの表示 | 既定は非表示にし、トグルで色違いの十字を出す。プロパティ欄に `switchOffset` の入力欄と、スタビ位置の候補の選択欄を置く(下の「単位8のスタビ候補」) | 1の層、`PhyProperties.vue` |
順番の理由: 1で作る層を5と8が使う。2〜5は小さく独立しているので先に片付ける。
7は最も大きく、試して比べる部分なので、挙動を変えない切り出しを先に入れる。
#### 単位8のスタビ候補
スタビ位置はキーのサイズで候補を引き、キーごとに候補を複数選べる。何も選ばなくてもよい。
選んだ候補はプロジェクトファイルの `ui` にキーIDごとに持ち、PHYには入れない(いずれPHYへ移す)。
どのサイズも、何も選ばない状態から始める。
候補を選んでも `switchOffset` は変えない。`switchOffset` は手で入れる。
候補は、キーの (w,h) 矩形の中心から±の位置に置く2点で表す(1U = 19.05mm)。縦長のキーは長辺の向きに置く。
Cherry MX系の値を使う。出典は Deskthority wiki「Space bar dimensions」と、プレート・基板の生成ツール(kad、keyswitch-kicad-library)。
| 幅 | 候補 |
| --- | --- |
| 2〜2.75U | ±11.938mm |
| 3U | ±1U |
| 4U | ±1.5U |
| 4.5U | ±34.671mm |
| 5.5U | ±2.25U |
| 6U | ±2.5U(軸がずれた型。実物は `switchOffset.x` = +0.5U)、±2U |
| 6.25U | ±50mm(軸がずれた型もこの位置。実物は `switchOffset.x` = +0.625U)、±40mm、±42.5mm |
| 6.5U | ±2.75U |
| 7U | ±3U |
| 8U・9U・9.75U・10U | ±3.5U |
6Uの±2.5Uは、軸が中央の型(Topre等)と軸がずれた型で同じ位置になるため、1つの候補にまとめる。
ISO Enter・BAE等の異形キー(`w2`・`h2`・`x2`・`y2` のいずれかを持つキー)には、候補を出す仕組みだけを入れ、候補は置かない。
上にない幅のキーは候補なし。
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
### T-31 IDX Editorの作り直し
`ui/panels/IdxPanel.vue` を捨て、`ui/idx/` に一から作る。入力は [idx-editor-feedback.md](idx-editor-feedback.md)「方向が合意できたもの」。
`order` の操作(挿入・入れ替え・連番の並べ直し・走査・取り込みの照合)は `domain/` に純粋関数として置いてテストし、行と列の判定は `domain/rows.ts` を変えずに使う。
`ui/phy/viewport.ts` の純粋関数は使ってよいが、`ui/phy/` は変えない。
理由: 所感は画面の操作の作り直しで、`IdxFile` の形と `domain/idx.ts` は変える理由がない。
所感が定めていない部分は、下のとおり仮に決める。
- 表示する構成: エディタ内の状態として持ち、開いたときは `output.variantSelection`(欠けたグループは既定)から始める。
切り替えても `output` は書き換えず、Undoにも積まない。
- 画面: 左にバリアントのツリー、中央にキャンバス、右に並べ替え(プリセット・取り込み・未登録の登録)と選択中のキーの情報。
キーには選んだ構成での出力番号を出す。未登録のキーは番号を出さず、未登録と分かる見た目にする。並びのリストは置かない。
- 選択: クリックで1つ、Shift/Ctrl+クリックで追加・解除、何もない場所からのドラッグで範囲選択、Escと何もない場所のクリックで解除。
- ドロップの判定: ドロップ先のキーの幅を4等分し、左端の1/4は「そのキーの前へ挿入」、右端の1/4は「後へ挿入」、中央は入れ替え。
回転したキーはキーの向きで測る。ドラッグ中は挿入位置の線か入れ替えの印を出す。何もない場所へのドロップは取り消し。
ドラッグで動かすのは押したキー1つだけ。未登録のキーをドロップすると、その位置に登録する(入れ替えはしない)。
- 隠れたキーのくっつき先: 前後で最も近い表示中のキーを候補にし、VariantGroupの祖先を共有するほうへ付ける。
両方と共有する、どちらとも共有しない場合は手前に付ける。手前がなければ後ろに付ける。
表示中のキーに挟まれた隠れたキーの並びは、後ろに付くキーが出たら、そこから先をすべて後ろに付ける(順序を保つため)。
- 連番の並べ直し(プリセット・取り込みの共通操作): 渡された列のキーを、列の先頭キーがあった位置から連続して置く。
列に入らない表示中のキーは、互いの順序を保つ。列に未登録のキーがあれば登録する。
隠れたキーは、くっついている表示中のキーと一緒に動く(ドラッグでの移動と異なる)。
- 左右の半身: 判定した列のうち、隣り合う列の隙間が最も大きい所で分ける。最大の隙間が0.5U未満なら、範囲の中央のx座標で分ける。
回転したキーは、外接矩形で行と列を判定する。
- 向き: 行優先・列優先は全体に1つ(通常 / 左右逆)。半身ごと・行ごとに左右交互は、左手側と右手側に別々に指定する。
マトリクス順は向きを持たず、row→colの順に並べ、ELCで位置が決まらないキーは今の順序のまま末尾に置く。
- プレビュー: 行で走査する方式は行を線で結んで色分けし、列を選んで↑/↓で行の割り当てをずらす。
列優先は列を結び、行を選んで←/→でずらす。仮の出力番号と半身の境界も出す。Escで取り消し、適用で1回のUndo単位にする。
- 取り込み(QMK `keyboard.json` の `layout[]`、VIA定義の `layouts.keymap`、KLE JSON): 各キーの位置・回転を、表示中のキーの位置・回転と照合する(1対1、許容差0.01U)。
照合できたキーの列を連番の並べ直しに渡し、照合できなかった数を表示する。QMKで複数のレイアウトがあれば選ばせる。
覆すときに影響する箇所: `mock/src/ui/idx/`、`mock/src/domain/` の IDX の並べ替え関数とそのテスト、`mock/e2e/idx.spec.ts`。
### T-32 PHY Editorでの異形キー
PHY Editorで、2つ目の矩形(`x2`・`y2`・`w2`・`h2`)を持つキーを描き、編集し、追加できるようにする。
理由: データモデル・KLEの読み書きは2つ目の矩形を扱えるのに、キャンバスは主矩形しか描かず、編集手段もなかった。
- 描画: 2つの矩形の和の外形を1つの輪郭で描く。選択枠も同じ外形にする。当たり判定と範囲選択は両方の矩形で取る。
2つ目の矩形を持たないキーは今までどおり角丸の矩形で描く。
- 編集: プロパティ欄に「異形キー」のチェックを置き、オンのときだけ2つ目の矩形の欄(X2・Y2・Width2・Height2)を出す。
オンにしたときの初期値はKLEの既定(0, 0, w, h)。オフにすると4つとも消す。Width・Heightを変えても2つ目の矩形は変えない。
- 追加: ツールバーの「キーを足す」(1U)はそのまま残し、隣に「異形キーを足す」のselectを置く。
候補は `domain/` のプリセット表から出し、今はISO Enter(KLEのプリセットと同じ `w1.25 h2`、`x2 -0.25 w2 1.5 h2 1`)だけを置く。
プリセットを表に足せば、selectの項目も増える。ショートカットのキー追加は1Uのまま。
- 追加位置: 1Uキーと同じ規則(T-30 単位6)で求めた位置に、キーの外接矩形の左上を合わせる。ISO Enterは原点が外接矩形の0.25U右になる。
- スイッチ位置・スタビ: 変えない。スイッチは主矩形の中心(`switchOffset` 0)、スタビ候補は置かない(T-30 単位8)。
覆すときに影響する箇所: `mock/src/domain/keyShape.ts` とそのテスト、`mock/src/ui/phy/` の描画・選択枠・プロパティ欄・ツールバー、`mock/e2e/phy.spec.ts`。
## B. 明示的な未決事項
### 未確認事項
| 出典 | 仮決定 |
|---|---|
| --- | --- |
| 全体 P-1 アナログ値の解釈の所属 | モック対象外。ELCにも仮置きしない |
| MAP P-1 動作定義とキーごとの設定の保存形式 | モック対象外。MAPは現行Schemaの `layers[].keymap[]` だけを扱う。モック後の叩き台として: マクロ・Staticの動作は `map.definitions[]`(`name` で識別、`kind` と種別ごとの内容)に置き、キーごとの設定はPHYのキーIDに付ける(`map.keySettings[keyId]`) |
| MAP P-2 VIAのコマンドは0.34.4のソースどおり[推測] | 推測に依存しない。実機通信を作らない。疑似デバイスはアプリ側の操作を模擬するだけで、VIAの動作とは扱わない |
@@ -75,7 +189,7 @@ KLE本家の挙動の確認は、既存の検証ハーネスで本家の `serial
### backlog
| 項目 | 仮決定 |
|---|---|
| --- | --- |
| PHY-P-2 KLE取り込みの照合基準 | 新規PHYとしての取り込みだけ。照合・更新は作らない |
| PHY-P-6 見た目情報の所属層 | 現行Schemaどおり、キー単位とファイル単位の `kleCompatibility` に退避し書き戻す。編集UIは作らない。ラベルと `a` の往復はT-20 |
| PHY-P-8 ラベルのMAP適用 | 自動適用しない。ラベルは表示だけ |
@@ -100,6 +214,7 @@ ELC本文にある「LEDのドライバIC方式」「デジタル側の提供者
仕様だけでは実装が一意にならない箇所。
### T-10 プロジェクトファイル
1ファイル `*.bkle.json` に束ねる。
```ts
@@ -116,55 +231,69 @@ type ProjectFile = {
各層を単独のJSONとして書き出す・読み込む機能も持つ。モック独自の拡張は各層の中に入れず、`ProjectFile` の直下に置く。
### T-11 ID
新規ノードは種別の接頭辞 + ランダム文字列(`key-`, `cl-`, `cmp-`, `vg-`, `v-` + `crypto.randomUUID()` の先頭12桁)。
プロファイル・提供者・LEDチェーンも同じ規則。PHY内の全ノードでIDを一意にする。
移動・並べ替え・reparentではIDを変えない。複製は配下も含めて新しいIDにし、ELC・MAP・IDXへの参照は複製しない。
移動・並べ替え・reparentではIDを変えない。複製(コピー&ペーストを含む)は配下も含めて新しいIDにし、ELC・MAP・IDXへの参照は複製しない。
複製した範囲の中で閉じた参照(VariantGroupの `default`)は新しいIDへ付け替える。複製したキーはIDXの末尾へ足さず、未登録のままにする。
同じプロジェクト内のカット&ペーストはreparentとして扱い、IDと参照を保つ(削除+複製にすると T-15 で参照が消える)。
KLEの取り込みは毎回新しいIDを振る。
### T-12 phyReference
- `source`: プロジェクト内では `project:phy`。単独ファイルでは読み込んだPHYのファイル名(利用者がPHYを選び直せる)
- `checksum`: PHY全体をJCS(RFC 8785)で正規化し、UTF-8のSHA-256を `sha256-jcs:<hex>` とする
- 不一致は警告だけ。IDを付け替えず、参照の検証をやり直す。保存時に更新する
### T-13 version
モックが出力する各層とプロジェクトファイルは `0.1.0`。読み込みは `0.1.0` だけを受け付ける(文書の例にある `1.0.0` は互換の約束とは扱わない)。
### T-14 読み込んだ未知のフィールド
保持して書き戻す。新しく書き出すデータには追加しない。
### T-15 ノードの削除
IDX・ELC・MAP・LEDチェーンにある参照を一覧で示し、同じ操作(1回のUndo単位)で削除する。
Variantを削除したときは、VariantGroupの `default` を先頭のVariantへ移す。子が1つになるVariantGroupの削除は、確認して止める。
### T-16 検証の段階
- 保存を止めるもの: Schema違反、ID重複、参照先がない
- 出力(`mock-target.json`)を止めるもの: IDXに未登録のキー、選択構成のキーの未配線・未割り当て、非排他キーの接続先の衝突
- どちらでも警告として一覧に出す。編集途中の状態は保存できる
### T-17 Undo/Redo
プロジェクト全体のスナップショットで持つ。ドラッグ1回を1操作にまとめる。
### T-18 IDXの初期の並び
KLE取り込み時は、取り込んだファイルでのキーの出現順で `order` を1回だけ作る。手で置いたキーは作った順に末尾へ足す。
その後はPHYから作り直さない。IDXに未登録のキーは「未登録」と表示する。
`decal` と部品ノードは対象外、`ghost` は対象にする。
### T-19 row/col の割り当て(Generatorのプレビュー)
出力設定に順序付きの `rowPins` と `colPins` を利用者が指定し、その添字をrow/colとする。
各接点キーの両端子が、片方は `rowPins`、もう片方は `colPins` に属することを検査する。満たさない構成(charlieplexing、direct)は「未対応」と表示して出力しない。
端子の `a`/`b` の順、キーの位置、IDXの順からは推定しない。
### T-20 KLEの入出力の細部
- 入力は厳密なJSON(KLEの緩いJSONは読まない)。VIA定義の `layouts.keymap` は、KLE JSONとして幾何だけを取り込む
- ラベルは欄の位置と空欄を保って保持する。`a` 等の属性の引き継ぎとリセットは入出力の処理で解決する
- 書き出しは、同じ絶対角度のキーに共通の回転中心を使い、キーの順を固定する。座標は小数6桁に丸める(PHYの値は丸めない)
- 書き出し時のバリアント選択は `output.variantSelection` を使い、指定のないものはPHYの `default`
### T-21 プロファイルと座標の単位
モックで編集できるのは、全プロファイルの `unitSize` が同じ場合だけ。違うものは読み込んで保持するが、編集不可と表示する。
理由: 親の座標系のUを、どのプロファイルでmmへ換算するかが決まっていない([discrepancies.md](discrepancies.md) D-6)。
### T-22 MAPの割り当て
- 未割り当ては `keymap` に要素がない状態で表す。`KC_NO`・`KC_TRNS` は明示の割り当てとして区別する
- 同じレイヤーでの `keyId` の重複は禁止
- 新規MAPは名前 `Base` の空のレイヤー1枚から始める。レイヤー名は大文字小文字を区別し、MAP内で一意
@@ -173,10 +302,13 @@ KLE取り込み時は、取り込んだファイルでのキーの出現順で `
- 編集できるキーコードは、基本キー・修飾キー・`KC_NO`・`KC_TRNS`・上のレイヤー動作・修飾キー付き(`LCTL(kc)` 等)に限る。知らないコードは保持し、表示だけする
### T-23 キーコードの表示
QMKの表記をそのまま出し、基本キーだけUS配列の文字を添える。表示用の小さな対応表はUI側に置き、HSTのデータ形式としては扱わない。
### T-24 疑似デバイスとの対応(後段・任意)
MAP P-3 の問いを、モック用に次のように固定する。
- 出力のたびに「出力記録」を作り、選択構成・キーID↔出力番号↔row/col・レイヤー名↔番号を保存する。疑似デバイスは記録のIDを持つ
- 書き込みは選択構成のキーだけ。選択外のバリアントのキーの割り当てはMAPに残す
- 読み出した値は、記録にある選択構成のキーだけへ戻す。通信上のアドレスからPHYキーを推定しない
@@ -185,17 +317,23 @@ MAP P-3 の問いを、モック用に次のように固定する。
- 「現在のMAP」「出力時のMAP」「最後に読んだ値」を分けて持つ。読み出しは差分を見せてから取り込む。自動同期はしない
### T-25 非排他キーの接続先の衝突判定
ダイオードがあれば、接続先を(アノード側のピン, カソード側のピン)の順序付きの組として比べる。ダイオードがなければ順序のない組として比べる。
理由: 順序のない組で比べると、Cr0wn60のようなcharlieplexingが常にエラーになる([discrepancies.md](discrepancies.md) D-5)。
### T-26 MCUのピン名
提供者の `part` は1種類(RP2040)だけをモックに組み込み、ピン名(`GP0`〜`GP29`)を候補として出す。知らない `part` は自由入力にする。
提供者のIDに `.` を含めない(`PinRef` の区切りと衝突するため)。
### T-27 reparentの計算
ワールド変換を使って `新しいローカル変換 = (新しい親のワールド変換)⁻¹ · 旧ワールド変換` とする。
仕様の式を、親が兄弟でない場合にも当てはまる形で実装する([discrepancies.md](discrepancies.md) D-7)。
reparent・reframeの前後で、絶対位置の差が1e-9U以下であることをテストで確かめる。
### T-28 スナップ
ドラッグと増減ボタンは、操作開始時の値から0.25U・15度刻み。数値の直接入力はグリッド外の値も受け付ける。計算結果は丸めない。
移動(ドラッグ・矢印キー)はグリッドの刻みに従う。刻みはエディタの状態として持ち、候補は 1U・0.25U・0.125U・1/12U、初期値は0.25U。
サイズは0.25U、回転は15度刻みで固定する。いずれも操作開始時の値から刻む。
数値の直接入力はグリッド外の値も受け付ける。計算結果は丸めない。
+42 -9
View File
@@ -10,40 +10,73 @@
- キーの上を順になぞって配線する。ピンの指定は、なぞる前でも後でもできる。
- 通常のマトリクスは、rowだけ・colだけを分けてなぞれるようにする(rowモード / colモード)。
- 編集中は、片方の端子だけ配線した状態を保存できる。ELCの端子を省略可能にし、片側だけのキーは検証で拾う。
ELCの保存形式の変更になるので、本決定は別途行う。
ELCの保存形式の変更になるので、本決定は別途行う。
- 倍マトリクスでは、同じrow/colの組が編集中に重なってよい。ダイオードの向きで区別するので衝突にはならない(T-25)。
重なった場合も保存は止めない(T-16)。
重なった場合も保存は止めない(T-16)。
### 倍マトリクス
- 倍マトリクスモードは配線補助の設定であり、ボードの属性ではない。ELCには保存しない(ELCはトポロジを区分として持たない)。
- 途中保存したプロジェクトで設定を復元できるよう、プロジェクト内のエディタの状態として保存する。
- Generatorの出力設定を作るときの初期値に使える。常には連動させない(走査方式を決めるのは出力設定)。
倍マトリクスの出力はGeneratorが未対応(T-19)なので、効くのは対応後。
- rowモード/colモードのどちらで作業中かは保存しない。
- エディタはrow/colやCOL2ROWの概念を持たない。順/逆の解釈はGeneratorが行う。エディタ内の規約は次の2つだけ。
- rowモードでなぞると端子a、colモードでなぞると端子bに書く。
- なぞったときの既定の向きは `a-to-b`。
- 倍マトリクスモードでなぞったとき、同じピンの組を持つキーが既にあれば、新しいキーの向きを反転する。
- 排他なキー(レイアウトバリアント)との重なりは数えない。排他キーは同じ組を同じ向きで共有するのが正しい。
- 判定は両端子が揃った時点で行う。rowモードとcolモードに分けてなぞる場合は、後から付いた端子の時点。
- 配線し直すキー自身の旧配線は数えない。
- 両方の向きが既に埋まっている場合は反転せず、衝突として警告する。なぞる操作は止めない(T-16)。
- モードの切り替えでは既存の配線を書き換えない。モードはこれからなぞる操作の補助だけを決める。
- 切り替えた時点では「どちらが二個目か」を決める順序がない(なぞった順はELCに残らない)。
- 既存の重なりの振り分けは、切り替えとは別の一回限りのコマンドにする。
- 倍から通常へ戻しても配線は変えない。向きの不揃いは検証で拾う。
- 向きを変える操作を2つ持つ。どちらも、選択中のキーの向きが混ざっていても、相方を選んでいてもいなくてもよい。
- 相方と交換: 選んだキーの(順序のない)ピンの組を重複なく集め、その組を持つ全キーの向きを反転する。
組ごとに1回だけ反転するので、相方を同時に選んでいても二重に反転しない。排他キーも一緒に反転する。
相方のいないキーは単に反転する。
- このキーだけ反転: 選んだキーだけを反転する。同じ向きで衝突している組は、交換では解消できないため。
- 向きは三角形のオーバーレイで見せる。ダイオード記号ではないのでカソードの棒は付けず、`a→b` の向きだけを表す。
相方のいるキーといないキーの区別も見えるようにする(塗り/白抜きなど)。
### ピン提供者
- ボード(Pro Micro互換など、MCUを載せた開発基板)も提供者として扱いたい。多くの利用者はチップではなくボードを使う。
現行の `mcu` 提供者はチップの品番だけを持つ。
現行の `mcu` 提供者はチップの品番だけを持つ。
### LEDチェーン
- per-key LEDとアンダーグローを別チェーンにできること。仕様上はできる(チェーンに入れるLEDは設計者が選ぶ)。
モックでできないのは下記「モックの実装上の問題」による。
モックでできないのは下記「モックの実装上の問題」による。
- チェーン内の順番を指定できること。配線しやすい向きがあるため。キーの配線と同じく、なぞって指定する。
## 要検討
- 倍マトリクスの向きの指定方法(選択範囲だけダイオードの向きを逆にする、など)。
- 二乗マトリクス(同じピンがrowにもcolにも現れ、向きはダイオードだけで区別する)の操作。通常のマトリクスと同じ操作で足りるか。
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
倍マトリクスの操作(自動反転・交換)が使えそうだが、いったん別に検討する。統合は両方の検討後に、矛盾がないかを確認して行う。
- directの操作。row/colの指定がなく、片方の端子は電源レールになる。
- mux・シフトレジスタを使う場合の操作。rowかcolの一方を親の提供者の論理ピンにする形になるか。
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
- ボードを提供者にしたときの形。ボードのピン名とMCUのピンの対応、外に出ていないピンの扱い、分割キーボードでの使い方。
- 対応している提供者を図付きで見せる。ボードのピン配置図と一緒に考える。
- ピン名の候補の一覧が「今使えるピン」と誤解される問題。ダイアログに分けるなど。ボードを提供者にすれば、外に出ていないピンは候補から消える。
- GPIOに直結したLED(インジケータなど)。仕様にないので `backlog/README.md` のELC-P-4とした。
## ELC Editorの範囲外
- Generatorのプレビューが倍マトリクスを「未対応」にできない。T-19の検査は両端子が `rowPins`/`colPins` に分かれているかだけを見て、
向きが揃っているかを見ない。倍マトリクスの配線は検査を通り、同じ(row, col)を持つキーが2つある設定を出力してしまう。
単純なマトリクスに限る(ELC-P-3)なら、向きが揃っていることも検査条件に要る。
## モックの実装上の問題
作り直しで解消する前提で、原因の記録だけ残す。
- 選ばれていないバリアントのキーを配線できない: 出力用の選択(`output.variantSelection`)で絞ったキーだけを表示し、
ELC画面に選択を変えるUIがない(IDX・Generatorのタブでしか変えられない)。仕様では全バリアントのキーを配線する。
ELC画面に選択を変えるUIがない(IDX・Generatorのタブでしか変えられない)。仕様では全バリアントのキーを配線する。
- per-key LEDが意図しないチェーンに入る: 配線用のキーボード表示のクリック(`clickKey`)が、編集中のチェーンにそのキーを追加する。
1つのLEDが複数のチェーンに入っても検査しない。
1つのLEDが複数のチェーンに入っても検査しない。
- 一括配線はキーをクリックで選ぶ方式で、配線の順はクリック順ではなくPHYの表示順になる。
+51 -11
View File
@@ -14,18 +14,58 @@
- n < i のとき、キーは位置iに入り、元のn+1〜iは1つずつ前へずれる(通常のリストの移動と同じ)。
- 入れ替え: 位置iのキーを位置nのキーの上にドロップすると、2つを入れ替える。他のキーは動かない。
## 要検討
### プリセット(一括の並べ替え)
- プリセット(一括の並べ替え)の種類。候補: 分割キーボード用(左手側を先に並べる)、行と列の入れ替え。
- なぞって連番を振る操作。なぞり始めたキーの位置から連番にする。複雑な配置で役立ちそうだが、実装は重い。
- バリアント選択のドロップダウンの位置。左上に置く案。
- 欠番(番号の飛び)を作れないこと。現行仕様では、出力番号は `order` 内の位置で表し、飛ばない(`IDXレイヤー.md`「保存形式」)。
- 場面: 4x4の右下(11, 15)をoption A、縦2UキーをBにする。2Uを `order` の末尾に置くと、Bでは12〜14が1つずつ前へずれる。
「2Uを末尾にする」と「バリアント外のキーの番号をずらさない」を両立するには、11を欠番にするしかない。
- 番号を詰める方式は2026-09-22に決めた(`journal/2026-09-22-idx-variant-output.md`)。番号の互換性はバリアントを選んだ構成ごとに扱い、
別の構成で同じ番号が同じキーを指すことは保証しない。ただし、そのときの例は1対1の排他キー(ANSI/ISO)だけだった。
- 今の仕様のままなら、2Uを11の位置に置けば12〜14はずれない(QMKのテンキーの+キーも上端の位置に置く慣習)。
- 構成をまたいで番号が揃っていてほしい利用場面(手書きのkeymap.cを共有する、など)があるかを確認してから扱いを決める。
- 種類ごとに個別のプリセットを作らず、3つの軸の組み合わせで表す。
- 対象: 全体 / 選択範囲
- 走査: 行優先 / 列優先 / 半身ごと(左手側を先に並べる) / 行ごとに左右交互 / マトリクス順(ELCのrow → col)
- 向き: 通常 / 左右逆。半身ごとに指定できる(右手側だけ逆、など)
- 既存の定義(QMK `keyboard.json` の `layout[]`、VIA定義、KLEのキー順)からの取り込みは、上の組み合わせとは別の操作にする。
既存基板の番号を変えずに引き継ぐためのもの。
- 行と列の判定: そろっている軸は座標でまとめ、ずれている軸は隣とのつながりでたどる。
行と列の両方をつながりでたどろうとすると、互いが互いを必要として循環するため。
- カラムスタッガード: 列はx座標でまとめる。行は、隣の列のキーのうちy座標が最も近いキーを同じ行とする。
ロウスタッガードは縦横を入れ替える。格子配列はどちらでもよい。どちらの軸がそろっているかは座標から判定する。
- 上から何番目かで数える方式は、端の列だけ行数が違う配列でずれる。
- 隣り合う列の間の対応は1対1にする。行は対応のつながりで決まるため、1か所の誤りで2つの行が1つに合体する
(同じ列の2キーが、隣の列の同じキーに対応した場合)。
- 縦長のキー(縦2U、ISO Enter)は上端の行、横長のキー(スペースバーなど)は左端の列に属させる。外れたら手で直す。
- 左右の半身の間の隙間も、隣り合う列の1組として対応付ける。左右対称の配置なら内側の列同士のy座標がそろうので、これで行が対応する。
- 判定は完全でなくてよい。プリセットは `order` に一度書き込むだけで、外れても直せばよい。
強いスタッガードで列ごと1行ずれる、回転した親指クラスター、左右非対称の配置などは、下のプレビューで直す。
- 適用前のプレビュー: 判定した行(列)をキャンバス上に表示し(同じ行を線で結ぶ、色分けする、など)、外れに気づけるようにする。
列を選んで↑/↓で行の割り当てを1行ずらせる。割り当てはこの操作中だけの状態で、保存しない。
- 適用後に `order` 上でずらす方式は取らない。`order` はどこからどこまでが1行分かを持たないため。
- 残りの細かい修正は、選択範囲への適用とドラッグでの並べ替えで行う。
### なぞって連番を振る操作
- 後回しにする。プリセット・選択範囲への適用・ドラッグで足りないのは、行がはっきりしない配置(放射状など)に限られる見込み。
- 入り口として、「キーの列を受け取り、先頭キーの位置から連番で並べ直す」を共通の基本操作にする。
プリセットは走査でキーの列を作って渡す。なぞる操作や「順にクリックして番号を振る」は、後から同じ操作に乗せる。
- 排他キー: `order` では既に別々の要素であり(`IDXレイヤー.md`「レイアウトバリアント」)、選択後に同時には残らない。
そのため排他キー同士の前後はどの出力番号にも影響せず、決め方は任意でよい(保存データの差分が安定すれば足りる)。
出力番号に効くのは、複数行にまたがるキー(縦2U、ISO Enter)を共通キーに対してどの行に置くか。上端の行に置く(「欠番」の項目)。
### 欠番
- 欠番(番号の飛び)は作らない。現行仕様(`IDXレイヤー.md`「保存形式」)のままとする。
構成をまたいで番号がそろっていてほしい利用場面を、今は挙げられないため。
- 場面: 4x4の右下(11, 15)をoption A、縦2UキーをBにする。2Uを `order` の末尾に置くと、Bでは12〜14が1つずつ前へずれる。
2Uを11の位置に置けば12〜14はずれない(QMKのテンキーの+キーも上端の位置に置く慣習)。
- 構成をまたいで番号をそろえたい場面が出てきたら考え直す。
### バリアント選択
- 左ペインを、VariantGroupとバリアントのツリーにする。入れ子のグループは親バリアントの下に置く。
PHY Editorの左ペインのツリーと見た目をそろえる。クラスターは出さない。
- キャンバスには選択した構成のキーだけを表示し、その上で並べ替える。
全バリアントを表示すると、排他キー(ANSI/ISOのEnterなど)が同じ場所に重なって操作できないため。
- 隠れたキー(選ばれていないバリアントのキー)は、同じVariantGroupで選択中のバリアントのキーにくっつける。
- 例: `order` が `[a, h, b]` で、hがISO Enter、aがANSI Enterとする。
aとbの間へxをドロップすると `[a, h, x, b]` になり、hが見える構成でも「xはEnterの後」という意図が保たれる。
- 両隣とも同じVariantGroupでなければ、手前のキーにくっつける。
- 表示中のキーを別の場所へ動かしても、隠れたキーはその場に残る。
## モックの実装上の問題
+2 -2
View File
@@ -59,9 +59,9 @@ Remapの現状は末尾の「参考: Remapのキーマップ編集」にまと
- レイヤー系(`TO/TG/MO/OSL/TT/DF`、`LM()`)は、レイヤー数の分だけ展開した候補を並べる。
- 一覧にあるもの(BASIC・LAYER以外の主なもの):
- FUNCTIONS: Combo On/Off、Auto Shift、VIA User Key、Programmable Button、
User、Leader、Dynamic Macro、Macro 0〜15
User、Leader、Dynamic Macro、Macro 0〜15
- SPECIAL: One Shot、Key Override、Tapping Term、Auto Correct、Repeat Key、
Key Lock、Caps Word、Steno、Unicode Mode
Key Lock、Caps Word、Steno、Unicode Mode
- Tap Dance(`TD()`)は一覧にも検索にも見つからなかった。
- 変更済みの表示: キーとレイヤー番号に点が付く。上部に「元 > 新」の差分とキャンセルが出る。書き込みボタンで変更をまとめて実機へ反映する。
- そのほか: キーマップの保存・共有(Mine / Shared / History)、PDFのキーマップ表、Layout Option、Test Matrix、マクロエディタ、ライティング設定。
+98 -24
View File
@@ -1,38 +1,105 @@
# PHY Editor モックの所感(画面仕様の入力)
## 作り直し1回目(T-29)を触った所感
**仕様ではない。** 括弧内の「モックでは」はコードで確かめた現状。
### 選択の表示
- 選択枠が細くて見づらい。もっと太くする。
- 選択枠が、後から描かれた隣のキーの下に隠れる。選択枠は他のキーより手前に描く。
### キャンバス操作
- キーをドラッグして画面の端まで持っていっても、キャンバスがスクロールしない。
### キーの追加
- 追加したキーが既存のキーに重なる(モックでは、最後に選んだキーの右隣、同じ親の中に置く)。
- 案: IDXで出た行の判定規則を流用し、選択中のキーの行の右端に追加する。
- ロウスタッガードでもカラムスタッガードでも同じ規則を使う。カラムスタッガードでは、追加したキーが
想定の縦ずれと違う位置に出ることがあるが、利用者が直せばよい。
### クラスターの表示
- クラスター名だけでなく原点も表示したい。原点に丸などを出す。
- 選択中のクラスターだけに出す。選択していないクラスターにも出すと、画面がごちゃごちゃする。
### 長押しでの選択の切り替え
- 切り替えが思ったより早い。もう少し余裕を持たせる(モックでは、押してから500msで始まり、450msごとに次へ進む)。
- 今の見た目では分かりづらい。案:
- カーソルの周りに円形のインジケータを出し、次に切り替わるタイミングを見せる。
- 押している場所にある選択先の一覧と、今どれが選ばれているかをリストで見せる。
- リストを出すなら、ループで待つより、長押しでリストを出して自分で選ぶほうが速いかもしれない。
- 作り直しでは、ループ方式(円形インジケータとリスト付き)と、長押しでリストを出して選ぶ方式の両方を入れ、切り替えて比べる。
### バリアント
- 切り替えのポップオーバーで、どちらを表示していても Option A に印が付いている
(モックでは、印は既定のバリアントを示す。初版「バリアントの切り替え」のとおり)。
- 既定の印のままでよい。既定の印だと分かる見た目にする(「既定」と書くなど)。
- バッジの外にバリアント名がはみ出しているように見える(モックでは、バッジの枠は26px四方で、
その中に表示中のバリアント名を書いている。名前が長いと枠からはみ出す)。
- 案: 名前を書かずバッジだけにする、またはバッジの中に名前を収める。
- クラスター内のキーを選んでバリアントグループを作ると、クラスターの外に作られる。クラスターの中に作るほうがよい。
### パネル
- 「階層」ボタンで階層パネルを閉じると、キャンバスが階層パネルの幅に、プロパティ欄がキャンバスの幅に詰まり、右側に隙間が空く。
- 閉じたときは、キャンバスが広がって空いた幅を埋める。
### 表示の補助
- スイッチ位置・スタビライザー位置の想定をオーバーレイ表示したい。
- 普段は非表示にし、どこかにトグルを置く。
- 色違いの十字で示す。
- スタビライザー位置はキーの幅から推定して表示だけする(データは変えない)。
- スイッチ位置のオフセット(`switchOffset`)はPHYのデータにあるが、モックのプロパティ欄では入力できない。
スタビライザー位置はPHYのデータに無い。
- スイッチの中心がずれるキーが何種類かあるので、プロパティ欄に `switchOffset` の入力欄を置く。
ボタンやキー操作で変える手段は要らない。
### 後回し
- アイコンが分かりづらい。後で作り直す。モックの間はこのままでよい。
## 以下初版
モックを触った所感と、それを受けた議論の整理。**仕様ではない。**
画面仕様を一から作るときの入力にし、仕様へ反映したら消す。
## 方向が合意できたもの
### 方向が合意できたもの
### 選択
#### 選択
- 何を選択しているかが一目で分かること(モックは選択枠がほぼ見えない。下記「モックの実装上の問題」)。
- クリックでの追加選択は1件ずつのトグルのみ(連続選択の「順番」は作らない)。
- 複数選択はキャンバス上のドラッグ(範囲選択)でもできる。
- キャンバスでクリックすると、それまでの選択によらずキーが選ばれる。
- クラスターは長押しで選ぶ。押している間、一定間隔で選択先が直近の親から順に上がり、
一番上の親の次はキーに戻ってループする。離したときの状態で選択が確定する。途中の選択先はインジケータで示す。
一番上の親の次はキーに戻ってループする。離したときの状態で選択が確定する。途中の選択先はインジケータで示す。
- Shift+長押しで、クラスターを選択に足せる。
- 親とその配下は同時に選択しない(変換が二重にかかるだけで、使い道がない)。
- クラスターは長押しのほか、階層パネルとキー操作(親へ上がる・子へ降りる)でも選べる。長押しはヘルプで案内する。
- タッチ操作(長押しが標準のメニューと重なる)は当面考えない。
- 何もない場所のクリック・Escで選択を外せる。
- 何もない場所のクリック・Escで選択を外せる。クラスターの矩形の内側でもキーのない隙間は何もない場所として扱い、
クリックで選択を外し、ドラッグで範囲選択にする。
### クラスター
#### クラスター
- クラスターの当たり判定・選択表示は子を囲む矩形。クラスターのローカル座標で取る(回転に追従するOBB)。
- 階層(ツリー)パネルはクラスター・VariantGroupの選択と構造の確認に必要。キーの一覧としては不要なので、キーは畳むなど目立たせない。
- VariantGroupはクラスターの一種という認識。移動・回転の「親」にVariantGroupも含む。
### フォーカスモード
#### フォーカスモード
- 特定のクラスター内だけを編集対象にし、他を薄く表示するモードを作る。
- 入る: クラスターを選んだ状態でダブルクリック。ダブルクリックを始めた時点で選ばれていたクラスターに入る
(1回目のクリックで選択がキーに移っても、入る先は変わらない)。キーボードではEnter。
(1回目のクリックで選択がキーに移っても、入る先は変わらない)。キーボードではEnter。
- 出る: Esc。選択があるときのEscはまず選択を外し、選択がない状態のEscでフォーカスを抜ける。
### 移動・回転・サイズ
#### 移動・回転・サイズ
- 移動・サイズ変更・削除をキーボードで操作できる。削除は参照の確認(T-15)を省かない。確認は操作した場所の近くに出す。
- 矢印キーでの移動方向は、直近の親(クラスター・VariantGroup)の軸方向。ドラッグと同じ。
@@ -40,57 +107,64 @@
- 複数選択に対して回転以外の操作(移動など)もできる。
- 複数キーを共通の中心で回したいなら、それはクラスターをなす。選択ノードを個別に回転する操作はそれとして残す。
### 表示
#### 表示
- キャンバスは画面サイズに追従する(モックは1152×896px固定)。
- 読み込み時は全体が収まり中央に来るようにズームする。同じ操作をボタン・キーでも呼べる(全体表示)。
- ボタンはアイコン化したい。ツールチップを付ける。
### 呼び方と見出し
#### 呼び方と見出し
- PHYの要素は、キー・クラスター・部品・バリアントグループ・バリアントと呼ぶ(仕様書の呼び方に揃える)。
「レイアウト」は配置全体を指す語(QMKの `LAYOUT_*`、KLE)とぶつかるので、バリアントの呼び名に使わない。
「レイアウト」は配置全体を指す語(QMKの `LAYOUT_*`、KLE)とぶつかるので、バリアントの呼び名に使わない。
- 階層の最上位は root と呼ぶ(フォーカス中のパンくずなど)。
- 別の親へ移す操作(reparent)は「移し先…」とし、行き先は候補の一覧(root・クラスター・バリアント)で見せる。
「移動」は座標の移動と紛れるので使わない。
「移動」は座標の移動と紛れるので使わない。
- 見出しを付けすぎない。操作ボタンはキャンバスの上に置き、階層パネルは木だけにして見出しを付けない
(開閉ボタンのツールチップは「階層」)。キャンバスにも見出しを付けない。
(開閉ボタンのツールチップは「階層」)。キャンバスにも見出しを付けない。
- プロパティ欄の項目名はモックのままでよい。
### バリアントの切り替え
#### バリアントの切り替え
- キャンバス上で、バリアントグループの右上に小さなバッジを常に出し、クリックで開くポップオーバーからバリアントを選ぶ。
バリアントがどこにあるかを一目で分かるようにするため、選択中だけにしない。
バリアントがどこにあるかを一目で分かるようにするため、選択中だけにしない。
- バッジはグループの座標系(OBB)の右上に置く。文字は正立させ、大きさは画面上で固定する。フォーカス外では薄く表示する。
- キーでは V / Shift+V で次・前のバリアントへ送る。バリアントグループを1つ選んでいるときだけ効く。
- 切り替えで隠れたノードの選択は、そのバリアントグループに移す。
- ポップオーバーでは既定のバリアントを印で示すだけにする。既定の変更とバリアントの追加・削除はデータを変えるので、
表示の切り替えと分けてプロパティ欄や操作ボタンで行う。
表示の切り替えと分けてプロパティ欄や操作ボタンで行う。
## 要検討
#### コピー&ペースト
### モックで試して決める
- IDと参照の扱いは [仮決定 T-11](decisions.md#t-11-id)。
- 貼り付け・複製したノードは、選択中のノードと同じ親に、元の位置からグリッド1刻みずらして置く。
選択がなければフォーカス先(フォーカス中でなければ root)の直下に置く。
- コピーした内容はアプリ内に持つ。システムのクリップボードからはKLE JSONだけを読む(別のプロジェクトへの貼り付けは当面作らない)。
- クリップボードのKLE JSONを貼り付けで取り込める。幾何だけを読み、新しいIDを振る(KLE取り込みと同じ経路で、手元のデータとは照合しない)。
### 要検討
#### モックで試して決める
触って比べないと決まらないもの。
- 長押しの詳細: 選択先が上がる間隔、インジケータの見た目、長押しとドラッグの切り分け(長押しで階層を選んでからドラッグに移れるか)。
- 個別回転とクラスター化してからの回転のどちらを主な操作にするか。
- キー割り当て。叩き台をモックに入れて試す。叩き台と残っている問いは [phy-editor-keybindings.md](phy-editor-keybindings.md)。
- グリッドの刻みの候補値。0.25U刻みはズーム1倍で16pxずつ飛び、ドラッグでの細かい調整がしづらい。
- グリッドの刻みの候補値の過不足。初期の候補は [仮決定 T-28](decisions.md#t-28-スナップ)。
- どこまでアイコンのみのボタンにするか。
- ホイールでのカーソル中心ズーム、ホバー時の強調(モックに無い。未議論)。
- バリアントのバッジが隣のバッジやキーと重ならないか。V をグループ内のキーを選んでいるときにも(いちばん近いグループに)効かせるか。
### 画面仕様までに決める
#### 画面仕様までに決める
試さなくても決められる、または試す前に決めておく必要があるもの。
- タブ名と検証結果欄の呼び方。PHY・ELCなどのレイヤー名は設計上の仮称で、画面に出すかどうかは別途決める。
全エディタに共通するので、PHY Editorとは別に扱う。
- コピー&ペースト(モックに無い。未議論)。新しいIDの振り方と他の層からの参照の扱いはデータモデルの問いで、
メインドキュメント側に置くべきものかもしれない。
全エディタに共通するので、PHY Editorとは別に扱う。
- 別のプロジェクトへ貼り付けられるようにする場合、貼り先に同じIDのProfileがない、またはIDが同じで中身が違うときの扱い。
## モックの実装上の問題
### モックの実装上の問題
作り直しで解消する前提で、原因の記録だけ残す。
+2 -3
View File
@@ -47,7 +47,7 @@ Mac では Ctrl を Cmd に読み替える。1文字のキー(R・G・V など)
| --- | --- | --- |
| 削除 | Delete / Backspace | Mac のキーボードに Delete キーがないため両方。参照の確認は省かない(合意済み) |
| 複製 | Ctrl+D | |
| コピー・カット・ペースト | Ctrl+C / Ctrl+X / Ctrl+V | キーだけ確保する。新しい ID と他の層からの参照の扱いは未議論 |
| コピー・カット・ペースト | Ctrl+C / Ctrl+X / Ctrl+V | ID と他の層からの参照の扱いは [decisions.md](decisions.md#t-11-id) の T-11 |
| キーを足す | Ins | KLE と同じ |
### E. 表示(データは変わらず、保存もしない)
@@ -57,7 +57,7 @@ Mac では Ctrl を Cmd に読み替える。1文字のキー(R・G・V など)
| 全体表示 / 選択範囲にズーム | Shift+1 / Shift+2 | Figma と同じ。`code` で判定する |
| ズームイン・アウト | `+` / `-` | Ctrl+± はブラウザのズームなので取らない |
| フォーカスモードに入る・出る | Enter / Esc | 合意済み |
| グリッドの刻みを切り替える | G / Shift+G | |
| グリッドの刻みを切り替える | G / Shift+G | 候補と初期値は [decisions.md](decisions.md#t-28-スナップ) の T-28 |
| 表示するバリアントを切り替える | V / Shift+V | 選んでいる VariantGroup の次・前の Variant へ送る |
| 表示のオン・オフ、階層パネルの開閉 | なし | 頻度が低い |
@@ -76,7 +76,6 @@ Mac では Ctrl を Cmd に読み替える。1文字のキー(R・G・V など)
### 残っている問い
- グリッドの刻みの候補値(1U・0.25U と、それより細かい値をいくつ用意するか)。モックで試すか、他者のフィードバックで決める。
- Ins は Mac のキーボードにない。Mac での代わり。
- Ctrl+D(Chrome のブックマーク)をページ側で横取りできるか。