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
45 changed files with 4249 additions and 578 deletions
@@ -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。
+108 -20
View File
@@ -1,41 +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("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => {
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(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("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();
const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id");
await page.getByRole("button", { name: "IDX", exact: true }).click();
const row = page.getByTestId(`idx-row-${keyId}`);
await expect(row).toBeVisible();
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
await expect(previewKey).toHaveText("16");
await row.getByRole("button", { name: `${keyId}を上へ` }).click();
await expect(previewKey).toHaveText("15");
await page.getByRole("button", { name: "Undo" }).click();
await expect(previewKey).toHaveText("16");
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("output numbers recalculate after switching to a variant that excludes ordered keys", async ({
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 keys = page.locator('[data-node-type="key"]');
const firstId = await keys.nth(0).getAttribute("data-node-id");
const secondId = await keys.nth(1).getAttribute("data-node-id");
await keys.nth(0).click();
await keys.nth(1).click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" });
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
await expect(remaining).toHaveCount(1);
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");
});
+306 -10
View File
@@ -15,6 +15,63 @@ async function makeCluster(page: import("@playwright/test").Page) {
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) => {
@@ -38,6 +95,43 @@ test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーが
expect(errors).toEqual([]);
});
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 () =>
@@ -107,7 +201,7 @@ test("長押しでクラスターを選び、ダブルクリックでフォー
const b = await key.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
await page.mouse.down();
await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 });
await 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();
@@ -151,7 +245,7 @@ test("入れ子の回転クラスターをフォーカスできる", async ({ pa
await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
await page.mouse.down();
await page.waitForTimeout(700);
await expect(page.locator(`[data-node-id="${innerId}"] .group-sel.candidate`)).toBeVisible({
await expect(page.locator(`[data-sel-id="${innerId}"].candidate`)).toBeVisible({
timeout: 1800,
});
await page.mouse.up();
@@ -164,17 +258,18 @@ test("入れ子の回転クラスターをフォーカスできる", async ({ pa
});
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
test.setTimeout(60_000);
const keys = page.locator('[data-node-type="key"]');
await keys.first().click();
const outline = page.locator('[data-node-type="key"] .sel').first();
const outline = page.locator(".selection-layer .sel").first();
await expect(outline).toBeVisible();
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("2px");
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 = group.locator(".badge");
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
@@ -199,6 +294,37 @@ test("選択枠は画面上で見え、バリアントバッジで表示を切
await expect(keys).toHaveCount(16);
});
test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", async ({
page,
}) => {
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");
@@ -210,7 +336,8 @@ test("削除確認は参照を列挙し、実行後はIDXからも消える", as
await confirm.getByRole("button", { name: "削除", exact: true }).click();
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
await page.getByRole("button", { name: "IDX", exact: true }).click();
await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0);
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 }) => {
@@ -412,7 +539,7 @@ test("削除のキーボード確認とバリアントポップオーバーを
await page.keyboard.press("Escape");
await keysSelectVariantGroup(page);
const badge = page.locator('[data-node-type="variantGroup"] .badge').first();
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();
@@ -456,20 +583,189 @@ test("長押し候補は離すまで選択を変えず、離すとクラスタ
const clusterId = await cluster.getAttribute("data-node-id");
const keys = page.locator('[data-node-type="key"]');
await keys.nth(4).click();
await expect(keys.nth(4).locator(".sel")).toBeVisible();
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(".long-label")).toContainText("2/", { timeout: 1800 });
await expect(keys.nth(4).locator(".sel")).toBeVisible();
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 } },
];
+23 -26
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 {
@@ -75,7 +76,7 @@ export function addKeyToParent(
f: PhyFile,
parentId: string | null,
at: Transform,
size: { w: number; h: number },
size: Size,
profile = f.profiles[0]!.id,
gen?: () => string,
): PhyFile {
@@ -114,9 +115,12 @@ export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[],
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
if (!parentId) return f.root;
const n = find(f, parentId);
if (n.type === "cluster") return n.children;
if (n.type === "variantGroup") throw new Error("Choose a variant as the parent");
// 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;
}
/** ローカル座標の差分でノードを移動する。 */
@@ -272,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")
@@ -397,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) => ({
@@ -405,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,
+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 };
}
+8 -6
View File
@@ -9,7 +9,7 @@ import { exportKle } from "../io/kleExport";
import { defaultLayout } from "./defaultLayout";
import type { Dispatch } from "./types";
import PhyEditor from "./phy/PhyEditor.vue";
import IdxPanel from "./panels/IdxPanel.vue";
import IdxEditor from "./idx/IdxEditor.vue";
import ElcPanel from "./panels/ElcPanel.vue";
import MapPanel from "./panels/MapPanel.vue";
import GeneratorPanel from "./panels/GeneratorPanel.vue";
@@ -186,12 +186,14 @@ function exportSelected() {
: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"
+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>
+13 -113
View File
@@ -1,42 +1,18 @@
<script setup lang="ts">
import type { Node } from "../../domain/phy";
import { localBounds } from "./logic";
import { keyPath } from "./logic";
defineOptions({ name: "NodeSvg" });
const props = defineProps<{
defineProps<{
nodes: Node[];
selected: string[];
variants: Record<string, string>;
cut: string[];
dim: boolean;
zoom: number;
angle: number;
longCandidate?: string | null;
}>();
const emit = defineEmits<{
down: [e: PointerEvent, n: Node, el: SVGGElement];
badge: [g: Node, e: PointerEvent];
focus: [id: string];
}>();
function bounds(n: Node) {
return n.type === "cluster"
? localBounds(n.children, props.variants)
: n.type === "variantGroup"
? localBounds(
n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [],
props.variants,
)
: { x: 0, y: 0, width: 0, height: 0 };
}
function badgeBox(n: Node) {
const b = bounds(n);
const size = 26 / props.zoom;
return {
x: b.x + b.width + 4 / props.zoom,
y: b.y - size - 4 / props.zoom,
size,
angle: -(props.angle + n.transform.rotation),
};
}
</script>
<template>
<g
@@ -52,23 +28,20 @@ function badgeBox(n: Node) {
v-if="n.type === 'key'"
class="node"
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
><rect
><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
><rect
v-if="selected.includes(n.id)"
:width="n.size.w"
:height="n.size.h"
class="sel"
:class="{ candidate: longCandidate === n.id }"
/></g>
: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"
@@ -115,20 +88,8 @@ function badgeBox(n: Node) {
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
>
<g v-else class="group"
@@ -140,40 +101,9 @@ function badgeBox(n: Node) {
:variants="variants"
:cut="cut"
:dim="dim"
:zoom="zoom"
:angle="angle + n.transform.rotation"
:long-candidate="longCandidate"
@down="(e, c, el) => emit('down', e, c, el)"
@badge="(g, e) => emit('badge', g, e)"
@focus="(id) => emit('focus', id)"
/><rect
v-if="selected.includes(n.id) || longCandidate === n.id"
:x="bounds(n).x"
:y="bounds(n).y"
:width="bounds(n).width"
:height="bounds(n).height"
class="group-sel"
:class="{ candidate: longCandidate === n.id }"
/><g
:transform="`rotate(${badgeBox(n).angle} ${badgeBox(n).x} ${badgeBox(n).y})`"
class="badge"
@click.stop="emit('badge', n, $event)"
><rect
:x="badgeBox(n).x"
:y="badgeBox(n).y"
:width="badgeBox(n).size"
:height="badgeBox(n).size"
rx="3"
@click.stop="emit('badge', n, $event)"
/><text
:x="badgeBox(n).x + 3 / zoom"
:y="badgeBox(n).y + 16 / zoom"
:style="{ fontSize: `${12 / zoom}px` }"
>{{ n.children.find((v) => v.id === (variants[n.id] || n.default))?.name
}}{{ n.default === (variants[n.id] || n.default) ? " ✓" : "" }}</text
></g
></g
>
/></g>
</g>
</template>
<style scoped>
@@ -216,21 +146,6 @@ function badgeBox(n: Node) {
stroke: #315d7a;
stroke-width: 0.03;
}
.sel,
.group-sel {
fill: none;
stroke: #1769d2;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.group-sel {
stroke-dasharray: 5 3;
}
.candidate {
stroke: #e87912;
stroke-width: 2.5px;
}
.group {
cursor: default;
}
@@ -243,19 +158,4 @@ function badgeBox(n: Node) {
.group-label {
fill: #222;
}
.badge {
cursor: pointer;
pointer-events: all;
}
.badge rect {
fill: #fff3c2;
stroke: #8a691b;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
}
.badge text {
font-size: 12px;
fill: #302709;
pointer-events: none;
}
</style>
+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>
+95 -37
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
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";
@@ -7,12 +7,27 @@ 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 { normalizeSelection, nextGrid, insertionContainer, structuralNodes, visibleNodeIds } from "./logic";
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";
@@ -74,7 +89,6 @@ const {
} = usePhyTreeModel(() => props.project, focus);
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
project: () => props.project,
pane,
svg,
size,
zoom,
@@ -117,7 +131,6 @@ const {
focusGridLines,
moveTargets,
parentMatrix: focusParentMatrix,
focusAngle,
affineText,
focusCrumbs,
propertyNode: prop,
@@ -188,6 +201,7 @@ const {
tabItem,
chooseVariant,
keyAdd,
presetAdd,
partAdd,
makeCluster,
ungroup,
@@ -217,6 +231,7 @@ 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,
@@ -236,12 +251,13 @@ const pointer = usePhyCanvasPointer({
isAncestor,
parentLabel,
pressTargets,
holdMode,
setSelection: setSel,
beginHistory: props.beginHistory,
commitHistory: props.commitHistory,
dispatch,
});
const { canvasDown, down, spaceUp, longLabel, longLabelPos, spaceHeld, holdCandidates, holdIndex } = pointer;
const { canvasDown, down, spaceUp, hold, spaceHeld } = pointer;
const keyHandler = usePhyKeyboard({
project: () => props.project,
mismatch: () => props.mismatch,
@@ -292,6 +308,15 @@ function openBadge(g: Node, e: PointerEvent | MouseEvent) {
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 &&
@@ -301,7 +326,7 @@ function onFocus(_id: string) {
}
</script>
<template>
<section class="workspace" :class="{ locked: mismatch }">
<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>
@@ -313,7 +338,8 @@ function onFocus(_id: string) {
:move-targets="moveTargets"
:focus="focus"
:crumbs="focus ? focusCrumbs(focus) : []"
@key-add="keyAdd"
@key-add="keyAdd()"
@preset-add="presetAdd"
@part-add="partAdd"
@cluster="makeCluster"
@ungroup="ungroup"
@@ -328,14 +354,20 @@ function onFocus(_id: string) {
@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="longLabel"
class="long-label"
:style="{ left: longLabelPos.x + 'px', top: longLabelPos.y + 'px' }"
>
{{ longLabel }}
</div>
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
<svg
ref="svg"
@@ -359,8 +391,6 @@ function onFocus(_id: string) {
:variants="variants"
:cut="cut"
:dim="true"
:zoom="zoom"
:angle="0"
/>
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
<g
@@ -374,11 +404,7 @@ function onFocus(_id: string) {
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="focusAngle(focus)"
@down="down"
@badge="openBadge"
@focus="onFocus"
/>
</g>
@@ -389,13 +415,39 @@ function onFocus(_id: string) {
:variants="variants"
:cut="cut"
:dim="false"
:long-candidate="longLabel ? (holdCandidates[holdIndex] ?? null) : null"
:zoom="zoom"
:angle="0"
@down="down"
@badge="openBadge"
@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"
@@ -425,7 +477,7 @@ function onFocus(_id: string) {
>
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
{{ v.name }} {{ g.default === v.id ? "✓" : "" }}
{{ v.name }}<span v-if="g.default === v.id" class="default-tag">既定</span>
</button></template
>
</div>
@@ -444,7 +496,12 @@ function onFocus(_id: string) {
フォーカス · F2 プロパティ
</p>
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
<p>長押し: キー上で500ms待つと選択先候補が450msごとに進み、離すと確定します。</p>
<p>
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
HOLD_INTERVAL
}}msごとに進み、離すと確定します。
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
</p>
</article>
</div>
</section>
@@ -459,6 +516,8 @@ function onFocus(_id: string) {
@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>
@@ -469,6 +528,9 @@ function onFocus(_id: string) {
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;
@@ -572,16 +634,12 @@ function onFocus(_id: string) {
.warning {
color: #9a4c00;
}
.long-label {
position: fixed;
z-index: 3;
left: 0;
top: 0;
transform: translate(10px, 10px);
pointer-events: none;
background: #fff3d8;
border: 1px solid #c87d13;
padding: 3px 7px;
border-radius: 4px;
.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>
+113
View File
@@ -1,11 +1,14 @@
<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];
@@ -14,7 +17,38 @@ const emit = defineEmits<{
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);
@@ -94,6 +128,74 @@ const frameR = ref(0);
})
"
/></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
@@ -174,4 +276,15 @@ const frameR = ref(0);
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>
+42
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
defineProps<{
mismatch: boolean;
selectedCount: number;
@@ -6,9 +7,12 @@ defineProps<{
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: [];
@@ -23,7 +27,14 @@ const emit = defineEmits<{
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);
@@ -36,6 +47,12 @@ function addPart(e: Event) {
<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>
@@ -69,6 +86,7 @@ function addPart(e: Event) {
◇
</button>
<select
class="move-to"
aria-label="移し先…"
:disabled="mismatch || !selectedCount"
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
@@ -93,6 +111,22 @@ function addPart(e: Event) {
<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">
@@ -113,4 +147,12 @@ function addPart(e: Event) {
.tools select {
font-size: 12px;
}
.tools button.on {
background: #dbe8fb;
border-color: #1769d2;
}
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
.tools select.move-to {
width: 6em;
}
</style>
+161 -20
View File
@@ -1,5 +1,8 @@
import type { Node, PhyFile, Transform } from "../../domain/phy";
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>(),
@@ -198,25 +201,7 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
items.forEach((n) => {
const w = compose(parent, matrix(n.transform));
if (n.type === "key") {
const corners = [
{ x: 0, y: 0 },
{ x: n.size.w, y: 0 },
{ x: n.size.w, y: n.size.h },
{ x: 0, y: n.size.h },
];
if (
n.size.x2 !== undefined ||
n.size.y2 !== undefined ||
n.size.w2 !== undefined ||
n.size.h2 !== undefined
) {
const x = n.size.x2 ?? 0,
y = n.size.y2 ?? 0,
ww = n.size.w2 ?? n.size.w,
hh = n.size.h2 ?? n.size.h;
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
}
points.push(...corners.map((p) => apply(w, p)));
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 });
@@ -229,8 +214,164 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
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;
}
+114 -69
View File
@@ -1,9 +1,12 @@
import { ref, type Ref } from "vue";
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 };
@@ -25,6 +28,7 @@ type Options = {
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;
@@ -32,18 +36,14 @@ type Options = {
};
export function usePhyCanvasPointer(options: Options) {
const longLabel = ref("");
const longLabelPos = ref({ x: 0, y: 0 });
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
const spaceHeld = ref(false);
const holdCandidates = ref<string[]>([]);
const holdIndex = ref(0);
let preClickSelection: string[] = [];
let preClickTarget: string | null = null;
let preClickAt = 0;
let holdTimer: number | undefined;
let holdCycle: number | undefined;
let holdShift = false;
let holdActivated = false;
let 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: {
@@ -57,6 +57,78 @@ export function usePhyCanvasPointer(options: Options) {
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;
}
@@ -134,7 +206,7 @@ export function usePhyCanvasPointer(options: Options) {
}
preClickTarget = node.id;
preClickAt = Date.now();
holdActivated = false;
longPress.reset();
dragState = {
id: node.id,
start: { x: event.clientX, y: event.clientY },
@@ -147,24 +219,7 @@ export function usePhyCanvasPointer(options: Options) {
};
window.addEventListener("pointermove", move, true);
window.addEventListener("pointerup", up, true);
if (node.type === "key") {
holdCandidates.value = options.pressTargets(node.id);
holdIndex.value = 0;
holdShift = event.shiftKey;
holdTimer = window.setTimeout(() => {
if (holdCandidates.value.length <= 1) return;
holdActivated = true;
holdIndex.value = 1;
longLabelPos.value = { x: event.clientX, y: event.clientY };
longLabel.value = `${options.parentLabel(holdCandidates.value[1]!)} 2/${holdCandidates.value.length}`;
holdCycle = window.setInterval(() => {
holdIndex.value = (holdIndex.value + 1) % holdCandidates.value.length;
const id = holdCandidates.value[holdIndex.value]!;
const label = holdIndex.value === 0 ? "キー" : options.parentLabel(id);
longLabel.value = `${label} ${holdIndex.value + 1}/${holdCandidates.value.length}`;
}, 450);
}, 500);
}
if (node.type === "key") longPress.start(event, node.id);
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
}
@@ -184,17 +239,19 @@ export function usePhyCanvasPointer(options: Options) {
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;
if (holdTimer && !longLabel.value) {
window.clearTimeout(holdTimer);
holdTimer = undefined;
}
if (longLabel.value) {
const id = holdCandidates.value[holdIndex.value]!;
longPress.cancelPending();
if (longPress.cycling()) {
const { id, shift } = longPress.take();
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...preClickSelection, id] : [id],
shift ? [...preClickSelection, id] : [id],
);
state.ids = [id];
state.base = options.project();
@@ -203,8 +260,6 @@ export function usePhyCanvasPointer(options: Options) {
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
]);
options.beginHistory();
if (holdCycle) window.clearInterval(holdCycle);
longLabel.value = "";
} else if (!state.moved) {
const includesAncestor = preClickSelection.some(
(id) => id === state.id || options.isAncestor(id, state.id),
@@ -223,23 +278,12 @@ export function usePhyCanvasPointer(options: Options) {
options.beginHistory();
}
state.moved = true;
let phy = state.base.phy;
for (const id of state.ids) {
const parent = state.parents.get(id);
const start = state.starts.get(id);
if (!parent || !start) continue;
const current = options.localPoint(parent, event.clientX, event.clientY);
phy = moveNode(
phy,
id,
snap(current.x - start.x, options.grid.value),
snap(current.y - start.y, options.grid.value),
);
}
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
updateDraggedPosition(event.clientX, event.clientY);
scheduleAutoPan();
}
function up() {
stopAutoPan();
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", up, true);
if (panDrag) {
@@ -260,12 +304,7 @@ export function usePhyCanvasPointer(options: Options) {
for (const item of options.keys()) {
if (!scope.has(item.key.id)) continue;
const transform = worldTransform(options.project().phy, item.key.id);
const corners = [
{ x: 0, y: 0 },
{ x: item.key.size.w, y: 0 },
{ x: item.key.size.w, y: item.key.size.h },
{ x: 0, y: item.key.size.h },
].map((point) => apply(transform, point));
const 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 (
@@ -300,19 +339,16 @@ export function usePhyCanvasPointer(options: Options) {
options.rangeBox.value = null;
return;
}
if (holdTimer) window.clearTimeout(holdTimer);
if (holdCycle) window.clearInterval(holdCycle);
if (longLabel.value && holdCandidates.value[holdIndex.value]) {
const id = holdCandidates.value[holdIndex.value]!;
const pick = longPress.finish();
if (pick) {
options.selected.value = normalizeSelection(
options.project().phy,
holdShift ? [...options.selected.value, id] : [id],
pick.shift ? [...options.selected.value, pick.id] : [pick.id],
);
}
longLabel.value = "";
const moved = dragState?.moved ?? false;
if (dragState?.moved) options.commitHistory();
else if (dragState && !holdActivated) {
else if (dragState && !longPress.wasActivated()) {
const id = dragState.id;
const next = dragState.shift
? options.selected.value.includes(id)
@@ -321,25 +357,34 @@ export function usePhyCanvasPointer(options: Options) {
: [id];
options.selected.value = normalizeSelection(options.project().phy, next);
}
if (holdActivated || moved) preClickTarget = null;
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,
longLabel,
longLabelPos,
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,
holdCandidates,
holdIndex,
priorSelection,
};
}
+51 -17
View File
@@ -1,13 +1,13 @@
import type { Ref } from "vue";
import type { ProjectFile } from "../../domain/project";
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
import {
addComponentToParent,
addVariant as addVariantCommand,
addKeyToParent,
clusterNodes,
cloneNodesWithNewIds,
createVariantGroup,
createVariantGroupInParent,
insertNodes,
reparent,
reparentToVariant,
@@ -16,6 +16,8 @@ import {
} 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 = {
@@ -119,25 +121,52 @@ export function usePhyCommands(options: Options) {
options.popover.value = null;
}
function keyAdd() {
/** 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) || "");
const parent = chosen ? options.selectedContainer(chosen.id) : insertionParent();
// 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 =
Math.round(
(chosen ? chosen.transform.x + (chosen.type === "key" ? chosen.size.w : 1) : center.x) /
options.grid.value,
) * options.grid.value;
const y = Math.round((chosen ? chosen.transform.y : center.y) / options.grid.value) * options.grid.value;
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 phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
id = options.allNodes(phy.root).at(-1)?.id || "";
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;
@@ -166,7 +195,7 @@ export function usePhyCommands(options: Options) {
{ x, y, rotation: 0 },
group === "その他" ? undefined : group,
);
id = options.allNodes(phy.root).at(-1)?.id || "";
id = addedId(project.phy, phy);
return { ...project, phy };
});
if (id) options.selected.value = [id];
@@ -205,12 +234,16 @@ export function usePhyCommands(options: Options) {
function variantGroup() {
if (!options.selected.value.length) return;
const parent = options.selectedContainer(options.selected.value[0]!);
let id = "";
options.dispatch("バリアントグループ作成", (project) => {
let phy = createVariantGroup(project.phy, "Variant Group", { x: 0, y: 0, rotation: 0 }, [
{ name: "Option A" },
{ name: "Option B" },
]);
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(
@@ -359,6 +392,7 @@ export function usePhyCommands(options: Options) {
tabItem,
chooseVariant,
keyAdd,
presetAdd,
partAdd,
makeCluster,
ungroup,
+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,
};
}
+3 -3
View File
@@ -6,7 +6,6 @@ import { localBounds } from "./logic";
type Options = {
project: () => ProjectFile;
pane: Ref<HTMLElement | undefined>;
svg: Ref<SVGSVGElement | undefined>;
size: Ref<{ w: number; h: number }>;
zoom: Ref<number>;
@@ -58,13 +57,14 @@ export function usePhyViewport(options: Options) {
}
onMounted(() => {
if (!options.pane.value) return;
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.pane.value);
observer.observe(options.svg.value);
fit();
});
onUnmounted(() => observer?.disconnect());
+20
View File
@@ -1,6 +1,26 @@
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,
+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"),
+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$/);
});
});
+19
View File
@@ -4,6 +4,7 @@ import {
addComponentToParent,
addKeyToParent,
cloneNodesWithNewIds,
createVariantGroupInParent,
duplicateNode,
insertNodes,
} from "../src/domain/phyCommands";
@@ -102,4 +103,22 @@ describe("PHY parent insertion and duplication", () => {
}
}
});
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"]);
});
});
+73 -1
View File
@@ -12,11 +12,13 @@ import {
pasteParent,
resolveKey,
snap,
selectionFrames,
variantBadges,
tabOrder,
treeIndex,
visibleNodeIds,
} from "../src/ui/phy/logic";
import { fitViewport, zoomAround } from "../src/ui/phy/viewport";
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",
@@ -34,6 +36,68 @@ const phy: PhyFile = {
],
};
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"]);
@@ -157,4 +221,12 @@ describe("PHY pure interaction rules", () => {
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);
});
});
+101
View File
@@ -71,6 +71,107 @@ KLE本家の挙動の確認は、既存の検証ハーネスで本家の `serial
入力は [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. 明示的な未決事項
### 未確認事項
+34 -1
View File
@@ -14,6 +14,33 @@
- 倍マトリクスでは、同じrow/colの組が編集中に重なってよい。ダイオードの向きで区別するので衝突にはならない(T-25)。
重なった場合も保存は止めない(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を載せた開発基板)も提供者として扱いたい。多くの利用者はチップではなくボードを使う。
@@ -27,9 +54,9 @@
## 要検討
- 倍マトリクスの向きの指定方法(選択範囲だけダイオードの向きを逆にする、など)。
- 二乗マトリクス(同じピンがrowにもcolにも現れ、向きはダイオードだけで区別する)の操作。通常のマトリクスと同じ操作で足りるか。
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
倍マトリクスの操作(自動反転・交換)が使えそうだが、いったん別に検討する。統合は両方の検討後に、矛盾がないかを確認して行う。
- directの操作。row/colの指定がなく、片方の端子は電源レールになる。
- mux・シフトレジスタを使う場合の操作。rowかcolの一方を親の提供者の論理ピンにする形になるか。
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
@@ -38,6 +65,12 @@
- ピン名の候補の一覧が「今使えるピン」と誤解される問題。ダイアログに分けるなど。ボードを提供者にすれば、外に出ていないピンは候補から消える。
- GPIOに直結したLED(インジケータなど)。仕様にないので `backlog/README.md` のELC-P-4とした。
## ELC Editorの範囲外
- Generatorのプレビューが倍マトリクスを「未対応」にできない。T-19の検査は両端子が `rowPins`/`colPins` に分かれているかだけを見て、
向きが揃っているかを見ない。倍マトリクスの配線は検査を通り、同じ(row, col)を持つキーが2つある設定を出力してしまう。
単純なマトリクスに限る(ELC-P-3)なら、向きが揃っていることも検査条件に要る。
## モックの実装上の問題
作り直しで解消する前提で、原因の記録だけ残す。