Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafd4dd2c4 | ||
|
|
c250efd8ae | ||
|
|
754b1d5d85 | ||
|
|
afd9af1914 | ||
|
|
3749f267b5 | ||
|
|
c180642fa8 | ||
|
|
2321c068f9 | ||
|
|
0838487f4a | ||
|
|
785daa23c2 | ||
|
|
eacf8a1581 | ||
|
|
2c388e3bdb | ||
|
|
b383fcf844 | ||
|
|
37ed6d5186 | ||
|
|
a4eabe3691 | ||
|
|
b314309e8c | ||
|
|
50e672768b | ||
|
|
e5678d79e6 | ||
|
|
f64d632136 | ||
|
|
9254d0649b | ||
|
|
0697e7a3b4 | ||
|
|
812e2edfc2 | ||
|
|
e50722948d | ||
|
|
38954e9b36 | ||
|
|
fe1b048ea4 | ||
|
|
e92708dc12 | ||
|
|
d110320cb8 | ||
|
|
85f333cb24 | ||
|
|
5c9f4c75ab | ||
|
|
4d1eb23cf8 | ||
|
|
beac52c20b | ||
|
|
fd21d64bcf | ||
|
|
af2aa31518 | ||
|
|
1d24de0e79 | ||
|
|
88f18b3b20 | ||
|
|
65db845dbb | ||
|
|
d7c35add15 | ||
|
|
2ecc535c16 | ||
|
|
101ffb73d1 | ||
|
|
3894df8069 | ||
|
|
c5ac41b1cc | ||
|
|
f604ca4043 | ||
|
|
8f6e74758e | ||
|
|
91f21dc191 | ||
|
|
f03858daa9 | ||
|
|
ef55ff9c81 | ||
|
|
c23513bae7 | ||
|
|
7695d0f51d | ||
|
|
fccbc28bee | ||
|
|
937fc27dbd | ||
|
|
af6ce99364 | ||
|
|
1a443c562d | ||
|
|
73e913e776 | ||
|
|
1c99f60719 | ||
|
|
7c927f5e12 | ||
|
|
a37fdd4abd | ||
|
|
333c651491 | ||
|
|
0dc7933077 | ||
|
|
dccc5f095f | ||
|
|
1414a7d725 | ||
|
|
49b023ceaa | ||
|
|
e8fe11bfa1 | ||
|
|
18e060d390 | ||
|
|
cceb9a3e53 | ||
|
|
cd502e260f | ||
|
|
44035a7915 | ||
|
|
76406e2f02 | ||
|
|
46905b1e79 | ||
|
|
63da6e77d8 | ||
|
|
54f292b08e | ||
|
|
2fa910ed74 | ||
|
|
bc2d33f856 | ||
|
|
142357a440 | ||
|
|
26df6edc06 | ||
|
|
a80f5ea215 | ||
|
|
5b671976d5 | ||
|
|
66a594a378 | ||
|
|
76c349fa0c |
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: decide
|
||||
description: 設計上の決定を記録する。メインドキュメントの更新・検討済み案の記録・journal作成・コミットを一度に行う。
|
||||
disable-model-invocation: true
|
||||
argument-hint: [決定の要旨]
|
||||
---
|
||||
|
||||
# 決定を記録する
|
||||
|
||||
共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。
|
||||
現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。
|
||||
「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。
|
||||
|
||||
## 手順
|
||||
|
||||
### 0. 決定内容を確認する
|
||||
|
||||
以下が揃っているか確認し、足りなければユーザーに聞く。推測で埋めない。
|
||||
|
||||
- 採用した案は何か
|
||||
- それによって**今後効く制約**は何か(却下理由ではなく、現在の状態として書ける文)
|
||||
- 落とした案があるか
|
||||
- この決定が未確認の推測に依存していないか
|
||||
|
||||
### 1. メインドキュメントを更新する
|
||||
|
||||
該当する直下の `*.md` を更新する。
|
||||
|
||||
- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。
|
||||
- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。
|
||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 比較表・検討過程・ベンチマーク結果をここに書かない。
|
||||
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
|
||||
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
|
||||
|
||||
### 2. 非採用一覧に残す案を選ぶ
|
||||
|
||||
各案について、この記録が将来のどんな再提案を防ぐかを判断する。
|
||||
本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、
|
||||
再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。
|
||||
|
||||
```
|
||||
- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)
|
||||
```
|
||||
|
||||
- 理由は書かない。journalファイル名だけ。
|
||||
- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。
|
||||
- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。
|
||||
|
||||
### 3. journal を書く
|
||||
|
||||
`journal/YYYY-MM-DD-<topic>.md` を作る。トピックを詰め込まない(1決定1ファイル)。
|
||||
|
||||
構成は上から順に:
|
||||
|
||||
1. **結論** — 何を採用したか
|
||||
2. **案の比較** — 簡潔な表。メリット/デメリットを1行ずつ
|
||||
3. 検証ログ・根拠・捨てた案の詳細
|
||||
|
||||
冒頭20行で結論と選択肢が分かること。整形は最低限でよい。
|
||||
他ファイルへのリンクも、他ファイルからのリンクも張らない。
|
||||
|
||||
### 4. 推測と未決の問いを扱う
|
||||
|
||||
この決定が未確認の推測に依存する場合:
|
||||
|
||||
- メインドキュメント本文に `[推測:P-n]` を付ける
|
||||
- 末尾の `## 未確認事項` に項目を足し、**何を確認できれば事実に格上げできるか**を書く
|
||||
|
||||
逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。
|
||||
本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。
|
||||
|
||||
本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。
|
||||
対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。
|
||||
必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。
|
||||
今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。
|
||||
|
||||
### 5. コミットする
|
||||
|
||||
メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。
|
||||
|
||||
メッセージ:
|
||||
|
||||
```
|
||||
<何を変えたか(1行)>
|
||||
|
||||
<なぜそうしたか 数行>
|
||||
|
||||
Journal: journal/2026-09-16-cluster-metadata.md
|
||||
```
|
||||
|
||||
「なぜ」の詳細はコミットメッセージに書ききらない。journal 側にある。
|
||||
|
||||
## 注意
|
||||
|
||||
- 決定が固まっていない段階では使わない。調査中は journal だけ書けばよい。
|
||||
- 複数の決定をまとめてコミットしない。分ける。
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: doc-lint
|
||||
description: ドキュメントがAGENTS.mdの運用ルール1〜5に沿っているか検査する。推測タグと未確認事項の対応漏れ、保留課題の書式・参照・必須欄、journalへのリンク、比較表の混入などを検出する。
|
||||
argument-hint: [ファイル...]
|
||||
---
|
||||
|
||||
# ドキュメントのルール違反を検査する
|
||||
|
||||
機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。
|
||||
共通の運用ルールは、AGENTS.mdの参照先であるCLAUDE.mdを正とする。
|
||||
**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。
|
||||
|
||||
## 1. スクリプトを走らせる
|
||||
|
||||
```
|
||||
python .agents/skills/doc-lint/doc-lint.py [ファイル...]
|
||||
```
|
||||
|
||||
Python 3.9以上で実行する。引数なしでリポジトリ直下の `*.md`、`journal/*.md`、`backlog/**/*.md` を全部見る。
|
||||
実行場所にかかわらずリポジトリを基準に分類し、明示したパスは実行ディレクトリから解決する。
|
||||
ERROR があれば終了コード 1。存在しない引数のファイルもERRORにする。
|
||||
|
||||
検出する項目:
|
||||
|
||||
| 種別 | 内容 |
|
||||
| --- | --- |
|
||||
| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない |
|
||||
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
|
||||
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
|
||||
| ERROR | `## 未確認事項` のIDが重複している |
|
||||
| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない |
|
||||
| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない |
|
||||
| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない |
|
||||
| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない |
|
||||
| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である |
|
||||
| ERROR | backlogに推測タグがある、参照先ファイルが存在しない |
|
||||
| ERROR | journal へのリンク / journal から他ファイルへのリンク |
|
||||
| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない |
|
||||
| ERROR | journal のファイル名が `YYYY-MM-DD-<topic>.md` 形式でない |
|
||||
| WARN | `## 検討済み・非採用` が5件を超えた |
|
||||
| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある |
|
||||
| WARN | journal の冒頭20行に結論が見当たらない |
|
||||
| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの |
|
||||
|
||||
**本文タグのない `## 未確認事項` の項目はエラーにしない。** 実装前に判断が必要な未解決の問いとして正当。
|
||||
設計判断は「完了条件」でよい。タグなしの項目が事実確認なのか設計判断なのかは、目視で確認する。
|
||||
|
||||
backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連仕様・未決事項・保留できる理由・再開条件・完了条件を必須とする。
|
||||
仕様書側の書式は `- [PHYの保留課題](backlog/README.md#phy)`。
|
||||
backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。
|
||||
|
||||
WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。
|
||||
未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の
|
||||
インラインリンク。Markdown全般のリンク検査ではない。
|
||||
「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、
|
||||
journal、backlog、ルールブック自体はこのWARNの対象外とする。
|
||||
WARNから課題の追加や本文の削除を自動決定しない。
|
||||
|
||||
## 2. スクリプトで見られない項目を確認する
|
||||
|
||||
こちらが本体。ファイルを読んで判断する。
|
||||
|
||||
- **「なぜBではないのか」が理由として残っていないか。**
|
||||
却下理由ではなく、現在効いている制約として書かれているか。
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、
|
||||
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
||||
記述を探し、`[推測:P-n]` が付いているか確認する。
|
||||
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
|
||||
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
|
||||
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
|
||||
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
|
||||
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
|
||||
件数・古さ・重複だけで一律に追加・削除しない。
|
||||
- **本文の未決記述は、読者に何を伝えるためにあるか。**
|
||||
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
|
||||
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
|
||||
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
|
||||
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
|
||||
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
|
||||
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
|
||||
- **コミット規約**(必要なら `git log --oneline` を見る)
|
||||
- 1コミット1決定になっているか
|
||||
- journal を伴う決定のメッセージに `Journal:` 行があるか
|
||||
|
||||
## 3. 報告する
|
||||
|
||||
機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。
|
||||
機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。
|
||||
目視確認を省いた場合はその旨を明記する。
|
||||
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
|
||||
|
||||
## 注意
|
||||
|
||||
- 既存ドキュメントへのルール適用は段階的に進めている。
|
||||
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
|
||||
再編前のファイルの WARN を個別に直そうとしない。
|
||||
|
||||
スクリプトを変更した場合の回帰検査:
|
||||
|
||||
```
|
||||
python -B -m unittest discover -s .agents/skills/doc-lint -p test_doc_lint.py
|
||||
```
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。
|
||||
|
||||
使い方:
|
||||
python .agents/skills/doc-lint/doc-lint.py [ファイル...]
|
||||
引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。
|
||||
明示したパスは実行ディレクトリから解決する。
|
||||
ERROR が1件でもあれば終了コード 1。
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
TAG = re.compile(r"\[推測:(P-\d+)\]")
|
||||
ENTRY = re.compile(r"^###\s+(P-\d+)\b")
|
||||
H2 = re.compile(r"^##\s+(.*?)\s*$")
|
||||
MDLINK = re.compile(r"\]\(([^)]+\.md[^)]*)\)")
|
||||
INLINE_LINK = re.compile(r"(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|[^\s)]+)\s*\)")
|
||||
INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S)
|
||||
ISSUE_ID = re.compile(r"(?<![A-Za-z0-9_-])P-\d+(?![A-Za-z0-9_-])")
|
||||
ISSUE_ANCHOR = re.compile(r"^p-\d+(?:-|$)")
|
||||
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
|
||||
JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$")
|
||||
PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S")
|
||||
COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S")
|
||||
BACKLOG_ENTRY = re.compile(r"^###\s+([A-Za-z][A-Za-z0-9_-]*-P-\d+)\s+\S")
|
||||
BACKLOG_LINK = re.compile(r"^- \[[^\]]+の保留課題\]\(([^)]+)\)$")
|
||||
BACKLOG_FIELDS = ("関連仕様", "未決事項", "保留できる理由", "再開条件", "完了条件")
|
||||
MANAGEMENT_SECTIONS = ("保留課題", "検討済み・非採用", "未確認事項")
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# ルールブック自身は、アンチパターンを例として引用するため散文ヒューリスティックの対象外
|
||||
PROSE_EXEMPT = {"CLAUDE.md"}
|
||||
|
||||
issues = []
|
||||
def add(sev, path, line, msg):
|
||||
issues.append((sev, path, line, msg))
|
||||
|
||||
def load(path):
|
||||
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
|
||||
raw = Path(path).read_text(encoding="utf-8").splitlines()
|
||||
out, fence = [], None
|
||||
for i, l in enumerate(raw, 1):
|
||||
marker = re.match(r"^ {0,3}(\x60{3,}|~{3,})(.*)$", l)
|
||||
if fence is not None:
|
||||
out.append((i, ""))
|
||||
if (marker and marker.group(1)[0] == fence[0]
|
||||
and len(marker.group(1)) >= fence[1] and not marker.group(2).strip()):
|
||||
fence = None
|
||||
continue
|
||||
if marker:
|
||||
fence = (marker.group(1)[0], len(marker.group(1)))
|
||||
out.append((i, ""))
|
||||
continue
|
||||
out.append((i, l))
|
||||
return out
|
||||
|
||||
def section(lines, title):
|
||||
"""## <title> 配下の (行番号, 本文) を返す。無ければ None。"""
|
||||
start = None
|
||||
for idx, (n, l) in enumerate(lines):
|
||||
m = H2.match(l)
|
||||
if m and m.group(1) == title:
|
||||
start = idx + 1
|
||||
break
|
||||
if start is None:
|
||||
return None
|
||||
body = []
|
||||
for n, l in lines[start:]:
|
||||
if H2.match(l):
|
||||
break
|
||||
body.append((n, l))
|
||||
return body
|
||||
|
||||
def local_target(path, target):
|
||||
"""相対リンクをリンク元基準で解決する。外部URLは対象外。"""
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None, ""
|
||||
linked = (path.parent / unquote(parsed.path)).resolve() if parsed.path else path
|
||||
return linked, unquote(parsed.fragment)
|
||||
|
||||
|
||||
def heading_index(lines):
|
||||
"""ATX見出しのアンカーを作る。同じ見出しの連番も扱う。"""
|
||||
headings = {}
|
||||
for n, line in lines:
|
||||
match = HEADING.match(line)
|
||||
if not match:
|
||||
continue
|
||||
title = match.group(1)
|
||||
slug = re.sub(r"[^\w\s-]", "", title).lower().replace(" ", "-")
|
||||
anchor, suffix = slug, 0
|
||||
while anchor in headings:
|
||||
suffix += 1
|
||||
anchor = f"{slug}-{suffix}"
|
||||
headings[anchor] = (n, title)
|
||||
return headings
|
||||
|
||||
|
||||
def body_paragraphs(lines):
|
||||
"""改行をまたぐ本文段落を返す。見出し・空行・次の箇条書きで区切る。"""
|
||||
paragraph = []
|
||||
in_management = False
|
||||
for n, line in lines:
|
||||
if HEADING.match(line):
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
paragraph = []
|
||||
h2 = H2.match(line)
|
||||
if h2:
|
||||
in_management = h2.group(1) in MANAGEMENT_SECTIONS
|
||||
elif line.startswith("# "):
|
||||
in_management = False
|
||||
continue
|
||||
if in_management:
|
||||
continue
|
||||
if not line.strip() or re.match(r"^\s*(?:[-*+]|\d+[.)])\s+", line):
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
paragraph = []
|
||||
if line.strip():
|
||||
paragraph.append((n, line))
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
|
||||
|
||||
def check_issue_references(path, rel, lines):
|
||||
"""課題リンクの構造はERROR、本文の参照漏れの候補はWARNにする。"""
|
||||
targets = {}
|
||||
valid_link_lines = set()
|
||||
# インラインコードも位置を変えずにマスクする。
|
||||
text = "\n".join(line for _, line in lines)
|
||||
masked = INLINE_CODE.sub(lambda m: re.sub(r"[^\n]", " ", m.group()), text).split("\n")
|
||||
prose_lines = [(n, line) for (n, _), line in zip(lines, masked)]
|
||||
for n, line in prose_lines:
|
||||
for match in INLINE_LINK.finditer(line):
|
||||
label, target = match.groups()
|
||||
target = target.strip("<>")
|
||||
linked, anchor = local_target(path, target)
|
||||
if linked is None:
|
||||
continue
|
||||
label_ids = ISSUE_ID.findall(label)
|
||||
if not (label_ids or ISSUE_ANCHOR.match(anchor) or anchor == "未確認事項"):
|
||||
continue
|
||||
if not linked.is_file():
|
||||
add("ERROR", rel, n, "未確認事項リンクの参照先ファイルがない: %s" % target)
|
||||
continue
|
||||
if linked not in targets:
|
||||
target_lines = load(linked)
|
||||
entries = {
|
||||
ln: ENTRY.match(value).group(1)
|
||||
for ln, value in (section(target_lines, "未確認事項") or [])
|
||||
if ENTRY.match(value)
|
||||
}
|
||||
targets[linked] = (heading_index(target_lines), entries)
|
||||
headings, entries = targets[linked]
|
||||
if not anchor or anchor not in headings:
|
||||
add("ERROR", rel, n, "未確認事項リンクの参照先見出しがない: %s" % target)
|
||||
continue
|
||||
target_line, _ = headings[anchor]
|
||||
if anchor == "未確認事項" and not label_ids:
|
||||
continue
|
||||
target_id = entries.get(target_line)
|
||||
if target_id is None:
|
||||
add("ERROR", rel, n, "課題リンクが未確認事項の個別項目を指していない: %s" % target)
|
||||
elif any(pid != target_id for pid in label_ids):
|
||||
add("ERROR", rel, n, "リンクラベルの課題番号と参照先が一致しない: %s → %s" % (label, target_id))
|
||||
else:
|
||||
valid_link_lines.add(n)
|
||||
|
||||
for paragraph in body_paragraphs(prose_lines):
|
||||
n = paragraph[0][0]
|
||||
text = "\n".join(line for _, line in paragraph)
|
||||
# リンクと推測タグには別の構造検査がある。裸の課題番号だけを探す。
|
||||
plain = TAG.sub("", INLINE_LINK.sub("", text))
|
||||
bare_ids = sorted(set(ISSUE_ID.findall(plain)))
|
||||
if bare_ids:
|
||||
add("WARN", rel, n, "本文にリンクのない課題番号がある(要確認): %s" % ", ".join(bare_ids))
|
||||
if (re.search(r"未決|未定", plain)
|
||||
and not any(ln in valid_link_lines for ln, _ in paragraph)
|
||||
and not TAG.search(text)):
|
||||
add("WARN", rel, n, "本文の未決・未定に課題への参照がない(要確認)。リンク・課題追加・言及削除を判断する")
|
||||
|
||||
|
||||
def check_backlog(path, rel, lines, seen_ids):
|
||||
blocks = []
|
||||
current = None
|
||||
for n, line in lines:
|
||||
if H2.match(line):
|
||||
current = None
|
||||
elif re.match(r"^###\s", line):
|
||||
match = BACKLOG_ENTRY.match(line)
|
||||
current = None
|
||||
if not match:
|
||||
add("ERROR", rel, n, "保留課題の見出しは ### <区分>-P-<番号> <題名> とする")
|
||||
continue
|
||||
pid = match.group(1)
|
||||
if pid in seen_ids:
|
||||
add("ERROR", rel, n, "保留課題のID %s が重複している(初出: %s)" % (pid, seen_ids[pid]))
|
||||
seen_ids[pid] = "%s:%d" % (rel, n)
|
||||
current = (pid, n, [])
|
||||
blocks.append(current)
|
||||
elif current is not None:
|
||||
current[2].append(line)
|
||||
|
||||
for pid, n, body in blocks:
|
||||
for field in BACKLOG_FIELDS:
|
||||
pattern = re.compile(r"^[-*]\s+" + re.escape(field) + r"\s*[::]\s*\S")
|
||||
if not any(pattern.match(line) for line in body):
|
||||
add("ERROR", rel, n, "%s に「- %s:」の内容がない" % (pid, field))
|
||||
|
||||
for n, line in lines:
|
||||
if TAG.search(line):
|
||||
add("ERROR", rel, n, "本文の前提となる推測タグはbacklogではなく仕様書に置く")
|
||||
for target in MDLINK.findall(line):
|
||||
linked, _ = local_target(path, target)
|
||||
if linked is not None and not linked.is_file():
|
||||
add("ERROR", rel, n, "backlogの参照先ファイルがない: %s" % target)
|
||||
|
||||
|
||||
def check_backlog_section(path, rel, lines):
|
||||
positions = []
|
||||
for title in MANAGEMENT_SECTIONS:
|
||||
headings = [n for n, line in lines if H2.match(line) and H2.match(line).group(1) == title]
|
||||
if len(headings) > 1:
|
||||
add("ERROR", rel, headings[1], "## %s が重複している" % title)
|
||||
if headings:
|
||||
positions.append(headings[0])
|
||||
if positions != sorted(positions):
|
||||
add("ERROR", rel, min(positions), "末尾の管理セクションは保留課題 → 検討済み・非採用 → 未確認事項の順に置く")
|
||||
|
||||
deferred = section(lines, "保留課題")
|
||||
if deferred is None:
|
||||
return
|
||||
contents = [(n, line) for n, line in deferred if line.strip()]
|
||||
if not contents:
|
||||
add("ERROR", rel, 1, "空の ## 保留課題 はセクションごと削除する")
|
||||
for n, line in contents:
|
||||
match = BACKLOG_LINK.fullmatch(line)
|
||||
if not match:
|
||||
add("ERROR", rel, n, "保留課題には - [<区分>の保留課題](backlogのファイル#見出し) だけを書く")
|
||||
continue
|
||||
target = match.group(1)
|
||||
linked, anchor = local_target(path, target)
|
||||
if (linked is None or not linked.is_relative_to(ROOT / "backlog")
|
||||
or linked.suffix != ".md" or not anchor):
|
||||
add("ERROR", rel, n, "保留課題はbacklog内のMarkdownファイルの見出しへリンクする: %s" % target)
|
||||
continue
|
||||
if not linked.is_file():
|
||||
add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target)
|
||||
continue
|
||||
headings = heading_index(load(linked))
|
||||
if anchor not in headings:
|
||||
add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target)
|
||||
|
||||
|
||||
def check(path, seen_backlog_ids=None):
|
||||
path = Path(path).resolve()
|
||||
rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix()
|
||||
base = path.name
|
||||
is_journal = path.is_relative_to(ROOT / "journal")
|
||||
is_backlog = path.is_relative_to(ROOT / "backlog")
|
||||
lines = load(path)
|
||||
|
||||
if is_backlog:
|
||||
check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {})
|
||||
elif not is_journal:
|
||||
check_backlog_section(path, rel, lines)
|
||||
if base not in PROSE_EXEMPT:
|
||||
check_issue_references(path, rel, lines)
|
||||
|
||||
unconf = section(lines, "未確認事項")
|
||||
rejected = section(lines, "検討済み・非採用")
|
||||
unconf_span = set(n for n, _ in unconf) if unconf else set()
|
||||
tagged = {pid for n, line in lines if n not in unconf_span for pid in TAG.findall(line)}
|
||||
|
||||
# --- ルール4: 推測タグと未確認事項の対応 ---
|
||||
entries = set()
|
||||
if unconf is not None:
|
||||
blocks = [] # [(pid, 行番号, [本文行])] 重複IDも個別に検査する
|
||||
for n, l in unconf:
|
||||
m = ENTRY.match(l)
|
||||
if m:
|
||||
pid = m.group(1)
|
||||
if pid in entries:
|
||||
add("ERROR", rel, n, "未確認事項のID %s が重複している" % pid)
|
||||
entries.add(pid)
|
||||
blocks.append((pid, n, []))
|
||||
elif blocks:
|
||||
blocks[-1][2].append(l)
|
||||
for pid, ln, body in blocks:
|
||||
has_promote = any(PROMOTE.match(x) for x in body)
|
||||
has_complete = any(COMPLETE.match(x) for x in body)
|
||||
if pid in tagged and not has_promote:
|
||||
add("ERROR", rel, ln,
|
||||
"%s に「- 格上げ条件:」の行がない(何を確認すれば事実に格上げできるか)" % pid)
|
||||
elif not has_promote and not has_complete:
|
||||
add("ERROR", rel, ln,
|
||||
"%s に「- 格上げ条件:」または「- 完了条件:」の内容がない" % pid)
|
||||
|
||||
for n, l in lines:
|
||||
if n in unconf_span:
|
||||
continue
|
||||
for pid in TAG.findall(l):
|
||||
if unconf is None or pid not in entries:
|
||||
add("ERROR", rel, n,
|
||||
"本文の [推測:%s] に対応する項目が ## 未確認事項 にない" % pid)
|
||||
# 逆向き(本文タグのない未確認事項)は「未解決の問い」として正当なのでエラーにしない
|
||||
|
||||
# --- ルール3: journal への/からのリンク禁止 ---
|
||||
for n, l in lines:
|
||||
for target in MDLINK.findall(l):
|
||||
linked, _ = local_target(path, target)
|
||||
if linked is not None and linked.is_relative_to(ROOT / "journal"):
|
||||
add("ERROR", rel, n, "journal へのリンクが張られている: %s" % target)
|
||||
elif is_journal and linked is not None:
|
||||
add("ERROR", rel, n, "journal から他ファイルへのリンクが張られている: %s" % target)
|
||||
|
||||
# --- ルール1: 検討済み・非採用 ---
|
||||
if rejected is not None:
|
||||
items = [(n, l) for n, l in rejected if l.strip().startswith(("-", "*"))]
|
||||
for n, l in items:
|
||||
if not re.search(r"\(journal/[^)]+\.md\)", l):
|
||||
add("ERROR", rel, n,
|
||||
"検討済み・非採用の項目が journal ファイル名を参照していない")
|
||||
if len(items) > 5:
|
||||
add("WARN", rel, items[5][0],
|
||||
"検討済み・非採用が %d 件(目安5件)。再提案を防ぐ必要性で掲載を見直す" % len(items))
|
||||
|
||||
# --- journal 固有 ---
|
||||
if is_journal:
|
||||
if not JOURNALFN.match(base):
|
||||
add("ERROR", rel, 1, "journal のファイル名が YYYY-MM-DD-<topic>.md 形式でない")
|
||||
head = " ".join(l for n, l in lines[:20])
|
||||
if "結論" not in head:
|
||||
add("WARN", rel, 1, "冒頭20行に結論が見当たらない(結論を先頭に置く)")
|
||||
|
||||
# --- ルール1: 調査・比較の混入(要目視確認) ---
|
||||
if not is_journal and not is_backlog and base not in PROSE_EXEMPT:
|
||||
# 検討済み・非採用 は却下案を列挙する場所なので対象外
|
||||
skip = set(n for n, _ in rejected) if rejected else set()
|
||||
for n, l in lines:
|
||||
if n in skip:
|
||||
continue
|
||||
if "メリット" in l and "デメリット" in l:
|
||||
add("WARN", rel, n, "比較表らしき記述(メリット/デメリット)。journal 行きでは?")
|
||||
elif re.search(r"却下|不採用|見送っ|も検討したが|案[A-Z]|[A-Z]案", l):
|
||||
add("WARN", rel, n, "却下案の検討過程らしき記述。制約文に書き換えるか journal へ")
|
||||
elif re.search(r"優先度", l) and l.lstrip().startswith("#"):
|
||||
add("WARN", rel, n, "優先度評価は journal 行きでは?")
|
||||
|
||||
def main(argv):
|
||||
issues.clear()
|
||||
targets = [Path(arg).resolve() for arg in argv[1:]]
|
||||
if not targets:
|
||||
targets = (sorted(ROOT.glob("*.md")) + sorted((ROOT / "journal").glob("*.md"))
|
||||
+ sorted((ROOT / "backlog").rglob("*.md")))
|
||||
targets = list(dict.fromkeys(targets))
|
||||
if not targets:
|
||||
print("対象ファイルなし")
|
||||
return 0
|
||||
seen_backlog_ids = {}
|
||||
for t in targets:
|
||||
if t.is_file():
|
||||
check(t, seen_backlog_ids)
|
||||
else:
|
||||
add("ERROR", t.as_posix(), 1, "対象ファイルが見つからない")
|
||||
|
||||
errs = [i for i in issues if i[0] == "ERROR"]
|
||||
warns = [i for i in issues if i[0] == "WARN"]
|
||||
for sev, path, line, msg in errs + warns:
|
||||
print("%-5s %s:%d %s" % (sev, path, line, msg))
|
||||
print("\n%d files / ERROR %d / WARN %d" % (len(targets), len(errs), len(warns)))
|
||||
return 1 if errs else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,300 @@
|
||||
"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("doc_lint", Path(__file__).with_name("doc-lint.py"))
|
||||
lint = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(lint)
|
||||
|
||||
|
||||
def backlog_item(pid="PHY-P-14"):
|
||||
return (
|
||||
f"### {pid} 原点の吸着\n\n"
|
||||
"- 関連仕様: [PHY](../PHY.md)\n"
|
||||
"- 未決事項: 吸着が必要か\n"
|
||||
"- 保留できる理由: 数値入力で編集できる\n"
|
||||
"- 再開条件: 原点移動を試作するとき\n"
|
||||
"- 完了条件: 吸着の要否を決める\n"
|
||||
)
|
||||
|
||||
|
||||
def issue_section():
|
||||
return (
|
||||
"## 未確認事項\n\n"
|
||||
"### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n"
|
||||
"### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n"
|
||||
)
|
||||
|
||||
|
||||
class DocLintTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name).resolve()
|
||||
root_patch = patch.object(lint, "ROOT", self.root)
|
||||
root_patch.start()
|
||||
self.addCleanup(root_patch.stop)
|
||||
self.write("PHY.md", "# PHY\n")
|
||||
|
||||
def write(self, relative, content):
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_lint(self, *paths):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
status = lint.main(["doc-lint.py", *(str(path) for path in paths)])
|
||||
return status, output.getvalue()
|
||||
|
||||
def test_default_scan_includes_backlog_and_accepts_fixed_links(self):
|
||||
self.write("PHY.md", "# PHY\n\n## 保留課題\n\n- [PHYの保留課題](backlog/README.md#phy)\n")
|
||||
self.write("backlog/README.md", "# 保留課題\n\n## PHY\n\n" + backlog_item())
|
||||
self.write("journal/2026-09-22-decision.md", "# 決定\n\n## 結論\n\n採用した。\n")
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("3 files / ERROR 0", output)
|
||||
|
||||
def test_backlog_fields_must_have_values(self):
|
||||
for field in lint.BACKLOG_FIELDS:
|
||||
with self.subTest(field=field):
|
||||
text = backlog_item()
|
||||
text = "\n".join(f"- {field}: " if line.startswith(f"- {field}:") else line
|
||||
for line in text.splitlines())
|
||||
path = self.write("backlog/README.md", "## PHY\n" + text)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn(f"- {field}:", output)
|
||||
|
||||
def test_duplicate_backlog_ids_across_nested_files(self):
|
||||
self.write("backlog/one.md", "## PHY\n" + backlog_item())
|
||||
self.write("backlog/sub/two.md", "## PHY\n" + backlog_item().replace("../PHY.md", "../../PHY.md"))
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("保留課題のID PHY-P-14 が重複", output)
|
||||
|
||||
def test_incomplete_last_item_cannot_borrow_fields_from_next_section(self):
|
||||
self.write("backlog/README.md", "## PHY\n### PHY-P-14 吸着\n\n## ELC\n" + backlog_item("ELC-P-1"))
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("PHY-P-14 に「- 完了条件:」", output)
|
||||
|
||||
def test_invalid_backlog_heading_and_missing_reference(self):
|
||||
path = self.write("backlog/README.md", "## PHY\n### P-14 吸着\n" + backlog_item().replace("../PHY.md", "../missing.md"))
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("保留課題の見出し", output)
|
||||
self.assertIn("参照先ファイルがない", output)
|
||||
|
||||
def test_spec_backlog_section_rejects_prose_empty_and_broken_targets(self):
|
||||
self.write("backlog/README.md", "## PHY\n" + backlog_item())
|
||||
cases = {
|
||||
"内容をここにも要約する。": "だけを書く",
|
||||
"": "空の ## 保留課題",
|
||||
"- [PHYの保留課題](backlog/missing.md#phy)": "参照先ファイルがない",
|
||||
"- [PHYの保留課題](backlog/README.md#missing)": "参照先見出しがない",
|
||||
"- [PHYの保留課題](backlog/README.md)": "見出しへリンク",
|
||||
"- [PHYの保留課題](PHY.md#phy)": "backlog内",
|
||||
}
|
||||
for content, expected in cases.items():
|
||||
with self.subTest(content=content):
|
||||
path = self.write("PHY.md", "# PHY\n\n## 保留課題\n\n" + content + "\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_management_sections_have_one_order(self):
|
||||
path = self.write("PHY.md", "## 未確認事項\n\n(なし)\n\n## 検討済み・非採用\n\n## 未確認事項\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("順に置く", output)
|
||||
self.assertIn("## 未確認事項 が重複", output)
|
||||
|
||||
def test_decisions_accept_completion_but_tagged_assumptions_require_evidence(self):
|
||||
text = "## 未確認事項\n\n### P-1 操作方式\n- 完了条件: 操作方式を決める\n"
|
||||
path = self.write("PHY.md", text)
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
path.write_text("本文 [推測:P-1]\n\n" + text, encoding="utf-8")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("格上げ条件", output)
|
||||
path.write_text("本文 [推測:P-1]\n\n" + text.replace("完了条件", "格上げ条件"), encoding="utf-8")
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
|
||||
def test_absolute_journal_paths_keep_journal_checks(self):
|
||||
path = self.write("journal/bad.md", "# 調査\n\n[仕様](../PHY.md)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("journal のファイル名", output)
|
||||
self.assertIn("journal から他ファイルへのリンク", output)
|
||||
|
||||
def test_backlog_is_not_prose_linted_but_cannot_link_to_journal(self):
|
||||
self.write("journal/2026-09-22-decision.md", "## 結論\n")
|
||||
path = self.write("backlog/README.md", "## PHY\n" + backlog_item() + "\n候補はA案。\n[履歴](../journal/2026-09-22-decision.md)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("journal へのリンク", output)
|
||||
self.assertIn("WARN 0", output)
|
||||
|
||||
def test_code_examples_do_not_create_real_sections_or_tags(self):
|
||||
path = self.write("PHY.md", "```markdown\n## 保留課題\n[推測:P-9]\n```\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
|
||||
def test_missing_explicit_file_is_an_error(self):
|
||||
status, output = self.run_lint(self.root / "missing.md")
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("対象ファイルが見つからない", output)
|
||||
|
||||
def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self):
|
||||
self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section())
|
||||
encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理")
|
||||
path = self.write(
|
||||
"PHY.md",
|
||||
"# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n"
|
||||
"[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n"
|
||||
f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(),
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("ERROR 0 / WARN 0", output)
|
||||
|
||||
def test_issue_links_reject_missing_file_heading_and_fragment(self):
|
||||
self.write("MAP.md", "# MAP\n\n" + issue_section())
|
||||
for target, expected in (
|
||||
("missing.md#p-3-対応状態管理", "参照先ファイルがない"),
|
||||
("MAP.md#p-3-古い題名", "参照先見出しがない"),
|
||||
("MAP.md", "参照先見出しがない"),
|
||||
):
|
||||
with self.subTest(target=target):
|
||||
path = self.write("PHY.md", f"[P-3]({target})\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_issue_label_must_match_real_entry_in_unconfirmed_section(self):
|
||||
self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section())
|
||||
for link, expected in (
|
||||
("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||
("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||
("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"),
|
||||
("[P-3](MAP.md#未確認事項)", "個別項目を指していない"),
|
||||
):
|
||||
with self.subTest(link=link):
|
||||
path = self.write("PHY.md", link + "\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_duplicate_heading_slug_resolves_actual_target(self):
|
||||
self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section())
|
||||
path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n")
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("個別項目を指していない", output)
|
||||
|
||||
def test_unresolved_prose_warns_without_failing_or_modifying_documents(self):
|
||||
text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section()
|
||||
path = self.write("PHY.md", text)
|
||||
before = path.read_bytes()
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("リンクのない課題番号", output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
def test_another_issue_link_does_not_hide_bare_id(self):
|
||||
path = self.write(
|
||||
"PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section()
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("リンクのない課題番号がある(要確認): P-3", output)
|
||||
|
||||
def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self):
|
||||
for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"):
|
||||
with self.subTest(link=link):
|
||||
path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section())
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
|
||||
def test_broken_link_does_not_count_as_resolved_reference(self):
|
||||
path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("参照先見出しがない", output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
|
||||
def test_paragraph_and_list_boundaries_do_not_borrow_links(self):
|
||||
for body in (
|
||||
"範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n",
|
||||
"- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n",
|
||||
"1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n",
|
||||
"範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n",
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
path = self.write("PHY.md", body + "\n" + issue_section())
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
path = self.write(
|
||||
"PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section()
|
||||
)
|
||||
self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1])
|
||||
|
||||
def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self):
|
||||
path = self.write(
|
||||
"PHY.md",
|
||||
"\x60未決 P-8 [P-8](#missing)\x60\n\n"
|
||||
"\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n"
|
||||
"~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n"
|
||||
"[P-99](https://example.com/#p-99)\n\n"
|
||||
"範囲は未定 [推測:P-1]。\n\n"
|
||||
"## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n",
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("ERROR 0 / WARN 0", output)
|
||||
|
||||
def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self):
|
||||
self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n")
|
||||
self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n")
|
||||
self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n")
|
||||
self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n")
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("WARN 0", output)
|
||||
|
||||
def test_links_inside_unconfirmed_items_are_still_checked(self):
|
||||
path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("参照先見出しがない", output)
|
||||
|
||||
def test_rejected_count_warning_does_not_select_entries_for_deletion(self):
|
||||
path = self.write(
|
||||
"PHY.md", "## 検討済み・非採用\n\n"
|
||||
+ "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6))
|
||||
)
|
||||
before = path.read_bytes()
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("6 件(目安5件)", output)
|
||||
self.assertIn("再提案を防ぐ必要性", output)
|
||||
self.assertNotIn("古いものから落とす", output)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: restructure-doc
|
||||
description: 既存ドキュメント1ファイルをAGENTS.mdのルール1〜4に沿って仕分ける。事実・推測・採用案を残し、調査過程をjournalへ移す。
|
||||
disable-model-invocation: true
|
||||
argument-hint: <ファイル名>
|
||||
---
|
||||
|
||||
# ドキュメントを再編する
|
||||
|
||||
運用ルール策定前に書かれたドキュメントを、AGENTS.md のルール1〜4に沿った形に作り直す。
|
||||
**1回の実行で1ファイルだけ**扱う。
|
||||
|
||||
## 最重要の制約
|
||||
|
||||
**情報を失わせない。** この作業は「移動と再配置」であって、要約でも削除でもない。
|
||||
|
||||
- 内容を短くしない。判断に迷った記述は、消さずに残してユーザーに聞く。
|
||||
- メインドキュメントから外す記述は、必ず journal に移す。捨てない。
|
||||
- 元の主張を勝手に言い換えない。特に断定度を上げ下げしない。
|
||||
|
||||
## 手順
|
||||
|
||||
### 1. 全体を読む
|
||||
|
||||
対象ファイルを最初から最後まで読む。部分的に読んで判断しない。
|
||||
|
||||
### 2. 4分類する
|
||||
|
||||
各ブロック(見出し単位、または段落単位)を次のどれかに分ける。
|
||||
|
||||
| 分類 | 行き先 |
|
||||
| --- | --- |
|
||||
| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す |
|
||||
| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) |
|
||||
| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` |
|
||||
| d. 調査過程 / 案の比較 / 却下案 / 優先度評価 | `journal/` へ移す |
|
||||
|
||||
判定のヒント:
|
||||
|
||||
- メリット/デメリット表、優先度リスト、「〜も検討したが」 → d
|
||||
- 「〜できるはず」「〜と考えられる」「実現可能」で、実際に試していない → c
|
||||
- 外部仕様の記述(KLEのプロパティ定義など)は、出典を確認できていれば b、していなければ c
|
||||
|
||||
### 3. 分類結果を提示して承認を得る
|
||||
|
||||
**ここで必ず止まる。** ファイルを書き換える前に、分類の一覧をユーザーに見せて確認する。
|
||||
特に b と c の境目(裏取りできているか)は自分で判断せず聞く。
|
||||
|
||||
### 4. メインドキュメントを書き直す
|
||||
|
||||
- a を中心に再構成する。b は文中に自然に置く。
|
||||
- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。
|
||||
|
||||
### 5. 未確認事項を立てる
|
||||
|
||||
c に分類したものを末尾の `## 未確認事項` にまとめる。
|
||||
|
||||
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、対応する項目を置く
|
||||
- まだ本文に書けない未解決の問い → 本文タグなしで項目だけ置く
|
||||
- 各項目に**格上げ条件**を必ず書く。思いつかないものはユーザーに聞く(空欄で残さない)
|
||||
|
||||
```markdown
|
||||
### P-1 KLEはlabels[9]の内容を保存時に保持する
|
||||
- 位置づけ: 本文「クラスター管理」が前提にしている
|
||||
- 格上げ条件: KLE本家でインポート→エクスポートし、labels[9]が残ることを確認する
|
||||
```
|
||||
|
||||
### 6. journal へ移す
|
||||
|
||||
d を `journal/YYYY-MM-DD-<元ファイル名>.md` に移す。
|
||||
|
||||
- 冒頭に**結論**(このファイルの再編で何が確定し、何を落としたか)
|
||||
- 次に案の比較(元ファイルの表をそのまま持ってきてよい)
|
||||
- 以下に元の調査記述
|
||||
- 整形は最低限でよい
|
||||
|
||||
### 7. コミットする
|
||||
|
||||
1ファイル1コミット。メインドキュメントと journal を同じコミットに入れる。
|
||||
|
||||
```
|
||||
<ファイル名> を運用ルールに沿って再編
|
||||
|
||||
<何を残し、何をjournalへ移したか 数行>
|
||||
|
||||
Journal: journal/2026-09-16-<元ファイル名>.md
|
||||
```
|
||||
|
||||
## 注意
|
||||
|
||||
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
|
||||
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
|
||||
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
|
||||
@@ -7,8 +7,9 @@ argument-hint: [決定の要旨]
|
||||
|
||||
# 決定を記録する
|
||||
|
||||
CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録する。
|
||||
どれか1つでも欠けると後から追えなくなるので、全部やる。
|
||||
共通の運用ルールは CLAUDE.md を正とする。
|
||||
現在の仕様はメインドキュメント、判断の経緯はjournal、変更の要旨はコミットに記録する。
|
||||
「検討済み・非採用」には、将来の再提案を防ぐために必要な案を選んで残す。
|
||||
|
||||
## 手順
|
||||
|
||||
@@ -27,20 +28,25 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
||||
|
||||
- 採用した案・現在の設定を書く。「なぜAなのか」は1〜3行まで。
|
||||
- 「なぜBではないのか」は書かない。代わりに**制約文**として書く。
|
||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 比較表・検討過程・ベンチマーク結果をここに書かない。
|
||||
- 他の担当の詳細を外へ移したあとも、この文書だけで担当範囲が分かるか確認する。
|
||||
境界の理解に必要な一文と参照は残す。参照先の仕様は複製しない。
|
||||
|
||||
### 2. 落とした案を1行残す
|
||||
### 2. 非採用一覧に残す案を選ぶ
|
||||
|
||||
メインドキュメント末尾の `## 検討済み・非採用` に追記する。
|
||||
各案について、この記録が将来のどんな再提案を防ぐかを判断する。
|
||||
本文の制約だけで十分なら一覧へ重複記載しない。実際に検討した別方針で、
|
||||
再提案されうるものは、経緯への入口として末尾の `## 検討済み・非採用` に1案1行で残す。
|
||||
|
||||
```
|
||||
- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)
|
||||
```
|
||||
|
||||
- 理由は書かない。journalファイル名だけ。
|
||||
- セクションが5行を超えたら、古いものから落とす。長くなるのは書きすぎのサイン。
|
||||
- 目安は5行まで。件数や古さだけで機械的に削除せず、再提案を防ぐ必要性で掲載を見直す。
|
||||
- 掲載する案がなければセクションを設けない。一覧から外しても経緯はjournalに残す。
|
||||
|
||||
### 3. journal を書く
|
||||
|
||||
@@ -55,7 +61,7 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
||||
冒頭20行で結論と選択肢が分かること。整形は最低限でよい。
|
||||
他ファイルへのリンクも、他ファイルからのリンクも張らない。
|
||||
|
||||
### 4. 推測を扱う
|
||||
### 4. 推測と未決の問いを扱う
|
||||
|
||||
この決定が未確認の推測に依存する場合:
|
||||
|
||||
@@ -65,6 +71,11 @@ CLAUDE.md のルール1〜4に従い、決定を4箇所に分散して記録す
|
||||
逆に、この決定で検証が済んだ推測があれば、タグを外して `## 未確認事項` から削除する。
|
||||
本文タグと末尾項目は必ず対で操作する(片方だけ残さない)。
|
||||
|
||||
本文の未決記述は、読者が現在の対応範囲を理解するために必要かを判断する。
|
||||
対応する問いがあればCLAUDE.mdのルール4に従ってリンクし、問いの詳細は複製しない。
|
||||
必要な問いが未登録なら未確認事項を追加し、実装にも現在の説明にも不要な言及なら本文から外す。
|
||||
今は答えに依存せず進められる課題はルール5に従う。「未決」という単語だけで分類しない。
|
||||
|
||||
### 5. コミットする
|
||||
|
||||
メインドキュメントと journal を**同じコミット**に入れる。1コミット1決定。
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: doc-lint
|
||||
description: ドキュメントがCLAUDE.mdの運用ルール1〜4に沿っているか検査する。推測タグと未確認事項の対応漏れ、journalへのリンク、比較表の混入などを検出する。
|
||||
description: ドキュメントがCLAUDE.mdの運用ルール1〜5に沿っているか検査する。推測タグと未確認事項の対応漏れ、保留課題の書式・参照・必須欄、journalへのリンク、比較表の混入などを検出する。
|
||||
argument-hint: [ファイル...]
|
||||
---
|
||||
|
||||
# ドキュメントのルール違反を検査する
|
||||
|
||||
機械的に判定できる項目はスクリプトで検出し、判断が要る項目だけ目視で見る。
|
||||
共通の運用ルールは CLAUDE.md を正とする。
|
||||
**検出して報告するだけで、勝手に直さない。** 修正は内容を確認してもらってから。
|
||||
|
||||
## 1. スクリプトを走らせる
|
||||
@@ -15,50 +16,89 @@ argument-hint: [ファイル...]
|
||||
python .claude/skills/doc-lint/doc-lint.py [ファイル...]
|
||||
```
|
||||
|
||||
引数なしで直下の `*.md` と `journal/*.md` を全部見る。ERROR があれば終了コード 1。
|
||||
Python 3.9以上で実行する。引数なしでリポジトリ直下の `*.md`、`journal/*.md`、`backlog/**/*.md` を全部見る。
|
||||
実行場所にかかわらずリポジトリを基準に分類し、明示したパスは実行ディレクトリから解決する。
|
||||
ERROR があれば終了コード 1。存在しない引数のファイルもERRORにする。
|
||||
|
||||
検出する項目:
|
||||
|
||||
| 種別 | 内容 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| ERROR | 本文の `[推測:P-n]` に対応する `## 未確認事項` の項目がない |
|
||||
| ERROR | `## 未確認事項` の項目に `- 格上げ条件:` の行がない |
|
||||
| ERROR | 本文の推測タグに対応する項目に `- 格上げ条件:` の内容がない |
|
||||
| ERROR | タグのない未確認事項に `- 格上げ条件:` または `- 完了条件:` の内容がない |
|
||||
| ERROR | `## 未確認事項` のIDが重複している |
|
||||
| ERROR | 未確認事項へのローカルリンクの参照先ファイル・見出しがない、または個別項目へのリンクが未確認事項の項目を指していない |
|
||||
| ERROR | リンクラベルの課題番号と参照先の課題番号が一致しない |
|
||||
| ERROR | 仕様書の `## 保留課題` がリンク箇条書き以外を含む、空である、参照先ファイル・見出しがない |
|
||||
| ERROR | 末尾の管理セクションが重複する、保留課題 → 検討済み・非採用 → 未確認事項の順でない |
|
||||
| ERROR | backlogの項目IDが不正・検査対象内で重複する、必須5欄がない・空である |
|
||||
| ERROR | backlogに推測タグがある、参照先ファイルが存在しない |
|
||||
| ERROR | journal へのリンク / journal から他ファイルへのリンク |
|
||||
| ERROR | `## 検討済み・非採用` の項目が journal ファイル名を参照していない |
|
||||
| ERROR | journal のファイル名が `YYYY-MM-DD-<topic>.md` 形式でない |
|
||||
| WARN | `## 検討済み・非採用` が5件を超えた |
|
||||
| WARN | 本文の段落・箇条書き項目に、リンクのない課題番号や、課題への参照を伴わない「未決」「未定」がある |
|
||||
| WARN | journal の冒頭20行に結論が見当たらない |
|
||||
| WARN | メインドキュメントに比較表・優先度評価・却下案の記述らしきもの |
|
||||
|
||||
**本文タグのない `## 未確認事項` の項目はエラーにしない。** 「まだ本文に書けない未解決の問い」として正当。
|
||||
**本文タグのない `## 未確認事項` の項目はエラーにしない。** 実装前に判断が必要な未解決の問いとして正当。
|
||||
設計判断は「完了条件」でよい。タグなしの項目が事実確認なのか設計判断なのかは、目視で確認する。
|
||||
|
||||
backlogでは `### PHY-P-14 題名` のように区分付きIDを使い、関連仕様・未決事項・保留できる理由・再開条件・完了条件を必須とする。
|
||||
仕様書側の書式は `- [PHYの保留課題](backlog/README.md#phy)`。
|
||||
backlogは短い候補・制約を保持できるため、メインドキュメント向けの散文ヒューリスティックは適用しない。
|
||||
|
||||
WARN の散文検出は当たりが粗い。取りこぼしも誤検出もあるので、出た/出ないを結論にしない。
|
||||
未確認事項リンクの検査対象は `[P-3](#p-3-...)`・`[MAPのP-3](MAPレイヤー.md#p-3-...)` 等の
|
||||
インラインリンク。Markdown全般のリンク検査ではない。
|
||||
「未決」「未定」のWARNは、次行の参照も同じ段落として扱う。コード例、末尾の管理セクション、
|
||||
journal、backlog、ルールブック自体はこのWARNの対象外とする。
|
||||
WARNから課題の追加や本文の削除を自動決定しない。
|
||||
|
||||
## 2. スクリプトで見られない項目を確認する
|
||||
|
||||
こちらが本体。ファイルを読んで判断する。
|
||||
|
||||
- **「なぜBではないのか」が理由として残っていないか。**
|
||||
却下理由ではなく、現在効いている制約として書かれているか。
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- **裏取りしていない記述にタグが付いていないか。** 検査項目の中で最も重要で、
|
||||
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
||||
記述を探し、`[推測:P-n]` が付いているか確認する。
|
||||
却下理由ではなく、現在効いている制約として書かれているか。
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- **本文が前提にする未確認の記述にタグが付いているか。** 検査項目の中で最も重要で、
|
||||
機械では判定できない。「〜できるはず」「〜と考えられる」「実現可能」で実際に試していない
|
||||
記述を探し、`[推測:P-n]` が付いているか確認する。
|
||||
- **同じ事実が複数ドキュメントに複製されていないか。** 片方だけ古くなる。
|
||||
- **他の担当の説明を移した結果、責務の境界まで消えていないか。**
|
||||
この文書だけで担当範囲が分かるための一文と参照は残し、他の担当の詳細仕様は参照先で管理する。
|
||||
- **非採用一覧の各項目は、将来のどんな再提案を防ぐか。**
|
||||
本文の制約で十分か、実際に検討した別方針として経緯への入口が必要かを判断する。
|
||||
件数・古さ・重複だけで一律に追加・削除しない。
|
||||
- **本文の未決記述は、読者に何を伝えるためにあるか。**
|
||||
現在の対応範囲は本文に残し、問いの詳細は未確認事項へ集約する。
|
||||
対応項目へのリンク、必要な課題の追加、不要な言及の削除のいずれかを判断する。
|
||||
リンクが存在しても、その問いが本文の内容に対応するかは目視で確認する。
|
||||
- **保留中の対応範囲が仕様本文にあるか。** 現在の実装が答えに依存する前提をbacklogへ隠していないか。
|
||||
- **再開条件・完了条件が具体的か。** 仕様書の保留課題リンクは該当区分に向き、解決済みの課題が残っていないか。
|
||||
- **journal の冒頭20行で、結論と選択肢が分かるか。** 見出しの有無ではなく中身を読む。
|
||||
- **コミット規約**(必要なら `git log --oneline` を見る)
|
||||
- 1コミット1決定になっているか
|
||||
- journal を伴う決定のメッセージに `Journal:` 行があるか
|
||||
- 1コミット1決定になっているか
|
||||
- journal を伴う決定のメッセージに `Journal:` 行があるか
|
||||
|
||||
## 3. 報告する
|
||||
|
||||
ERROR / WARN / 目視で見つけたもの を分けて出す。
|
||||
機械検査のERROR / WARNと、目視で確認した内容・未確認の範囲を分けて報告する。
|
||||
機械検査が0件でも、内容の分類・責務の境界・記録の必要性が適切だった証明にはしない。
|
||||
目視確認を省いた場合はその旨を明記する。
|
||||
修正案は出してよいが、適用は確認を取ってから。事実関係の誤りを見つけた場合も、直さず指摘する。
|
||||
|
||||
## 注意
|
||||
|
||||
- 運用ルール策定前に書かれた既存ドキュメントは、まだルール1〜4が適用されていない。
|
||||
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
|
||||
再編前のファイルの WARN を個別に直そうとしない。
|
||||
- 既存ドキュメントへのルール適用は段階的に進めている。
|
||||
WARN が大量に出るのは想定内で、再編(`/restructure-doc`)のタイミングで解消する。
|
||||
再編前のファイルの WARN を個別に直そうとしない。
|
||||
|
||||
スクリプトを変更した場合の回帰検査:
|
||||
|
||||
```
|
||||
python -B -m unittest discover -s .claude/skills/doc-lint -p test_doc_lint.py
|
||||
```
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLAUDE.md の運用ルール1〜4のうち、機械的に判定できる項目を検査する。
|
||||
"""CLAUDE.md の運用ルール1〜5のうち、機械的に判定できる項目を検査する。
|
||||
|
||||
使い方:
|
||||
python .claude/skills/doc-lint/doc-lint.py [ファイル...]
|
||||
引数なしなら リポジトリ直下の *.md と journal/*.md を対象にする。
|
||||
引数なしならリポジトリ直下の *.md、journal/*.md、backlog/**/*.md が対象。
|
||||
明示したパスは実行ディレクトリから解決する。
|
||||
ERROR が1件でもあれば終了コード 1。
|
||||
"""
|
||||
import sys, re, os, io, glob
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
TAG = re.compile(r"\[推測:(P-\d+)\]")
|
||||
ENTRY = re.compile(r"^###\s+(P-\d+)\b")
|
||||
H2 = re.compile(r"^##\s+(.*?)\s*$")
|
||||
MDLINK = re.compile(r"\]\(([^)]+\.md[^)]*)\)")
|
||||
INLINE_LINK = re.compile(r"(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|[^\s)]+)\s*\)")
|
||||
INLINE_CODE = re.compile(r"(\x60+).*?\1", re.S)
|
||||
ISSUE_ID = re.compile(r"(?<![A-Za-z0-9_-])P-\d+(?![A-Za-z0-9_-])")
|
||||
ISSUE_ANCHOR = re.compile(r"^p-\d+(?:-|$)")
|
||||
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
|
||||
JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$")
|
||||
PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S")
|
||||
COMPLETE = re.compile(r"^\s*[-*]\s*完了条件\s*[::]\s*\S")
|
||||
BACKLOG_ENTRY = re.compile(r"^###\s+([A-Za-z][A-Za-z0-9_-]*-P-\d+)\s+\S")
|
||||
BACKLOG_LINK = re.compile(r"^- \[[^\]]+の保留課題\]\(([^)]+)\)$")
|
||||
BACKLOG_FIELDS = ("関連仕様", "未決事項", "保留できる理由", "再開条件", "完了条件")
|
||||
MANAGEMENT_SECTIONS = ("保留課題", "検討済み・非採用", "未確認事項")
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# ルールブック自身は、アンチパターンを例として引用するため散文ヒューリスティックの対象外
|
||||
PROSE_EXEMPT = {"CLAUDE.md"}
|
||||
@@ -28,14 +40,21 @@ def add(sev, path, line, msg):
|
||||
|
||||
def load(path):
|
||||
"""コードフェンス内を空行に潰した (行番号, 本文) の列を返す。"""
|
||||
raw = io.open(path, encoding="utf-8").read().splitlines()
|
||||
out, fence = [], False
|
||||
raw = Path(path).read_text(encoding="utf-8").splitlines()
|
||||
out, fence = [], None
|
||||
for i, l in enumerate(raw, 1):
|
||||
if l.lstrip().startswith("```"):
|
||||
fence = not fence
|
||||
marker = re.match(r"^ {0,3}(\x60{3,}|~{3,})(.*)$", l)
|
||||
if fence is not None:
|
||||
out.append((i, ""))
|
||||
if (marker and marker.group(1)[0] == fence[0]
|
||||
and len(marker.group(1)) >= fence[1] and not marker.group(2).strip()):
|
||||
fence = None
|
||||
continue
|
||||
if marker:
|
||||
fence = (marker.group(1)[0], len(marker.group(1)))
|
||||
out.append((i, ""))
|
||||
continue
|
||||
out.append((i, "" if fence else l))
|
||||
out.append((i, l))
|
||||
return out
|
||||
|
||||
def section(lines, title):
|
||||
@@ -55,15 +74,208 @@ def section(lines, title):
|
||||
body.append((n, l))
|
||||
return body
|
||||
|
||||
def check(path):
|
||||
rel = path.replace(os.sep, "/")
|
||||
base = os.path.basename(rel)
|
||||
is_journal = rel.startswith("journal/")
|
||||
def local_target(path, target):
|
||||
"""相対リンクをリンク元基準で解決する。外部URLは対象外。"""
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None, ""
|
||||
linked = (path.parent / unquote(parsed.path)).resolve() if parsed.path else path
|
||||
return linked, unquote(parsed.fragment)
|
||||
|
||||
|
||||
def heading_index(lines):
|
||||
"""ATX見出しのアンカーを作る。同じ見出しの連番も扱う。"""
|
||||
headings = {}
|
||||
for n, line in lines:
|
||||
match = HEADING.match(line)
|
||||
if not match:
|
||||
continue
|
||||
title = match.group(1)
|
||||
slug = re.sub(r"[^\w\s-]", "", title).lower().replace(" ", "-")
|
||||
anchor, suffix = slug, 0
|
||||
while anchor in headings:
|
||||
suffix += 1
|
||||
anchor = f"{slug}-{suffix}"
|
||||
headings[anchor] = (n, title)
|
||||
return headings
|
||||
|
||||
|
||||
def body_paragraphs(lines):
|
||||
"""改行をまたぐ本文段落を返す。見出し・空行・次の箇条書きで区切る。"""
|
||||
paragraph = []
|
||||
in_management = False
|
||||
for n, line in lines:
|
||||
if HEADING.match(line):
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
paragraph = []
|
||||
h2 = H2.match(line)
|
||||
if h2:
|
||||
in_management = h2.group(1) in MANAGEMENT_SECTIONS
|
||||
elif line.startswith("# "):
|
||||
in_management = False
|
||||
continue
|
||||
if in_management:
|
||||
continue
|
||||
if not line.strip() or re.match(r"^\s*(?:[-*+]|\d+[.)])\s+", line):
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
paragraph = []
|
||||
if line.strip():
|
||||
paragraph.append((n, line))
|
||||
if paragraph:
|
||||
yield paragraph
|
||||
|
||||
|
||||
def check_issue_references(path, rel, lines):
|
||||
"""課題リンクの構造はERROR、本文の参照漏れの候補はWARNにする。"""
|
||||
targets = {}
|
||||
valid_link_lines = set()
|
||||
# インラインコードも位置を変えずにマスクする。
|
||||
text = "\n".join(line for _, line in lines)
|
||||
masked = INLINE_CODE.sub(lambda m: re.sub(r"[^\n]", " ", m.group()), text).split("\n")
|
||||
prose_lines = [(n, line) for (n, _), line in zip(lines, masked)]
|
||||
for n, line in prose_lines:
|
||||
for match in INLINE_LINK.finditer(line):
|
||||
label, target = match.groups()
|
||||
target = target.strip("<>")
|
||||
linked, anchor = local_target(path, target)
|
||||
if linked is None:
|
||||
continue
|
||||
label_ids = ISSUE_ID.findall(label)
|
||||
if not (label_ids or ISSUE_ANCHOR.match(anchor) or anchor == "未確認事項"):
|
||||
continue
|
||||
if not linked.is_file():
|
||||
add("ERROR", rel, n, "未確認事項リンクの参照先ファイルがない: %s" % target)
|
||||
continue
|
||||
if linked not in targets:
|
||||
target_lines = load(linked)
|
||||
entries = {
|
||||
ln: ENTRY.match(value).group(1)
|
||||
for ln, value in (section(target_lines, "未確認事項") or [])
|
||||
if ENTRY.match(value)
|
||||
}
|
||||
targets[linked] = (heading_index(target_lines), entries)
|
||||
headings, entries = targets[linked]
|
||||
if not anchor or anchor not in headings:
|
||||
add("ERROR", rel, n, "未確認事項リンクの参照先見出しがない: %s" % target)
|
||||
continue
|
||||
target_line, _ = headings[anchor]
|
||||
if anchor == "未確認事項" and not label_ids:
|
||||
continue
|
||||
target_id = entries.get(target_line)
|
||||
if target_id is None:
|
||||
add("ERROR", rel, n, "課題リンクが未確認事項の個別項目を指していない: %s" % target)
|
||||
elif any(pid != target_id for pid in label_ids):
|
||||
add("ERROR", rel, n, "リンクラベルの課題番号と参照先が一致しない: %s → %s" % (label, target_id))
|
||||
else:
|
||||
valid_link_lines.add(n)
|
||||
|
||||
for paragraph in body_paragraphs(prose_lines):
|
||||
n = paragraph[0][0]
|
||||
text = "\n".join(line for _, line in paragraph)
|
||||
# リンクと推測タグには別の構造検査がある。裸の課題番号だけを探す。
|
||||
plain = TAG.sub("", INLINE_LINK.sub("", text))
|
||||
bare_ids = sorted(set(ISSUE_ID.findall(plain)))
|
||||
if bare_ids:
|
||||
add("WARN", rel, n, "本文にリンクのない課題番号がある(要確認): %s" % ", ".join(bare_ids))
|
||||
if (re.search(r"未決|未定", plain)
|
||||
and not any(ln in valid_link_lines for ln, _ in paragraph)
|
||||
and not TAG.search(text)):
|
||||
add("WARN", rel, n, "本文の未決・未定に課題への参照がない(要確認)。リンク・課題追加・言及削除を判断する")
|
||||
|
||||
|
||||
def check_backlog(path, rel, lines, seen_ids):
|
||||
blocks = []
|
||||
current = None
|
||||
for n, line in lines:
|
||||
if H2.match(line):
|
||||
current = None
|
||||
elif re.match(r"^###\s", line):
|
||||
match = BACKLOG_ENTRY.match(line)
|
||||
current = None
|
||||
if not match:
|
||||
add("ERROR", rel, n, "保留課題の見出しは ### <区分>-P-<番号> <題名> とする")
|
||||
continue
|
||||
pid = match.group(1)
|
||||
if pid in seen_ids:
|
||||
add("ERROR", rel, n, "保留課題のID %s が重複している(初出: %s)" % (pid, seen_ids[pid]))
|
||||
seen_ids[pid] = "%s:%d" % (rel, n)
|
||||
current = (pid, n, [])
|
||||
blocks.append(current)
|
||||
elif current is not None:
|
||||
current[2].append(line)
|
||||
|
||||
for pid, n, body in blocks:
|
||||
for field in BACKLOG_FIELDS:
|
||||
pattern = re.compile(r"^[-*]\s+" + re.escape(field) + r"\s*[::]\s*\S")
|
||||
if not any(pattern.match(line) for line in body):
|
||||
add("ERROR", rel, n, "%s に「- %s:」の内容がない" % (pid, field))
|
||||
|
||||
for n, line in lines:
|
||||
if TAG.search(line):
|
||||
add("ERROR", rel, n, "本文の前提となる推測タグはbacklogではなく仕様書に置く")
|
||||
for target in MDLINK.findall(line):
|
||||
linked, _ = local_target(path, target)
|
||||
if linked is not None and not linked.is_file():
|
||||
add("ERROR", rel, n, "backlogの参照先ファイルがない: %s" % target)
|
||||
|
||||
|
||||
def check_backlog_section(path, rel, lines):
|
||||
positions = []
|
||||
for title in MANAGEMENT_SECTIONS:
|
||||
headings = [n for n, line in lines if H2.match(line) and H2.match(line).group(1) == title]
|
||||
if len(headings) > 1:
|
||||
add("ERROR", rel, headings[1], "## %s が重複している" % title)
|
||||
if headings:
|
||||
positions.append(headings[0])
|
||||
if positions != sorted(positions):
|
||||
add("ERROR", rel, min(positions), "末尾の管理セクションは保留課題 → 検討済み・非採用 → 未確認事項の順に置く")
|
||||
|
||||
deferred = section(lines, "保留課題")
|
||||
if deferred is None:
|
||||
return
|
||||
contents = [(n, line) for n, line in deferred if line.strip()]
|
||||
if not contents:
|
||||
add("ERROR", rel, 1, "空の ## 保留課題 はセクションごと削除する")
|
||||
for n, line in contents:
|
||||
match = BACKLOG_LINK.fullmatch(line)
|
||||
if not match:
|
||||
add("ERROR", rel, n, "保留課題には - [<区分>の保留課題](backlogのファイル#見出し) だけを書く")
|
||||
continue
|
||||
target = match.group(1)
|
||||
linked, anchor = local_target(path, target)
|
||||
if (linked is None or not linked.is_relative_to(ROOT / "backlog")
|
||||
or linked.suffix != ".md" or not anchor):
|
||||
add("ERROR", rel, n, "保留課題はbacklog内のMarkdownファイルの見出しへリンクする: %s" % target)
|
||||
continue
|
||||
if not linked.is_file():
|
||||
add("ERROR", rel, n, "保留課題の参照先ファイルがない: %s" % target)
|
||||
continue
|
||||
headings = heading_index(load(linked))
|
||||
if anchor not in headings:
|
||||
add("ERROR", rel, n, "保留課題の参照先見出しがない: %s" % target)
|
||||
|
||||
|
||||
def check(path, seen_backlog_ids=None):
|
||||
path = Path(path).resolve()
|
||||
rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix()
|
||||
base = path.name
|
||||
is_journal = path.is_relative_to(ROOT / "journal")
|
||||
is_backlog = path.is_relative_to(ROOT / "backlog")
|
||||
lines = load(path)
|
||||
|
||||
if is_backlog:
|
||||
check_backlog(path, rel, lines, seen_backlog_ids if seen_backlog_ids is not None else {})
|
||||
elif not is_journal:
|
||||
check_backlog_section(path, rel, lines)
|
||||
if base not in PROSE_EXEMPT:
|
||||
check_issue_references(path, rel, lines)
|
||||
|
||||
unconf = section(lines, "未確認事項")
|
||||
rejected = section(lines, "検討済み・非採用")
|
||||
unconf_span = set(n for n, _ in unconf) if unconf else set()
|
||||
tagged = {pid for n, line in lines if n not in unconf_span for pid in TAG.findall(line)}
|
||||
|
||||
# --- ルール4: 推測タグと未確認事項の対応 ---
|
||||
entries = set()
|
||||
@@ -80,9 +292,14 @@ def check(path):
|
||||
elif blocks:
|
||||
blocks[-1][2].append(l)
|
||||
for pid, ln, body in blocks:
|
||||
if not any(PROMOTE.match(x) for x in body):
|
||||
has_promote = any(PROMOTE.match(x) for x in body)
|
||||
has_complete = any(COMPLETE.match(x) for x in body)
|
||||
if pid in tagged and not has_promote:
|
||||
add("ERROR", rel, ln,
|
||||
"%s に「- 格上げ条件:」の行がない(何を確認すれば事実に格上げできるか)" % pid)
|
||||
elif not has_promote and not has_complete:
|
||||
add("ERROR", rel, ln,
|
||||
"%s に「- 格上げ条件:」または「- 完了条件:」の内容がない" % pid)
|
||||
|
||||
for n, l in lines:
|
||||
if n in unconf_span:
|
||||
@@ -96,9 +313,10 @@ def check(path):
|
||||
# --- ルール3: journal への/からのリンク禁止 ---
|
||||
for n, l in lines:
|
||||
for target in MDLINK.findall(l):
|
||||
if target.lstrip("./").startswith("journal/"):
|
||||
linked, _ = local_target(path, target)
|
||||
if linked is not None and linked.is_relative_to(ROOT / "journal"):
|
||||
add("ERROR", rel, n, "journal へのリンクが張られている: %s" % target)
|
||||
elif is_journal:
|
||||
elif is_journal and linked is not None:
|
||||
add("ERROR", rel, n, "journal から他ファイルへのリンクが張られている: %s" % target)
|
||||
|
||||
# --- ルール1: 検討済み・非採用 ---
|
||||
@@ -110,7 +328,7 @@ def check(path):
|
||||
"検討済み・非採用の項目が journal ファイル名を参照していない")
|
||||
if len(items) > 5:
|
||||
add("WARN", rel, items[5][0],
|
||||
"検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items))
|
||||
"検討済み・非採用が %d 件(目安5件)。再提案を防ぐ必要性で掲載を見直す" % len(items))
|
||||
|
||||
# --- journal 固有 ---
|
||||
if is_journal:
|
||||
@@ -121,7 +339,7 @@ def check(path):
|
||||
add("WARN", rel, 1, "冒頭20行に結論が見当たらない(結論を先頭に置く)")
|
||||
|
||||
# --- ルール1: 調査・比較の混入(要目視確認) ---
|
||||
if not is_journal and base not in PROSE_EXEMPT:
|
||||
if not is_journal and not is_backlog and base not in PROSE_EXEMPT:
|
||||
# 検討済み・非採用 は却下案を列挙する場所なので対象外
|
||||
skip = set(n for n, _ in rejected) if rejected else set()
|
||||
for n, l in lines:
|
||||
@@ -135,16 +353,21 @@ def check(path):
|
||||
add("WARN", rel, n, "優先度評価は journal 行きでは?")
|
||||
|
||||
def main(argv):
|
||||
targets = argv[1:]
|
||||
issues.clear()
|
||||
targets = [Path(arg).resolve() for arg in argv[1:]]
|
||||
if not targets:
|
||||
targets = sorted(glob.glob("*.md")) + sorted(glob.glob("journal/*.md"))
|
||||
targets = (sorted(ROOT.glob("*.md")) + sorted((ROOT / "journal").glob("*.md"))
|
||||
+ sorted((ROOT / "backlog").rglob("*.md")))
|
||||
targets = list(dict.fromkeys(targets))
|
||||
if not targets:
|
||||
print("対象ファイルなし"); return 0
|
||||
print("対象ファイルなし")
|
||||
return 0
|
||||
seen_backlog_ids = {}
|
||||
for t in targets:
|
||||
if os.path.isfile(t):
|
||||
check(t)
|
||||
if t.is_file():
|
||||
check(t, seen_backlog_ids)
|
||||
else:
|
||||
print("見つからない: %s" % t)
|
||||
add("ERROR", t.as_posix(), 1, "対象ファイルが見つからない")
|
||||
|
||||
errs = [i for i in issues if i[0] == "ERROR"]
|
||||
warns = [i for i in issues if i[0] == "WARN"]
|
||||
@@ -154,4 +377,6 @@ def main(argv):
|
||||
return 1 if errs else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.exit(main(sys.argv))
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""文書の分類・参照・完了条件の回帰検査。標準ライブラリだけで実行する。"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("doc_lint", Path(__file__).with_name("doc-lint.py"))
|
||||
lint = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(lint)
|
||||
|
||||
|
||||
def backlog_item(pid="PHY-P-14"):
|
||||
return (
|
||||
f"### {pid} 原点の吸着\n\n"
|
||||
"- 関連仕様: [PHY](../PHY.md)\n"
|
||||
"- 未決事項: 吸着が必要か\n"
|
||||
"- 保留できる理由: 数値入力で編集できる\n"
|
||||
"- 再開条件: 原点移動を試作するとき\n"
|
||||
"- 完了条件: 吸着の要否を決める\n"
|
||||
)
|
||||
|
||||
|
||||
def issue_section():
|
||||
return (
|
||||
"## 未確認事項\n\n"
|
||||
"### P-1 動作の範囲\n\n- 完了条件: 範囲を決める\n\n"
|
||||
"### P-3 対応・状態管理\n\n- 完了条件: 管理方法を決める\n"
|
||||
)
|
||||
|
||||
|
||||
class DocLintTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name).resolve()
|
||||
root_patch = patch.object(lint, "ROOT", self.root)
|
||||
root_patch.start()
|
||||
self.addCleanup(root_patch.stop)
|
||||
self.write("PHY.md", "# PHY\n")
|
||||
|
||||
def write(self, relative, content):
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_lint(self, *paths):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
status = lint.main(["doc-lint.py", *(str(path) for path in paths)])
|
||||
return status, output.getvalue()
|
||||
|
||||
def test_default_scan_includes_backlog_and_accepts_fixed_links(self):
|
||||
self.write("PHY.md", "# PHY\n\n## 保留課題\n\n- [PHYの保留課題](backlog/README.md#phy)\n")
|
||||
self.write("backlog/README.md", "# 保留課題\n\n## PHY\n\n" + backlog_item())
|
||||
self.write("journal/2026-09-22-decision.md", "# 決定\n\n## 結論\n\n採用した。\n")
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("3 files / ERROR 0", output)
|
||||
|
||||
def test_backlog_fields_must_have_values(self):
|
||||
for field in lint.BACKLOG_FIELDS:
|
||||
with self.subTest(field=field):
|
||||
text = backlog_item()
|
||||
text = "\n".join(f"- {field}: " if line.startswith(f"- {field}:") else line
|
||||
for line in text.splitlines())
|
||||
path = self.write("backlog/README.md", "## PHY\n" + text)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn(f"- {field}:", output)
|
||||
|
||||
def test_duplicate_backlog_ids_across_nested_files(self):
|
||||
self.write("backlog/one.md", "## PHY\n" + backlog_item())
|
||||
self.write("backlog/sub/two.md", "## PHY\n" + backlog_item().replace("../PHY.md", "../../PHY.md"))
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("保留課題のID PHY-P-14 が重複", output)
|
||||
|
||||
def test_incomplete_last_item_cannot_borrow_fields_from_next_section(self):
|
||||
self.write("backlog/README.md", "## PHY\n### PHY-P-14 吸着\n\n## ELC\n" + backlog_item("ELC-P-1"))
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("PHY-P-14 に「- 完了条件:」", output)
|
||||
|
||||
def test_invalid_backlog_heading_and_missing_reference(self):
|
||||
path = self.write("backlog/README.md", "## PHY\n### P-14 吸着\n" + backlog_item().replace("../PHY.md", "../missing.md"))
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("保留課題の見出し", output)
|
||||
self.assertIn("参照先ファイルがない", output)
|
||||
|
||||
def test_spec_backlog_section_rejects_prose_empty_and_broken_targets(self):
|
||||
self.write("backlog/README.md", "## PHY\n" + backlog_item())
|
||||
cases = {
|
||||
"内容をここにも要約する。": "だけを書く",
|
||||
"": "空の ## 保留課題",
|
||||
"- [PHYの保留課題](backlog/missing.md#phy)": "参照先ファイルがない",
|
||||
"- [PHYの保留課題](backlog/README.md#missing)": "参照先見出しがない",
|
||||
"- [PHYの保留課題](backlog/README.md)": "見出しへリンク",
|
||||
"- [PHYの保留課題](PHY.md#phy)": "backlog内",
|
||||
}
|
||||
for content, expected in cases.items():
|
||||
with self.subTest(content=content):
|
||||
path = self.write("PHY.md", "# PHY\n\n## 保留課題\n\n" + content + "\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_management_sections_have_one_order(self):
|
||||
path = self.write("PHY.md", "## 未確認事項\n\n(なし)\n\n## 検討済み・非採用\n\n## 未確認事項\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("順に置く", output)
|
||||
self.assertIn("## 未確認事項 が重複", output)
|
||||
|
||||
def test_decisions_accept_completion_but_tagged_assumptions_require_evidence(self):
|
||||
text = "## 未確認事項\n\n### P-1 操作方式\n- 完了条件: 操作方式を決める\n"
|
||||
path = self.write("PHY.md", text)
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
path.write_text("本文 [推測:P-1]\n\n" + text, encoding="utf-8")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("格上げ条件", output)
|
||||
path.write_text("本文 [推測:P-1]\n\n" + text.replace("完了条件", "格上げ条件"), encoding="utf-8")
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
|
||||
def test_absolute_journal_paths_keep_journal_checks(self):
|
||||
path = self.write("journal/bad.md", "# 調査\n\n[仕様](../PHY.md)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("journal のファイル名", output)
|
||||
self.assertIn("journal から他ファイルへのリンク", output)
|
||||
|
||||
def test_backlog_is_not_prose_linted_but_cannot_link_to_journal(self):
|
||||
self.write("journal/2026-09-22-decision.md", "## 結論\n")
|
||||
path = self.write("backlog/README.md", "## PHY\n" + backlog_item() + "\n候補はA案。\n[履歴](../journal/2026-09-22-decision.md)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("journal へのリンク", output)
|
||||
self.assertIn("WARN 0", output)
|
||||
|
||||
def test_code_examples_do_not_create_real_sections_or_tags(self):
|
||||
path = self.write("PHY.md", "```markdown\n## 保留課題\n[推測:P-9]\n```\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
|
||||
def test_missing_explicit_file_is_an_error(self):
|
||||
status, output = self.run_lint(self.root / "missing.md")
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("対象ファイルが見つからない", output)
|
||||
|
||||
def test_issue_links_accept_same_file_cross_file_and_encoded_anchors(self):
|
||||
self.write("MAPレイヤー.md", "# MAP\n\n" + issue_section())
|
||||
encoded = quote("MAPレイヤー.md") + "#" + quote("p-3-対応状態管理")
|
||||
path = self.write(
|
||||
"PHY.md",
|
||||
"# PHY\n\n範囲は未決。\n[P-1](#p-1-動作の範囲)で扱う。\n\n"
|
||||
"[MAPのP-3](MAPレイヤー.md#p-3-対応状態管理)へ。\n\n"
|
||||
f"[MAPのP-3]({encoded})へ。\n\n" + issue_section(),
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("ERROR 0 / WARN 0", output)
|
||||
|
||||
def test_issue_links_reject_missing_file_heading_and_fragment(self):
|
||||
self.write("MAP.md", "# MAP\n\n" + issue_section())
|
||||
for target, expected in (
|
||||
("missing.md#p-3-対応状態管理", "参照先ファイルがない"),
|
||||
("MAP.md#p-3-古い題名", "参照先見出しがない"),
|
||||
("MAP.md", "参照先見出しがない"),
|
||||
):
|
||||
with self.subTest(target=target):
|
||||
path = self.write("PHY.md", f"[P-3]({target})\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_issue_label_must_match_real_entry_in_unconfirmed_section(self):
|
||||
self.write("MAP.md", "# MAP\n\n### P-2 本文の見出し\n\n" + issue_section())
|
||||
for link, expected in (
|
||||
("[P-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||
("[MAPのP-3](MAP.md#p-1-動作の範囲)", "課題番号と参照先が一致しない"),
|
||||
("[P-2](MAP.md#p-2-本文の見出し)", "個別項目を指していない"),
|
||||
("[P-3](MAP.md#未確認事項)", "個別項目を指していない"),
|
||||
):
|
||||
with self.subTest(link=link):
|
||||
path = self.write("PHY.md", link + "\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn(expected, output)
|
||||
|
||||
def test_duplicate_heading_slug_resolves_actual_target(self):
|
||||
self.write("MAP.md", "# MAP\n\n### P-1 動作の範囲\n\n" + issue_section())
|
||||
path = self.write("PHY.md", "[P-1](MAP.md#p-1-動作の範囲-1)\n")
|
||||
self.assertEqual(self.run_lint(path)[0], 0)
|
||||
path.write_text("[P-1](MAP.md#p-1-動作の範囲)\n", encoding="utf-8")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("個別項目を指していない", output)
|
||||
|
||||
def test_unresolved_prose_warns_without_failing_or_modifying_documents(self):
|
||||
text = "# PHY\n\n方式は未定。P-3で検討する。\n\n" + issue_section()
|
||||
path = self.write("PHY.md", text)
|
||||
before = path.read_bytes()
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("リンクのない課題番号", output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
def test_another_issue_link_does_not_hide_bare_id(self):
|
||||
path = self.write(
|
||||
"PHY.md", "P-3と[P-1](#p-1-動作の範囲)を参照。\n\n" + issue_section()
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("リンクのない課題番号がある(要確認): P-3", output)
|
||||
|
||||
def test_unrelated_links_and_section_links_do_not_hide_unresolved_prose(self):
|
||||
for link in ("[仕様](PHY.md)", "[一覧](#未確認事項)", "[外部](https://example.com/)"):
|
||||
with self.subTest(link=link):
|
||||
path = self.write("PHY.md", f"範囲は未決。{link}\n\n" + issue_section())
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
|
||||
def test_broken_link_does_not_count_as_resolved_reference(self):
|
||||
path = self.write("PHY.md", "範囲は未決。\n[P-1](#p-1-missing)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("参照先見出しがない", output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
|
||||
def test_paragraph_and_list_boundaries_do_not_borrow_links(self):
|
||||
for body in (
|
||||
"範囲は未決。\n\n[P-1](#p-1-動作の範囲)\n",
|
||||
"- 範囲は未決。\n- [P-1](#p-1-動作の範囲)\n",
|
||||
"1. 範囲は未決。\n2. [P-1](#p-1-動作の範囲)\n",
|
||||
"範囲は未決。\n## 次の話\n[P-1](#p-1-動作の範囲)\n",
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
path = self.write("PHY.md", body + "\n" + issue_section())
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("未決・未定に課題への参照がない", output)
|
||||
path = self.write(
|
||||
"PHY.md", "- 範囲は未決。\n [P-1](#p-1-動作の範囲)\n\n" + issue_section()
|
||||
)
|
||||
self.assertIn("ERROR 0 / WARN 0", self.run_lint(path)[1])
|
||||
|
||||
def test_code_examples_external_links_and_assumptions_are_not_prose_warnings(self):
|
||||
path = self.write(
|
||||
"PHY.md",
|
||||
"\x60未決 P-8 [P-8](#missing)\x60\n\n"
|
||||
"\x60\x60\x60markdown\n未定 P-8 [P-8](#missing)\n\x60\x60\x60\n\n"
|
||||
"~~~markdown\n未定 P-8 [P-8](#missing)\n~~~\n\n"
|
||||
"[P-99](https://example.com/#p-99)\n\n"
|
||||
"範囲は未定 [推測:P-1]。\n\n"
|
||||
"## 未確認事項\n\n### P-1 調査\n\n- 格上げ条件: 検証する\n",
|
||||
)
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("ERROR 0 / WARN 0", output)
|
||||
|
||||
def test_management_sections_and_non_spec_files_are_exempt_from_prose_warnings(self):
|
||||
self.write("CLAUDE.md", "未決 P-8 [P-8](#missing)\n")
|
||||
self.write("journal/2026-09-22-decision.md", "## 結論\n\n未定 P-8\n")
|
||||
self.write("backlog/README.md", "## PHY\n\n" + backlog_item() + "\n未定 P-8\n")
|
||||
self.write("PHY.md", "# PHY\n\n" + issue_section() + "\n未定 P-8\n")
|
||||
status, output = self.run_lint()
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("WARN 0", output)
|
||||
|
||||
def test_links_inside_unconfirmed_items_are_still_checked(self):
|
||||
path = self.write("PHY.md", issue_section() + "\n[P-8](#missing)\n")
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 1, output)
|
||||
self.assertIn("参照先見出しがない", output)
|
||||
|
||||
def test_rejected_count_warning_does_not_select_entries_for_deletion(self):
|
||||
path = self.write(
|
||||
"PHY.md", "## 検討済み・非採用\n\n"
|
||||
+ "".join(f"- 案{i} (journal/2026-09-22-decision.md)\n" for i in range(6))
|
||||
)
|
||||
before = path.read_bytes()
|
||||
status, output = self.run_lint(path)
|
||||
self.assertEqual(status, 0, output)
|
||||
self.assertIn("6 件(目安5件)", output)
|
||||
self.assertIn("再提案を防ぐ必要性", output)
|
||||
self.assertNotIn("古いものから落とす", output)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -29,7 +29,7 @@ argument-hint: <ファイル名>
|
||||
各ブロック(見出し単位、または段落単位)を次のどれかに分ける。
|
||||
|
||||
| 分類 | 行き先 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| a. 採用した案 / 現在の設定 / 設計に影響する制約 | メインドキュメントに残す |
|
||||
| b. 裏取りの済んだ事実 | メインドキュメントに残す(文中のどこでもよい) |
|
||||
| c. 未確認の推測・未解決の問い | メインドキュメント末尾の `## 未確認事項` |
|
||||
@@ -50,8 +50,8 @@ argument-hint: <ファイル名>
|
||||
|
||||
- a を中心に再構成する。b は文中に自然に置く。
|
||||
- 「なぜBではないのか」の記述が残っていたら、**制約文**に書き換える。
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- ✗ `階層構造はKLEが非対応なので諦めた`
|
||||
- ○ `階層クラスターはKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 落とした案があれば末尾 `## 検討済み・非採用` に1案1行(理由なし、journalファイル名を参照)。
|
||||
|
||||
### 5. 未確認事項を立てる
|
||||
@@ -92,5 +92,5 @@ Journal: journal/2026-09-16-<元ファイル名>.md
|
||||
## 注意
|
||||
|
||||
- 複数ファイルを一度に触らない。ファイル間で内容が重複していると気づいた場合は、
|
||||
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
|
||||
勝手に統合せず、指摘だけしてそのファイルの再編を終える。
|
||||
- 再編の途中で事実関係の誤りを見つけても、勝手に直さない。指摘する。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// VS Codeのユーザー設定(markdownlint.config)と同じ規則にそろえる
|
||||
{
|
||||
"MD007": { "indent": 4 }
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"[markdown]": {
|
||||
"editor.detectIndentation": false,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Beyond KLE
|
||||
|
||||
キーボード設計のデータモデルとワークフローを再設計するプロジェクト。
|
||||
PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ) / IME(入力変換) の5層に分離する。
|
||||
PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ) / HST(ホスト) の5層に分離する。
|
||||
|
||||
- 応答・ドキュメントは日本語。
|
||||
- 現状はコード未着手、設計ドキュメントのみ。
|
||||
@@ -9,24 +9,30 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
## ドキュメント地図
|
||||
|
||||
| ファイル | 内容 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `keyboard-design-layers.md` | レイヤー構造とツール分割方針(最上位) |
|
||||
| `PHYレイヤー.md` | PHY層の仕様 |
|
||||
| `ELCレイヤー.md` | ELC層の仕様 |
|
||||
| `IDXレイヤー.md` | IDX層の仕様 |
|
||||
| `MAPレイヤー.md` | MAP層とエディタの責務・実機との読み書き方針 |
|
||||
| `HSTレイヤー.md` | HST層(ホストによるキーコードの解釈)の仕様 |
|
||||
| `phase1-json-schema.md` | 各層のJSON Schema |
|
||||
| `keyboard-making-workflow.md` | 既存の自作キーボード制作フローとの対応 |
|
||||
| `implementation-plan.md` | 実装方針の決定事項・検証用サンプル |
|
||||
| [`backlog/README.md`](backlog/README.md) | 後回しにできる問い・検証・改善候補 |
|
||||
| `samples/` | 検証用サンプル(KLE JSON、VIA定義)。出典とテストケースは `implementation-plan.md` |
|
||||
|
||||
**既存ドキュメントは下記ルールの策定前に書かれたもので、ルール1〜4は未適用。**
|
||||
全体の再編は別タスクとして行う。それまで各ファイルをメイン/journalのどちらに分類するかは未確定なので、
|
||||
指示なく移動・分割・書き直しをしない。
|
||||
既存ドキュメントには下記ルールを段階的に適用する。PHY・ELC・IDXの保留課題はbacklogへ分離済み。
|
||||
全体の再編は別タスクとして行い、指示なく他の内容を移動・分割・書き直ししない。
|
||||
|
||||
初期コミット以前の経緯は追跡できない。現在のドキュメント群を「初期検討段階」のベースラインとして扱い、
|
||||
それ以前の決定理由を掘り起こそうとしない。
|
||||
|
||||
## ドキュメント運用ルール
|
||||
|
||||
現在の仕様・将来取り組む課題・過去の検討を分け、仕様を読む際に判断状況が混ざらないようにする。
|
||||
未確認の前提は確定事項と区別し、保留課題は再開するまで見つけられる状態に保つ。
|
||||
|
||||
### 1. メインドキュメント(リポジトリ直下の `*.md`)
|
||||
|
||||
書くもの: **採用した案 / 現在の設定 / 設計に影響する制約**。
|
||||
@@ -34,12 +40,13 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
- 複数案から選んだ場合、「なぜAなのか」を1〜3行で書く。「なぜBではないのか」は書かない。
|
||||
- 却下案の比較表・検討過程・ベンチマーク結果はここに置かない(→ `journal/`)。
|
||||
- 却下案の再提案を防ぐ情報は、比較ではなく**制約文**として書く。
|
||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 末尾に `## 検討済み・非採用` を置き、落とした案を**1案1行**で残す。理由は書かず、journalファイル名を参照する。
|
||||
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
|
||||
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
|
||||
- 目安5行まで。増えたら古いものから落とす。ここが長くなるのは書きすぎのサイン。
|
||||
- ✗ `B案はKLEパーサーが壊れるので却下した`
|
||||
- ○ `クラスター情報はKLE JSONに出力しない(KLE互換性の制約)`
|
||||
- 落とした案があれば、末尾に `## 検討済み・非採用` を置き、**1案1行**で残す。理由は書かず、journalファイル名を参照する。
|
||||
- 例: `- labels[9] へのID埋め込み (journal/2026-09-16-cluster-metadata.md)`
|
||||
- コミットは `git log --oneline -- journal/2026-09-16-cluster-metadata.md` で引ける(journal削除後も追跡可)
|
||||
- 載せるのは、実際に検討した別方針のうち再提案されうるものに限る。本文の制約文で足りるなら重複させない。
|
||||
- 目安5行まで。増えたら、再提案を防ぐ必要性が低いものから落とす。長くなるのは書きすぎのサイン。
|
||||
|
||||
### 2. 履歴と根拠
|
||||
|
||||
@@ -47,13 +54,13 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
- **なぜそう決めたか / 調査 / 案の比較** → `journal/` に置き、その決定と**同じコミット**に含める。
|
||||
- コミットメッセージ末尾に参照を1行入れる: `Journal: journal/2026-09-16-cluster-metadata.md`
|
||||
- journal は作業ツリーから削除する前提(人間にとってのノイズになるため)。履歴には残るので、
|
||||
削除後は `git show <sha>:journal/...` で読む。
|
||||
削除後は `git show <sha>:journal/...` で読む。
|
||||
|
||||
### 3. journal の書き方
|
||||
|
||||
- 1ファイル1トピック。`journal/YYYY-MM-DD-<topic>.md`。トピックを詰め込まない。
|
||||
- 構成は上から順に: **結論** → **案の比較(簡潔な表)** → 検証ログ・根拠・捨てた案。
|
||||
冒頭20行で結論と選択肢が分かること。
|
||||
冒頭20行で結論と選択肢が分かること。
|
||||
- 他のファイルから journal へリンクを張らない。journal 同士も張らない。参照経路はコミットメッセージのみ。
|
||||
- 結論がメインドキュメントに反映されたら削除してよい。
|
||||
- 整形は最低限でよい。人間向けの読みやすさは要求しない。
|
||||
@@ -61,11 +68,16 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
### 4. 事実と推測
|
||||
|
||||
- **裏取りの済んだ事実**は、ドキュメント内のどこに書いてもよい。文中に自然に混ぜてよい。
|
||||
ただし同じ事実を複数ドキュメントに複製しない。必要ならリンクする。
|
||||
- **未確認の推測**だけを末尾に隔離する。2種類ある:
|
||||
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
|
||||
- まだ本文に書けない未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
|
||||
- `## 未確認事項` の各項目には、**何を確認できれば事実に格上げできるか**を必ず書く。
|
||||
ただし同じ事実を複数ドキュメントに複製しない。必要ならリンクする。
|
||||
- **現在の仕様・実装が答えに依存する未確認事項**は、仕様書の末尾に残す。2種類ある:
|
||||
- 本文が前提にしている推測 → 本文に `[推測:P-n]` を付け、末尾の `## 未確認事項` に対応項目を置く
|
||||
- 実装前に判断が必要な未解決の問い → 本文タグなしで `## 未確認事項` に直接置く
|
||||
- 本文が未決の問いに触れるときは、「未決」と書いて終わらせず該当項目へリンクする。
|
||||
同一ファイル内は `[P-3](#p-3-...)`、他ファイルからは `[MAPのP-3](MAPレイヤー.md#p-3-...)` の形とする。
|
||||
問いの内容は `## 未確認事項` にだけ書き、本文へ写さない(二重管理を避ける)。
|
||||
- 未確認の推測には、**何を確認できれば事実に格上げできるか**を「格上げ条件」として書く。
|
||||
設計判断には、**何を決めれば解決するか**を「完了条件」として書く。意思決定を事実確認と混同しない。
|
||||
- 答えに依存せず進められる課題は `backlog/` に置く(ルール5)。保留中の対応範囲・暫定仕様は仕様書に残す。
|
||||
- 検証できたらタグを外し、該当項目を削除する。
|
||||
- 「今回検討して捨てた仮説」は `## 未確認事項` に書かない。journal に残して消す。
|
||||
- 推測に基づいて実装しない。先に検証するか、ユーザーに確認する。
|
||||
@@ -80,11 +92,44 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
- 格上げ条件: KLE本家でインポート→エクスポートし、labels[9]が残ることを確認する
|
||||
```
|
||||
|
||||
### 5. 保留課題(`backlog/`)
|
||||
|
||||
書くもの: **今は解決しなくても進められる問い・検証・改善候補**。
|
||||
|
||||
- 当面は `backlog/README.md` にまとめる。増えて読みにくくなったらレイヤー別・トピック別に分ける。
|
||||
- 項目には、関連仕様・未決事項・保留できる理由・再開条件・完了条件を書く。
|
||||
再開条件は「いつ考え直すか」、完了条件は「何を確認または決定すれば終わるか」を示す。
|
||||
- 仕様書から移す項目は、元の番号にレイヤー名などを付けて識別する(例: `PHY-P-14`)。
|
||||
判断に必要な候補や制約は短く残す。長い比較・調査ログは `journal/` に置く。
|
||||
- 問いの本文は二重管理しない。仕様書には保留中の対応範囲・暫定仕様を残し、必要に応じてbacklogへリンクする。
|
||||
- 仕様書から保留課題を分離した場合、末尾の管理セクションを `## 保留課題` → `## 検討済み・非採用` →
|
||||
`## 未確認事項` の順に置く(存在するものだけ)。`## 保留課題` には下記のリンク箇条書きだけを書く。
|
||||
項目名の列挙・問いの要約・保留理由・対応範囲は書かない。対応範囲は仕様本文、問いの詳細はbacklogに置く。
|
||||
- リンクは `- [<区分>の保留課題](backlog/README.md#<区分の見出し>)` の形とする。
|
||||
分割後は該当するbacklogファイルへ向ける。複数区分を参照する場合は1リンク1行とし、参照先の見出しまで指定する。
|
||||
該当課題がなくなったらリンクを削除し、空になった `## 保留課題` はセクションごと削除する。
|
||||
- 現在の仕様が依存する未確認の前提は、backlogへ移して隠さない。仕様書の `## 未確認事項` に残す。
|
||||
- backlogへの移動は仮説の採用や実装の許可を意味しない。実装は採用済みの仕様・対応範囲に従う。
|
||||
- 解決したら必要な結論を仕様書へ反映し、項目と不要になった参照を削除する。取り下げた項目も削除し、完了一覧は蓄積しない。
|
||||
調査・判断の経緯はルール2・3に従って履歴に残す。
|
||||
- 未解決の課題は作業ツリーに残す。結論反映後に削除できるjournalとは寿命が異なる。
|
||||
|
||||
仕様書側の書式:
|
||||
|
||||
```markdown
|
||||
## 保留課題
|
||||
|
||||
- [PHYの保留課題](backlog/README.md#phy)
|
||||
```
|
||||
|
||||
## Claude への作業指示
|
||||
|
||||
- ドキュメントを更新するときは、上のルール1〜4のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
|
||||
- ドキュメントを更新するときは、上のルール1〜5のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
|
||||
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
|
||||
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
|
||||
- Markdownをコミットする前に、変更したファイルへ `npx markdownlint-cli2 --fix <files>` をかける。
|
||||
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
|
||||
規則は `.markdownlint.jsonc`。変更していないファイルの指摘は直さない。
|
||||
|
||||
## 未確認事項
|
||||
|
||||
|
||||
+19
-20
@@ -25,6 +25,9 @@ ELCが扱う「ピン」は物理的なマイコンピンに限らない。**ピ
|
||||
現時点で具体形を定めるのは、マイコンとアナログマルチプレクサの2種別。
|
||||
デジタル側でピンを増やす部品も提供者として扱うが、種別ごとの記述は必要になった時点で定める。
|
||||
|
||||
同じ `PinRef` が複数の提供者の `select` に現れることは一律には禁止しない。
|
||||
複数mux構成の走査出力は、実配線と制御方法を確認した構成から対応する。
|
||||
|
||||
### キーの端子
|
||||
|
||||
キーは検出方式(detect)を持ち、検出方式ごとに端子の構成が決まる。各端子は接続先の論理ピンを持つ。
|
||||
@@ -32,7 +35,7 @@ ELCが扱う「ピン」は物理的なマイコンピンに限らない。**ピ
|
||||
- 接点(contact): a, b の2端子。端子は無極性で、a と b に順序の意味はない
|
||||
- アナログ(analog): out 1端子。加えて電源の vcc, gnd。ホールセンサがこれにあたる
|
||||
|
||||
光学スイッチの端子構成は未定 [推測:P-1]。
|
||||
光学スイッチは当面未対応とする。端子構成を確認するまで、既存の検出方式に仮置きしない。
|
||||
|
||||
### 配線トポロジを区分として持たない
|
||||
|
||||
@@ -46,6 +49,12 @@ ELCが扱う「ピン」は物理的なマイコンピンに限らない。**ピ
|
||||
row / col はELCの正本に含めない。charlieplexing で便宜的に行と列の形で書くことはあるが、
|
||||
それは表記の都合であって配線の事実ではない。QMKなどが要求する row / col は、出力時に割り当てる派生情報とする。
|
||||
|
||||
走査方向の決定と追加指定はFirmware Generatorが受け持ち、ELCには保存しない。
|
||||
接点マトリクスでは、どちら側のピン群を駆動し、どちら側から読み取るかを指す。
|
||||
配線・ダイオードの向きと整合する方向を出力器が選び、追加指定が必要な場合は出力設定で受け取る。
|
||||
配線だけから全方式の走査手順が一意に決まることは前提にしない。
|
||||
方式ごとの必要な設定は、その方式の出力に対応する際に確認し、未対応の構成は出力しない。
|
||||
|
||||
### ダイオード
|
||||
|
||||
接点キーはダイオードの向きを持つ。向きは端子名で表す(`a-to-b` はアノードがa側)。省略時はダイオードなし。
|
||||
@@ -70,9 +79,13 @@ PHYがレイアウトバリアントを持つ場合も、ELCは選択に関係
|
||||
設計エラーとする。接点マトリクスなら、どちらが押されたかを区別できないため。
|
||||
排他かどうかはPHYの木から判る。
|
||||
|
||||
ファームウェア向けのキー番号と接続先の対応表は、バリアント選択後のIDXの並びから生成する。
|
||||
同じ接続先を共有する排他キーも、選択後に同時には残らない。ELCの保存データは選択によって書き換えない。
|
||||
|
||||
LEDチェーンもバリアントごとに分けない。排他なキー位置の両方にLEDが載る基板も、
|
||||
バリアント固有のキーにLEDが載らない基板もあるが、どちらもチェーンは1本で、選択によって並びが変わらない。
|
||||
載らないキーは、PHYがキーごとに持つper-key LEDの有無でそのまま表せる。
|
||||
キー配列の出力で行うバリアントの取捨・番号の詰め直しは、LEDチェーンには適用しない。
|
||||
|
||||
### 他レイヤーへの参照
|
||||
|
||||
@@ -130,6 +143,10 @@ type ELC = {
|
||||
}
|
||||
```
|
||||
|
||||
## 保留課題
|
||||
|
||||
- [ELCの保留課題](backlog/README.md#elc)
|
||||
|
||||
## 検討済み・非採用
|
||||
|
||||
- `matrix.type` による配線方式の列挙 (journal/2026-09-20-elc-pin-provider.md)
|
||||
@@ -138,22 +155,4 @@ type ELC = {
|
||||
|
||||
## 未確認事項
|
||||
|
||||
### P-1 光学スイッチの端子構成
|
||||
|
||||
- 位置づけ: 本文「キーの端子」が扱えていない残り
|
||||
- 現状の認識: 発光側と受光側が独立した端子を持つのか、接点と同じ2端子として扱えるのかが分からない
|
||||
- 格上げ条件: 光学スイッチを使った実在のPCBの回路図を読み、端子数と配線の形を確認する
|
||||
|
||||
### P-2 複数マルチプレクサ構成の作り
|
||||
|
||||
- 位置づけ: 本文「ピンとピン提供者」が前提にしている。`select` を提供者ごとに持たせてよいかに関わる
|
||||
- 現状の認識: 選択ピンを複数のmuxで共有して共通ピンだけ分ける形と、イネーブルピンで切り替える形が考えられるが、
|
||||
どちらが一般的か確認していない。前者なら同じ `PinRef` が複数の提供者の `select` に現れる
|
||||
- 格上げ条件: 複数muxを使った実在のキーボードの配線を確認し、共有の形を確かめる
|
||||
|
||||
### P-3 駆動側と検出側の役割をELCが明示的に持つ必要があるか
|
||||
|
||||
- 位置づけ: 本文「配線トポロジを区分として持たない」の残り
|
||||
- 現状の認識: 接点マトリクスではダイオードの向きから、directでは電源レールの参照から、
|
||||
どちらを駆動しどちらを検出するかが決まりそうに見えるが、全方式で導出できるか確かめていない
|
||||
- 格上げ条件: 各方式について、端子の接続先とダイオードの向きだけから走査手順が一意に決まるかを確認する
|
||||
(なし)
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# HSTレイヤー
|
||||
|
||||
## 概要
|
||||
|
||||
HST(ホスト)レイヤーは、接続先のホストがキーボードから受け取ったキーコードをどう解釈するかを記述する。
|
||||
MAPが出力するのはHIDのキーコードであり、画面上の文字や動作は、ホストのOSとキーボード配列の設定で決まる。
|
||||
HSTはこの対応を持ち、MAPエディタとFirmware Generatorが参照する。入力がHSTを通過するわけではない。
|
||||
|
||||
名前は、HIDでキーボード(device)の接続相手をhostと呼ぶことに合わせる。
|
||||
IMEのかな漢字変換はOSの仕事であり、HSTの対象外とする。
|
||||
|
||||
## プロファイル
|
||||
|
||||
HSTのデータは、ホスト環境ごとのプロファイルの集まりとする。
|
||||
プロファイルはキーボードに依存しないため、プロジェクトの外にある共有カタログとして版を管理する。
|
||||
プロジェクトは、参照するプロファイルのIDと、利用者ごとの差分(XKBのオプションなど)だけを持つ。
|
||||
|
||||
### 識別
|
||||
|
||||
プロファイルは、OSとホスト配列の組で識別する。必要な場合だけ、入力方式やその設定を加える。
|
||||
|
||||
- ホスト配列は、OSがキーコードを解釈するための配列の設定である。キーキャップの刻印やOSの表示言語とは独立に決まる
|
||||
(日本語のWindowsで英語配列を設定する、など)。
|
||||
- Linux(XKB)では、配列(layout)・変種(variant)・オプション(options)の組になる。
|
||||
- `win-jp` のような短い名前は、人が選ぶためのプリセット名として付ける。
|
||||
- 各OSの識別子(WindowsのKLID、XKBの名前、macOSの入力ソースID)と対応付けられるようにする。
|
||||
|
||||
```json
|
||||
{ "os": "windows", "layout": "jp106" }
|
||||
{ "os": "macos", "layout": "us" }
|
||||
{ "os": "linux", "layout": "jp", "options": ["ctrl:nocaps"] }
|
||||
```
|
||||
|
||||
「日本語入力を使う環境」のような設定は、プロファイルの識別に含める。
|
||||
IMEが今ONか、のような実行時の状態は識別に含めない(次節の引数として扱う)。
|
||||
|
||||
### 記述する内容
|
||||
|
||||
プロファイルは、順方向の対応だけを記述する。
|
||||
|
||||
```txt
|
||||
(キーコード, 修飾キー, ホスト状態) → 文字 / 動作 / 未定義 / 不明
|
||||
```
|
||||
|
||||
- ホスト状態: CapsLock、かなロック、IMEのON/OFFなど、同じキーコードの結果を変える状態。
|
||||
HSTは状態の種類を引数として記述するだけで、実行時の状態を観測しない。
|
||||
- OS固有のキーの意味: `LANG1`/`LANG2`(Macのかな/英数)、変換/無変換、Cmd/Ctrlの役割など。
|
||||
- 成立の前提: 動作に必要なOS側の設定(Unicode入力の方式など)。HSTは設定を変更する機能を持たない。
|
||||
- 対応可否: 対応済み・未対応・不明の3値とする。カタログに載っていない動作を「使えない」と扱わない。
|
||||
|
||||
## 使う側の責務
|
||||
|
||||
HSTは逆引き(文字から、それを入力するキー操作を求めること)を持たない。
|
||||
逆引きは、使う側が順方向の表から求める[推測:P-1]。
|
||||
|
||||
- 順方向の表から導けるので、両方を持つと二重管理になる。
|
||||
- 逆引きの候補には、ファームウェアの機能に依存するものがある。
|
||||
例えば、US刻印のキーキャップをJIS配列のホストで使う場合、Shiftの有無で文字の組が合わないキーには、
|
||||
Shift時に別のキーコードを送る機能(QMKのKey Override)が必要になる。
|
||||
これはホストの知識ではないため、HSTに持たせない。
|
||||
|
||||
使う側の担当は次のとおり。
|
||||
|
||||
- MAPエディタ: キーコードの表示、逆引きによる入力補助、対象ホストで意味を持たないキーコードの警告
|
||||
- Firmware Generator: `send_string` が前提にする配列、Unicode入力の方式など、ホストに依存する生成設定
|
||||
|
||||
## MAPとの境界
|
||||
|
||||
- MAPは具体的なキーコードを保存する。逆引きは編集時の入力補助であり、選んだ候補のキーコードをMAPへ書く一方向の変換とする。
|
||||
実機に残るのはキーコードだけなので、この形なら実機との往復を単純に保てる。
|
||||
- HSTは割り当てを所有・変更しない。PHYのキーIDも参照しない。
|
||||
|
||||
## 対象外
|
||||
|
||||
- 実行時の状態の観測・同期(今どのOSに接続しているか、IMEがONか)
|
||||
- OS設定の変更、IMEの変換処理
|
||||
- ホスト側での再割り当て(AutoHotkey、Karabiner、IMEのローマ字テーブルなど)
|
||||
- アプリごとのショートカット(「コピー」のような操作の汎用的な辞書)
|
||||
|
||||
## 検討済み・非採用
|
||||
|
||||
- 名前をSYSとする (journal/2026-09-24-hst-layer-scope.md)
|
||||
- 逆引きの候補をプロファイルが持つ (journal/2026-09-24-hst-layer-scope.md)
|
||||
|
||||
## 未確認事項
|
||||
|
||||
### P-1 使う側が逆引きの候補と実現可否を判断できる
|
||||
|
||||
- 位置づけ: 本文「使う側の責務」が前提にしている。HSTにファームウェアの知識を持たせない線引きは、これが成り立つことに依存する。
|
||||
- 格上げ条件: US刻印のキーキャップをJIS配列のホストで使う構成で、Shiftの有無で文字の組が合わないキーについて、
|
||||
HSTの順方向の表と、Generatorのビルド設定(有効な機能)だけから、MAPエディタがKey Overrideを含む候補を作れることを試作で確認する。
|
||||
|
||||
### P-2 対象ホストを複数持つか
|
||||
|
||||
- 位置づけ: 未解決の問い。Win/Mac両用のキーボードでは、表示と検証を複数のホストに対して行う必要がある。
|
||||
OSの切り替えをMAPのレイヤーで表す既存の慣習と、どう対応付けるかも関わる。
|
||||
- 完了条件: プロジェクトが参照するプロファイルの数と、MAPのレイヤーとの対応付けの有無をユーザーが決める。
|
||||
+57
-40
@@ -2,20 +2,24 @@
|
||||
|
||||
## 概要
|
||||
|
||||
IDXレイヤーは、PHYのキーに「キー番号」を与える。キー番号は、キーマップがキーを指すための識別子である。
|
||||
IDXレイヤーは、全バリアントにわたるPHYのキーの並び順を持つ。
|
||||
番号を使う形式への出力時に、選択したバリアントで使うキーだけを残し、0から連続するキー番号を与える。
|
||||
保存した並びの位置と、選択後の出力でのキー番号は区別する。
|
||||
|
||||
キー番号は他のどの情報からも導出できない。設計者が決める並び順そのものであり、
|
||||
そのためIDXが正本として持つ。ELCのrow/colが「配線から決まるので出力時に導出する」のと、
|
||||
理由が裏返しの関係にある。
|
||||
並び順は他のどの情報からも導出できない。設計者が決める情報なので、IDXが正本として持つ。
|
||||
ELCのrow/colが「配線から決まるので出力時に導出する」のと、理由が裏返しの関係にある。
|
||||
|
||||
IDXが持つのは、番号を振る対象のキーと、その並び順だけとする。
|
||||
IDXが持つのは、番号を振る対象のキーと、その並び順だけとする。バリアントの選択は保存しない。
|
||||
|
||||
## キー番号は公開インターフェース
|
||||
|
||||
基板を公開すると、他人が書いたキーマップがそのキー番号に依存する。
|
||||
番号を振り直すと、既存のキーマップが別のキーを指す。
|
||||
基板を公開すると、他人が書いたキーマップが、出力したレイアウトのキー番号に依存する。
|
||||
同じレイアウトで番号を振り直すと、既存のキーマップが別のキーを指す。
|
||||
|
||||
そのためキー番号は、出力のたびに生成し直す派生情報として扱わず、固定した正本として保存する。
|
||||
そのため並び順は正本として保存し、出力のたびに幾何や配線から生成し直さない。
|
||||
同じ並びと同じバリアント選択からは、同じ出力番号を得る。
|
||||
番号の互換性は出力レイアウトごとに扱い、異なるバリアントの同じ番号が同じキーを指すとは限らない。
|
||||
並びへの挿入・削除・並べ替えでは、影響する出力レイアウトの番号が変わることを考慮する。
|
||||
|
||||
## 幾何から導出しない
|
||||
|
||||
@@ -32,7 +36,7 @@ PHYの座標・ツリー順をキー番号に使わない。
|
||||
ELCのマトリクス位置をキー番号に使わない。
|
||||
|
||||
- マトリクスの並びとキー番号の並びは一致しない。分割キーボードでは、マトリクスは片方の半身の行を
|
||||
並べてからもう片方の行を並べるが、キー番号は視覚的な行ごとに左右を交互に取るのが通例である
|
||||
並べてからもう片方の行を並べるが、キー番号は視覚的な行ごとに左右を交互に取るのが通例である
|
||||
- マトリクスには欠番がある。キー番号は実在するキーに対して連続だが、マトリクスは疎である
|
||||
- 基板リビジョンでピンを差し替えたときに、キー番号が動いてはならない
|
||||
|
||||
@@ -51,35 +55,50 @@ row / col 自体がELCの正本ではなく出力時の派生情報である。
|
||||
|
||||
## レイアウトバリアント
|
||||
|
||||
PHYがレイアウトバリアントを持つ場合も、キー番号の空間は1つとする。バリアントごとにIDXを分けない。
|
||||
キー番号は公開インターフェースであり、どの空間のものかを断らずに解釈できなければならない。
|
||||
特定のレイアウト向けの並びは、選んだバリアントに属さないキーを落として得る。
|
||||
ただしこれだけでは足りない場合がある。QMKのコミュニティレイアウトでは、同じマトリクス位置が
|
||||
レイアウトによって別の物理キーになり(ANSIのバックスラッシュとISOの `#~`)、残ったキーの読み順が変わる。
|
||||
ISO系で引数順が一致しない。
|
||||
IDXは、番号を振る対象のキーを全バリアントにわたって1つの `order` に持つ。
|
||||
各PHYキーIDを別々の要素として並べ、同じIDは重複させない。同じ接続先に配線された排他キーも
|
||||
別々の要素にする。物理キーごとに、出力先が要求する並びの中の位置を与えられるようにするため。
|
||||
|
||||
排他なキー同士は、同じキー番号を共有できる。`order` の要素にキーIDの配列を書くと、
|
||||
その位置の番号を配列内のキーで共有する。
|
||||
出力時は、PHYのVariantGroupごとにバリアントを選び、次の手順で出力用の並びを得る。
|
||||
|
||||
番号を共有できるのは、ELCで同じ接続先の組に配線されたキーに限る。
|
||||
3つの処理系が要求するのはいずれも「番号の位置 → マトリクス位置」の1対1の対応表であり、
|
||||
1つの番号に対応するマトリクス位置は1つしかないため。
|
||||
逆に、同じ接続先の組に配線された排他キーは番号を共有しなければならない。
|
||||
別々の番号を与えると、その対応表に同じマトリクス位置が2回現れ、1回のキー押下で両方の番号が反応する。
|
||||
1. 選択したバリアントの配下と、バリアントに属さない共通のキーを対象にする。
|
||||
入れ子の場合は、祖先のバリアントも選択されているキーだけを対象にする
|
||||
2. `order` から対象外のキーを除く。残ったキー同士の順序は変えない
|
||||
3. 残った並びの位置を、0から連続する出力用のキー番号とする
|
||||
|
||||
選択によって番号が詰まることはない。あるバリアントでしか実装されないキーの番号は、
|
||||
別のバリアントを選んだときには使われない番号として残る。
|
||||
保存した `order` は書き換えない。レイアウトごとに独立したIDXや並び順も保存しない。
|
||||
同じ接続先の排他キーは選択後に同時には残らないため、別々の要素でも出力の対応表に重複しない。
|
||||
番号を使う形式への出力は、バリアントを選択した構成を単位とする。
|
||||
|
||||
例えば、ANSIのバックスラッシュとISOの `#` が同じ接続先を使う場合も、次のように並べられる。
|
||||
QとAは共通キー、バックスラッシュと `#` は排他キーとする。
|
||||
|
||||
| キー | 保存したorderでの位置 | ANSI出力の番号 | ISO出力の番号 |
|
||||
| --- | --- | --- | --- |
|
||||
| Q | 0 | 0 | 0 |
|
||||
| バックスラッシュ | 1 | 1 | — |
|
||||
| A | 2 | 2 | 1 |
|
||||
| # | 3 | — | 2 |
|
||||
|
||||
全体の並びは `Q → バックスラッシュ → A → #`、ANSI出力は `Q → バックスラッシュ → A`、
|
||||
ISO出力は `Q → A → #` になる。
|
||||
|
||||
既存コミュニティレイアウトとの互換出力は、要求順を再現できることを確認したものを対象とする。
|
||||
単一の `order` で既存レイアウト全般を再現できることは、現行仕様の前提にしない。
|
||||
|
||||
## 他レイヤーとの関係
|
||||
|
||||
IDXはPHYのキーIDだけを参照する。ELCを参照しない。
|
||||
|
||||
MAPはIDXを参照しない。MAPもキーの参照にPHYのキーIDを使う。
|
||||
キーマップを配列として出力するとき、IDXの並び順で並べ替える。
|
||||
これにより、IDXにキーを挿入して番号がずれても、MAPは書き換えずに済む。
|
||||
キーマップを配列として出力するとき、バリアント選択後のIDXの並び順で並べ替える。
|
||||
ELCから作るキー番号と接続先の対応表にも、同じ選択後の並びを使う。
|
||||
これにより、IDXの編集やバリアント選択で出力番号が変わっても、MAP内のキーIDへの割り当ては書き換えずに済む。
|
||||
PHYのIDで参照するELC・MAPの保存データ自体には、番号を詰める処理はない。
|
||||
|
||||
MAPが番号を持たないキーにキーコードを割り当てていた場合、そのキーは出力に現れない。
|
||||
IDXの `order` と突き合わせて検出できる。
|
||||
IDXの `order` と突き合わせて検出できる。`order` に含まれていても、選ばれなかったバリアントの
|
||||
キーへの割り当ては、その出力には含めない。
|
||||
|
||||
## 持たないもの
|
||||
|
||||
@@ -92,30 +111,28 @@ IDXの `order` と突き合わせて検出できる。
|
||||
|
||||
```ts
|
||||
type IDX = {
|
||||
order: (string | string[])[] // PHYのキーID。並びの位置がキー番号。
|
||||
// 配列は、その位置の番号を共有する排他バリアントのキー
|
||||
order: string[] // 全バリアントの対象キーのPHY ID。各IDは1回だけ現れる。
|
||||
// 出力時に対象外のキーを除き、残った並びの位置をキー番号とする
|
||||
}
|
||||
```
|
||||
|
||||
並びの位置を番号とする形は、ELCの `LEDChain.order` と同じ形である。
|
||||
欠番・重複・番号の飛びが構造として起こらない。
|
||||
番号は個別のフィールドとして持たず、出力時の並びの位置で表すため、出力番号に欠番や飛びは生じない。
|
||||
キーIDの重複は許さない。
|
||||
|
||||
`order` に挙げたキーだけが番号を持つ。PHYの部品ノードと `decal` のキーは対象にならない。
|
||||
出力番号の対象は `order` に挙げたキーに限る。PHYの部品ノードと `decal` のキーは対象にならない。
|
||||
|
||||
## 保留課題
|
||||
|
||||
- [IDXの保留課題](backlog/README.md#idx)
|
||||
|
||||
## 検討済み・非採用
|
||||
|
||||
- PHYのツリー順をキー番号とする (journal/2026-09-21-idx-necessity.md)
|
||||
- ELCのマトリクス順からキー番号を導出する (journal/2026-09-21-idx-necessity.md)
|
||||
- `keyId` と `index` の対を並べる形 (journal/2026-09-21-idx-necessity.md)
|
||||
- 排他キーで番号を共有し、選択後も番号を詰めない (journal/2026-09-22-idx-variant-output.md)
|
||||
- MAPがキー番号を参照する (journal/2026-09-21-idx-necessity.md)
|
||||
- レイアウトごとに別のIDXを持つ (journal/2026-09-21-multi-layout.md)
|
||||
|
||||
## 未確認事項
|
||||
|
||||
### P-1 一致しないコミュニティレイアウトの並びをどう与えるか
|
||||
|
||||
- 位置づけ: 本文「レイアウトバリアント」の残余。一致しない場合があることは確認済み
|
||||
- 現状の認識: QMKの実データで、コミュニティレイアウトとの173組のうち30組(すべてISO系)が
|
||||
`order` からの取捨では得られない。並べ替えの規則を与えるか、レイアウトごとの並びを持つかが未定。
|
||||
後者は「レイアウトごとに別のIDXを持つ」として一度落としている
|
||||
- 格上げ条件: 埋め方をユーザーが決める
|
||||
(なし)
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# MAPレイヤー
|
||||
|
||||
## 概要
|
||||
|
||||
MAPレイヤーは、キーマップレイヤーごとのキーへの割り当てを持つ。
|
||||
MAPエディタは、その設計・編集と、実機との読み書きを提供する。
|
||||
ここでいうキーマップレイヤーは、PHY・ELC等の設計レイヤーとは区別する。
|
||||
|
||||
主眼は、新しく作成するキーボードのワークフローにある。
|
||||
キーマップを設計し、実機へ反映し、使って調整した結果をプロジェクトへ戻せるようにする。
|
||||
既存キーボード全般への接続・取り込みを、初期対応の前提にはしない。
|
||||
|
||||
## MAPデータと他レイヤーの関係
|
||||
|
||||
キー参照と出力時の並べ替えは、[IDXレイヤー「他レイヤーとの関係」](IDXレイヤー.md#他レイヤーとの関係)に従う。
|
||||
MAPのキー参照にはPHYのキーIDを使い、実機との通信に使うアドレスを正本のキー参照にはしない。
|
||||
|
||||
現行の保存形式は、[JSON SchemaのMAP節](phase1-json-schema.md#4-mapレイヤー-json-schema)にある
|
||||
レイヤー別のキーコード割り当てを出発点とする。
|
||||
キーコードはQMKの表記を基本とする。当面の対象ファームウェアがQMKであるため。
|
||||
レイヤーとStaticの動作定義への参照は、QMKの番号ではなく名前で書く([DynamicとStatic](#dynamicとstatic))。
|
||||
|
||||
ファームウェアの設定値は、キーボード全体に効くものをFirmware Generatorの設定とし、
|
||||
キーごとに上書きする値(tapping termのper key設定等)をMAPが持つ。
|
||||
QMKの機能ごとにどちらに属するかを個別に判断しないで済むようにするため。
|
||||
|
||||
動作定義とキーごとの設定の保存形式は、[P-1](#p-1-動作定義とキーごとの設定の保存形式)で扱う。
|
||||
|
||||
新規作成したMAPは、すべてのキーが未割り当ての状態から始める。
|
||||
PHYのラベルからの自動適用は行わない([PHY「ラベル」](PHYレイヤー.md#ラベル))。
|
||||
|
||||
## キーマップレイヤーの識別
|
||||
|
||||
キーマップレイヤーは、並び順と独立した名前で識別する。名前はMAP内で一意とする。
|
||||
レイヤーを切り替える動作も名前でレイヤーを参照し、番号へは出力時に並び順から変換する。
|
||||
レイヤーの並べ替えや挿入で参照先がずれないようにするため。IDXがキー番号を正本にしないのと同じ理由による。
|
||||
|
||||
## MAPエディタの責務
|
||||
|
||||
- PHYの配置を参照し、キーマップレイヤーごとの割り当てを編集・保存する。
|
||||
- 対応する実機へ割り当てを書き込み、実機の現在の割り当てを読み出す。
|
||||
- 実機で使いながら調整し、その結果をMAPへ取り込むワークフローを提供する。
|
||||
|
||||
読み書きは一組として対応する。読み出しには、現在値の確認に加え、Remap等で調整した結果を
|
||||
プロジェクトへ戻す用途も含む。保存と実機への反映の操作、差分表示、自動同期の有無は、
|
||||
[P-3](#p-3-プロジェクトと実機の対応状態管理)で扱う。
|
||||
|
||||
## DynamicとStatic
|
||||
|
||||
Dynamicは、対応ファームウェアが公開している通信プロトコルを使って、再ビルドせずに設定を読み書きする経路。
|
||||
Staticは、設定をファームウェアへ組み込むために出力する経路とする。
|
||||
Staticの出力は[全体方針のFirmware Generator](keyboard-design-layers.md#firmware-generator)が担い、MAPエディタは持たない。
|
||||
|
||||
MAPエディタはDynamicの読み書きを提供する。Dynamicで変更できる範囲は、
|
||||
対象ファームウェアと既存プロトコルが公開する範囲に従う。
|
||||
|
||||
当面の対象ファームウェアはQMKとし、対応範囲を次のように分ける。
|
||||
|
||||
- Dynamic: QMK本家のVIAプロトコルが公開する項目に限る。
|
||||
- Static: QMK本家masterの公式機能のうちVIAで変更できないものは、Firmware Generatorで生成する対象とする。
|
||||
- 次のものには当面対応しない。
|
||||
- Vial等のフォーク
|
||||
- XAP、developブランチ等、master以外にある機能
|
||||
- Community Modules(VIAモジュールを除く)
|
||||
- キーボード・キーマップ固有のコード(VIAのキーボード独自メニューを含む)には、原則として対応しない。
|
||||
|
||||
QMKはVIAをCommunity Modulesの形式へ移し、従来の実装を将来のリリースで削除すると告知している(QMK 0.34.4のChangeLog「VIA Feature Migration」)。
|
||||
VIAがモジュールへ移っても、QMKとVIAの両方に追従する。そのため、移行がmasterに入る前に、VIAモジュールを有効にした出力と読み書きに対応する。
|
||||
|
||||
Staticで定義する動作(タップダンス等)をキーへ割り当てるときは、定義を名前で参照し、出力時に番号へ変換する。
|
||||
割り当て自体はVIAで読み書きできても、参照先の定義はStaticにしかないため、
|
||||
[レイヤーの参照](#キーマップレイヤーの識別)と同じく並び順に依存させない。
|
||||
|
||||
QMK本家のVIAには、VIAの管理外にある実機の保存設定(Magic設定、保存されたデフォルトレイヤー、Unicodeの入力モード等)を読み出す手段がない。
|
||||
これらを実機で変更しても、MAPエディタはプロジェクトへ取り込めない。
|
||||
|
||||
プロジェクトのMAP、Staticで組み込んだ初期キーマップ、実機に保存された現在の割り当ては区別する。
|
||||
実機で調整してMAPへ取り込んだ割り当ては、Firmware Generatorへの入力としてStaticの初期キーマップへ反映できるようにする。
|
||||
実機の保存内容を初期化しても、調整後の割り当てに戻せるようにするため。
|
||||
|
||||
## 対応プロトコル
|
||||
|
||||
最初に対応する通信プロトコルはVIAとし、対応プロトコルを絞って段階的に増やす。
|
||||
ユーザーが好むRemapと同じ通信経路を使い、新規キーボードの設計から実機での調整へつなぐため。
|
||||
|
||||
- QMKやVial側にBeyond KLE独自のプロトコルを追加しない。そのためのファームウェアのフォークも行わない。
|
||||
- VIA対応では、対象ファームウェアの既存のVIA機能を使って読み書きする。
|
||||
- VIAへの対応は、Remapというアプリ全体の機能を再現することを意味しない。
|
||||
- 初期の実装対象はVIAに限る。
|
||||
|
||||
対応環境は、QMKのコミットと、使うモジュールの一覧(各モジュールの出所とコミット)で固定し、
|
||||
更新するタイミングはユーザーが個別に判断する。QMKに同梱されたモジュールは、QMKのコミットで決まる。
|
||||
VIAもモジュールの1つとして扱い、QMKリポジトリの外へ移った場合にも同じ形で指定できるようにするため。
|
||||
MAPエディタのUIには、対応しているVIAプロトコルとキーコード体系のバージョンを明示する。
|
||||
接続した実機とはこの2つのバージョンで照合し、一致しない実機には対応しない。
|
||||
実機から取得できるバージョン情報がこの2つに限られるため。
|
||||
|
||||
最初に対応する環境は、QMK 0.34.4(commit `08c662f`)とする。このときキーコード体系は0.0.9、VIAプロトコルは0x000D。
|
||||
VIAは同梱の従来実装(`quantum/via.c`)で、追加のモジュールは使わない。
|
||||
|
||||
## VIAで読み書きする項目
|
||||
|
||||
VIAプロトコル0x000Dのコマンドのうち、MAPエディタが扱うものを次のように分ける[推測:P-2]。
|
||||
|
||||
- 対応する:
|
||||
- VIAプロトコルとキーコード体系のバージョンの読み出し(実機との照合に使う)
|
||||
- レイヤー数の読み出し
|
||||
- レイヤー別のキーコード割り当ての読み書き
|
||||
- マクロの読み書き(個数・容量の読み出しとリセットを含む)
|
||||
- 別の課題で扱う:
|
||||
- エンコーダの割り当て([MAP-P-4](backlog/README.md#map-p-4-onoff以外の入力への割り当て))
|
||||
- layout option([P-3](#p-3-プロジェクトと実機の対応状態管理))
|
||||
- 当面対応しない: キーマップ・EEPROMのリセット、ブートローダへの移行、デバイスの識別表示、
|
||||
マトリクス状態の読み出し(キーテスター)、custom channel(照明・オーディオ・キーボード独自メニュー)
|
||||
|
||||
実機のファームウェアでどの機能が有効かは、VIAでは読み出せない。
|
||||
新規キーボードではFirmware Generatorのビルド設定を正とする。既存キーボードでは、利用者が同じ形式で申告した内容を信用する。
|
||||
レイヤー数、マクロの個数と容量は実機から読み出し、書き込む前にMAPの内容が収まるかを確認する。
|
||||
|
||||
## 持たないもの
|
||||
|
||||
物理配置、配線、キーの出力番号は、それぞれPHY・ELC・IDXで扱う。
|
||||
ホストによるキーコードの解釈は[HSTレイヤー](HSTレイヤー.md)、IMEの入力変換はOSの責務とし、外部ツールで編集できる照明等の設定も、
|
||||
VIAで通信できるという理由だけでMAPの所有データには含めない。
|
||||
MAPエディタは、キーコードを当面US配列の文字で表示する。
|
||||
|
||||
アナログ入力の解釈の所属は、[全体方針のP-1](keyboard-design-layers.md#p-1-アナログ値の解釈をどの層が持つか)で扱う。
|
||||
|
||||
## 保留課題
|
||||
|
||||
- [MAPの保留課題](backlog/README.md#map)
|
||||
- [PHYの保留課題](backlog/README.md#phy)
|
||||
|
||||
## 検討済み・非採用
|
||||
|
||||
- MAPエディタをStaticに閉じる (journal/2026-09-22-map-via-workflow.md)
|
||||
|
||||
## 未確認事項
|
||||
|
||||
### P-1 動作定義とキーごとの設定の保存形式
|
||||
|
||||
- 位置づけ: MAPのデータモデルを定めるための設計判断。
|
||||
- 未決事項:
|
||||
- マクロ(VIAで読み書きするため内容をMAPが持つ)と、タップダンス・コンボ等のStaticで定義する動作を、MAPにどう保存するか。
|
||||
- キーごとに上書きする設定を、PHYのキー、キーとレイヤーの組、キーコードのどれに付けるか。
|
||||
- 完了条件: 動作定義とキーごとの設定の保存形式をユーザーが決める。
|
||||
|
||||
### P-2 VIAのコマンドはQMK 0.34.4のソースどおりに動作する
|
||||
|
||||
- 位置づけ: 本文「VIAで読み書きする項目」が前提にしている。コマンドの内容はソース(`quantum/via.c`)と公式文書から確認したもので、実機では検証していない。
|
||||
- 格上げ条件: QMK 0.34.4でビルドした新規キーボードの構成で、対応するコマンドの読み書きと、レイヤー数・マクロの個数と容量の読み出しを実機で確認する。
|
||||
|
||||
### P-3 プロジェクトと実機の対応・状態管理
|
||||
|
||||
- 位置づけ: 実機への読み書きをMAPへ結び付けるための設計判断。
|
||||
- 未決事項:
|
||||
- 出力した構成とPHYキーID・通信上のアドレスの対応をどこに保持し、接続した実機とどう照合するか。
|
||||
- VIAの割り当ては接続先(row/col)単位で保存されるため、同じ接続先に配線された排他キーは、実機では1つの割り当て先を共有する。書き込み時に選択外のバリアントのキーの割り当てをどう扱うか、読み出した値をどのPHYキーへ戻すか(実機側のlayout optionの選択に従うか)。
|
||||
- バリアントや配線の変更後の扱い。
|
||||
- プロジェクトと実機、プロジェクトとStaticで組み込んだ初期キーマップの差分表示・保存・反映の操作。
|
||||
- 完了条件: 対応情報の生成・保存・照合方法と状態管理を定め、設計から実機へ反映し、Remap等での変更を元のPHYキーIDへの割り当てとして取り込む手順を確認する。排他キーを含む構成で、書き込みと読み出しがバリアントの選択に従って元のPHYキーへ対応することを確認する。
|
||||
+26
-43
@@ -3,7 +3,7 @@
|
||||
## 概要
|
||||
|
||||
PHYレイヤーは、キースイッチやキーキャップ、その他周辺部品の物理的な配置に関する情報を提供する。
|
||||
付加的な情報として、matrix情報(ELCレイヤー)や入力キー情報(MAP/IMEレイヤー)を含ませることができるが、必須ではない。
|
||||
付加的な情報として、matrix情報(ELCレイヤー)や入力キー情報(MAP/HSTレイヤー)を含ませることができるが、必須ではない。
|
||||
|
||||
## 構造
|
||||
|
||||
@@ -101,6 +101,8 @@ LED・OLED・ロータリーエンコーダ・ネジ穴のように、設計者
|
||||
フットプリントの選択には使わない。予約名以外および省略時は既定の形で描く。
|
||||
ユーザーが付ける名前は `@` で始めない限り予約と衝突しない。
|
||||
|
||||
当面は表裏の描き分けを行わない。描画用の予約グループ名を、PCB上の実装面の指定としては使わない。
|
||||
|
||||
部品ノードはクラスターの子になれる。クラスターを動かせば配下の部品も追随する。
|
||||
|
||||
LEDの配線はPHYが持たない。LEDの識別番号もPHYは持たない。番号は配線で決まるため、ELCが持つ。
|
||||
@@ -113,6 +115,7 @@ IDXはLEDの番号を持たない。RGB効果が必要とする位置と区分
|
||||
### ラベル
|
||||
|
||||
キーはラベルを参考情報として持つ。KLE JSONの取り込みでは照合に使う。
|
||||
当面はラベルからMAPへの自動適用は行わない。
|
||||
|
||||
### グルーピング
|
||||
|
||||
@@ -150,7 +153,7 @@ PHYは全バリアントのキーを同時に持つ。基板上のフットプ
|
||||
- Variantの中に別のVariantGroupを置ける。あるバリアントを選んだときだけ現れる選択肢を表す
|
||||
- 選択箇所が複数ある場合はVariantGroupを複数置く。同じ親の下に並べてよい
|
||||
- 選択が互いに独立なら、VariantGroupを分けて選択肢の数を減らしてよい。
|
||||
ANSI/ISOとBackspaceの分割のように、独立な2つの選択が1つの選択肢リストに畳まれている基板がある
|
||||
ANSI/ISOとBackspaceの分割のように、独立な2つの選択が1つの選択肢リストに畳まれている基板がある
|
||||
|
||||
VariantGroupはクラスターと同じ変換を持つ。Variantは変換を持たない。
|
||||
バリアントは同じ場所を占める代替物であり、選択によって配下の絶対配置が動く構造を作らないため。
|
||||
@@ -170,7 +173,8 @@ VariantGroupごとにバリアントを1つ選んでから階層を畳んで生
|
||||
Variantをまたぐノードの移動は、クラスター間の移動と同じreparentとして扱う。絶対配置は変わらない。
|
||||
組み替えの対象を同一の親に限る規則がそのまま効くため、複数のVariantにまたがる選択は組み替えできない。
|
||||
|
||||
選択はPHYを畳むときにだけ使う。他レイヤーはPHYのノードIDを参照するだけで、選択を見ない。
|
||||
ELC・IDX・MAPの保存データは全バリアントのPHYノードIDを参照し、選択によって書き換えない。
|
||||
選択は、PHYを単一の配置に畳むときや、IDX・MAPから選択した構成のキー配列を生成するときに使う。
|
||||
|
||||
### クラスターの組み替え
|
||||
|
||||
@@ -194,9 +198,12 @@ Transform は回転と平行移動だけを持つ剛体変換であり、合成
|
||||
|
||||
- 組み替えの対象は同一の親を持つノードに限る。複数の親にまたがる選択は組み替えできない
|
||||
- クラスター化したときの初期値は、原点を選択の境界矩形の左上、基準角度を0とする。
|
||||
ただし選択したノード全部が同じ回転角度を持つ場合はその角度を基準角度に採り、境界矩形もその角度の座標系で取る
|
||||
ただし選択したノード全部が同じ回転角度を持つ場合はその角度を基準角度に採り、境界矩形もその角度の座標系で取る
|
||||
- 組み替えで生じた非グリッド値はそのまま保持する。クラスターを解除して親に戻ったノードも同じ
|
||||
|
||||
当面、reframeは通常の移動・回転と別の明示的な操作とし、原点の指定は数値入力で行う。
|
||||
原点の吸着やフォーカス状態・ハンドルに連動した操作は、試作後に検討する。
|
||||
|
||||
## 保存形式
|
||||
|
||||
各ノードは親から見た変換を持つ。キー・クラスター・部品ノード・バリアントノードを混在させた木構造として保持する。
|
||||
@@ -329,18 +336,22 @@ const kleData = (ConfigObject | KeyLabel)[][]
|
||||
|
||||
- 回転角度は正規化せずそのまま出力する。KLEのデータは任意の角度を受け付け、15度刻みはKLEのUI上の操作単位にすぎない
|
||||
- rx, ry はPHYに持たないので、出力時に選ぶ。同じ絶対角度を持つキーには共通の値を与える
|
||||
(クラスターの原点が使える)。KLEが1行にまとめるのは r, rx, ry が一致し y が等しいキーだけという制約による
|
||||
(クラスターの原点が使える)。KLEが1行にまとめるのは r, rx, ry が一致し y が等しいキーだけという制約による
|
||||
- 書き出す x, y は丸める。行のまとまりは y の厳密一致で決まるので、絶対座標への変換で入った誤差が残ると行が割れる
|
||||
- ユニットサイズ(mm)、ビジュアルオフセット、クラスター構造、部品ノード、per-key LEDはKLE JSONに出力されない
|
||||
- バリアント構造はKLE JSONに出力しない。VariantGroupごとにバリアントを1つ選び、その配下だけを畳んで出力する
|
||||
- 部品グループ・スイッチの向きはKLE JSONに出力しない。sm, sb, st には取り込み時に保持した値をそのまま書き戻す
|
||||
- r, rx, ry は行の先頭にのみ置く。KLE本家はそれ以外の位置に置かれたものを読まない
|
||||
- p, sm, sb, st は空文字に戻す指定がKLE側で無視される。値を持つキーと持たないキーが混在する配置は、
|
||||
KLEで開いた時点で後続のキーに前の値が残る
|
||||
KLEで開いた時点で後続のキーに前の値が残る
|
||||
- KLEの `p` はキーキャップのプロファイル名(SA、DCSなど)であり、PHYのプロファイルとは対応させない
|
||||
|
||||
### KLE JSONの取り込み
|
||||
|
||||
当面は新規PHYとしての読み込みを対象とする。既存PHYへの照合・更新は、照合規則を検証するまで未対応とする。
|
||||
以下は照合・更新に対応する際の制約であり、曖昧な対応を自動確定しない。
|
||||
誤照合で既存IDを別のキーに割り当てると、他レイヤーの参照にも影響するため。
|
||||
|
||||
既存のPHYデータを畳んだ配置に対して、KLE JSONの各キーを形状(位置・サイズ・回転)とラベルで照合する。
|
||||
|
||||
- 照合できたキーは元のノードとして扱う。KLE側で配置が変わっていれば、元の親から見た相対座標に戻して反映する
|
||||
@@ -355,10 +366,17 @@ KLE本家はこの位置の r, rx, ry をエラーにするが、VIAとRemapは
|
||||
|
||||
KLEの rx, ry, r はキーの絶対配置 (x, y, rotation) に変換して取り込む。元の rx, ry は保持しない。
|
||||
|
||||
見た目の属性(c, t, p, a, f, f2, fa, l, n)は、KLE JSONへ書き戻せるように保持する。どの層で持つかは決まっていない。
|
||||
見た目の属性(c, t, p, a, f, f2, fa, l, n)は、KLE JSONへ書き戻せるように保持する。
|
||||
当面はキー単位の `kleCompatibility` に不透明なデータとして退避し、見た目の編集機能は持たない。
|
||||
|
||||
KLEの sm, sb, st(ファイル単位では switchMount, switchBrand, switchType)も同様に、不透明なまま保持して書き戻す。
|
||||
Beyond KLEはスイッチの品種を持たないため、対応づける先がない。
|
||||
ファイル単位の属性(switchMount, switchBrand, switchType, backcolor, radii, background, css)は、
|
||||
ファイル単位の `kleCompatibility` に退避する。正式な所属層を決めるまでは、この退避先で保持する。
|
||||
|
||||
## 保留課題
|
||||
|
||||
- [PHYの保留課題](backlog/README.md#phy)
|
||||
|
||||
## 検討済み・非採用
|
||||
|
||||
@@ -370,39 +388,4 @@ Beyond KLEはスイッチの品種を持たないため、対応づける先が
|
||||
|
||||
## 未確認事項
|
||||
|
||||
### P-2 KLE JSON取り込み時の照合基準
|
||||
|
||||
- 位置づけ: 本文「KLE JSONの取り込み」の具体化に必要な未解決の問い
|
||||
- 現状の認識: 形状の許容誤差、ラベルの扱い、複数候補がある場合の決め方が未定
|
||||
- 格上げ条件: 照合規則を決めたうえで、KLE本家でキーの移動・追加・削除をしたJSONを取り込み、意図どおりに対応づくことを確認する
|
||||
|
||||
### P-6 キーキャップの見た目情報を持つ層
|
||||
|
||||
- 位置づけ: 本文「見た目の属性は保持する。どの層で持つかは決まっていない」の未解決部分
|
||||
- 現状の認識: c, t, p, a, f系に加え、l, n もキーキャップの見た目属性であり、PHYとは別の層が要りそう。退避だけして書き戻す sm, sb, st も同じ置き場の問題を持つ。そもそもキーキャップは設計時に決める必要が薄く、カスタムキーボードの文脈に属するため、Beyond KLEの扱う範囲かどうかも未定。ファイル単位の backcolor, radii, background, css も同じ問いに含まれる
|
||||
- 格上げ条件: キーキャップの情報をBeyond KLEの範囲に含めるかをユーザーが決め、保持する層を決める
|
||||
|
||||
### P-8 ラベルをMAPに適用するか
|
||||
|
||||
- 位置づけ: 未解決の問い。本文ではラベルを参考情報として持つことだけを決めている
|
||||
- 現状の認識: どのラベルがMAPに適用できるかは、おそらく機械的には判断できない。自動で適用してユーザーが不要なものを落とす方式も考えられる
|
||||
- 格上げ条件: 適用するか、する場合は自動適用とユーザーによる取捨の流れをユーザーが決める
|
||||
|
||||
### P-12 部品の表裏を描画でどう扱うか
|
||||
|
||||
- 位置づけ: 本文「部品ノードは表裏を持たない」の残余
|
||||
- 現状の認識: どちらの面に載るかはPCB設計ツールが決める情報なのでPHYには持たない。ただしエディタ上で裏面の部品を描き分けたくなる可能性があり、`@` 始まりの予約グループ名だけで表現できるかは未検証
|
||||
- 格上げ条件: 予約グループ名による描画を実装し、表裏の描き分けが必要になるか、必要なら予約名で足りるかを確認する
|
||||
|
||||
### P-13 reframeと通常の移動・回転をエディタ上でどう区別するか
|
||||
|
||||
- 位置づけ: 本文「クラスターの組み替え」の残余。データモデル側は決まっており、操作系だけが未定
|
||||
- 現状の認識: クラスターへのフォーカス状態に紐づけ(中に入っている間は配下の絶対配置が変わらない)、
|
||||
そのうえで原点ハンドルと通常の移動・回転ハンドルを分ける案が有力。専用モードを設ける案もある
|
||||
- 格上げ条件: 操作系をユーザーが決める
|
||||
|
||||
### P-14 クラスター原点の吸着先
|
||||
|
||||
- 位置づけ: 未解決の問い。数値入力以外に、原点をどこへ吸着させるか
|
||||
- 現状の認識: キーのローカル原点(左上角)・キー中心・境界矩形の角と中心が候補。相対グリッドとは別の機構になる
|
||||
- 格上げ条件: 原点移動を実装し、吸着なしで足りるか、足りない場合どの候補が要るかを確認する
|
||||
(なし)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# 保留課題
|
||||
|
||||
現在の実装が答えに依存せず進められる問い・検証・改善候補をまとめる。
|
||||
保留中の対応範囲は各仕様書を正とし、運用は[ドキュメント運用ルール](../CLAUDE.md)に従う。
|
||||
項目の識別子は移動元のレイヤー名と番号を引き継ぐ。
|
||||
|
||||
## PHY
|
||||
|
||||
### PHY-P-2 KLE JSON取り込み時の照合基準
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「KLE JSONの取り込み」
|
||||
- 未決事項: 形状の許容誤差、ラベルの扱い、複数候補がある場合の決め方。
|
||||
- 保留できる理由: 新規PHYとしての読み込みは、既存ノードとの照合なしで実装できる。
|
||||
- 再開条件: 既存PHYへのKLE JSONの照合・更新に着手するとき。
|
||||
- 完了条件: 照合規則と曖昧な候補の扱いを決め、KLE本家でキーの移動・追加・削除をしたJSONを取り込み、
|
||||
意図したIDに対応づき、他レイヤーの参照を誤って付け替えないことを確認する。
|
||||
|
||||
### PHY-P-6 キーキャップの見た目情報を持つ層
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「KLE JSONの取り込み」、[JSON Schema](../phase1-json-schema.md)の `kleCompatibility`
|
||||
- 未決事項: キーキャップの見た目編集をBeyond KLEの範囲に含めるか、正式にはどの層で情報を持つか。
|
||||
対象はキー単位の c, t, p, a, f系, l, n と、退避する sm, sb, st、ファイル単位の
|
||||
switchMount, switchBrand, switchType, backcolor, radii, background, css。
|
||||
- 保留できる理由: 現行Schemaの `kleCompatibility` で、正式な所属層や編集機能を決めずに保持・書き戻しができる。
|
||||
- 再開条件: 見た目の編集機能、または退避先から正式な所属層への移行を検討するとき。
|
||||
- 完了条件: 扱う範囲と所属層をユーザーが決め、既存の退避情報を失わない保持・移行方法を定める。
|
||||
|
||||
### PHY-P-8 ラベルをMAPに適用するか
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「ラベル」
|
||||
- 未決事項: ラベルをMAPへ適用するか。機械的に適用先を判断できるかは未確認で、候補を自動適用してユーザーが不要なものを落とす案もある。
|
||||
- 保留できる理由: ラベルは参考情報として保持でき、MAPへの自動適用なしで各レイヤーを扱える。
|
||||
- 再開条件: MAPのキーコード割り当てを補助する機能を検討するとき。
|
||||
- 完了条件: 適用の要否と、適用する場合の候補提示・ユーザーによる取捨の流れをユーザーが決める。
|
||||
|
||||
### PHY-P-12 部品の表裏を描画でどう扱うか
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「部品ノード」
|
||||
- 未決事項: エディタ上で表裏の描き分けが必要か。必要なら `@` 始まりの予約グループ名だけで足りるか。
|
||||
- 保留できる理由: 表裏を描き分けなくても配置は編集でき、実装面はPCB設計ツール側で決められる。
|
||||
- 再開条件: 部品ノードと予約グループ名による描画を試作し、表示を評価するとき。
|
||||
- 完了条件: 描き分けの要否を確認し、必要なら実装面の指定と混同しない表示方法を決める。
|
||||
|
||||
### PHY-P-13 reframeと通常の移動・回転をエディタ上でどう区別するか
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「クラスターの組み替え」
|
||||
- 未決事項: reframeの操作系。クラスターへのフォーカス状態に紐づけて原点ハンドルと通常のハンドルを分ける案と、専用モードを設ける案がある。
|
||||
- 保留できる理由: データ変換は定義済みで、通常の移動・回転とは別の明示的な操作として試作できる。
|
||||
- 再開条件: クラスター編集を試作し、操作の分かりやすさを評価するとき。
|
||||
- 完了条件: 配下が追随する操作と絶対配置を保つ操作を区別できる操作系をユーザーが決める。
|
||||
|
||||
### PHY-P-14 クラスター原点の吸着先
|
||||
|
||||
- 関連仕様: [PHYレイヤー](../PHYレイヤー.md)「クラスターの組み替え」
|
||||
- 未決事項: 吸着が必要か。候補はキーのローカル原点(左上角)・キー中心・境界矩形の角と中心で、相対グリッドとは別の機構になる。
|
||||
- 保留できる理由: 数値入力だけで原点移動は成立する。
|
||||
- 再開条件: 原点移動を試作し、操作性を評価するとき。
|
||||
- 完了条件: 吸着の要否と、必要な場合の候補を決める。
|
||||
|
||||
## ELC
|
||||
|
||||
### ELC-P-1 光学スイッチの端子構成
|
||||
|
||||
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「キーの端子」
|
||||
- 未決事項: 発光側と受光側が独立した端子を持つのか、接点と同じ2端子として扱えるのか。
|
||||
- 保留できる理由: 光学スイッチを対応範囲から外したまま、接点・アナログの配線を扱える。
|
||||
- 再開条件: 光学スイッチの配線に対応するとき。
|
||||
- 完了条件: 実在のPCBの回路図で端子数と配線の形を確認し、必要な端子構成を定める。
|
||||
|
||||
### ELC-P-2 複数マルチプレクサ構成の作り
|
||||
|
||||
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「ピンとピン提供者」
|
||||
- 未決事項: 選択ピンを共有して共通ピンを分ける構成と、enableで切り替える構成の実配線・制御方法。
|
||||
どちらが一般的かは未確認。前者は複数providerの `select` に同じ `PinRef` を書く形で表現できる。
|
||||
- 保留できる理由: providerごとの参照を保存する形は維持でき、複数muxの走査出力を検証済みの構成に限定できる。
|
||||
- 再開条件: 複数muxを使う構成のファームウェア出力に対応するとき。
|
||||
- 完了条件: 実在のキーボードの配線でピン共有とenableの使い方を確認し、現行モデルでの表現と必要な制御を確かめる。
|
||||
|
||||
### ELC-P-3 方式ごとの走査設定の確認
|
||||
|
||||
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「配線トポロジを区分として持たない」
|
||||
- 未決事項: 各方式で出力器が決められる走査方法と、ユーザーの追加指定が必要になる範囲。
|
||||
接点マトリクスではダイオードの向き、directでは電源レールの参照から役割を判断できる範囲を確認する。
|
||||
- 保留できる理由: 走査方向の決定・追加指定の責任は出力器側と決まり、ELCの配線保存はそのまま進められる。
|
||||
- 再開条件: 各方式のファームウェア出力に着手するとき。
|
||||
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
|
||||
全方式で走査手順が一意に決まることの証明は求めない。
|
||||
|
||||
### ELC-P-4 GPIOに直結したLEDの表し方
|
||||
|
||||
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「LEDの識別番号」
|
||||
- 未決事項: インジケータなど、チェーンに入らずマイコンのピンで直接点灯するLEDをELCにどう持つか。
|
||||
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
|
||||
- 保留できる理由: チェーン方式(WS2812系)のLEDとキーの配線は、直結LEDと独立に扱える。
|
||||
- 再開条件: インジケータLEDの出力に対応するとき、またはLED配線の編集画面の仕様を作るとき。
|
||||
- 完了条件: 実在の基板で直結LEDの配線を確認し、ELCでの表し方と、ファームウェア出力に必要な情報を定める。
|
||||
|
||||
## IDX
|
||||
|
||||
### IDX-P-1 単一のorderから既存コミュニティレイアウトの並びを得られる範囲
|
||||
|
||||
- 関連仕様: [IDXレイヤー](../IDXレイヤー.md)「レイアウトバリアント」
|
||||
- 未決事項: 採用済みの方式で既存コミュニティレイアウトの要求順を再現できる範囲の実測。
|
||||
同じPHYキーIDを使う共通キー同士の要求順がレイアウト間で逆転する場合は、単一の `order` からの取捨では表せないが、その実例の有無は未確認。
|
||||
- 保留できる理由: 全バリアントのキーを別々の要素として並べ、選択後に番号を詰める仕様は決定済み。
|
||||
既存レイアウト全般との互換性を前提にせず実装できる。
|
||||
- 再開条件: 既存コミュニティレイアウトとの互換出力を検証するとき。
|
||||
- 完了条件: 実データのキーを共通キーとバリアント別のPHYキーに対応づけ、全体の `order` から各レイアウトの要求順を取捨で再現できるか確認する。
|
||||
再現できないものが残る場合は、対応範囲と扱いを決める。
|
||||
|
||||
## MAP
|
||||
|
||||
### MAP-P-4 ON/OFF以外の入力への割り当て
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「MAPデータと他レイヤーの関係」、[PHYレイヤー](../PHYレイヤー.md)「部品ノード」
|
||||
- 未決事項: ロータリーエンコーダの回転のように、ON/OFFのキーではない入力へ割り当てる方法。
|
||||
MAPはPHYのキーIDを参照するが、エンコーダはPHYで部品ノードとして置かれる。
|
||||
参照先の種類、入力ごとの割り当て先(回転方向など)、VIAでの読み書きの対象にするかが未定。
|
||||
アナログ値の解釈の所属は[全体方針のP-1](../keyboard-design-layers.md#p-1-アナログ値の解釈をどの層が持つか)で扱い、この項目とは分ける。
|
||||
- 保留できる理由: キーへの割り当てと実機との読み書きは、キー以外の入力を扱わなくても実装できる。
|
||||
- 再開条件: エンコーダ等を載せたキーボードの割り当てをMAPで扱うとき。
|
||||
- 完了条件: 対象とする入力の種類と、MAPからの参照方法・保存形式をユーザーが決め、VIAで読み書きする場合は必要なコマンドを確認する。
|
||||
|
||||
### MAP-P-5 キーコードを表示するOS配列
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「持たないもの」、[HSTレイヤー](../HSTレイヤー.md)
|
||||
- 未決事項: MAPエディタがキーコードをどのOS配列の文字で表示するか。HSTのどのプロファイルを使うか。
|
||||
少なくともUS/JISの切り替えは欲しい。利用者が増えれば他言語への対応も求められる。
|
||||
- 保留できる理由: キーコードの割り当てと実機との読み書きは、表示に使うOS配列と独立して実装できる。
|
||||
- 再開条件: MAPエディタの表示を実装するとき、またはHSTの[P-2](../HSTレイヤー.md#p-2-対象ホストを複数持つか)を決めるとき。
|
||||
- 完了条件: 表示に使うHSTプロファイルの指定方法と置き場所をユーザーが決める。
|
||||
|
||||
### MAP-P-6 QMKのdevelopブランチ・Community Modulesへの対応
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「DynamicとStatic」
|
||||
- 未決事項: 当面非対応としたdevelopブランチの機能とCommunity Modules(VIAモジュール以外)を、DynamicとStaticの対象に含めるか。
|
||||
含める場合、developブランチのコミットの指定方法と、UIでの対応バージョンの示し方。
|
||||
- 保留できる理由: QMK本家masterの公式機能だけで、Dynamicの読み書きとStaticの生成を実装できる。
|
||||
- 再開条件: masterの機能だけでは足りない動作や設定が必要になったとき。
|
||||
または、VIAのモジュール移行([qmk_firmware#26315](https://github.com/qmk/qmk_firmware/pull/26315))がdevelopに入ったとき。
|
||||
VIAモジュールへの対応はmasterに入る前に済ませる([MAPレイヤー「DynamicとStatic」](../MAPレイヤー.md#dynamicとstatic))。
|
||||
- 完了条件: 対象に含める範囲と、対応バージョンの管理方法をユーザーが決める。
|
||||
|
||||
### MAP-P-7 再ビルド時に実機の調整を失わないワークフロー
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「DynamicとStatic」、[MAPのP-3](../MAPレイヤー.md#p-3-プロジェクトと実機の対応状態管理)
|
||||
- 未決事項: QMK本家のVIAは、実機に保存したVIAのデータ(キーマップ・マクロ・layout option)を、ビルド日時をもとにした値で検証し、一致しなければ初期化する(`quantum/via.c` の `via_eeprom_is_valid()`。実機では未検証)。
|
||||
再ビルドしたファームウェアを書き込む前に、実機の調整をプロジェクトへ読み出す手順を、ワークフローでどう保証するか。警告、読み出しの強制、Firmware Generatorとの連携などが候補。
|
||||
- 保留できる理由: 実機からの読み出しとStaticの初期キーマップへの反映はすでに仕様にある。手作業の手順で運用できる。
|
||||
- 再開条件: Firmware Generatorでの書き込みや、プロジェクトと実機の状態管理(P-3)を設計するとき。
|
||||
- 完了条件: 再ビルドの前に実機の調整を取り込めるワークフローをユーザーが決め、書き込み後にVIAのデータが初期化されることを実機で確認する。
|
||||
|
||||
### MAP-P-8 QMK以外のファームウェアでのキーコード表記
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「MAPデータと他レイヤーの関係」、[全体方針のFirmware Generator](../keyboard-design-layers.md#firmware-generator)
|
||||
- 未決事項: MAPはQMKのキーコード表記を基本とする。KMK等のQMK以外のファームウェアへ出力するとき、QMK表記から変換するか、共通の表記を別に設けるか。
|
||||
- 保留できる理由: 当面の対象ファームウェアはQMKだけで、QMK表記のまま保存・読み書き・出力できる。
|
||||
- 再開条件: QMK以外のファームウェアへの出力や、実機との通信に着手するとき。
|
||||
- 完了条件: QMK以外のファームウェアで使うキーコードの表現と、QMK表記との対応方法をユーザーが決める。
|
||||
|
||||
### MAP-P-9 Cコードの生成とユーザーのCの組み込み
|
||||
|
||||
- 関連仕様: [MAPレイヤー](../MAPレイヤー.md)「MAPデータと他レイヤーの関係」「DynamicとStatic」、[全体方針のFirmware Generator](../keyboard-design-layers.md#firmware-generator)
|
||||
- 未決事項: QMKのkeymap.jsonでは、タップダンス・コンボ・キーオーバーライドや、キーごとの設定(`get_tapping_term` 等のコールバック)を表現できない。
|
||||
JSONを正本にしたままこれらをCとして生成する方法と、ユーザーが用意したC/hで一部を上書きする方法。
|
||||
QMKのコールバックは1つの関数として定義するため、生成したCとユーザーのCが同じ関数を定義するとビルドできない。候補:
|
||||
- 生成した関数の先頭でユーザー用のフックを呼び、フックが処理しなかった場合はJSONの値を使う(有力)
|
||||
- ユーザーが引き受ける関数をJSONまたはhのプロトタイプで宣言し、Generatorはその関数を出力しない
|
||||
`ACTION_TAP_DANCE_FN*` やリーダーキーのように、本質的にCで書く動作をJSONから参照する方法も含む。
|
||||
MAPの「キーマップ固有のコードには原則として対応しない」との関係も整理する。
|
||||
- 保留できる理由: レイヤー別の割り当てとVIAでの読み書きは、keymap.jsonで表現できる範囲だけで実装できる。
|
||||
- 再開条件: Firmware Generatorでタップダンス・コンボ・キーごとの設定等を出力するとき、またはP-1で動作定義とキーごとの設定の保存形式を決めるとき。
|
||||
- 完了条件: 生成するCの構成と、ユーザーのC/hを組み込む方法をユーザーが決め、両方を含む構成でQMKのビルドが通ることを確認する。
|
||||
+45
-18
@@ -13,16 +13,41 @@ PHYの正本は相対座標の木だが、既存エディタ(KLE, kle-ng)の移
|
||||
|
||||
### テストケース
|
||||
|
||||
1. **60% ANSI** - 標準的なレイアウト
|
||||
2. **Split keyboard** - 左右分離型
|
||||
3. **Ergonomic layout** - カラムスタッガード + 回転
|
||||
4. **Macro pad** - シンプルな4x4
|
||||
入力はKLE JSONとし、取り込みを通してPHYへ変換する。
|
||||
|
||||
| ケース | サンプル | 主に確かめること |
|
||||
|---|---|---|
|
||||
| 60% | Cr0wn60 | 行スタッガード、幅の違うキー、左右対称配列の中央の隙間 |
|
||||
| 分割 | crkbd、jisplit89 | 左右の分離、カラムスタッガードと回転した親指キー(crkbd)、キー数の多いJIS配列の分割(jisplit89) |
|
||||
| 分割 + バリアント | Kyria、Iris Rev.7 | 回転したクラスター内のバリアント(Kyria)、部品(エンコーダ)を含む3択のバリアント(Iris Rev.7) |
|
||||
| マクロパッド | 4x4 | 最小構成 |
|
||||
| charlieplexing | Cr0wn60(VIA定義) | 行と列が同じピンを共有する配線を、ELCの接続先で表せるか |
|
||||
|
||||
リポジトリに置くサンプルは `samples/` にある。
|
||||
|
||||
- `kle/cr0wn60.json`: [EwdErna/Cr0wn60](https://github.com/EwdErna/Cr0wn60) の `keyboard-layout.json`(919cc9e、MIT)
|
||||
- `via/cr0wn60.json`: 同リポジトリの `cr0wn60-via.json`。ラベルにマトリクス位置を持つ
|
||||
- `kle/macropad-4x4.json`: このリポジトリで作成
|
||||
|
||||
Cr0wn60はcharlieplexingで、行と列に同じ9本のピンを割り当て、使わない位置をマスクしている。
|
||||
ピンの割り当ては、ファームウェア [EwdErna/qmk_firmware](https://github.com/EwdErna/qmk_firmware) の
|
||||
`dev/ewderna_22` ブランチ(68d9249)の `keyboards/ewderna/cr0wn/cr0wn60/` にある
|
||||
(`info.json` の `matrix_pins`、`cr0wn60.c` の `matrix_mask`)。
|
||||
|
||||
その他はコピーせず、[the-via/keyboards](https://github.com/the-via/keyboards)(GPL-3.0)から、コミット 9e3e9f4 の次のファイルの `layouts.keymap` を取得して使う。
|
||||
|
||||
- crkbd: `src/crkbd/crkbd.json`
|
||||
- jisplit89: `src/jisplit89/jisplit89.json`
|
||||
- Kyria: `src/splitkb/kyria.json`
|
||||
- Iris Rev.7: `src/keebio/iris/iris-rev7.json`
|
||||
|
||||
VIAの定義に含まれるKLE JSONは、ラベルにキー名ではなくマトリクス位置(`行,列`)を持つ。
|
||||
バリアントは、ラベルの別の欄に `選択肢の番号,候補の番号` の形で示す。
|
||||
キー名のラベルによる照合の検証には使えない。ELCの接続先とバリアントの検証に使う。
|
||||
|
||||
### データ例
|
||||
|
||||
以下は旧形式(`keys` 配列、`position` / `size.width`)で書かれており、現行のPHYスキーマ(`phase1-json-schema.md`)に合わせた変換が必要。
|
||||
|
||||
#### 60% ANSI PHY Layer
|
||||
#### 60% ANSI PHY Layer (抜粋)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -38,20 +63,22 @@ PHYの正本は相対座標の木だが、既存エディタ(KLE, kle-ng)の移
|
||||
"unitSize": { "x": 19.05, "y": 19.05 }
|
||||
}
|
||||
],
|
||||
"keys": [
|
||||
// Escape key
|
||||
"root": [
|
||||
{
|
||||
"id": "k00", "profile": "standard",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1, "height": 1 },
|
||||
"metadata": { "label": "Esc" }
|
||||
"type": "key",
|
||||
"id": "k00",
|
||||
"profile": "standard",
|
||||
"transform": { "x": 0, "y": 0, "rotation": 0 },
|
||||
"size": { "w": 1, "h": 1 },
|
||||
"labels": ["Esc"]
|
||||
},
|
||||
// Space bar
|
||||
{
|
||||
"id": "k60", "profile": "standard",
|
||||
"position": { "x": 5, "y": 4 },
|
||||
"size": { "width": 6.25, "height": 1 },
|
||||
"metadata": { "label": "Space" }
|
||||
"type": "key",
|
||||
"id": "k60",
|
||||
"profile": "standard",
|
||||
"transform": { "x": 5, "y": 4, "rotation": 0 },
|
||||
"size": { "w": 6.25, "h": 1 },
|
||||
"labels": ["Space"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
- ijprest/keyboard-layout-editor の改造は不可。全権利留保で、作者はフォークを避けたいと明記している
|
||||
- 改造の現実的な候補は adamws/kle-ng (MIT, Vue3+TS, 2026-09時点で活発)。法的には可
|
||||
- ただし KLE も kle-ng も内部データはフラットな Key[] (絶対座標 + rotation_x/y)。
|
||||
PHY座標モデル(相対座標の木)を採った以上、改造しても編集操作の中核は書き直しになる
|
||||
PHY座標モデル(相対座標の木)を採った以上、改造しても編集操作の中核は書き直しになる
|
||||
- 自作寄り。KLE JSON の読み書きはライブラリで賄える(kle-serial / kle-serial2, いずれもMIT)
|
||||
- 技術選定(言語・UIフレームワーク・描画・ライブラリ)は別途やり直す。Vue案はNuxtに慣れているからというだけ(ユーザー談)
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | ライセンス | 流用できるもの | 書き直しになるもの |
|
||||
|---|---|---|---|
|
||||
| --- | --- | --- | --- |
|
||||
| ijprest KLE 改造 | 全権利留保。個別許諾要 | 描画, プロパティUI | データモデル, 編集操作。AngularJS 1.2.28/bower の保守 |
|
||||
| kle-ng 改造 | MIT | 描画(ISO形状含む), プロパティUI, undo, plate/PCB生成 | keyboard.ts ストア(62KB)と、それを直接書き換える移動・回転・履歴 |
|
||||
| 自作 | - | kle-serial(読), kle-serial2(読み書き) | 全部。ただし木モデルに合わせて最初から書ける |
|
||||
@@ -22,14 +22,15 @@
|
||||
## 検証ログ
|
||||
|
||||
### ijprest/keyboard-layout-editor
|
||||
|
||||
- LICENSE.md (ローカル HEAD d2945e5 / 本家 master 2026-09-18 取得、同文)
|
||||
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
|
||||
- "But it's not permissively licensed!"
|
||||
- "In the near-term I would like to avoid forks of the website, since I feel it would cause confusion
|
||||
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
|
||||
- "But it's not permissively licensed!"
|
||||
- "In the near-term I would like to avoid forks of the website, since I feel it would cause confusion
|
||||
in the community to have several similar-but-different general-purpose layout editors available."
|
||||
- 別用途(メーカーのカスタマイズ用エディタ等)なら無償で特別ライセンスを出す、とある。
|
||||
Beyond KLE は汎用レイアウトエディタそのものなので、この例外には当てはまりにくい
|
||||
- "I claim no ownership over the input (your keyboard layouts) or output" → ビュワーとして使う・JSONを扱うのは問題なし
|
||||
- 別用途(メーカーのカスタマイズ用エディタ等)なら無償で特別ライセンスを出す、とある。
|
||||
Beyond KLE は汎用レイアウトエディタそのものなので、この例外には当てはまりにくい
|
||||
- "I claim no ownership over the input (your keyboard layouts) or output" → ビュワーとして使う・JSONを扱うのは問題なし
|
||||
- 本家最終コミット 580b916 2024-09-15 "Update to https"。その前は 2019-07-04。実質停止
|
||||
- 規模(HEAD): kb.js 1810 / kb.html 1410 / kb.css 453 / serial.js 374 / render.js 275 行
|
||||
- 依存: angular 1.2.28, jquery 2.0.2, bootstrap 3.3.5, bower
|
||||
@@ -37,36 +38,40 @@
|
||||
- ローカルクローン(Documents/Dev/keyboard-layout-editor)の未コミット差分はPNG解像度調整と円形プロファイル追加のみ(ユーザー談)。本件と無関係
|
||||
|
||||
### kle-serial (ijprest)
|
||||
|
||||
- MIT, Copyright (c) 2013-2019 Ian Prest。npm @ijprest/kle-serial 0.15.1 (2019-06-30)
|
||||
- README: "This library currently only handles deserialization; the serialization code still needs to be ported."
|
||||
|
||||
### kle-serial2 (adamws, ijprest/kle-serial のフォーク)
|
||||
|
||||
- MIT。2025-08-22作成、最終push 2026-02-14、star 1
|
||||
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r _rs a f fa w h w2 h2 x2 y2 l n d
|
||||
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r_rs a f fa w h w2 h2 x2 y2 l n d
|
||||
- 出自の懸念: 元READMEは serialize を「KLE本体から移植予定」としていた。kle-serial2 の serialize 冒頭
|
||||
(current, cluster={r,rx,ry}, align=4, rows/row)は KLE serial.js:166- と構造が似る。
|
||||
ただしKLEフォーマット自体がこの構造を要求するので、流用の証拠にはならない。未確認
|
||||
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
|
||||
(current, cluster={r,rx,ry}, align=4, rows/row)は KLE serial.js:166- と構造が似る。
|
||||
ただしKLEフォーマット自体がこの構造を要求するので、流用の証拠にはならない。未確認
|
||||
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
|
||||
|
||||
### adamws/kle-ng
|
||||
|
||||
- MIT, Copyright (c) 2025 adamws。Vue 3 + Pinia + TS + Vite, bootstrap 5, three.js, ergogen, makerjs
|
||||
- 最終コミット 9eb350f 2026-09-03 (0.48.0)。star 267
|
||||
- 規模: TypeScript 2.97MB + Vue 0.99MB (GitHub languages API, テスト込み)
|
||||
- src/stores/keyboard.ts: `const keys: Ref<Key[]> = ref([])`。フラット、絶対座標 + rotation_x/y。階層・グループなし
|
||||
- 移動: key.x = D.add(originalPos.x, snappedDeltaX) と直接書き換え
|
||||
- 回転: key.rotation_angle を直接書き換え、lock-rotations時は rotation_x/y も更新
|
||||
- undo: keys 全体の JSON スナップショット、50件
|
||||
- 移動: key.x = D.add(originalPos.x, snappedDeltaX) と直接書き換え
|
||||
- 回転: key.rotation_angle を直接書き換え、lock-rotations時は rotation_x/y も更新
|
||||
- undo: keys 全体の JSON スナップショット、50件
|
||||
- matrix-drawing.ts (28KB), pcbGenerator.ts, plateGenerator.ts あり。IDX/ELC の参考になりうる(未読)
|
||||
- ijprest のコードから派生したかは README に記載なし。未確認
|
||||
|
||||
### その他
|
||||
|
||||
- ymkn/keyboard-layout-studio: MIT, Vue3, SVG, KLE import, QMK keyboard.json / Vial 出力, matrix割り当て。star 4, 21 commits。回転・グループの記載なし
|
||||
- ローカル Documents/Dev/kbd-editor: Nuxt3 + Pinia, 2025-07, 約2,500行 (deserializer.ts, Key.vue, KeyPropEditor.vue 等)。ユーザーが以前自作を試みたもの。うまくいかなかった記憶あり(ユーザー談)。中身の確認は保留
|
||||
|
||||
## 規模見積もり(PHY Editor MVP, 1人・フルタイム換算の粗い目安)
|
||||
|
||||
| 項目 | 自作 | kle-ng改造 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| KLE読込 | 1-2日 (kle-serial) | 既存 |
|
||||
| KLE書出 + 畳み込み | 2-4日 | 畳み込みだけ新規 |
|
||||
| 木モデル・変換合成・逆変換 | 3-5日 | 3-5日 + 既存ストアへの差し込み |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
## 案の比較
|
||||
|
||||
| 案 | 出典 | 問題 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| labelにIDを埋め込み、別ファイルのクラスターJSONと紐づけ | PHYレイヤー.md(旧) | VIAのlabelハックと同じ道。IDが長くなりラベルが見づらい |
|
||||
| `_beyondkle` メタデータ + labels[8] のハイブリッド | kle-compatibility.md | 同上。独自プロパティはKLEが保存時に落とす可能性 |
|
||||
| ゴーストキーにクラスター情報を格納 | kle-compatibility-analysis.md | 下記。所属情報が残らず、結局labelハックに戻る |
|
||||
@@ -28,12 +28,12 @@ PHY座標モデルの決定(相対座標の木が正本)により、KLE JSONは
|
||||
### ゴーストキー案の穴
|
||||
|
||||
- キーの所属は `key.clusterId` という独自プロパティで持たせる設計だった(kle-compatibility-analysis.md の createCluster)。
|
||||
KLEのシリアライザは既知のプロパティしか書き出さないはずで(未確認)、保存時に所属が消える。
|
||||
残すには各キーのlabelにIDを入れることになり、labelハックに戻る
|
||||
KLEのシリアライザは既知のプロパティしか書き出さないはずで(未確認)、保存時に所属が消える。
|
||||
残すには各キーのlabelにIDを入れることになり、labelハックに戻る
|
||||
- 矩形に含まれるかで所属を判定する方法は、入れ子や回転があると曖昧になる
|
||||
- ゴーストキー自体も labels[0]="CLUSTER_META"、labels[4]=JSON という形で、汚す対象がダミーキーに変わっただけ
|
||||
- KLE JSONを読む他ツール(QMK変換、KiCad配置プラグインなど)がゴーストキーを実在するキーとして扱う懸念(未確認)。
|
||||
KLE JSONを出力する目的は互換性なので、偽のキーを混ぜるのは本末転倒
|
||||
KLE JSONを出力する目的は互換性なので、偽のキーを混ぜるのは本末転倒
|
||||
|
||||
### KLEを改造するか自作するかとは独立
|
||||
|
||||
@@ -45,4 +45,4 @@ KLEを改造する場合でも、内部データと保存形式は自分で決
|
||||
- 照合基準の詳細(許容誤差、ラベルの扱い、複数候補の解決)。PHYレイヤー.md の未確認事項 P-2
|
||||
- KLEが保存時にキー順を並べ替えるか(未確認)。配列順を照合に使えるかに関わる
|
||||
- kle-compatibility.md / kle-compatibility-analysis.md の埋め込み案の記述は今回の決定と食い違うが、
|
||||
既存ドキュメントの再編は別タスクのため触っていない
|
||||
既存ドキュメントの再編は別タスクのため触っていない
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## 案の比較
|
||||
|
||||
| 項目 | 案 | 採否 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| d, g | PHYでキーとして持つ / 取り込み時に捨てる | 持つ |
|
||||
| 見た目属性 | 捨てる / 解釈せず保持して書き出し時に戻す | 保持。KLEをビュワーに使うとき色などが戻る方が実用的 |
|
||||
| ラベル | 捨てる / 参考情報として持つ | 持つ。照合に必要 |
|
||||
@@ -19,8 +19,8 @@
|
||||
## ユーザーから得た事実・所感
|
||||
|
||||
- l(ステップキー)にはスイッチ位置がずれるものがある(Stepped Caps Lockが典型)。ただし l なら必ずずれるわけではない(昔のXFERなど)
|
||||
→ 「キャップの位置・サイズからスイッチ座標を特定する」という本文と食い違う。P-5として分離
|
||||
→ 「キャップの位置・サイズからスイッチ座標を特定する」という本文と食い違う。P-5として分離
|
||||
- l, n はキーキャップの見た目属性であり、PHYとは別の層が要りそう。キーキャップは設計時に決める必要が薄く、
|
||||
カスタムキーボードの文脈に属する。Beyond KLEの範囲かは結論なし
|
||||
カスタムキーボードの文脈に属する。Beyond KLEの範囲かは結論なし
|
||||
- 一部のキーだけ違うスイッチを使うキーボードもあるので、スイッチ種別はキーごとの上書きが要るかもしれない。PCB用なのでPHYに要るか
|
||||
- ラベルのうちどれがMAPに適用できるかは判断できない見込み。自動で適用してユーザーが落とす案もある
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## 案の比較
|
||||
|
||||
| 案 | 採否 |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| キャップ形状(l 等)からスイッチ位置を導出 | 不可。l でもずれないキーがある(昔のXFER) |
|
||||
| キーごとにオフセットを持つ | 採用 |
|
||||
| PHY以外の層(キーキャップ層など)で持つ | 不採用 |
|
||||
@@ -17,5 +17,5 @@
|
||||
- Stepped Caps Lockなど、スイッチがキャップ中心からずれるキーがある(ユーザー提供の事実)
|
||||
- ずれるかどうかはキャップ形状から決まらないので、キーごとに持つしかない
|
||||
- スイッチ位置はPCB・プレートの配置を決める物理情報であり、PHYの責務。キーキャップの見た目情報が別層に移っても(P-6)、
|
||||
キャップ外形とスイッチの相対位置は配置そのものなのでPHYに残る
|
||||
キャップ外形とスイッチの相対位置は配置そのものなのでPHYに残る
|
||||
- ローカル座標系にしたのは、キーの回転に追従させるため
|
||||
|
||||
@@ -14,7 +14,7 @@ PHYレイヤー.md の既存記述が2箇所でこれを前提にしていた。
|
||||
## 案の比較
|
||||
|
||||
| 案 | 過去のKLE資産 | 誤り |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 自動推定 | 一発で階層が付く場合がある | 推定を外したときユーザーが直せない(直す手段=手動操作がないため) |
|
||||
| 手動の組み替え | 選択してまとめる手数がかかる | ユーザーの意図がそのまま入るので誤りがない |
|
||||
| 両方 | 同上 | 手動操作があれば推定の価値は初期値の提案に落ちる。初期値は小さく済む(下記) |
|
||||
@@ -43,7 +43,7 @@ Size / switchOffset / orientation はローカル値で親の変換を受けな
|
||||
|
||||
- スナップは手作業の入力時のみ。計算結果には適用しない
|
||||
- グリッドは操作開始時の値を基準とする相対グリッド。ローカル原点基準の絶対グリッドにしない
|
||||
(非グリッド値を持つキーを後から掴んだとき、絶対配置が勝手に動くのを防ぐ)
|
||||
(非グリッド値を持つキーを後から掴んだとき、絶対配置が勝手に動くのを防ぐ)
|
||||
|
||||
## 浮動小数の累積(実装時の注意、本文には書かない)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
### per-key LEDの表現
|
||||
|
||||
| 案 | 位置 | 採否 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| キーのboolean | 持たない | ○ |
|
||||
| キーに追随する自動生成ノード | ノードが座標を持つ | × 座標に自由度がない |
|
||||
| ユーザーが1個ずつ置く | 持つ | × キー数ぶんの手作業 |
|
||||
@@ -21,7 +21,7 @@
|
||||
### 独立して置く部品
|
||||
|
||||
| 案 | 対象 | 採否 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| 部品ノード(汎用) | LED / OLED / エンコーダ / ネジ穴 / MCU など | ○ |
|
||||
| LEDノード専用 | LEDのみ | × 次の部品で同じ議論をやり直す |
|
||||
|
||||
@@ -57,9 +57,9 @@ partGroup(中身を解釈しないラベル)をそのまま流用できるので
|
||||
## 捨てた案・送った先
|
||||
|
||||
- ファイル単位の「per-key LEDがある」フラグ: キーごとのbooleanから導出できるので存在フラグとしては不要。
|
||||
既定値を与える設定としては意味があるが、ファイル単位設定の置き場は他にも要求があり別トピックへ。
|
||||
今回はキーごとのbooleanのみ決め、省略時は false とする
|
||||
既定値を与える設定としては意味があるが、ファイル単位設定の置き場は他にも要求があり別トピックへ。
|
||||
今回はキーごとのbooleanのみ決め、省略時は false とする
|
||||
- レイアウトによって条件付きでLEDの有無が変わる構成: ANSI/ISO Enter のようなキー自体の排他と同じ問題で、
|
||||
LED固有ではない。マルチレイアウト(未導入の概念)側へ送る
|
||||
LED固有ではない。マルチレイアウト(未導入の概念)側へ送る
|
||||
- 部品の表裏をPHYが持つ: PCB設計ツールが決める情報。ただし描画の描き分けとの絡みが残るので P-12
|
||||
- チェーン順序をPHYが持つ: 電気的な接続なのでELC。ただし接点マトリクスと同じ層でよいかに確信がなく P-11
|
||||
|
||||
@@ -8,7 +8,7 @@ PHYは端子配置(terminal) / ソケット方式(socket) / スイッチの向
|
||||
## 案の比較
|
||||
|
||||
| 論点 | 案 | 採否 |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| ブランド | KLEの sb 相当をPHYで持つ | 不採用 |
|
||||
| ブランド | 持たない | 採用 |
|
||||
| 種別の定義 | (a) スイッチ側の足の配置。MX-3pin と MX-5pin は別タイプ | 採用 |
|
||||
@@ -24,13 +24,13 @@ PHYは端子配置(terminal) / ソケット方式(socket) / スイッチの向
|
||||
|
||||
- ブランドは同じボードに対して利用者が選ぶもので、穴もパッドも配線対象も変えない。ボード設計の情報ではない
|
||||
- KLEの st は `MX1A-11Nx` のようなベンダー品番で、ブランド・種別・バリエーションが混ざった文字列。ここから穴形状を機械的に導けないため、
|
||||
Beyond KLEの端子配置との対応づけは成立しない。名前も意味もKLEと別物なので、同名を避けて terminal とした
|
||||
Beyond KLEの端子配置との対応づけは成立しない。名前も意味もKLEと別物なので、同名を避けて terminal とした
|
||||
- 端子配置は電気的インターフェース(2端子接点 / アナログ / 光学)も決める。PHYが持てばELCが別途スイッチ情報を持つ必要がない。
|
||||
これがPHYに置く根拠になった
|
||||
これがPHYに置く根拠になった
|
||||
- 向きは90度と270度で別物(LED穴・ボス穴の位置が変わる)なので4方向。キーの回転とは別の値で、キーのローカル座標系に対して持つ
|
||||
- 階層化を採らなかったのは、欲しいのが分類ラベルであって継承ではないため。低背かどうかも定義表の列で表せる
|
||||
- 未知の種別を特別扱いしなかったのは、KiCadのフットプリント名がライブラリ依存で、既知の種別でも出力時のマッピングが要るため。
|
||||
未知は「マッピングが未登録」なだけになる
|
||||
未知は「マッピングが未登録」なだけになる
|
||||
|
||||
## (b)案を捨てた経緯
|
||||
|
||||
@@ -43,4 +43,4 @@ LED穴も (b) と同じ形の論点だが、LED専用ノードを立てる可能
|
||||
## 捨てた仮説
|
||||
|
||||
- 「マウント方式」をKLEの sm と同じ軸だと考えていた。KLEの sm は cherry / alps のような軸規格で、
|
||||
プレートマウント/PCBマウントでもホットスワップ/直付けでもない。ユーザーの意図はソケット有無だったため socket に落ち着いた
|
||||
プレートマウント/PCBマウントでもホットスワップ/直付けでもない。ユーザーの意図はソケット有無だったため socket に落ち着いた
|
||||
|
||||
@@ -66,10 +66,11 @@ ZMKの matrix transform `map`:
|
||||
引数の数え間違いでキーが1つずれるのが定番のバグであることも、この層が手書きである傍証になる。
|
||||
|
||||
出典:
|
||||
- https://docs.qmk.fm/reference_configurator_support
|
||||
- https://docs.qmk.fm/reference_info_json
|
||||
- https://zmk.dev/docs/config/layout
|
||||
- https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md
|
||||
|
||||
- <https://docs.qmk.fm/reference_configurator_support>
|
||||
- <https://docs.qmk.fm/reference_info_json>
|
||||
- <https://zmk.dev/docs/config/layout>
|
||||
- <https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md>
|
||||
|
||||
## 根拠: マトリクス順 ≠ キー番号順 の実物
|
||||
|
||||
@@ -96,7 +97,7 @@ QMKの分割も同じ構造。
|
||||
> Since the matrix scanning procedure operates on entire rows, it first populates the left half's rows,
|
||||
> then the right half's. Thus, the matrix as QMK views it has double the rows instead of double the columns.
|
||||
|
||||
出典: https://docs.qmk.fm/features/split_keyboard
|
||||
出典: <https://docs.qmk.fm/features/split_keyboard>
|
||||
|
||||
## 保存形式を `order: string[]` にした理由
|
||||
|
||||
@@ -109,7 +110,7 @@ ELCの `LEDChain.order` で既に「並びの位置が番号」の形を採っ
|
||||
## 捨てた仮説
|
||||
|
||||
- 「IDXはdecal/ghostの区別を持つべきではないか」。decalはPHYが `decal` フラグを持っているので
|
||||
そこから分かる。ghostは何を意味するかがKLE由来で曖昧なので、IDXの側で規定しない。
|
||||
`order` に挙げたキーだけが番号を持つ、で足りる
|
||||
そこから分かる。ghostは何を意味するかがKLE由来で曖昧なので、IDXの側で規定しない。
|
||||
`order` に挙げたキーだけが番号を持つ、で足りる
|
||||
- 「VIAとの往復でキー番号が要るのでは」。VIAのプロトコルはrow/col指定なので、キー番号は出てこない。
|
||||
ただし裏取りしていないので、ドキュメントには書かなかった
|
||||
ただし裏取りしていないので、ドキュメントには書かなかった
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
公開されているKLE JSON 1505件(キー111,619個)を集め、PHYへ変換して書き戻す往復を通した。
|
||||
|
||||
- 幾何(位置・サイズ・回転・2つ目の矩形・decal・ghost)は全件で無損失。PHYのデータモデルで表せないKLEの
|
||||
幾何情報は無かった
|
||||
幾何情報は無かった
|
||||
- 置き場がないのは見た目の属性だけ(c, t, p, a, f, f2, fa, n, l と sm, sb, st、ファイル単位の
|
||||
backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの
|
||||
backcolor, radii, background, css)。検証では退避用のフィールドに逃がした。PHY P-6 の対象そのもの
|
||||
- KLE本家のデシリアライザが読めないJSONが実在する(1505件中1件)
|
||||
- `l`(stepped)が付いたキーが16個あった。KLEはstepped Caps Lockに「段差がある」ことしか持たず、
|
||||
スイッチ位置のオフセットは持たない。PHYの `switchOffset` は取り込みでは決まらない
|
||||
スイッチ位置のオフセットは持たない。PHYの `switchOffset` は取り込みでは決まらない
|
||||
|
||||
## コーパス
|
||||
|
||||
@@ -39,7 +39,7 @@ KLE本家のリポジトリ (ijprest/keyboard-layout-editor) から `serial.js`
|
||||
|
||||
- 1504/1504 で配置が一致(1件は手順1で本家が例外を投げるため対象外)
|
||||
- 直列化結果そのものが本家の出力と完全一致: 1408件。小数4桁に丸めると一致(誤差のみ): 20件。
|
||||
残り76件は行の切り方が違うだけで、読み直した配置は一致する
|
||||
残り76件は行の切り方が違うだけで、読み直した配置は一致する
|
||||
|
||||
出現頻度(キー単位、111,619個中):
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
木案はそこが弱いが、次の点で勝る。
|
||||
|
||||
- 条件付きの選択肢(あるバリアントを選んだときだけ現れる選択肢)が入れ子で自然に出る。
|
||||
タグ案では選択肢の依存関係を別に持つ必要がある
|
||||
タグ案では選択肢の依存関係を別に持つ必要がある
|
||||
- 「どのキーがどの選択肢に属するか」を木の上で直接見られる。タグ案は全ノードを走査しないと分からない
|
||||
- 空のVariant(「エンコーダーを載せない」)が、子のいないノードとしてそのまま置ける。
|
||||
タグ案ではファイル単位の宣言が必須になる(キーのタグから選択肢を復元できないため)
|
||||
タグ案ではファイル単位の宣言が必須になる(キーのタグから選択肢を復元できないため)
|
||||
|
||||
別レイヤー案は、選択箇所が「どのスイッチを実装するか」という物理の話であり、
|
||||
PHY以外の3レイヤーがすべてPHYのノードIDを参照する構造からも外れるので落とした。
|
||||
@@ -58,7 +58,7 @@ supersetの並びから選択外のキーを落とした派生情報として出
|
||||
## 捨てた検討
|
||||
|
||||
- キーごとに `variants: string[]` を持たせて複数の選択肢にまたがらせる形。
|
||||
そもそも複数選択肢で共通のキーが実在するかを確認できていないので、必要になってから決める
|
||||
(PHYレイヤー.md P-15)
|
||||
そもそも複数選択肢で共通のキーが実在するかを確認できていないので、必要になってから決める
|
||||
(PHYレイヤー.md P-15)
|
||||
- 選択状態(どのバリアントを選んでいるか)をPHYとは別ファイルに持つ案。
|
||||
既定のバリアントをVariantGroupに持たせれば足りる。編集中の表示切り替えはエディタの状態
|
||||
既定のバリアントをVariantGroupに持たせれば足りる。編集中の表示切り替えはエディタの状態
|
||||
|
||||
@@ -57,7 +57,7 @@ VariantGroupを「ANSI/ISO」と「Backspace分割の有無」に分ければ、
|
||||
|
||||
- 入れ子のVariantGroupで表す: 出現集合が交差するので不可能
|
||||
- 独立したVariantGroupに分けて、成立しない組み合わせを禁止する仕組みを足す:
|
||||
複製を許せば要らない。禁止の表現を足すほうが重い
|
||||
複製を許せば要らない。禁止の表現を足すほうが重い
|
||||
- 同じ位置のキーに違う番号が振られたら警告する: 排他キーは位置もサイズも違ってよいので基準にならない
|
||||
|
||||
## この調査の限界
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 保留課題をbacklogへ分離する
|
||||
|
||||
## 結論
|
||||
|
||||
文書を、現行仕様・保留課題・検討履歴の3種類に分ける。
|
||||
後回しにできる問い・検証・改善候補はbacklog/README.mdにまとめる。
|
||||
現在の仕様が依存する未確認の前提は、引き続き仕様書の末尾に残す。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 置き場 | 判断 |
|
||||
| --- | --- |
|
||||
| 各仕様書の未確認事項にすべて残す | 現行実装の前提と、将来考えればよい課題が混ざる |
|
||||
| journalへ移す | 結論反映後に削除できる履歴と、再開まで残す課題では寿命が異なる |
|
||||
| 直下にbacklog.mdを置く | 直下へ追加したくないというユーザーの意向で採らない |
|
||||
| backlog/README.mdへまとめる | 採用。役割をフォルダで区別し、必要になれば分割できる |
|
||||
|
||||
## 運用の意図
|
||||
|
||||
既存ルールの狙いは、採用した仕様に過去の検討を混ぜず、未確認の前提を事実と誤認して実装しないこと。
|
||||
保留課題は現在の仕様でも解決済みの履歴でもないため、第3の文書種別として扱う。
|
||||
|
||||
移動の基準は未確認かどうかではなく、現在の実装がその答えに依存するかどうか。
|
||||
保留中の対応範囲・暫定仕様は仕様書に明記し、その範囲で進められる問いだけを移す。
|
||||
backlogへの移動自体は、仮説の採用や実装の許可を意味しない。
|
||||
|
||||
各項目は元の番号にレイヤー名を付け、関連仕様・未決事項・保留理由・再開条件・完了条件を持つ。
|
||||
再開条件で「いつ考え直すか」を、完了条件で「何を確認または決定すれば終わるか」を残す。
|
||||
事実確認と設計上の意思決定をともに「格上げ」と呼ぶことはやめる。
|
||||
解決または取り下げた項目は削除し、完了済み課題の一覧は作らない。
|
||||
|
||||
仕様書側の「保留課題」は、対応するbacklogの区分へのリンク箇条書きだけに固定した。
|
||||
問いの要約や項目名の列挙を重ねると片方だけ古くなるため、詳細はbacklogで一元管理する。
|
||||
末尾は保留課題・検討済み非採用・未確認事項の順とし、空の保留課題セクションは残さない。
|
||||
|
||||
## 適用範囲
|
||||
|
||||
PHY・ELC・IDXの10件を整理した。ELC-P-3は責任分担の決定後に残る方式別の確認として扱い、
|
||||
IDX-P-1は別途決定済みの仕様を維持して既存レイアウトとの互換性の実測だけを残した。
|
||||
実機確認や既存コミュニティレイアウトの再調査は行っていない。
|
||||
他の既存文書の全体再編は、この変更には含めない。
|
||||
|
||||
doc-lintもルール5に対応させる。backlogを検査対象に加え、項目の必須欄、
|
||||
仕様書側の固定書式と参照先、事実確認と設計判断の完了条件を検査する。
|
||||
@@ -0,0 +1,29 @@
|
||||
# ELCの走査設定を出力器に置く
|
||||
|
||||
## 結論
|
||||
|
||||
ELCは配線を保存し、走査方向の決定と追加指定はFirmware Generatorの出力設定に置く。
|
||||
接点マトリクスでいう走査方向は、どちら側のピン群を駆動して、どちら側から読み取るか。
|
||||
全方式で走査手順が配線から一意に決まることは、ELCの成立条件にしない。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 判断 |
|
||||
| --- | --- |
|
||||
| 全方式で配線から走査手順を一意に導出できることを先に検証する | 配線保存の実装まで止める条件としては強すぎる |
|
||||
| ELCに走査方向の指定を持たせる | 配線と出力時の選択の責任が混ざるため採らない |
|
||||
| 出力器が走査方向を選び、必要なら出力設定で指定を受ける | 採用。ELCの責任を配線に保てる |
|
||||
|
||||
## 判断の根拠と残件
|
||||
|
||||
旧ELC P-3は「端子の接続先とダイオードの向きだけから走査手順が一意に決まるか」を格上げ条件にしていた。
|
||||
しかし、複数の適切な方法から出力器が選べるなら、一意性は必要ない。
|
||||
先に決める必要があったのは、配線だけでは決まらない場合の指定をどこが受け持つかである。
|
||||
|
||||
この変更は責任分担の決定であり、各方式の実配線や走査方法を新たに検証した結果ではない。
|
||||
出力器は配線・ダイオードの向きと整合する方法を選び、必要な追加指定は出力設定から受け取る。
|
||||
未対応の構成は出力しない。
|
||||
|
||||
方式ごとの導出可能な範囲と必要な追加設定は、その方式の出力に着手するときに確認する。
|
||||
残件はbacklogのELC-P-3「方式ごとの走査設定の確認」に引き継いだ。
|
||||
PHYの編集やELCの配線保存は、この確認に依存せず進められる。
|
||||
@@ -0,0 +1,38 @@
|
||||
# IDXの全体の並びとバリアント選択後の出力番号
|
||||
|
||||
## 結論
|
||||
|
||||
IDXは全バリアントの対象キーを `order: string[]` に持つ。同じ配線を使う排他キーも別々に並べる。
|
||||
出力時に選択した構成のキーだけを残し、残った並びの位置を0から連続する出力番号とする。
|
||||
保存したorderは変更しない。公開番号の互換性は出力レイアウトごとに扱う。
|
||||
ユーザーがこの方針を採用した。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 扱い |
|
||||
| --- | --- |
|
||||
| 1つのorderに全キーを置き、出力時に取捨して詰める | 採用 |
|
||||
| 同じ配線の排他キーは番号を共有し、選択後も番号を詰めない | 今回置き換えた旧仕様 |
|
||||
| レイアウトごとに独立したIDXを保存する | 採用しない |
|
||||
|
||||
## 決定理由と例
|
||||
|
||||
ユーザーは、ANSIのバックスラッシュとISOの # が同じ配線を使っても、IDXでは別々の位置を持つことを希望した。
|
||||
QとAが共通キーなら、全体のorderを `Q, backslash, A, hash` と置くことで、
|
||||
ANSIでは `Q, backslash, A`、ISOでは `Q, A, hash` を取捨だけで得られる。
|
||||
ISO出力の番号は0, 1, 2であり、保存したorderでの位置0, 2, 3とは区別する。
|
||||
|
||||
旧仕様は、全バリアントを含む番号と配線の対応表に同じ接続先が重複することを理由に、番号共有を必須としていた。
|
||||
今回の出力単位はバリアント選択済みの構成であり、排他キーは同時に残らないため、その理由は当てはまらない。
|
||||
全バリアントを一度に含むキー番号の対応表は、今回の出力方式には含めない。
|
||||
|
||||
MAPはPHYキーIDへの割り当てを保持する。出力時に配線の対応表とMAPの配列へ同じ選択後の並びを適用する。
|
||||
ELCの保存データは全バリアントの配線を保持し、LEDチェーンも選択によって取捨・詰め直しをしない。
|
||||
|
||||
## 未検証の範囲
|
||||
|
||||
例での解消は、既存コミュニティレイアウト全般に対応できることの検証ではない。
|
||||
従来の実データ調査は、既存のLAYOUT_all等の並びから他の並びを取捨できるかを調べたものだった。
|
||||
今回の、別々のPHYキーIDを使って全体のorderを構成する方式については改めて確認が必要。
|
||||
共通キー同士の要求順がレイアウト間で逆転する場合には単一のorderで表せない。
|
||||
その実例の有無と互換出力の対応範囲を、IDX P-1の残余として残す。
|
||||
@@ -0,0 +1,46 @@
|
||||
# MAPエディタの実機との往復とVIA対応
|
||||
|
||||
## 結論
|
||||
|
||||
新規作成するキーボードのワークフローを主眼とし、MAPエディタは実機の読み書きを一組で提供する。
|
||||
最初の通信プロトコルはVIA。対応プロトコルを絞り、段階的に増やす。
|
||||
独自プロトコルの追加や、そのためのQMK・Vial等のフォークは行わない。
|
||||
高度な動作定義、Staticの対応範囲、同期方式はまだ決めていない。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 扱い |
|
||||
| --- | --- |
|
||||
| 既存プロトコルで実機の読み書きを提供し、VIAから始める | 採用 |
|
||||
| MAPエディタをStaticに閉じる | 採用しない |
|
||||
| Dynamicの対応範囲を読み出しだけに限定する | ユーザーが読み書きの両対応を指定 |
|
||||
| 独自プロトコルのためにファームウェアをフォークする | ユーザーが意図しないと明示 |
|
||||
|
||||
## 判断の根拠
|
||||
|
||||
ユーザーは、既存キーボード全般への対応よりも、新しく作るキーボードのワークフローを重視している。
|
||||
VIAを話すなら読み書きの両方を提供し、利用者が好むRemapと同じ通信経路から始める方針となった。
|
||||
当初のアシスタントによる「互換性」の説明はファイル変換に寄りすぎており、実機との通信を中心に修正した。
|
||||
VIA向けのキーボード定義生成と、MAPの割り当ての読み書きは別の責務として整理した。
|
||||
|
||||
## 調査で確認した範囲
|
||||
|
||||
公式文書・実装を参照した。実機による読み書きの検証は行っていない。
|
||||
|
||||
- RemapはWebHIDを使い、QMKのVIAクライアント向け機能へアクセスする。Workbenchにはソース編集・ビルド・書き込みもある。
|
||||
参照: <https://github.com/remap-keys/remap#references>
|
||||
参照: <https://remap-keys.app/docs/workbench>
|
||||
- VIAのキーボード定義には配置とmatrix座標の対応がある。QMKのVIA実装はキー単位の読み書きにlayer・row・colを使う。
|
||||
参照: <https://caniusevia.com/docs/specification/>
|
||||
参照: <https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c>
|
||||
- Vialではタップダンスやコンボを実機へ保存できるが、枠数等にはビルド時の制約がある。
|
||||
参照: <https://get.vial.today/manual/tap-dance.html>
|
||||
参照: <https://get.vial.today/manual/combos.html>
|
||||
- ZMK Studioは既存behaviorの割り当てを変更できるが、新しいbehavior定義等は対象外。実機に保存した変更とソース側の設定の扱いにも注意が必要。
|
||||
参照: <https://zmk.dev/docs/features/studio>
|
||||
|
||||
## 未採用の提案との区別
|
||||
|
||||
共通の動作モデル、ファームウェア固有表現の保持、Staticを先に実装する順序、差分を選んで反映するUIは、
|
||||
アシスタントの提案段階にとどまる。今回の合意として固定しない。
|
||||
Vial・ZMK Studioを次にどの順序で実装するかも決めていない。
|
||||
@@ -0,0 +1,96 @@
|
||||
# QMKでのDynamic/Staticの対応範囲
|
||||
|
||||
## 結論
|
||||
|
||||
当面の対象ファームウェアはQMK。
|
||||
|
||||
- Dynamic: QMK本家のVIAプロトコルが公開する項目のみ
|
||||
- Static: QMK本家の公式機能のうちVIAで変更できないものは、Firmware Generatorで生成する対象
|
||||
- 非公式機能(Vial等のフォーク、XAP等のmaster以外のブランチ)は当面非対応
|
||||
- developブランチ・Community Modulesは当面非対応(将来広げたい→backlog MAP-P-6)。キーボード・キーマップ固有コードは原則非対応
|
||||
- 対応QMKは特定コミットに固定し、更新タイミングはユーザーが個別に判断する
|
||||
- UIにはVIAプロトコルとキーコード体系のバージョンを表示し、実機とはこの2つで照合。不一致の実機は非対応
|
||||
(当初はUIにコミットを出す案だったが、実機から取れるのがこの2つだけなのでユーザーがバージョン表記に変更)
|
||||
- Staticで定義する動作(タップダンス等)はキーから名前で参照し、出力時に番号へ変換する
|
||||
- VIA管理外のEEPROM設定は読み出せない旨を制約として明記
|
||||
|
||||
どこまでを初期対応するか(P-1)、最初に対応するコミット(P-2)は未決。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 扱い |
|
||||
| --- | --- |
|
||||
| DynamicはQMK本家VIAのみ、他の公式機能はStatic | 採用(ユーザー指定) |
|
||||
| VialでDynamicの範囲を広げる(タップダンス・コンボ・QMK Settings等) | 当面非対応(フォークのため) |
|
||||
| XAP | 当面非対応(master外) |
|
||||
| QMKの最新へ自動追従 | 採用しない。ユーザーが恣意的に更新時期を決める |
|
||||
|
||||
## 調査結果(2026-09-23時点のソース)
|
||||
|
||||
実機での検証はしていない。
|
||||
|
||||
### A. QMK本家 VIA
|
||||
|
||||
参照: <https://raw.githubusercontent.com/qmk/qmk_firmware/master/quantum/via.h>
|
||||
VIA_PROTOCOL_VERSION = 0x000D
|
||||
|
||||
command:
|
||||
|
||||
- 0x01 get_protocol_version
|
||||
- 0x02/0x03 get/set_keyboard_value
|
||||
- 0x01 uptime(get), 0x02 layout_options(get/set), 0x03 switch_matrix_state(get),
|
||||
0x04 firmware_version(get), 0x05 device_indication(set), 0x06 keycodes_version(get)
|
||||
- 0x04/0x05 dynamic_keymap_get/set_keycode (layer,row,col)
|
||||
- 0x06 dynamic_keymap_reset
|
||||
- 0x07/0x08/0x09 custom_set/get_value, custom_save
|
||||
- channel 0 custom(キーボード独自メニュー)
|
||||
- 1 backlight: brightness, effect
|
||||
- 2 rgblight: brightness, effect, effect_speed, color
|
||||
- 3 rgb_matrix: brightness, effect, effect_speed, color
|
||||
- 4 audio: enable, clicky_enable
|
||||
- 5 led_matrix: brightness, effect, effect_speed
|
||||
- 0x0A eeprom_reset
|
||||
- 0x0B bootloader_jump
|
||||
- 0x0C..0x10 dynamic_keymap_macro_get_count / get_buffer_size / get_buffer / set_buffer / reset
|
||||
- 0x11 dynamic_keymap_get_layer_count
|
||||
- 0x12/0x13 dynamic_keymap_get/set_buffer
|
||||
- 0x14/0x15 dynamic_keymap_get/set_encoder
|
||||
|
||||
MAPに関係するもの: キーコード、レイヤー数、エンコーダ、マクロ、layout_options。
|
||||
照明・オーディオはMAPの所有データ外(MAPレイヤー.md「持たないもの」)。
|
||||
|
||||
### B. Vial(フォーク、今回非対応)
|
||||
|
||||
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/vial.h>
|
||||
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/qmk_settings.c>
|
||||
VIAL_PROTOCOL_VERSION = 6
|
||||
|
||||
- keyboard_id、圧縮キーボード定義JSONの取得(get_size/get_def)
|
||||
- unlock/lock(物理キー押下による解除)
|
||||
- dynamic entry: tap dance, combo, key override, alt repeat key
|
||||
- 拡張マクロ(EXT_TAP/DOWN/UP)
|
||||
- QMK Settings (QSID):
|
||||
1 grave_esc_override, 2 combo_term, 3 auto_shift, 4 auto_shift_timeout,
|
||||
5 osk_tap_toggle, 6 osk_timeout, 7 tapping_term, 9-17 mousekey各種(MK_3_SPEED以外),
|
||||
18 tap_code_delay, 19 tap_hold_caps_delay, 20 tapping_toggle,
|
||||
21 magic_settings(Ctrl/Caps, Alt/GUI, Ctrl/GUI, Grave/Esc, BSpc/BSlash入替, GUI無効, NKRO),
|
||||
22 permissive_hold, 23 hold_on_other_key_press, 24 retro_tapping,
|
||||
25 quick_tap_term, 26 chordal_hold, 27 flow_tap_term
|
||||
- VialRGB(RGB Matrixエフェクト列挙・設定)。ソース未確認
|
||||
|
||||
### C. プロトコル外で実機上だけ変わるもの(Dynamicの定義外)
|
||||
|
||||
当初「すべてEEPROMに保存される」と書いたが誤り。確認結果:
|
||||
|
||||
- 保存されない(再起動で消える): ダイナミックマクロ(docs/features/dynamic_macros.md
|
||||
"lost when the keyboard is unplugged or otherwise rebooted")、DT_UP/DT_DOWN(docs/tap_hold.md "*not* persistent")
|
||||
- EEPROMに保存される(quantum/eeconfig.h): keymap config(Magic)、default layer、unicode mode、audio、backlight、
|
||||
rgblight/rgb_matrix/led_matrix、haptic、steno mode、handedness、connection、kb/user固有データ
|
||||
- QMK本家via.cにはこれらを読み出すコマンドがない。照明・オーディオはcustom channel 1〜5で読めるがMAPの所有外。
|
||||
custom channel 0はweakの via_custom_value_command_kb() へ流れる(キーボード固有コード、原則非対応)。
|
||||
- 参考: via_eeprom_is_valid() はビルド日時由来のmagicで検証し、不一致ならVIAのデータ(キーマップ・マクロ・layout options)を
|
||||
初期化する。再ビルドして書き込むとDynamicの変更は消える前提になる。
|
||||
|
||||
### XAP
|
||||
|
||||
QMK公式の次世代プロトコルとして開発されていたが、masterには入っていない認識。未確認。
|
||||
@@ -0,0 +1,29 @@
|
||||
# GeneratorのC出力とユーザーのCによる上書き
|
||||
|
||||
## 結論
|
||||
|
||||
未決。backlog MAP-P-9 として保留(ユーザー指定)。フック方式が有力(ユーザー「フック方式いいかも」)。
|
||||
JSONを第一級にしてCを自動生成し、ユーザーのC/hで一部を上書きできるようにしたい、というのがユーザーの意向。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 内容 | 所見 |
|
||||
| --- | --- | --- |
|
||||
| (b) フック | 生成関数の先頭でユーザーのフックを呼び、未処理ならJSONの値 | キー単位の例外だけ書ける。JSONが正本のまま |
|
||||
| (a) 関数ごと置き換え(JSONで宣言) | 宣言された関数はGeneratorが出力しない | 単純。その関数の範囲ではJSONの値が無効になる |
|
||||
| (a') 関数ごと置き換え(hのプロトタイプで宣言) | ユーザーのhにあるプロトタイプの関数を出力しない(ユーザー案) | (a)と同じ性質。Generatorがhを解析する必要 |
|
||||
| weak属性で上書き | 生成側をweakにしてユーザーの定義で上書き | 使えない(下記) |
|
||||
| ユーザーのCを解析して自動判定 | — | 壊れやすい |
|
||||
|
||||
## 根拠(QMK 0.34.4)
|
||||
|
||||
- keymap.jsonで書けるもの: keyboard, keymap, layout, layers, encoders, dip_switches, macros, keycodes(名前宣言), config, modules,
|
||||
host_language 等(data/schemas/keymap.jsonschema)。タップダンス・コンボ・キーオーバーライド・キーごとの設定はない。
|
||||
- キーごとの設定は `*_PER_KEY` を定義した上での `get_tapping_term(keycode, record)` 等のコールバック。keyrecordを受けるので
|
||||
キーコード単位でも位置単位でも書ける。
|
||||
- keymap.jsonとkeymap.cを並べると、json2cで生成したkeymap.cがユーザーのkeymap.cを取り込む
|
||||
(builddefs/build_keyboard.mk:148-157、OTHER_KEYMAP_C)。QMK自体がJSON + ユーザーCの共存に対応している。
|
||||
- 取り込みは同一翻訳単位なので、生成側weak + ユーザー側strongは再定義エラーになる。QMK本体のweak既定実装もあるため、
|
||||
別ファイルにしても生成側をweakにする方式は選ばれる定義が不定。
|
||||
- プロトタイプの再宣言はCでは合法(同一シグネチャなら何度でも)。QMKが宣言済みの関数をユーザーのhに書いてもよい。
|
||||
シグネチャ違いはコンパイルエラーになるので検出にも使える。
|
||||
@@ -0,0 +1,35 @@
|
||||
# IMEレイヤーの再定義(HST)と線引き
|
||||
|
||||
## 結論
|
||||
|
||||
- IMEレイヤーをHST(ホスト)レイヤーに改める。役割は、ホストがキーコードをどう解釈するかの記述。IMEの変換はOSの仕事で対象外。
|
||||
- データは、ホスト環境ごとのプロファイルを集めた共有カタログ。キーボードに依存しない。プロジェクトはプロファイルのIDと差分だけを持つ。
|
||||
- 識別はOS×ホスト配列(+必要なら入力方式)。`win-jp` などはプリセット名。
|
||||
- プロファイルは順方向の表 f(キーコード, 修飾キー, ホスト状態) → 文字/動作/未定義/不明、成立の前提、対応可否(3値)だけを持つ。
|
||||
- 逆引き・候補の生成・ファームウェアでの実現はMAPエディタとGeneratorが行う。ユーザーは、これが実現できるかに不安がある → HSTのP-1。
|
||||
- MAPはキーコードを保存する。逆引きは入力補助で、一方向の変換。
|
||||
- 複数ホストの扱いは未決 → HSTのP-2。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 論点 | 当初の提案(人との議論) | Claude | Codex | 採用 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 名前 | SYS | HOST/HST | SYS → 最終的にHOST/HSTに同意 | HST |
|
||||
| 識別子 | WIN-JAP (OS×言語) | OS×配列の構造 | OS×配列×入力方式+プロファイル名 | OS×配列(+入力方式) |
|
||||
| 範囲 | OS・言語ごとのキーの振る舞い・表示 | ホストの解釈の記述 | 意図を環境ごとの出力に変換、まで候補 → 最終的に「対応関係を提供、変更はしない」 | 記述のみ |
|
||||
| 逆引き | - | 使う側で順方向の表から求める | 候補と成立条件をプロファイルが持つ | 使う側 |
|
||||
| ホスト状態 | - | 関数の引数として記述 | 設定と実行時の状態を分ける | 両方(矛盾しない) |
|
||||
|
||||
## 根拠・経緯
|
||||
|
||||
- SYSは、HIDのSystem Control用途ページ(電源・スリープ)や「システム設定全般」と紛らわしい。hostはHIDでキーボードの接続相手を指す用語で、QMKも「host keyboard layout」と呼ぶ。
|
||||
- 言語は軸として誤り: 日本語Windowsで英語配列を使える。MacではUS/JISが入力ソースとキーボード種別で決まる。XKBはlayout/variant/optionsの3要素。
|
||||
「英語」にもUS・UKがある。`JAP` はISOコードでもない(ja/jpn)。
|
||||
- 逆引きをプロファイルに持たせない理由: (1) 順方向の表から導けるので二重管理になる。
|
||||
(2) 候補がファームウェアの機能に依存する。例: US刻印+JISホストでShiftの組が合わないキーにはKey Overrideが要る。HSTにQMKの知識が混ざる。
|
||||
- Codexの指摘で採用したもの: 表示の切り替えと送信内容の変更を混ぜない。知らない動作を「使えない」と扱わない(対応可否を3値にする)。OS設定への依存は記述する。
|
||||
- MAPの正本をキーコードにする理由の修正: Claudeは当初「VIAでは実機にキーコードしか残らず、意図を復元できないので、キーコードでなければならない」とした。
|
||||
Codexが、意図をプロジェクト側に残す設計も可能で、難しくなるのは実機で変更された値の取り込みだけだと反論し、これが正しい。
|
||||
採用する理由は「実機との往復を単純に保てるから」とする。
|
||||
- 「コピー」のような操作の辞書は、アプリやユーザー設定まで相手が広がるので対象外。
|
||||
- 未採用のアイデア: キートップの刻印生成にHSTを使う(PHYのラベルへ流す)。検討していないので仕様には書かない。
|
||||
@@ -0,0 +1,164 @@
|
||||
# QMK 0.34.4 の機能棚卸し(MAP P-2 の下調べ)
|
||||
|
||||
## 結論
|
||||
|
||||
- 対象: QMK `0.34.4`(tag, commit `08c662f286ddfd12a985f57b584b02eca5af0ae6`, 2026-09-05)。masterの最新リリース。
|
||||
ユーザーが最初の対応環境として0.34.4を採用(2026-09-24)。
|
||||
- キーコード体系 `QMK_KEYCODES_VERSION` = `0.0.9`(quantum/keycodes.h)
|
||||
- `VIA_PROTOCOL_VERSION` = `0x000D`(quantum/via.h。9/23時点の調査と同じ)
|
||||
- MAPから見たQMKの機能は次の6種類に分かれる(下の表)。スキーマ・編集機能への落とし込みは未着手。
|
||||
- 既存ドキュメントに影響しうる事実が2つある:
|
||||
1. **VIAのモジュール化**(20260830 ChangeLog「VIA Feature Migration」#26315)。VIA統合の保守をVIAチームへ移し、
|
||||
旧来のfeature実装は将来のリリースで削除予定と告知済み。PR本体はdevelop向けのDraftで、0.34.4時点のVIAは従来の
|
||||
`quantum/via.c`。MAPレイヤー.md は「Dynamic = QMK本家のVIA」「Community Modules は当面非対応」としているため、
|
||||
VIAがモジュール側へ移った時点でこの2つが衝突する。
|
||||
2. **VIAのマトリクス状態取得の無効化**(20250831 ChangeLog #25414)。`id_switch_matrix_state` は既定で全0を返す。
|
||||
`VIA_INSECURE` 定義時、または `SECURE_ENABLE` でアンロック中のみ実値。Remap等のキーテスターは既定では動かない。
|
||||
- QMKのキーごとの設定(tapping term等)はデータテーブルではなく、keycode/keyrecordを受けるCのコールバック関数で表現される。
|
||||
keymap.json(data-driven)で書けるのはレイヤー・エンコーダ・DIPスイッチ・マクロ・カスタムキーコード・config・modules まで。
|
||||
タップダンス・コンボ・キーオーバーライド・キーごとの設定はCでしか書けない。
|
||||
|
||||
## 分類の概要
|
||||
|
||||
| 区分 | 主なもの | 実機への保存 | VIAで読み書き |
|
||||
| --- | --- | --- | --- |
|
||||
| A. キーコード(単体で完結) | 基本キー、修飾付き、MT/LT/OSM等、レイヤー操作、Magic、メディア、マウス等 | キーマップ | ○(16bit値として) |
|
||||
| B. 定義を参照するキーコード | TD(n)、QK_MACRO_n、UM/UP、QK_KB_n/QK_USER_n | 参照はキーマップ、定義はファームウェア | 参照のみ○。定義はマクロのみ○ |
|
||||
| C. キー以外の起動条件を持つ動作 | コンボ、キーオーバーライド、リーダー、オートコレクト、Tri Layer | ファームウェア | × |
|
||||
| D. タップ・ホールド等の挙動設定 | TAPPING_TERM等 + キーごとのコールバック | ファームウェア(DTTの変更は揮発) | × |
|
||||
| E. キー以外の入力への割り当て | エンコーダ、DIPスイッチ、ポインティング、ジョイスティック等 | エンコーダはキーマップ | エンコーダのみ○ |
|
||||
| F. 実機の永続状態 | Magic設定、保存されたデフォルトレイヤー、Unicodeモード等 | EEPROM | × |
|
||||
|
||||
MAPの所有外(照明・オーディオ・ディスプレイ・ハードウェア構成)は末尾にまとめた。
|
||||
|
||||
---
|
||||
|
||||
## A. キーコード体系 0.0.9
|
||||
|
||||
data/constants/keycodes/*.hjson を 0.0.1〜0.0.9 の順にマージして得た範囲。
|
||||
|
||||
| 範囲 | 名前 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 0x0000–0x00FF | QK_BASIC | KC_NO/KC_TRNS、HID基本キー(basic 161件)、system 3、media 27、mouse 19(QK_MOUSE_*)、修飾8 |
|
||||
| 0x0100–0x1FFF | QK_MODS | `LCTL(kc)` 等。kcは基本キーのみ、修飾は5bit(左右混在不可) |
|
||||
| 0x2000–0x3FFF | QK_MOD_TAP | `MT(mod, kc)`。kcは基本キーのみ |
|
||||
| 0x4000–0x4FFF | QK_LAYER_TAP | `LT(layer, kc)`。layer 0–15、kcは基本キーのみ |
|
||||
| 0x5000–0x51FF | QK_LAYER_MOD | `LM(layer, mod)`。layer 0–15 |
|
||||
| 0x5200– | QK_TO / MOMENTARY / DEF_LAYER / TOGGLE_LAYER / ONE_SHOT_LAYER / ONE_SHOT_MOD / LAYER_TAP_TOGGLE / PERSISTENT_DEF_LAYER | TO/MO/DF/TG/OSL/OSM/TT/PDF。各32(layer 0–31) |
|
||||
| 0x5600–0x56FF | QK_SWAP_HANDS | SH_T(kc)、SH_TOGG等7件 |
|
||||
| 0x5700–0x57FF | QK_TAP_DANCE | TD(0–255) |
|
||||
| 0x7000–0x70FF | QK_MAGIC | Ctrl/Caps、Alt/GUI、Ctl/GUI、Grave/Esc、BSpc/BSlash、Esc/Caps入替、GUI無効、NKRO、EE_HANDS 等35件 |
|
||||
| 0x7100–0x71FF | QK_MIDI | ノート・オクターブ・移調・ベロシティ・チャンネル等 |
|
||||
| 0x7200–0x73FF | QK_SEQUENCER | 9件 |
|
||||
| 0x7400–0x743F | QK_JOYSTICK | ボタン0–31 |
|
||||
| 0x7440–0x747F | QK_PROGRAMMABLE_BUTTON | 1–32 |
|
||||
| 0x7480–0x74BF | QK_AUDIO | Audio/Clicky/Music 15件 |
|
||||
| 0x74C0–0x751F | QK_STENO | 0.0.9で拡張・改名(STN_*→ QK_STENO_* / ST_*、旧名はエイリアス) |
|
||||
| 0x7700–0x777F | QK_MACRO | QK_MACRO_0–31(名前付き)。VIA/keymap.jsonのマクロ |
|
||||
| 0x7780–0x77BF | QK_CONNECTION | 出力先切替(USB/2.4GHz/BT)、BTプロファイル1–5等15件 |
|
||||
| 0x77C0–0x77FF | QK_COMMUNITY_MODULE | Community Modules用 |
|
||||
| 0x7800–0x78FF | QK_LIGHTING | Backlight 7、LED Matrix 11、RGBLight(underglow)11 + RGB_MODE_* 10、RGB Matrix 15 |
|
||||
| 0x7C00–0x7DFF | QK_QUANTUM | 下記 |
|
||||
| 0x7E00–0x7E3F | QK_KB | QK_KB_0–31(キーボード固有) |
|
||||
| 0x7E40–0x7FFF | QK_USER | QK_USER_0–31(キーマップ固有。SAFE_RANGE以降) |
|
||||
| 0x8000–0xFFFF | QK_UNICODE / UNICODEMAP / UNICODEMAP_PAIR | UC(cp≤0x7FFF)、UM(i)、UP(i,j) |
|
||||
|
||||
QK_QUANTUM の内訳(73件): Bootloader/Reboot/Debug/EEPROMクリア/Make、Auto Shift操作6、Grave Escape、
|
||||
Velocikey、Space Cadet 7、Unicodeモード切替8、Haptic 13、Combo on/off/toggle、Dynamic Macro 5、Leader、
|
||||
Key Lock、One Shot on/off/toggle、Key Override on/off/toggle、Secure 4、Dynamic Tapping Term 3、Caps Word、
|
||||
Autocorrect 3、Tri Layer Lower/Upper、Repeat/Alt Repeat、Layer Lock。
|
||||
|
||||
補足:
|
||||
|
||||
- 20250831で旧RGB・マウスの非推奨キーコードを削除(#25444)。
|
||||
- VIAはキーマップを16bit値で読み書きするため、上のどの値もキーへ書ける。ファームウェアで無効な機能の値は何もしない。
|
||||
|
||||
## B. 定義を参照するキーコード
|
||||
|
||||
| 機能 | キーコード | 定義の中身 | 定義の置き場所 |
|
||||
| --- | --- | --- | --- |
|
||||
| Tap Dance | TD(n) | `ACTION_TAP_DANCE_DOUBLE(kc1,kc2)` / `_LAYER_MOVE(kc,layer)` / `_LAYER_TOGGLE(kc,layer)` / `_FN` / `_FN_ADVANCED` / `_FN_ADVANCED_WITH_RELEASE`。組み込みは前3つで、残りはC関数 | C(`tap_dance_actions[]`) |
|
||||
| マクロ | QK_MACRO_n | tap/down/up/delay(+keymap.jsonではbeep)と文字列。VIAの保存形式は SS_TAP/DOWN/UP/DELAY_CODE を含むバイト列 | keymap.json `macros` またはVIA(EEPROM)。VIA既定16個 |
|
||||
| Unicode Map | UM(i), UP(i,j) | コードポイント表 | C(`unicode_map[]`) |
|
||||
| 固有キーコード | QK_KB_n, QK_USER_n | 任意のC処理 | C(`process_record_*`)。keymap.json `keycodes` で名前宣言のみ可 |
|
||||
| Send String | (キーコードなし) | 文字列送信。マクロやC処理から呼ぶ | C |
|
||||
|
||||
## C. キー以外の起動条件を持つ動作
|
||||
|
||||
| 機能 | 定義 | 主な設定 |
|
||||
| --- | --- | --- |
|
||||
| Combos | キーコード列(最大8、EXTRA_LONGで16)→出力キーコード | COMBO_TERM、per-combo: term/must_hold/must_tap/must_press_in_order、COMBO_STRICT_TIMER、COMBO_ONLY_FROM_LAYER / COMBO_REF_DEFAULT(参照レイヤー)、Modifier Combos、キー解放・再押下の扱い |
|
||||
| Key Overrides | trigger, trigger_mods, layers(ビットマスク), negative_mod_mask, suppressed_mods, replacement, options(ko_option_t), custom_action, enabled | MODS_TO_NEUTRALIZE |
|
||||
| Leader Key | QK_LEADER の後のキー列(1〜5キー)→C処理 | LEADER_TIMEOUT、LEADER_PER_KEY_TIMING、LEADER_NO_TIMEOUT、STRICT_KEY_PROCESSING |
|
||||
| Autocorrect | 誤字→訂正の辞書(ビルド時にtrieへ変換) | 辞書ファイル、MIN/MAX_LENGTH |
|
||||
| Tri Layer | lower+upper → adjust レイヤー | 3レイヤーの番号 |
|
||||
| Caps Word / Repeat Key / Layer Lock / Key Lock / Swap Hands | 定義データはほぼなし。Swap Handsは `hand_swap_config`(マトリクス対応表)が必要 | Caps Wordの継続キー判定等はC |
|
||||
|
||||
## D. タップ・ホールド等の挙動設定(docs/tap_hold.md 他)
|
||||
|
||||
全体設定(config.h / keyboard.jsonのconfig)とキーごとのコールバックの組で提供される。
|
||||
|
||||
| 設定 | 全体 | キーごと |
|
||||
| --- | --- | --- |
|
||||
| Tapping Term | TAPPING_TERM | TAPPING_TERM_PER_KEY → get_tapping_term(keycode, record) |
|
||||
| Dynamic Tapping Term | DYNAMIC_TAPPING_TERM_ENABLE(キーで増減、揮発) | — |
|
||||
| Permissive Hold | PERMISSIVE_HOLD | get_permissive_hold |
|
||||
| Hold On Other Key Press | HOLD_ON_OTHER_KEY_PRESS | get_hold_on_other_key_press |
|
||||
| Quick Tap Term | QUICK_TAP_TERM | get_quick_tap_term |
|
||||
| Flow Tap(2025-05追加) | FLOW_TAP_TERM | is_flow_tap_key、get_flow_tap_term(keycode, record, prev_keycode) |
|
||||
| Chordal Hold(2025-02追加) | CHORDAL_HOLD + 左右手の割り当て(chordal_hold_layout) | get_chordal_hold(tap_hold_keycode, …, other_keycode, …) |
|
||||
| Retro Tapping | RETRO_TAPPING | get_retro_tapping |
|
||||
| Speculative Hold(2025-11追加) | SPECULATIVE_HOLD | get_speculative_hold |
|
||||
| Tapping Force Hold | — | get_tapping_force_hold |
|
||||
| Tapping Toggle | TAPPING_TOGGLE(TTのタップ回数) | — |
|
||||
| Auto Shift | AUTO_SHIFT_TIMEOUT 等 | get_autoshift_timeout、get_auto_shifted_key、REPEAT/NO_AUTO_REPEAT_PER_KEY |
|
||||
| One Shot | ONESHOT_TAP_TOGGLE、ONESHOT_TIMEOUT | — |
|
||||
| Combo | COMBO_TERM | get_combo_term 等(コンボごと) |
|
||||
| その他 | TAP_CODE_DELAY、TAP_HOLD_CAPS_DELAY、DEBOUNCE | — |
|
||||
|
||||
- キーごとのコールバックは keycode と keyrecord(押された位置・レイヤーを含む)を受ける。
|
||||
つまり「キーコード単位」「物理位置単位」のどちらでも書ける(docs/tap_hold.md「Why do we include the key record」)。
|
||||
- Chordal Holdの手の割り当ては物理位置(マトリクス)単位のデータ。
|
||||
|
||||
## E. キー以外の入力への割り当て
|
||||
|
||||
| 機能 | MAP相当のデータ | 備考 |
|
||||
| --- | --- | --- |
|
||||
| Encoders | ENCODER_MAP: レイヤーごと・エンコーダごとの ccw/cw キーコード | keymap.json `encoders`。VIAで読み書き可(0x14/0x15) |
|
||||
| DIP Switch | DIP_SWITCH_MAP: スイッチごとの on/off キーコード | keymap.json `dip_switches`(2025-11追加)。VIA不可 |
|
||||
| Pointing Device | Auto Mouse Layer(レイヤー番号・タイムアウト)、ドラッグスクロール等はC | センサー設定はハードウェア寄り |
|
||||
| Joystick / Digitizer / MIDI / Steno / Programmable Button / Sequencer | キーコードでボタン・音等を割り当て。軸・プロトコル設定は全体 | Stenoは0.0.9でキーコード改名 |
|
||||
|
||||
## F. 実機の永続状態(quantum/eeconfig.h)
|
||||
|
||||
VIA管理外でEEPROMに保存されるもの: debug、keymap config(Magic: Ctrl/Caps入替、Caps→Ctrl、Alt/GUI入替、GUI無効、Grave/Esc、BSpc/BSlash、NKRO 他)、
|
||||
default layer(PDF)、Unicode入力モード、audio、backlight、rgblight/rgb_matrix/led_matrix、haptic、steno mode、
|
||||
handedness(EE_HANDS)、connection、kb/user固有データ、Community Modules の永続データ(1.1.3で追加)。
|
||||
QMK本家VIAにはこれらを読み出すコマンドがない(9/23調査の再確認)。
|
||||
|
||||
## MAP所有外の機能(参考の一覧)
|
||||
|
||||
- 照明: Backlight、RGBLight、RGB Matrix、LED Matrix、LED Indicators
|
||||
- 表示: OLED、Quantum Painter(LVGL含む)、ST7565、HD44780
|
||||
- 音・触覚: Audio、Haptic Feedback
|
||||
- ハードウェア構成: Split Keyboard、Custom Matrix、Debounce、Converters、PS/2 Mouse、Wireless、Battery、USBPD
|
||||
- ホスト連携: Raw HID、OS Detection、Secure、Command、Bootmagic、WPM、Deferred Exec、Virtser、Plover HID
|
||||
- 拡張: Community Modules(1.1.3。modules/qmk/ に hello_world, super_alt_tab, split_data_sync, nvm_test, flow_*_matrix_effect)
|
||||
|
||||
## VIA 0x000D の読み書き範囲(0.34.4で再確認)
|
||||
|
||||
9/23の journal(qmk-dynamic-scope)と同じコマンド構成。差分・補足:
|
||||
|
||||
- `id_switch_matrix_state` は既定で0を返す(上記)。
|
||||
- `id_eeprom_reset` は `VIA_EEPROM_ALLOW_RESET` 定義時のみ有効。
|
||||
- エンコーダは `ENCODER_MAP_ENABLE` 時のみ。
|
||||
- 既定: DYNAMIC_KEYMAP_LAYER_COUNT 4、DYNAMIC_KEYMAP_MACRO_COUNT 16。上限はEEPROM容量に従う。
|
||||
|
||||
## 調査方法
|
||||
|
||||
- `git clone --depth 1 --branch 0.34.4 --filter=blob:none --sparse` で docs / quantum / data / builddefs を取得。
|
||||
- 機能一覧: docs/_sidebar.json、builddefs/generic_features.mk の GENERIC_FEATURES。
|
||||
- キーコード: data/constants/keycodes/keycodes_*.hjson をバージョン順にマージ。
|
||||
- 変更点: docs/ChangeLog/20250223〜20260830。
|
||||
- VIA移行PR: <https://github.com/qmk/qmk_firmware/pull/26315(develop向けDraft。VIA_EEPROM_MAGIC_ADDR> 以外は後方互換を維持する方針と記載)
|
||||
- 実機での検証はしていない。
|
||||
@@ -0,0 +1,46 @@
|
||||
# VIAのモジュール化への追従
|
||||
|
||||
## 結論
|
||||
|
||||
- QMKはVIA統合をCommunity Modules形式へ移し、旧実装を将来削除すると告知(20260830 ChangeLog「VIA Feature Migration」#26315)。
|
||||
- 方針「QMKとVIAの両方に追従する」は変えない(ユーザー判断)。
|
||||
→ Community Modulesは当面非対応のままだが、VIAモジュールだけは例外として、移行がmasterに入る前に対応する。
|
||||
最悪、Community Modulesへの対応はVIAだけでもよい(ユーザー発言)。
|
||||
- キーテスター(switch_matrix_state)が既定で動かない件は気にしない(ユーザー判断)。
|
||||
|
||||
## 案の比較
|
||||
|
||||
| 案 | 扱い |
|
||||
| --- | --- |
|
||||
| VIAモジュールだけCommunity Modules非対応の例外にする | 採用 |
|
||||
| Community Modules全般に対応を広げる | MAP-P-6のまま保留 |
|
||||
| 旧VIA実装が残るQMKに固定し続ける | 追従方針に反するので採用しない |
|
||||
|
||||
## 根拠(2026-09-24時点)
|
||||
|
||||
- docs/ChangeLog/20260830.md(QMK 0.34.4):
|
||||
"upkeep of the VIA integration will transition to the VIA team" /
|
||||
"in a future release, the old VIA feature-based implementation is expected to be removed."
|
||||
- PR <https://github.com/qmk/qmk_firmware/pull/26315> : develop向けDraft。追加ファイルは
|
||||
modules/qmk/via/{qmk_module.json, via.c, via.h, post_config.h, post_rules.mk}。
|
||||
qmk_module.json にバージョン欄なし。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持すると記載。
|
||||
- 0.34.4時点のVIAは従来どおり quantum/via.c、VIA_PROTOCOL_VERSION 0x000D。
|
||||
- 同梱モジュールの有効化は keymap.json の `"modules": ["qmk/via"]` の形(data/schemas/keymap.jsonschema の modules、
|
||||
builddefs/build_keyboard.mk の generate-community-modules-*)。対応コストは小さい見込み。
|
||||
- 保守がVIAチームへ移るため、将来QMKリポジトリ外へ出る可能性はあるが未確認。
|
||||
|
||||
## 追記: 対応環境の指定方法
|
||||
|
||||
結論: 対応環境 = QMKのコミット + 使うモジュールの一覧(出所とコミット)。VIAは一覧の1項目。
|
||||
QMK同梱モジュールはQMKのコミットで決まる。実機との照合は従来どおりVIAプロトコル・キーコード体系のバージョン。
|
||||
|
||||
| 案 | 扱い |
|
||||
| --- | --- |
|
||||
| QMKのコミット + モジュール一覧 | 採用。VIAが外部へ出てもMAP-P-6の他モジュールでも同じ形 |
|
||||
| QMKのコミット + VIAのバージョンだけ追加 | VIAの特別扱いになるため採用しない |
|
||||
| QMKのコミットだけ(現状) | VIAがリポジトリ外へ出ると実装が決まらない |
|
||||
|
||||
- きっかけ: ユーザーが「QMKに加えてVIAのバージョンも明記したほうがよいか」と相談。
|
||||
- #26315の案ではVIAは modules/qmk/via(QMK同梱)で、qmk_module.json にバージョン欄がない。
|
||||
この案のままならQMKのコミットだけで決まるが、保守移管後に外へ出る可能性に備える。
|
||||
- モジュールのバージョンは実機から取得できないため、照合には使わない。
|
||||
@@ -0,0 +1,33 @@
|
||||
# VIAで読み書きする項目と機能・容量の確認方法
|
||||
|
||||
## 結論
|
||||
|
||||
- 対応: バージョン読み出し、レイヤー数、キーコード割り当て(まとめ読み書き含む)、マクロ
|
||||
- 別課題: エンコーダ(MAP-P-4)、layout option(P-3)
|
||||
- 当面非対応: リセット系・ブートローダ移行、デバイスの識別表示、キーテスター、custom channel
|
||||
- 有効な機能: 新規キーボードはGeneratorのビルド設定を正とする。既存キーボードは利用者の申告を信用する(ユーザー案)。
|
||||
申告はGeneratorの設定と同じ形式にし、新規と既存を1つのモデルで扱う。
|
||||
- 容量(レイヤー数・マクロ個数・容量)は実機から読んで照合する。
|
||||
- P-2は判断が済んだので、実機で未検証という推測(P-2)に置き換えた。
|
||||
|
||||
## 案の比較(VIA 0x000D, QMK 0.34.4 quantum/via.c)
|
||||
|
||||
| 項目 | コマンド | 扱い | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| プロトコル・キーコード体系バージョン | 0x01, 0x02 id 0x06 | 対応 | 実機照合に必須 |
|
||||
| レイヤー数 | 0x11 | 対応 | 容量確認 |
|
||||
| キーコード | 0x04/0x05, 0x12/0x13 | 対応 | MAPの本体 |
|
||||
| マクロ | 0x0C–0x10 | 対応 | P-1で内容をMAPが持つとした |
|
||||
| エンコーダ | 0x14/0x15 | 別課題 | MAP-P-4 |
|
||||
| layout options | 0x02/0x03 id 0x02 | 別課題 | P-3の排他キーの読み戻しと一体 |
|
||||
| リセット・ブートローダ | 0x06, 0x0A, 0x0B | 当面非対応 | 操作でありMAPのデータではない。0x0Aは VIA_EEPROM_ALLOW_RESET 時のみ |
|
||||
| デバイスの識別表示 | 0x03 id 0x05 | 当面非対応 | 任意の補助機能 |
|
||||
| キーテスター | 0x02 id 0x03 | 非対応 | 既定で全0(#25414)。ユーザーも不要と判断 |
|
||||
| custom channel | 0x07–0x09 | 非対応 | 照明・オーディオはMAP所有外、channel 0は固有コード |
|
||||
|
||||
## 機能・容量の確認
|
||||
|
||||
- VIAから読めるのは容量(レイヤー数、マクロ個数、マクロバッファサイズ)とバージョンだけ。
|
||||
どの機能を有効にしてビルドしたか(タップダンス、コンボ等)は読めない。キーボード固有のVIA定義JSONにも含まれない。
|
||||
- 無効な機能のキーコードを書いてもエラーにならず、実機で何もしないだけ。申告の誤りは壊れる方向には働かない。
|
||||
- 既存キーボードは初期対応の前提ではない(MAPレイヤー.md「概要」)が、申告方式ならモデルを増やさずに扱える。
|
||||
@@ -0,0 +1,85 @@
|
||||
# スタビライザー位置とswitchOffsetの裏取り(T-30 単位8)
|
||||
|
||||
## 結論
|
||||
|
||||
- 6U以上の幅の中には、スタビライザー位置が何通りもあるものがある。6U・6.25U・7Uがそう。
|
||||
6.5U と 8〜10U は、出典にある範囲では1通り。
|
||||
- switchOffset が要るのは2U未満のキーに限らない。6U offset・6.25U(Cherry G80のWindowsキー付き)・BAE(ANSI互換)では
|
||||
スイッチがキーの中心から外れる。逆に、2U未満でよくあるステップドCaps Lockは、KLEの表し方のおかげで offset 0 になる。
|
||||
- ISO Enterは「(w,h)矩形の中心にスイッチ、2U間隔のスタビを縦向きに置く」とすれば、出典にある生成ツールの扱いと合う。
|
||||
実物の寸法の出典はまだ見つけていない。
|
||||
- BAEは、スイッチとスタビの配置が型によって違う。決まった形はない。
|
||||
|
||||
座標はすべてキーのローカル座標で、(w,h)矩形の中心を原点にした値(U、1U = 19.05mm)。
|
||||
PHYの `switchOffset` と同じ基準になる。スタビは中心から±の位置に2つ置く。
|
||||
|
||||
## 候補表(Cherry MX系、横向き。縦長のキーは長辺の向きに読み替える)
|
||||
|
||||
| 幅 | スタビ(中心から) | switchOffset.x | 使っているキーボード・出典 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2〜2.75U | ±11.938mm(±0.6267U) | 0 | kad・kiswitch・ai03 で一致 |
|
||||
| 3U | ±1U | 0 | DT(Deskthority wiki、以下同じ)、kad、kiswitch |
|
||||
| 4U | ±1.5U | 0 | DT、kad(ai03 は3U以上をすべて±1Uにしている) |
|
||||
| 4.5U | ±34.671mm(±1.82U) | 0 | DT、kad |
|
||||
| 5.5U | ±2.25U | 0 | DT、kad |
|
||||
| 6U offset | ±2.5U(芯々95mm) | +0.5U | DT: Cherry G80-1800/11900/11908/11800。kad、kiswitch `6uOffset` |
|
||||
| 6U 中央A | ±2U | 0 | DT: Razer BlackWidow、TG3 BL-82 |
|
||||
| 6U 中央B | ±2.5U(47.625mm) | 0 | kiswitch の 6U スタビ、kad の6U値。フォーラム: FC660C、Topre |
|
||||
| 6.25U 標準 | ±50mm(±2.6247U) | 0 | DT: Filco、Ducky、Poker、Das 3 ほか。kad・kiswitch・ai03 で一致 |
|
||||
| 6.25U G80 | ±50mm | +0.625U(11.9mm) | DT: Cherry G80-1000/3000(Windowsキー付き)、Das 2。キーキャップに中央のマウントがない |
|
||||
| 6.25U 40mm | ±40mm(±2.1U) | 0 | DT: Leopold FC200、SteelSeries 6GV2/7G、Zowie Celeritas |
|
||||
| 6.25U 42.5mm | ±42.5mm | 0 | DT: Leopold FC500、Keycool 87(2012年より前のモデル) |
|
||||
| 6.5U | ±2.75U(52.38mm) | 0 | DT: Corsair K60〜K95、QuickFire TK、G710+。kad |
|
||||
| 7U | ±3U | 0 | DT: Cherry G80-1000/1800/3000(Windowsキーなし)ほか。kad・kiswitch・ai03 で一致 |
|
||||
| 8U / 9U / 9.75U / 10U | ±3.5U | 0 | DT(9U以上も8Uと同じ間隔)。kiswitch は8U〜10Uに同じフットプリントを使う |
|
||||
|
||||
候補表から外したもの(実物の位置が出典から決まらない):
|
||||
|
||||
- 6U CMStorm QuickFire Ultimate: 「43.5mm, 49mm」「芯々98mm」とだけ書かれていて、軸がどれだけずれているか読み取れない
|
||||
- 7U Compaq MX-11800: 軸から見てスタビが左3U・右2.25U。キーキャップの中でどう対称になっているかは書かれていない。
|
||||
スタビがキーの中心に対して対称なら±2.625U、switchOffset +0.375U になり、6.25U と同じ100mmのワイヤーになるが、これは推測
|
||||
- 7U Propac: 「55mm apart」とあるだけ
|
||||
|
||||
## 異形キー
|
||||
|
||||
KLEのプリセット(`keys.json`)と、PHYの `size` の主矩形(w,h):
|
||||
|
||||
| キー | KLE | スイッチ | スタビ |
|
||||
| --- | --- | --- | --- |
|
||||
| ISO Enter | w1.25 h2, w2 1.5 h2 1, x 0.25, x2 -0.25 | 主矩形の中心(0,0) | 縦向きに±0.6267U。kad は h>w なら自動で縦にする。ai03 は長辺を幅として扱うが、向きは利用者が `_rs` で回す |
|
||||
| BAE (a) ANSI互換 | w1.5 h2, w2 2.25 h2 1, x 0.75, x2 -0.75, y2 1 | 下段2.25Uの中心 = (-0.375, +0.5) | (-0.375±0.6267, +0.5) の横向き。GMK CYL BAE: 「ANSIの2.25U Enterのスイッチとスタビの位置、1.5UのPipeキーの位置にマウントがある」 |
|
||||
| BAE (b) 旧Cherry | 同上 | 不明 | 検索の要約には「上段1.5Uを中心にISO風のスタビ、補助のMXスイッチ」とあったが、原文は確認できていない |
|
||||
| ステップドCaps Lock | w1.25 w2 1.75 l | 主矩形 = 高い側の1.25Uの中心。よくある軸の位置(左寄り)と一致し、offset 0 | なし |
|
||||
| ステップドCaps Lock(軸が中央) | 同上 | 1.75Uの中心 = +0.25U | なし。数は少ない(Leopoldのセットにある) |
|
||||
| Center-stepped | w1 w2 1.5, x 0.25, x2 -0.25 l | 主矩形の中心 | なし |
|
||||
|
||||
KLEのステップドキーは、`w` が高い側、`w2` が全幅(kb.js の `stepped` の処理)。
|
||||
kad も主矩形の中心にスイッチを置く(`AltWidth > Width` のときは外形の計算だけを広げる)。
|
||||
|
||||
## switchOffset が0でないケース
|
||||
|
||||
- 2U以上: 6U offset (+0.5U)、6.25U G80 (+0.625U)、BAE(a) (-0.375, +0.5)、おそらく7U Compaq
|
||||
- 2U未満: 軸が中央にあるステップドCaps Lock (+0.25U) だけ。よくある左寄りの軸は0
|
||||
- 「2U未満のみ」という想定は当たらない
|
||||
|
||||
## ツールどうしの食い違い(参考)
|
||||
|
||||
- ai03 は6Uで、スイッチをKLEのキーの中心に置いたまま、スタビを+38.1/-57.15mmと左右非対称に置く。
|
||||
kad はスタビをキーの中心に対して±47.5mmで対称に置き、スイッチを+0.5Uずらす。DTの実測(マウントは左から0.5/3.5/5.5U)と合うのは kad
|
||||
- ai03 は3U〜5.5Uをすべて±19.05mmにしている。DT・kad とは4U以上で合わない
|
||||
- kad は2Uの値を11.9mmに丸めている。kiswitch・ai03 は11.938mm
|
||||
|
||||
## 出典
|
||||
|
||||
- Deskthority wiki "Space bar dimensions"(`?action=raw` で原文を確認): <https://deskthority.net/wiki/Space_bar_dimensions>
|
||||
- swill/kad `key.go` の `GetCherryStabOffset`、`Draw`: <https://github.com/swill/kad>
|
||||
- perigoso/keyswitch-kicad-library `KiSwitch/switch.py` の `StabilizerCherryMX.LU_TABLE`、`KiSwitch/keycap.py`:
|
||||
<https://github.com/perigoso/keyswitch-kicad-library>
|
||||
- ai03-2725/another-keyboard-builder `plategen.py` の `generate_stabs`: <https://github.com/ai03-2725/another-keyboard-builder>
|
||||
- KLE `keys.json`(異形キーのプリセット)、`kb.js` の `stepped`: <https://github.com/ijprest/keyboard-layout-editor>
|
||||
- GMK CYL BAE addon: <https://geon.works/products/gmk-cyl-bae-addon-kits>
|
||||
- ステップドCaps Lockの軸の位置(フォーラム): <https://deskthority.net/viewtopic.php?p=112099>、
|
||||
<https://deskthority.net/keyboards-f2/keycap-cherry-mx-stepped-capslock-t3794.html>
|
||||
- 6Uの中央軸とTopreの値(フォーラム): <https://www.keebtalk.com/t/topre-6u-stabilizer-spacing-spec/17047>
|
||||
|
||||
Alpsのスタビ(kad `GetAlpsStabOffset`: 1.75U 11.938、2〜2.75U 14.096、6.25U 41.859、6.5U 45.3mm。DT: 7U ±55mm)は、モックにスイッチの種別がないので扱っていない。
|
||||
@@ -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。
|
||||
+32
-20
@@ -38,29 +38,33 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
||||
|
||||
### 3. IDXレイヤー (キー識別)
|
||||
|
||||
- **役割**: PHYのキーに"キー番号"を与える
|
||||
- **役割**: 全バリアントにわたるPHYのキーの並び順を保存し、選択した構成の出力用キー番号を決める
|
||||
- **入力**: PHYのキーID
|
||||
- **対応ツール**: QMKの `LAYOUT` マクロ、ZMKの matrix transform、KMKの `coord_mapping`
|
||||
- **出力**: 番号を振るキーの並び。並びの位置がキー番号
|
||||
- **出力**: 全バリアントの対象キーIDを重複なく並べた `order`。出力時に対象外のキーを除き、0から連続する番号を与える
|
||||
- **備考**: キー番号はPHYの幾何からもELCの配線からも導出しない(導出すると、幾何の編集や再配線でキーマップがずれる)
|
||||
- **備考**: キー番号は公開インターフェースであり、出力のたびに生成し直さず固定する
|
||||
- **備考**: 並び順を正本として保存する。出力番号は公開インターフェースであり、互換性は出力レイアウトごとに扱う
|
||||
- **備考**: マトリックス座標とLEDの識別番号はIDXが持たない(どちらも配線で決まるためELCが持つ)
|
||||
- **備考**: レイアウトバリアントがあってもキー番号の空間は1つ。同じマトリクス位置に配線された排他キーは同じ番号を共有する
|
||||
- **備考**: 全バリアントで1つのIDXを持つ。同じ接続先に配線された排他キーも別々の要素とし、選択後の出力で番号を詰める。保存した `order` は変えない
|
||||
- **備考**: 既存のエコシステムでは、この並びが `LAYOUT` マクロの引数順などとして手書きされている。欠けている層ではなく、形式化されていない層
|
||||
|
||||
### 4. MAPレイヤー (キーマップ)
|
||||
|
||||
- **役割**: キーとキーコードの対応
|
||||
- **役割**: キーマップレイヤーごとのキーへの割り当て
|
||||
- **入力**: PHYのキーID
|
||||
- **対応ツール**: ファームウェア、VIA、Remap
|
||||
- **出力**: レイヤー別キーコード
|
||||
- **備考**: キーの参照にキー番号を使わない。配列としての並べ替えは、出力時にIDXの並び順で行う
|
||||
- **対応ツール**: MAPエディタ (Keymap Designer)
|
||||
- **出力**: MAP JSON (現行形式はレイヤー別キーコード割り当て)
|
||||
- **備考**: キーの参照にPHYのキーIDを使う。配列としての並べ替えは、出力時にバリアント選択後のIDXの並び順で行う
|
||||
- **詳細**: [MAPレイヤー](MAPレイヤー.md)。動作定義とキーごとの設定の保存形式は、[MAPのP-1](MAPレイヤー.md#p-1-動作定義とキーごとの設定の保存形式)で扱う
|
||||
|
||||
### 5. IMEレイヤー (入力変換)
|
||||
### 5. HSTレイヤー (ホスト)
|
||||
|
||||
- **役割**: キーコードと実際の入力文字の対応
|
||||
- **対応ツール**: OS
|
||||
- **備考**: MAPレイヤー設計時に考慮が必要
|
||||
- **役割**: 接続先のホスト(OSとキーボード配列の設定)が、キーコードをどう解釈するかの記述
|
||||
- **入力**: なし。どの層も参照せず、MAPエディタとFirmware Generatorから参照される
|
||||
- **対応ツール**: OSのキーボード配列、QMKの言語別キーコード(`keymap_japanese.h` など)
|
||||
- **出力**: ホスト環境ごとのプロファイル。キーボードに依存しない共有カタログとする
|
||||
- **備考**: IMEのかな漢字変換はOSの責務で、HSTの対象外
|
||||
- **詳細**: [HSTレイヤー](HSTレイヤー.md)
|
||||
|
||||
## ツール設計方針
|
||||
|
||||
@@ -75,7 +79,7 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
||||
#### Key Indexer
|
||||
|
||||
- PHY JSONを入力
|
||||
- キーにキー番号を割り当て
|
||||
- 全バリアントの対象キーを1つの並びに配置
|
||||
- 出力: IDX JSON
|
||||
|
||||
#### Wiring Editor
|
||||
@@ -85,18 +89,22 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
||||
- 視覚的に配線を確認
|
||||
- 出力: ELC JSON
|
||||
|
||||
#### Keymap Designer
|
||||
#### Keymap Designer (MAPエディタ)
|
||||
|
||||
- PHY JSONを入力
|
||||
- レイヤー別キーコード割り当て
|
||||
- VIA/Remapとの互換性
|
||||
- PHYの配置を参照し、キーマップレイヤーごとの割り当てを編集・保存する
|
||||
- 新規キーボードの設計から実機での調整までを扱い、割り当ての読み書きを一組で提供する
|
||||
- 実機との通信は既存のVIAプロトコルから対応し、対象を段階的に増やす
|
||||
- 出力: MAP JSON
|
||||
|
||||
具体的な責務と通信上の制約は、[MAPレイヤー](MAPレイヤー.md#mapエディタの責務)に従う。
|
||||
|
||||
#### Firmware Generator
|
||||
|
||||
- 全レイヤーの情報を統合
|
||||
- バリアントを選択し、IDXから対象外のキーを除いた並びで、キー番号の対応表とMAPの配列を生成
|
||||
- row / col など、ターゲットが要求する派生情報をここで割り当てる
|
||||
- QMK/VIA/KMK等へ変換
|
||||
- VIA向けキーボード定義は、配置と接続先の対応などを統合して生成する
|
||||
- 出力: ターゲット固有の設定ファイル
|
||||
|
||||
### 統合環境 (IDE)
|
||||
@@ -106,6 +114,9 @@ ELCはIDXを参照せず、IDXもELCを参照しない。MAPもIDXを参照し
|
||||
- **バージョン管理統合**: テキストベース定義でgit管理
|
||||
- **ワークフローサポート**: ガイド付きウィザード、テンプレート
|
||||
|
||||
ビルド・ファームウェアの書き込みは、MAP編集とは別の実行機能として扱う。
|
||||
同じ画面から各ツールを利用できることと、各レイヤーがそのデータや処理を所有することは区別する。
|
||||
|
||||
## プロトコル設計
|
||||
|
||||
### レイヤー間インターフェース
|
||||
@@ -117,13 +128,14 @@ PHY → ELC
|
||||
|
||||
PHY → IDX
|
||||
Input: キーID + 配置
|
||||
Output: 番号を振るキーIDの並び
|
||||
Output: 全バリアントの対象キーIDの並び
|
||||
|
||||
PHY → MAP
|
||||
Input: キーID
|
||||
Output: レイヤー別キーコード
|
||||
Output: MAP JSON (現行形式はレイヤー別キーコード割り当て)
|
||||
|
||||
All → Firmware
|
||||
Input: 各レイヤーのデータ + バリアント選択
|
||||
Output: QMK keyboard.json / VIA JSON / KMK .py
|
||||
```
|
||||
|
||||
@@ -156,7 +168,7 @@ All → Firmware
|
||||
## 既存ツールとの関係
|
||||
|
||||
- **KLE**: PHYデータを表示できる既存のビュワー。KLE JSONの読み書きで連携する
|
||||
- **VIA/Remap**: MAP Layerクライアントとして連携
|
||||
- **VIA/Remap**: 同じVIA対応実機の割り当てを編集する外部クライアント。外部で調整した結果もMAPエディタで読み出してプロジェクトへ戻す。読み書きする項目は[MAP「VIAで読み書きする項目」](MAPレイヤー.md#viaで読み書きする項目)、実機との対応・状態管理は[MAPのP-3](MAPレイヤー.md#p-3-プロジェクトと実機の対応状態管理)で扱う
|
||||
- **QMK Configurator**: Firmware Generatorのターゲット
|
||||
|
||||
## 配慮事項
|
||||
|
||||
+23
-22
@@ -11,9 +11,9 @@
|
||||
|
||||
- **ツール**: KLE (Keyboard Layout Editor)
|
||||
- **作業内容**:
|
||||
- キーの物理配置を決定
|
||||
- キーサイズ・間隔・角度を調整
|
||||
- 視覚的な配置確認
|
||||
- キーの物理配置を決定
|
||||
- キーサイズ・間隔・角度を調整
|
||||
- 視覚的な配置確認
|
||||
- **出力**: KLE JSON ファイル
|
||||
- **Beyond KLE対応**: **PHYレイヤー**
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
- **ツール**: 手動計算、KiCad等の回路図エディタ
|
||||
- **作業内容**:
|
||||
- キーマトリックスの行列構成を決定
|
||||
- マイコンピンとの対応付け
|
||||
- ダイオード配置の検討
|
||||
- キーマトリックスの行列構成を決定
|
||||
- マイコンピンとの対応付け
|
||||
- ダイオード配置の検討
|
||||
- **出力**: 回路図、ピン配置表
|
||||
- **現在の課題**: KLEからマトリックス情報への変換が手動
|
||||
- **Beyond KLE対応**: **ELCレイヤー**。行列構成という工程は持たず、キーの端子とピンの対応として書く
|
||||
@@ -34,9 +34,9 @@
|
||||
|
||||
- **ツール**: KiCad、Eagle、Fusion360等
|
||||
- **作業内容**:
|
||||
- KLE JSONから座標を抽出(手動またはスクリプト)
|
||||
- スイッチフットプリントの配置
|
||||
- マイコン・コネクタ等の部品配置
|
||||
- KLE JSONから座標を抽出(手動またはスクリプト)
|
||||
- スイッチフットプリントの配置
|
||||
- マイコン・コネクタ等の部品配置
|
||||
- **現在の課題**: KLEとPCBツール間の座標変換
|
||||
- **Beyond KLE対応**: **PHYレイヤー** → PCBツール連携。渡すのは配置であって、フットプリントの選択はPCBツール側に残る
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
|
||||
- **ツール**: PCB CADソフト
|
||||
- **作業内容**:
|
||||
- マトリックス配線の実装
|
||||
- 電源・信号線のルーティング
|
||||
- マトリックス配線の実装
|
||||
- 電源・信号線のルーティング
|
||||
- **Beyond KLE対応**: **ELCレイヤー**情報の活用
|
||||
|
||||
### 3. ファームウェア設定段階
|
||||
@@ -54,24 +54,25 @@
|
||||
|
||||
- **ツール**: QMK、KMK、VIA等
|
||||
- **作業内容**:
|
||||
- マトリックス定義(ROW/COL)
|
||||
- ピン配置設定
|
||||
- 物理レイアウト定義
|
||||
- キーマップ配列の並び順を決める(QMKなら `LAYOUT` マクロの引数順、ZMKなら matrix transform の
|
||||
`map`、KMKなら `coord_mapping`)
|
||||
- マトリックス定義(ROW/COL)
|
||||
- ピン配置設定
|
||||
- 物理レイアウト定義
|
||||
- キーマップ配列の並び順を決める(QMKなら `LAYOUT` マクロの引数順、ZMKなら matrix transform の
|
||||
`map`、KMKなら `coord_mapping`)
|
||||
- **現在の課題**: KLEとファームウェア設定の手動変換。並び順は手書きで、引数の数え間違いがそのまま
|
||||
キーのずれになる
|
||||
キーのずれになる
|
||||
- **Beyond KLE対応**: **PHY** + **IDX** + **ELC** → ファームウェア自動生成。
|
||||
ROW/COLはELCの配線から出力時に割り当てる。並び順は**IDXレイヤー**が正本として持つ
|
||||
ROW/COLはELCの配線から出力時に割り当てる。並び順は**IDXレイヤー**が全バリアントにわたって正本として持ち、
|
||||
出力時に選択したバリアントで使うキーだけを残して、キー番号を0から連続する番号に詰める
|
||||
|
||||
#### b) キーマップ設定
|
||||
|
||||
- **ツール**: QMK Configurator、VIA、Remap等
|
||||
- **作業内容**:
|
||||
- 各キーへのキーコード割り当て
|
||||
- レイヤー設計
|
||||
- マクロ・特殊機能の設定
|
||||
- **Beyond KLE対応**: **MAPレイヤー**。キーの参照にはPHYのキーIDを使い、配列への並べ替えは出力時にIDXの順で行う
|
||||
- 各キーへのキーコード割り当て
|
||||
- レイヤー設計
|
||||
- マクロ・特殊機能の設定
|
||||
- **Beyond KLE対応**: **MAPレイヤー**。キーの参照にはPHYのキーIDを使い、配列への並べ替えは出力時にバリアント選択後のIDXの順で行う
|
||||
|
||||
### 4. 製造・組み立て段階
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1 @@
|
||||
{ "printWidth": 110 }
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function projectJson(page: import("@playwright/test").Page) {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(file);
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
async function loadProject(page: import("@playwright/test").Page, project: unknown) {
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "case.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(project)),
|
||||
});
|
||||
}
|
||||
|
||||
test("provider, key terminals and diode edit wiring and show collisions", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await expect(page.getByText(/MCU · mcu · RP2040/)).toBeVisible();
|
||||
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
const first = view.locator(".kv-key").nth(0);
|
||||
await first.click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("15");
|
||||
await expect(first).toHaveClass(/ok/);
|
||||
await expect(first).toContainText("GP0/GP1");
|
||||
|
||||
await view.locator(".kv-key").nth(1).click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||
await expect(view.locator(".kv-key.error")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("batch wiring applies selected keys in row-major PHY order", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
const views = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
const batchView = views.nth(1);
|
||||
await batchView.locator(".kv-key").nth(0).click();
|
||||
await batchView.locator(".kv-key").nth(1).click();
|
||||
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
await expect(page.getByTestId("batch-preview")).toBeVisible();
|
||||
await expect(page.getByTestId("batch-preview").locator("tbody tr").nth(0)).toContainText("MCU.GP0");
|
||||
await page.getByTestId("batch-confirm").click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("14");
|
||||
await expect(views.first().locator(".kv-key").nth(0)).toContainText("GP0/GP1");
|
||||
await expect(views.first().locator(".kv-key").nth(1)).toContainText("GP0/GP2");
|
||||
});
|
||||
|
||||
test("batch wiring previews overwritten assignments and blocks insufficient capacity", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
await view.locator(".kv-key").first().click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP5");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP6");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
const batchView = page.locator(".wiring-box [data-testid=keyboard-view]").nth(1);
|
||||
for (let i = 0; i < 3; i++) await batchView.locator(".kv-key").nth(i).click();
|
||||
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
await expect(page.getByTestId("batch-capacity-error")).toBeVisible();
|
||||
await expect(page.getByTestId("batch-confirm")).toBeDisabled();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
const rows = page.getByTestId("batch-preview").locator("tbody tr");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(rows.first()).toContainText("上書き");
|
||||
});
|
||||
|
||||
test("diode direction is part of collision identity and unknown MCU parts accept free pin names", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
let source = await projectJson(page);
|
||||
const [k1, k2] = source.idx.order;
|
||||
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||
source.elc.keys = [
|
||||
{ key: k1, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "a-to-b" },
|
||||
{ key: k2, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "b-to-a" },
|
||||
];
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false })).toHaveCount(0);
|
||||
source.elc.keys[1].diode = "a-to-b";
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("provider-id").fill("ALT");
|
||||
await page.locator("input[list=mcu-parts]").fill("Unknown-MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await expect(page.getByText("未知の品番はピン名を自由入力します。")).toBeVisible();
|
||||
const key = page.locator(".wiring-box .kv-key").nth(2);
|
||||
await key.click();
|
||||
await page.getByLabel("a端子自由入力").fill("ALT.PIN_X");
|
||||
await page.getByLabel("b端子自由入力").fill("ALT.PIN_Y");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("13");
|
||||
});
|
||||
|
||||
test("LED chain candidates include only per-key LED keys and @led parts across all variants", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const source = await projectJson(page);
|
||||
const keys = [] as any[];
|
||||
const walk = (nodes: any[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "key") keys.push(n);
|
||||
else if (n.type === "cluster") walk(n.children);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v: any) => walk(v.children));
|
||||
});
|
||||
walk(source.phy.root);
|
||||
keys[0].perKeyLED = true;
|
||||
keys[1].perKeyLED = false;
|
||||
source.phy.root.push({
|
||||
type: "component",
|
||||
id: "cmp-test-led",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
partGroup: "@led",
|
||||
});
|
||||
source.phy.root.push({
|
||||
type: "component",
|
||||
id: "cmp-test-other",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
partGroup: "@screw",
|
||||
});
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByLabel("チェーンID").fill("LED1");
|
||||
await page.getByLabel("データピン").fill("MCU.DATA");
|
||||
await page.getByRole("button", { name: "作成" }).click();
|
||||
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[0].id}"]`)).toHaveCount(1);
|
||||
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-led"]')).toHaveCount(1);
|
||||
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[1].id}"]`)).toHaveCount(0);
|
||||
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-other"]')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
test("generates the 4x4 preview and blocks output when a wire is removed", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(file);
|
||||
const source = JSON.parse(await (await import("node:fs/promises")).readFile(file, "utf8"));
|
||||
const keyIds: string[] = source.idx.order;
|
||||
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||
source.elc.keys = keyIds.map((key, i) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: `MCU.GP${Math.floor(i / 4)}`, b: `MCU.GP${4 + (i % 4)}` },
|
||||
diode: "a-to-b",
|
||||
}));
|
||||
source.map.layers[0].keymap = keyIds.map((key, i) => ({ keyId: key, keycode: `KC_${i % 10}` }));
|
||||
const uploadProject = async (obj: unknown) => {
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooserPromise
|
||||
).setFiles({
|
||||
name: "fixture.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(obj)),
|
||||
});
|
||||
};
|
||||
await uploadProject(source);
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.getByTestId("row-pin-select").selectOption(`MCU.GP${i}`);
|
||||
await page.getByRole("button", { name: "row追加" }).click();
|
||||
await page.getByTestId("col-pin-select").selectOption(`MCU.GP${i + 4}`);
|
||||
await page.getByRole("button", { name: "col追加" }).click();
|
||||
}
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.getByTestId("mapping-count")).toHaveText("16行");
|
||||
const rows = page.locator(".preview tbody tr");
|
||||
await expect(rows).toHaveCount(16);
|
||||
await expect(rows.nth(0).locator("td").nth(2)).toHaveText("0");
|
||||
await expect(rows.nth(0).locator("td").nth(3)).toHaveText("0");
|
||||
await expect(rows.nth(4).locator("td").nth(2)).toHaveText("1");
|
||||
await expect(rows.nth(4).locator("td").nth(3)).toHaveText("0");
|
||||
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.locator(".wiring-box [data-testid=keyboard-view] .kv-key").first().click();
|
||||
await page.getByRole("button", { name: "配線解除" }).click();
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.locator(".errors").getByText("unwired-key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("project undo invalidates a result generated from the newer project state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.locator(".errors")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(".errors")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Redo" }).click();
|
||||
await expect(page.locator(".errors")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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("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();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(numberOf(page, 16)).toHaveText("16");
|
||||
await dragKey(page, 16, 0, 0.1);
|
||||
await expect(numberOf(page, 16)).toHaveText("0");
|
||||
await expect(numberOf(page, 0)).toHaveText("1");
|
||||
});
|
||||
|
||||
test("the variant tree switches the shown configuration without touching other keys' order", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const firstId = await keys.nth(0).getAttribute("data-node-id");
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
});
|
||||
|
||||
test("assign codes and rename referenced layers safely", async ({ page }) => {
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("レイヤー名を入力してください");
|
||||
await page.getByLabel("追加するレイヤー名").fill("Base");
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("already exists");
|
||||
|
||||
const keys = page.locator('[data-testid="keyboard-view"] [data-key-id]');
|
||||
const firstId = await keys.nth(0).getAttribute("data-key-id");
|
||||
const secondId = await keys.nth(1).getAttribute("data-key-id");
|
||||
await keys.nth(0).click();
|
||||
await page.getByLabel("キーコード").fill("KC_A");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${firstId}"] text`)).toContainText("KC_A");
|
||||
|
||||
await page.getByLabel("追加するレイヤー名").fill("Nav");
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("tab", { name: "Nav" })).toHaveAttribute("aria-selected", "true");
|
||||
await keys.nth(1).click();
|
||||
await page.getByLabel("キーコード").fill("MO(Nav)");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Nav)");
|
||||
|
||||
await page.getByLabel("新しい名前").fill("Navigation");
|
||||
await page.getByTestId("map-rename-layer").click();
|
||||
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Navigation)");
|
||||
await page.getByTestId("map-delete-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("referenced");
|
||||
});
|
||||
|
||||
test("assignment click creates one undo step and unknown existing codes stay read-only", async ({ page }) => {
|
||||
const key = page.locator('[data-testid="keyboard-view"] [data-key-id]').first();
|
||||
const id = await key.getAttribute("data-key-id");
|
||||
await key.click();
|
||||
await page.getByLabel("キーコード").fill("KC_A");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${id}"] text`)).toContainText("KC_A");
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(`[data-key-id="${id}"] text`)).not.toContainText("KC_A");
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filePath);
|
||||
const file = await readFile(filePath, "utf8");
|
||||
const source = JSON.parse(file);
|
||||
source.map.layers[0].keymap = [{ keyId: id, keycode: "KC_UNKNOWN_VENDOR_CODE" }];
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "unknown.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(source)),
|
||||
});
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await page.locator(`[data-testid="keyboard-view"] [data-key-id="${id}"]`).click();
|
||||
await expect(page.getByRole("status")).toContainText("未知のキーコード");
|
||||
await expect(page.getByRole("button", { name: "割り当て", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "割り当て解除" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("MAP editing controls are disabled when profile unit sizes differ", async ({ page }) => {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filePath);
|
||||
const source = JSON.parse(await readFile(filePath, "utf8"));
|
||||
source.phy.profiles.push({
|
||||
...source.phy.profiles[0],
|
||||
id: "alternate",
|
||||
name: "Alternate",
|
||||
unitSize: { x: 18, y: 18 },
|
||||
});
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "unit-mismatch.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(source)),
|
||||
});
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-rename-layer")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-delete-layer")).toBeDisabled();
|
||||
await expect(page.getByLabel("キーコード")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
});
|
||||
|
||||
async function makeCluster(page: import("@playwright/test").Page) {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||
return page.locator('[data-node-type="cluster"]').first();
|
||||
}
|
||||
|
||||
test("階層パネルを閉じると、キャンバスが空いた幅を埋める", async ({ page }) => {
|
||||
const canvas = page.locator(".canvas-pane");
|
||||
const properties = page.locator(".properties");
|
||||
const before = (await canvas.boundingBox())!;
|
||||
await page.getByRole("button", { name: "階層" }).click();
|
||||
await expect(page.locator(".tree")).toHaveCount(0);
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeGreaterThan(before.width + 200);
|
||||
const after = (await canvas.boundingBox())!;
|
||||
const side = (await properties.boundingBox())!;
|
||||
const workspace = (await page.locator(".workspace").boundingBox())!;
|
||||
expect(Math.abs(after.x - workspace.x)).toBeLessThan(1);
|
||||
expect(Math.abs(side.x + side.width - (workspace.x + workspace.width))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("選択レイヤーはキーより前面にあり、回転とフォーカスに追従する", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const leftId = await keys.first().getAttribute("data-node-id");
|
||||
await keys.first().click();
|
||||
const order = await page.locator("svg.canvas").evaluate((svg, id) => {
|
||||
const face = svg.querySelector(`[data-node-id="${id}"]`)!;
|
||||
const layer = svg.querySelector(`[data-sel-id="${id}"]`)!;
|
||||
return [
|
||||
face.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
layer.parentElement?.classList.contains("selection-layer"),
|
||||
];
|
||||
}, leftId);
|
||||
expect(order).toEqual([4, true]);
|
||||
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||
await page.getByLabel("Rotation").fill("35");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const key = cluster.locator('[data-node-type="key"]').first();
|
||||
const id = await key.getAttribute("data-node-id");
|
||||
await key.click();
|
||||
const aligned = async () =>
|
||||
page.evaluate((keyId) => {
|
||||
const face = document.querySelector(`[data-node-id="${keyId}"] .key-face`)!.getBoundingClientRect();
|
||||
const outline = document.querySelector(`[data-sel-id="${keyId}"]`)!.getBoundingClientRect();
|
||||
return [
|
||||
Math.abs(face.x - outline.x),
|
||||
Math.abs(face.y - outline.y),
|
||||
Math.abs(face.width - outline.width),
|
||||
Math.abs(face.height - outline.height),
|
||||
];
|
||||
}, id);
|
||||
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const focused = page.locator(`[data-node-id="${id}"]`).last();
|
||||
await focused.click();
|
||||
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const before = await key.getAttribute("transform");
|
||||
const box = await key.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => key.getAttribute("transform")).not.toBe(before);
|
||||
const after = await key.getAttribute("transform");
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(key).toHaveAttribute("transform", before!);
|
||||
await page.getByRole("button", { name: "Redo" }).click();
|
||||
await expect(key).toHaveAttribute("transform", after!);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const before = await key.getAttribute("transform");
|
||||
const keyBox = (await key.boundingBox())!;
|
||||
const canvasBox = (await page.locator("svg.canvas").boundingBox())!;
|
||||
const y = keyBox.y + keyBox.height / 2;
|
||||
await page.mouse.move(keyBox.x + keyBox.width / 2, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 });
|
||||
const atEdge = await key.getAttribute("transform");
|
||||
await page.waitForTimeout(700);
|
||||
expect(await key.getAttribute("transform")).not.toBe(atEdge);
|
||||
await page.mouse.up();
|
||||
const released = await key.getAttribute("transform");
|
||||
await page.waitForTimeout(250);
|
||||
expect(await key.getAttribute("transform")).toBe(released);
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(key).toHaveAttribute("transform", before!);
|
||||
});
|
||||
|
||||
test("選択中のクラスターにだけ原点の丸を出す", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const corner = (await keys.nth(0).locator(".key-face").boundingBox())!;
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await keys.nth(5).click();
|
||||
await expect(page.locator(".origin")).toHaveCount(0);
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||
const origin = page.locator(`[data-origin-id="${clusterId}"]`);
|
||||
await expect(origin).toHaveCount(1);
|
||||
const box = (await origin.boundingBox())!;
|
||||
expect(Math.abs(box.x + box.width / 2 - corner.x)).toBeLessThan(2);
|
||||
expect(Math.abs(box.y + box.height / 2 - corner.y)).toBeLessThan(2);
|
||||
await keys.nth(5).click();
|
||||
await expect(page.locator(".origin")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("クラスター化で画面位置を保つ", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const centers = async () =>
|
||||
keys.evaluateAll((es) =>
|
||||
es.slice(0, 2).map((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return [r.x + r.width / 2, r.y + r.height / 2];
|
||||
}),
|
||||
);
|
||||
const before = await centers();
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||
const after = await centers();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(after[i]![0]).toBeCloseTo(before[i]![0]!, 0);
|
||||
expect(after[i]![1]).toBeCloseTo(before[i]![1]!, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("複数選択のドラッグが全件に適用される", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const before = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
const box = await keys.nth(0).boundingBox();
|
||||
if (!box) throw new Error("key missing");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 27, box.y + box.height / 2 + 10, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const after = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
|
||||
expect(after[0]).not.toBe(before[0]);
|
||||
expect(after[1]).not.toBe(before[1]);
|
||||
});
|
||||
|
||||
test("回転クラスターの矢印移動は親軸で動き、ドラッグもポインタに追従する", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).click();
|
||||
await page.getByLabel("Rotation").fill("90");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const beforeBox = await key.boundingBox();
|
||||
const beforeTransform = await key.getAttribute("transform");
|
||||
await key.click();
|
||||
const pane = page.locator(".canvas-pane");
|
||||
await pane.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const afterBox = await key.boundingBox();
|
||||
expect(afterBox!.y).toBeGreaterThan(beforeBox!.y + 4);
|
||||
const dragBox = await key.boundingBox();
|
||||
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + dragBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const dragAfterBox = await key.boundingBox();
|
||||
expect(await key.getAttribute("transform")).not.toBe(beforeTransform);
|
||||
expect(dragAfterBox!.x).toBeGreaterThan(dragBox!.x + 10);
|
||||
expect(dragAfterBox!.y).toBeCloseTo(dragBox!.y, 0);
|
||||
});
|
||||
|
||||
test("長押しでクラスターを選び、ダブルクリックでフォーカスしてEscで戻る", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const b = await key.boundingBox();
|
||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||
await page.mouse.up();
|
||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||
const keyBox = await key.boundingBox();
|
||||
await page.mouse.dblclick(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2, { delay: 80 });
|
||||
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||
const pane = page.locator(".canvas-pane");
|
||||
await pane.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "root", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("入れ子の回転クラスターをフォーカスできる", async ({ page }) => {
|
||||
const outer = await makeCluster(page);
|
||||
const outerId = await outer.getAttribute("data-node-id");
|
||||
const childIds = await outer
|
||||
.locator('[data-node-type="key"]')
|
||||
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
await page.getByText("キー・部品 2件", { exact: true }).click();
|
||||
await page.locator(`[data-tree-id="${childIds[0]}"]`).first().click();
|
||||
await page
|
||||
.locator(`[data-tree-id="${childIds[1]}"]`)
|
||||
.first()
|
||||
.click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||
const inner = outer.locator('[data-node-type="cluster"]').first();
|
||||
const innerId = await inner.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
|
||||
await page.getByLabel("Rotation").fill("35");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const key = inner.locator('[data-node-type="key"]').first();
|
||||
await expect(key).toBeVisible();
|
||||
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||
await expect(page.locator(`[data-node-id="${childIds[0]}"]`).last()).toBeVisible();
|
||||
const focusedKey = page.locator(`[data-node-id="${childIds[0]}"]`).last();
|
||||
const focusedBox = await focusedKey.boundingBox();
|
||||
await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator(`[data-sel-id="${innerId}"].candidate`)).toBeVisible({
|
||||
timeout: 1800,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-long-press-in-focus.png") });
|
||||
const clickBox = await focusedKey.boundingBox();
|
||||
await page.mouse.dblclick(clickBox!.x + clickBox!.width / 2, clickBox!.y + clickBox!.height / 2, {
|
||||
delay: 80,
|
||||
});
|
||||
await expect(page.locator(`[data-node-id="${innerId}"]`)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.first().click();
|
||||
const outline = page.locator(".selection-layer .sel").first();
|
||||
await expect(outline).toBeVisible();
|
||||
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("3.5px");
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||
await page.getByLabel("Rotation").fill("35");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
|
||||
await expect(badge).toBeVisible();
|
||||
await badge.locator("rect").click();
|
||||
await page
|
||||
.locator(".popover")
|
||||
.getByRole("button", { name: /Option B/ })
|
||||
.click();
|
||||
await expect(keys).toHaveCount(14);
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||
await badge.locator("rect").click();
|
||||
const focusedBadgeBox = await badge.boundingBox();
|
||||
const focusedPopoverBox = await page.locator(".popover").boundingBox();
|
||||
expect(
|
||||
Math.hypot(focusedPopoverBox!.x - focusedBadgeBox!.x, focusedPopoverBox!.y - focusedBadgeBox!.y),
|
||||
).toBeLessThanOrEqual(80);
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-badge-in-focus.png") });
|
||||
await page
|
||||
.locator(".popover")
|
||||
.getByRole("button", { name: /Option A/ })
|
||||
.click();
|
||||
await expect(keys).toHaveCount(16);
|
||||
});
|
||||
|
||||
test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", 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");
|
||||
await key.click();
|
||||
await page.getByRole("button", { name: "削除", exact: true }).click();
|
||||
const confirm = page.locator(".confirm-float");
|
||||
await expect(confirm).toContainText("参照");
|
||||
await expect(confirm).toContainText(id!);
|
||||
await confirm.getByRole("button", { name: "削除", exact: true }).click();
|
||||
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0);
|
||||
await expect(page.getByTestId("idx-register-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const id = await key.getAttribute("data-node-id");
|
||||
const original = await key.getAttribute("transform");
|
||||
await key.click();
|
||||
const marker = await page.evaluate(() => {
|
||||
const data = new DataTransfer();
|
||||
window.dispatchEvent(
|
||||
new ClipboardEvent("copy", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||
);
|
||||
return data.getData("text/plain");
|
||||
});
|
||||
await page.evaluate((marker) => {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", marker);
|
||||
window.dispatchEvent(
|
||||
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||
);
|
||||
}, marker);
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(17);
|
||||
const pasted = page.locator('[data-node-type="key"]').last();
|
||||
expect(await pasted.getAttribute("data-node-id")).not.toBe(id);
|
||||
expect(await pasted.getAttribute("transform")).not.toBe(original);
|
||||
await page.evaluate(() => {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", JSON.stringify([["Pasted"]]));
|
||||
window.dispatchEvent(
|
||||
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||
);
|
||||
});
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(18);
|
||||
});
|
||||
|
||||
test("カット貼り付けはIDとワールド位置を保つ", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const id = await key.getAttribute("data-node-id");
|
||||
const transform = await key.getAttribute("transform");
|
||||
await key.click();
|
||||
const marker = await page.evaluate(() => {
|
||||
const data = new DataTransfer();
|
||||
window.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData: data }));
|
||||
return data.getData("text/plain");
|
||||
});
|
||||
await expect(key).toHaveClass(/cut/);
|
||||
await page.evaluate((value) => {
|
||||
const data = new DataTransfer();
|
||||
data.setData("text/plain", value);
|
||||
window.dispatchEvent(
|
||||
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||
);
|
||||
}, marker);
|
||||
const cutKey = page.locator(`[data-node-id="${id}"]`);
|
||||
await expect(cutKey).toHaveAttribute("transform", transform!);
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
});
|
||||
|
||||
test("プロジェクト保存読み込みとKLEから60キー新規を往復する", async ({ page }) => {
|
||||
await page.locator('[data-node-type="key"]').first().click();
|
||||
await page.getByLabel("Width").fill("1.5");
|
||||
await page.getByLabel("Width").dispatchEvent("change");
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const savePath = path.join(test.info().outputDir, "project.json");
|
||||
await download.saveAs(savePath);
|
||||
const saved = await readFile(savePath);
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({ name: "roundtrip.bkle.json", mimeType: "application/json", buffer: saved });
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", "1.5");
|
||||
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
|
||||
const kleChooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
|
||||
await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
|
||||
});
|
||||
|
||||
test("unitSize不一致では追加と数値編集が無効", async ({ page }) => {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const savePath = path.join(test.info().outputDir, "project.json");
|
||||
await download.saveAs(savePath);
|
||||
const source = JSON.parse(await readFile(savePath, "utf8"));
|
||||
source.phy.profiles.push({
|
||||
...source.phy.profiles[0],
|
||||
id: "alternate",
|
||||
name: "Alternate",
|
||||
unitSize: { x: 18, y: 18 },
|
||||
});
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "unit-mismatch.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(source)),
|
||||
});
|
||||
await expect(page.getByText("unitSize が一致しないため編集できません。")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "キーを足す" })).toBeDisabled();
|
||||
await page.locator('[data-node-type="key"]').first().click();
|
||||
await expect(page.getByLabel("Width")).toBeDisabled();
|
||||
const before = await page.locator('[data-node-type="key"] .key-face').first().getAttribute("width");
|
||||
await page.getByLabel("Width").evaluate((e: HTMLInputElement) => {
|
||||
e.value = "2";
|
||||
e.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", before!);
|
||||
});
|
||||
|
||||
test("KLE読込直後に全体表示し、cr0wn60の全キーがキャンバス内に収まる", async ({ page }) => {
|
||||
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
|
||||
await (await chooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
|
||||
const canvas = await page.locator("svg.canvas").boundingBox();
|
||||
const boxes = await page.locator('[data-node-type="key"]').evaluateAll((es) =>
|
||||
es.map((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
||||
}),
|
||||
);
|
||||
expect(canvas).not.toBeNull();
|
||||
for (const b of boxes) {
|
||||
expect(b.left).toBeGreaterThanOrEqual(canvas!.x);
|
||||
expect(b.top).toBeGreaterThanOrEqual(canvas!.y);
|
||||
expect(b.right).toBeLessThanOrEqual(canvas!.x + canvas!.width);
|
||||
expect(b.bottom).toBeLessThanOrEqual(canvas!.y + canvas!.height);
|
||||
}
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "cr0wn60-fit.png") });
|
||||
});
|
||||
|
||||
test("キャンバス上のShift選択で2件をクラスター化し、選択解除と親ドラッグを行う", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await expect(page.locator(".sel")).toHaveCount(2);
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Control+g");
|
||||
const cluster = page.locator('[data-node-type="cluster"]').first();
|
||||
await expect(cluster.locator('[data-node-type="key"]')).toHaveCount(2);
|
||||
const clusterBefore = await cluster.getAttribute("transform");
|
||||
const childrenBefore = await cluster
|
||||
.locator('[data-node-type="key"]')
|
||||
.evaluateAll((es) => es.map((e) => e.getAttribute("transform")));
|
||||
const key = cluster.locator('[data-node-type="key"]').first();
|
||||
const b = await key.boundingBox();
|
||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b!.x + b!.width / 2 + 28, b!.y + b!.height / 2 + 5, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await expect(cluster).not.toHaveAttribute("transform", clusterBefore!);
|
||||
expect(
|
||||
await cluster
|
||||
.locator('[data-node-type="key"]')
|
||||
.evaluateAll((es) => es.map((e) => e.getAttribute("transform"))),
|
||||
).toEqual(childrenBefore);
|
||||
const third = keys.nth(3);
|
||||
await third.click();
|
||||
await third.click({ modifiers: ["Shift"] });
|
||||
await expect(page.locator(".sel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("削除のキーボード確認とバリアントポップオーバーを対象の近くに置く", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
await key.click();
|
||||
const keyBox = await key.boundingBox();
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Delete");
|
||||
const confirm = page.locator(".confirm-float");
|
||||
const confirmBox = await confirm.boundingBox();
|
||||
const pane = await page.locator(".canvas-pane").boundingBox();
|
||||
expect(confirmBox!.width).toBeLessThanOrEqual(400);
|
||||
expect(confirmBox!.x).toBeGreaterThanOrEqual(pane!.x);
|
||||
expect(confirmBox!.y).toBeGreaterThanOrEqual(pane!.y);
|
||||
expect(confirmBox!.x + confirmBox!.width).toBeLessThanOrEqual(pane!.x + pane!.width);
|
||||
expect(confirmBox!.y + confirmBox!.height).toBeLessThanOrEqual(pane!.y + pane!.height);
|
||||
const gapX = Math.max(
|
||||
0,
|
||||
confirmBox!.x - (keyBox!.x + keyBox!.width),
|
||||
keyBox!.x - (confirmBox!.x + confirmBox!.width),
|
||||
);
|
||||
const gapY = Math.max(
|
||||
0,
|
||||
confirmBox!.y - (keyBox!.y + keyBox!.height),
|
||||
keyBox!.y - (confirmBox!.y + confirmBox!.height),
|
||||
);
|
||||
expect(Math.hypot(gapX, gapY)).toBeLessThanOrEqual(150);
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "delete-confirm.png") });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await keysSelectVariantGroup(page);
|
||||
const badge = page.locator(".badge-layer:not(.dim) .badge").first();
|
||||
await badge.locator("rect").click();
|
||||
const badgeBox = await badge.boundingBox();
|
||||
const popoverBox = await page.locator(".popover").boundingBox();
|
||||
expect(Math.hypot(popoverBox!.x - badgeBox!.x, popoverBox!.y - badgeBox!.y)).toBeLessThanOrEqual(80);
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "variant-popover.png") });
|
||||
});
|
||||
|
||||
async function keysSelectVariantGroup(page: import("@playwright/test").Page) {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||
}
|
||||
|
||||
test("Alt+上矢印は選択を親へ上げ、フォーカス描画にID重複を作らない", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).click();
|
||||
await page.getByLabel("Rotation").fill("35");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const key = cluster.locator('[data-node-type="key"]').first();
|
||||
const transform = await key.getAttribute("transform");
|
||||
await key.click();
|
||||
await page.locator(".canvas-pane").focus();
|
||||
await page.keyboard.press("Alt+ArrowUp");
|
||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||
await expect(key).toHaveAttribute("transform", transform!);
|
||||
await page.keyboard.press("Enter");
|
||||
const ids = await page
|
||||
.locator("svg.canvas [data-node-id]")
|
||||
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "focus-mode.png") });
|
||||
const pane = await page.locator("svg.canvas").boundingBox();
|
||||
await page.mouse.click(pane!.x + 8, pane!.y + 8);
|
||||
await expect(page.locator(".sel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("長押し候補は離すまで選択を変えず、離すとクラスターを選ぶ", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.nth(4).click();
|
||||
await expect(
|
||||
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||
).toBeVisible();
|
||||
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 1800 });
|
||||
await expect(
|
||||
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") });
|
||||
await page.mouse.up();
|
||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||
});
|
||||
|
||||
test("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
await page.getByRole("button", { name: "長押し: ループ" }).click();
|
||||
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
|
||||
const key = cluster.locator('[data-node-type="key"]').first();
|
||||
const keyId = await key.getAttribute("data-node-id");
|
||||
const b = await key.boundingBox();
|
||||
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
|
||||
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
|
||||
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||
await expect(page.locator(".hold-ring")).toHaveCount(0);
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||
await page.mouse.up();
|
||||
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
|
||||
// リストへ動かしてもキーはドラッグされない
|
||||
expect(await key.boundingBox()).toEqual(b);
|
||||
});
|
||||
|
||||
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
const keys = cluster.locator('[data-node-type="key"]');
|
||||
const before = await keys.nth(1).boundingBox();
|
||||
const b = (await keys.first().boundingBox())!;
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
|
||||
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
|
||||
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||
await page.mouse.up();
|
||||
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||
// クラスターごと動くので、押していないキーも動く
|
||||
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
|
||||
});
|
||||
|
||||
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
||||
const select = page.getByRole("combobox", { name: "部品を足す" });
|
||||
await select.selectOption("その他");
|
||||
await expect(select).toHaveValue("");
|
||||
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
|
||||
});
|
||||
|
||||
test("クラスター内でキーを足すと、足したキーを選ぶ", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
await page.getByText("キー・部品 2件", { exact: true }).click();
|
||||
await page.locator(`[data-tree-id="${before[0]}"]`).first().click();
|
||||
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||
await expect(clusterKeys).toHaveCount(3);
|
||||
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
const added = after.find((id) => !before.includes(id));
|
||||
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||
await expect(page.locator("[data-sel-id]")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("クラスターを選んでキーを足すと、そのクラスターに新しい行を作る", async ({ page }) => {
|
||||
const cluster = await makeCluster(page);
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||
await expect(clusterKeys).toHaveCount(3);
|
||||
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
const added = after.find((id) => !before.includes(id));
|
||||
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||
// The cluster holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||
const unit = right!.x - left!.x;
|
||||
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る", async ({ page }) => {
|
||||
await keysSelectVariantGroup(page);
|
||||
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||
const groupId = await group.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${groupId}"]`).first().click();
|
||||
const groupKeys = group.locator('[data-node-type="key"]');
|
||||
const before = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||
await expect(groupKeys).toHaveCount(before.length + 1);
|
||||
const after = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||
const added = after.find((id) => !before.includes(id));
|
||||
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||
// The variant holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||
const unit = right!.x - left!.x;
|
||||
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに合う", async ({ page }) => {
|
||||
const canvas = page.locator("svg.canvas");
|
||||
const box = (await canvas.boundingBox())!;
|
||||
const [, , w, h] = (await canvas.getAttribute("viewBox"))!.split(" ").map(Number);
|
||||
expect(Math.abs(w! - box.width)).toBeLessThan(1);
|
||||
expect(Math.abs(h! - box.height)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const id = await key.getAttribute("data-node-id");
|
||||
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||
const mark = page.locator(`[data-mount-id="${id}"]`);
|
||||
await expect(mark.locator(".switch")).toHaveCount(1);
|
||||
await expect(mark.locator(".stab")).toHaveCount(0);
|
||||
|
||||
await key.click();
|
||||
await expect(page.locator(".stabilizers")).toContainText("候補なし");
|
||||
await page.getByLabel("Width").fill("6.25");
|
||||
await page.getByLabel("Width").blur();
|
||||
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
|
||||
await page.getByLabel("±50mm").check();
|
||||
await page.getByLabel("±40mm").check();
|
||||
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||
|
||||
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
|
||||
const before = await switchX();
|
||||
await page.getByLabel("switchOffset X").fill("0.625");
|
||||
await page.getByLabel("switchOffset X").dispatchEvent("change");
|
||||
await expect.poll(switchX).toBeGreaterThan(before + 10);
|
||||
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(mark.locator(".stab")).toHaveCount(2);
|
||||
await expect(page.getByLabel("±40mm")).not.toBeChecked();
|
||||
|
||||
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ISO Enterを足すと外形で描き、プロパティの異形キーで2つ目の矩形を出し入れできる", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
await keys.first().click();
|
||||
const select = page.getByRole("combobox", { name: "異形キーを足す" });
|
||||
await select.selectOption("ISO Enter");
|
||||
await expect(select).toHaveValue("");
|
||||
await expect(keys).toHaveCount(17);
|
||||
const added = keys.last();
|
||||
const addedId = await added.getAttribute("data-node-id");
|
||||
await expect(added.locator("path.key-face")).toHaveCount(1);
|
||||
await expect(page.locator(`path[data-sel-id="${addedId}"]`)).toHaveCount(1);
|
||||
const irregular = page.getByLabel("異形キー", { exact: true });
|
||||
await expect(irregular).toBeChecked();
|
||||
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("-0.25");
|
||||
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.5");
|
||||
|
||||
await page.getByLabel("Width2", { exact: true }).fill("2");
|
||||
await page.getByLabel("Width2", { exact: true }).press("Enter");
|
||||
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("2");
|
||||
|
||||
await irregular.uncheck();
|
||||
await expect(added.locator("rect.key-face")).toHaveAttribute("width", "1.25");
|
||||
await expect(page.getByLabel("X2", { exact: true })).toHaveCount(0);
|
||||
await irregular.check();
|
||||
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("0");
|
||||
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.25");
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head><meta charset="UTF-8" /><title>Beyond KLE Mock</title></head>
|
||||
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "beyond-kle-mock",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"vue": "^3.5.43"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"prettier": "^3.9.9",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^5.0.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
use: { browserName: "chromium", headless: true, baseURL: "http://127.0.0.1:5173" },
|
||||
webServer: {
|
||||
command: "npx vite --host 127.0.0.1",
|
||||
url: "http://127.0.0.1:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+991
@@ -0,0 +1,991 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
ajv-formats:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(ajv@8.20.0)
|
||||
vue:
|
||||
specifier: ^3.5.43
|
||||
version: 3.5.43(typescript@6.0.3)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
'@types/node':
|
||||
specifier: ^26.6.2
|
||||
version: 26.6.2
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.9
|
||||
version: 6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))
|
||||
prettier:
|
||||
specifier: ^3.9.9
|
||||
version: 3.9.9
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1(@types/node@26.6.2)
|
||||
vitest:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2))
|
||||
vue-tsc:
|
||||
specifier: ^3.3.11
|
||||
version: 3.3.11(typescript@6.0.3)
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@oxc-project/types@0.151.0':
|
||||
resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||
resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.11':
|
||||
resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||
resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||
resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||
resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||
resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||
resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||
resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@26.6.2':
|
||||
resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.9':
|
||||
resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/mocker@5.0.1':
|
||||
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/spy@5.0.1':
|
||||
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||
|
||||
'@volar/source-map@2.4.28':
|
||||
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
||||
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
|
||||
|
||||
'@vue/language-core@3.3.11':
|
||||
resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==}
|
||||
|
||||
'@vue/reactivity@3.5.43':
|
||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||
|
||||
'@vue/runtime-core@3.5.43':
|
||||
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
||||
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
||||
|
||||
'@vue/server-renderer@3.5.43':
|
||||
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
||||
|
||||
'@vue/shared@3.5.43':
|
||||
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@2.3.2:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.8:
|
||||
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.33.0:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@1.4.2:
|
||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
obug@2.2.1:
|
||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.63.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.63.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prettier@3.9.9:
|
||||
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.2.11:
|
||||
resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.9.0:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
vite@8.3.1:
|
||||
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@5.0.1:
|
||||
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.1
|
||||
'@vitest/browser-preview': 5.0.1
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.1
|
||||
'@vitest/coverage-v8': 5.0.1
|
||||
'@vitest/ui': 5.0.1
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.2.0:
|
||||
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
|
||||
|
||||
vue-tsc@3.3.11:
|
||||
resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue@3.5.43:
|
||||
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@oxc-project/types@0.151.0': {}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@26.6.2':
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
vue: 3.5.43(typescript@6.0.3)
|
||||
|
||||
'@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.1
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
optionalDependencies:
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
|
||||
'@vitest/spy@5.0.1': {}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.28
|
||||
|
||||
'@volar/source-map@2.4.28': {}
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.2.0
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/shared': 3.5.43
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.28
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/language-core@3.3.11':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
alien-signals: 3.2.1
|
||||
muggle-string: 0.4.1
|
||||
path-browserify: 1.0.1
|
||||
picomatch: 4.0.7
|
||||
|
||||
'@vue/reactivity@3.5.43':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-core@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/runtime-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/shared@3.5.43': {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.8
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.8: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.33.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.33.0
|
||||
lightningcss-darwin-arm64: 1.33.0
|
||||
lightningcss-darwin-x64: 1.33.0
|
||||
lightningcss-freebsd-x64: 1.33.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.33.0
|
||||
lightningcss-linux-arm64-gnu: 1.33.0
|
||||
lightningcss-linux-arm64-musl: 1.33.0
|
||||
lightningcss-linux-x64-gnu: 1.33.0
|
||||
lightningcss-linux-x64-musl: 1.33.0
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
magic-string@1.4.2:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
playwright-core@1.63.0: {}
|
||||
|
||||
playwright@1.63.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prettier@3.9.9: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
rolldown@1.2.11:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.151.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.11
|
||||
'@rolldown/binding-android-arm64': 1.2.11
|
||||
'@rolldown/binding-darwin-arm64': 1.2.11
|
||||
'@rolldown/binding-darwin-x64': 1.2.11
|
||||
'@rolldown/binding-freebsd-x64': 1.2.11
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.11
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.11
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.11
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.11
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.11
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.11
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@8.9.0: {}
|
||||
|
||||
vite@8.3.1(@types/node@26.6.2):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.2.11
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.4.2
|
||||
obug: 2.2.1
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.2
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.2.0: {}
|
||||
|
||||
vue-tsc@3.3.11(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
'@vue/language-core': 3.3.11
|
||||
typescript: 6.0.3
|
||||
|
||||
vue@3.5.43(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-sfc': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/server-renderer': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import type { Node, Variant, VariantGroupNode } from "./phy";
|
||||
|
||||
export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string };
|
||||
type DeleteTarget =
|
||||
{ kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode };
|
||||
|
||||
function descendants(node: Node | Variant): string[] {
|
||||
const ids = [node.id];
|
||||
if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||
if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant));
|
||||
if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */
|
||||
export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] {
|
||||
const target = findTarget(p.phy.root, nodeId);
|
||||
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||
const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||
const out: CascadeReference[] = [];
|
||||
p.idx.order.forEach((id) => {
|
||||
if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id });
|
||||
});
|
||||
p.elc.keys.forEach((w, i) => {
|
||||
if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key });
|
||||
});
|
||||
p.elc.ledChains?.forEach((chain) =>
|
||||
chain.order.forEach((id) => {
|
||||
if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id });
|
||||
}),
|
||||
);
|
||||
p.map.layers.forEach((layer) =>
|
||||
layer.keymap.forEach((assignment) => {
|
||||
if (ids.has(assignment.keyId))
|
||||
out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId });
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */
|
||||
export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile {
|
||||
const o = structuredClone(p);
|
||||
const target = findTarget(o.phy.root, nodeId);
|
||||
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||
const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||
removeTarget(o.phy.root, target);
|
||||
|
||||
o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id));
|
||||
o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key));
|
||||
if (o.elc.ledChains)
|
||||
o.elc.ledChains = o.elc.ledChains.map((chain) => ({
|
||||
...chain,
|
||||
order: chain.order.filter((id) => !deletedIds.has(id)),
|
||||
}));
|
||||
o.map.layers = o.map.layers.map((layer) => ({
|
||||
...layer,
|
||||
keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)),
|
||||
}));
|
||||
|
||||
if (target.kind === "variant") {
|
||||
if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id;
|
||||
if (o.output.variantSelection[target.group.id] === nodeId)
|
||||
o.output.variantSelection[target.group.id] = target.group.default;
|
||||
}
|
||||
pruneVariantSelection(o);
|
||||
return o;
|
||||
}
|
||||
|
||||
function findTarget(nodes: Node[], id: string): DeleteTarget | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { kind: "node", node };
|
||||
if (node.type === "cluster") {
|
||||
const found = findTarget(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (node.type === "variantGroup")
|
||||
for (const variant of node.children) {
|
||||
if (variant.id === id) return { kind: "variant", variant, group: node };
|
||||
const found = findTarget(variant.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removeTarget(nodes: Node[], target: DeleteTarget): void {
|
||||
const id = target.kind === "node" ? target.node.id : target.variant.id;
|
||||
if (target.kind === "variant") {
|
||||
const { group, variant } = target;
|
||||
if (group.children.length <= 2)
|
||||
throw new Error("Cannot delete a variant leaving fewer than two variants");
|
||||
group.children.splice(group.children.indexOf(variant), 1);
|
||||
if (group.default === id) group.default = group.children[0]!.id;
|
||||
return;
|
||||
}
|
||||
const index = nodes.findIndex((node) => node.id === id);
|
||||
if (index >= 0) {
|
||||
nodes.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.type === "cluster") removeTarget(node.children, target);
|
||||
if (node.type === "variantGroup")
|
||||
for (const variant of node.children) removeTarget(variant.children, target);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneVariantSelection(project: ProjectFile): void {
|
||||
const valid = new Map<string, { ids: Set<string>; defaultId: string }>();
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === "cluster") walk(node.children);
|
||||
if (node.type === "variantGroup") {
|
||||
valid.set(node.id, {
|
||||
ids: new Set(node.children.map((variant) => variant.id)),
|
||||
defaultId: node.default,
|
||||
});
|
||||
node.children.forEach((variant) => walk(variant.children));
|
||||
}
|
||||
});
|
||||
walk(project.phy.root);
|
||||
for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) {
|
||||
const group = valid.get(groupId);
|
||||
if (!group) delete project.output.variantSelection[groupId];
|
||||
else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { PhyReference } from "./idx";
|
||||
export type PinRef = string;
|
||||
export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown };
|
||||
export type AnalogMuxProvider = {
|
||||
id: string;
|
||||
kind: "analog-mux";
|
||||
channels: number;
|
||||
select: PinRef[];
|
||||
common: PinRef;
|
||||
enable?: PinRef;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ContactWiring = {
|
||||
key: string;
|
||||
detect: "contact";
|
||||
terminals: { a: PinRef; b: PinRef };
|
||||
diode?: "a-to-b" | "b-to-a";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type AnalogWiring = {
|
||||
key: string;
|
||||
detect: "analog";
|
||||
terminals: { out: PinRef; vcc: PinRef; gnd: PinRef };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type KeyWiring = ContactWiring | AnalogWiring;
|
||||
export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown };
|
||||
export type ElcFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
providers: (McuProvider | AnalogMuxProvider)[];
|
||||
keys: KeyWiring[];
|
||||
ledChains?: LedChain[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */
|
||||
export function mcuPinCandidates(provider: McuProvider): string[] {
|
||||
return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : [];
|
||||
}
|
||||
/** Creates an empty ELC file. */
|
||||
export function createElc(ref: PhyReference): ElcFile {
|
||||
return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] };
|
||||
}
|
||||
/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */
|
||||
export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile {
|
||||
if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'");
|
||||
const o = structuredClone(elc);
|
||||
const i = o.providers.findIndex((x) => x.id === p.id);
|
||||
if (i < 0) o.providers.push(structuredClone(p));
|
||||
else {
|
||||
const old = o.providers[i]!;
|
||||
const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]);
|
||||
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||
o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as
|
||||
McuProvider | AnalogMuxProvider;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
/** Removes a provider and its dependent references. */
|
||||
export function removeProvider(elc: ElcFile, id: string): ElcFile {
|
||||
const o = structuredClone(elc),
|
||||
removed = new Set([id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const p of o.providers)
|
||||
if (p.kind === "analog-mux" && !removed.has(p.id)) {
|
||||
const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])];
|
||||
if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) {
|
||||
removed.add(p.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + "."));
|
||||
o.providers = o.providers.filter((p) => !removed.has(p.id));
|
||||
o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v)));
|
||||
if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data));
|
||||
return o;
|
||||
}
|
||||
/** Adds or edits terminal and diode configuration for a key. */
|
||||
export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
const i = o.keys.findIndex((x) => x.key === w.key);
|
||||
if (i < 0) o.keys.push(structuredClone(w));
|
||||
else {
|
||||
const old = o.keys[i]!;
|
||||
const known = new Set(["key", "detect", "terminals", "diode"]);
|
||||
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||
const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]);
|
||||
const terminalExtensions = Object.fromEntries(
|
||||
Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)),
|
||||
);
|
||||
const updated = {
|
||||
...extensions,
|
||||
...(old.detect === w.detect ? old : {}),
|
||||
...w,
|
||||
terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals },
|
||||
} as KeyWiring;
|
||||
if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode;
|
||||
o.keys[i] = updated;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
/** Removes wiring for a key. */
|
||||
export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
o.keys = o.keys.filter((x) => x.key !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Returns keys without any ELC entry. */
|
||||
export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] {
|
||||
const wired = new Set(elc.keys.map((k) => k.key));
|
||||
return keyIds.filter((k) => !wired.has(k));
|
||||
}
|
||||
/** Creates an LED chain with an independent ordered node list. T-11. */
|
||||
export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
o.ledChains ??= [];
|
||||
if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID");
|
||||
o.ledChains.push({ id, data, order: [] });
|
||||
return o;
|
||||
}
|
||||
/** Replaces LED order while preserving the chain's other fields. */
|
||||
export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
const c = o.ledChains?.find((x) => x.id === id);
|
||||
if (!c) throw new Error("Unknown LED chain");
|
||||
c.order = [...order];
|
||||
return o;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc";
|
||||
|
||||
/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */
|
||||
export function wireMatrixKeys(
|
||||
elc: ElcFile,
|
||||
keyIds: string[],
|
||||
rowPins: string[],
|
||||
colPins: string[],
|
||||
diode?: "a-to-b" | "b-to-a",
|
||||
): ElcFile {
|
||||
if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required");
|
||||
if (keyIds.length > rowPins.length * colPins.length)
|
||||
throw new Error("Matrix does not have enough row/column positions");
|
||||
let next = elc;
|
||||
keyIds.forEach((key, i) => {
|
||||
const wiring: ContactWiring = {
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! },
|
||||
...(diode ? { diode } : {}),
|
||||
};
|
||||
// Keep unknown extension fields on an existing ELC entry, just like normal edits.
|
||||
next = setKeyWiring(next, wiring);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import { flattenKeys } from "./geometry";
|
||||
import { validateProject, type ValidationIssue } from "./validate";
|
||||
export type MockTarget = {
|
||||
format: "mock-target";
|
||||
version: "0.1.0";
|
||||
layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>;
|
||||
idx: string[];
|
||||
matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] };
|
||||
keymaps: Array<{ name: string; keycodes: string[] }>;
|
||||
mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>;
|
||||
};
|
||||
export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] };
|
||||
/** Generates preview output after validation, without modifying the project. T-2/T-19. */
|
||||
export function generateMockTarget(p: ProjectFile): GeneratorResult {
|
||||
const errors = validateProject(p).filter(
|
||||
(x) => x.severity === "blocksOutput" || x.severity === "blocksSave",
|
||||
);
|
||||
if (!p.output.matrix)
|
||||
errors.push({
|
||||
severity: "blocksOutput",
|
||||
code: "matrix-settings-missing",
|
||||
message: "Matrix rowPins and colPins must be configured",
|
||||
});
|
||||
if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix")
|
||||
errors.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-scan-mode",
|
||||
message: `${p.output.matrix.kind} scan mode is not supported`,
|
||||
});
|
||||
if (errors.length) return { errors };
|
||||
const selected = flattenKeys(p.phy, p.output.variantSelection),
|
||||
active = new Set(selected.map((x) => x.key.id)),
|
||||
order = p.idx.order.filter((id) => active.has(id)),
|
||||
matrix = p.output.matrix!;
|
||||
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
|
||||
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
|
||||
const wiring = new Map(p.elc.keys.map((x) => [x.key, x]));
|
||||
const row: number[] = [],
|
||||
col: number[] = [];
|
||||
for (const id of order) {
|
||||
const w = wiring.get(id);
|
||||
if (!w || w.detect !== "contact")
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-detection",
|
||||
message: "Only contact matrix wiring is supported",
|
||||
ids: [id],
|
||||
},
|
||||
],
|
||||
};
|
||||
const a = w.terminals.a,
|
||||
b = w.terminals.b;
|
||||
let r: number | undefined, c: number | undefined;
|
||||
if (rowIndex.has(a) && colIndex.has(b)) {
|
||||
r = rowIndex.get(a);
|
||||
c = colIndex.get(b);
|
||||
} else if (rowIndex.has(b) && colIndex.has(a)) {
|
||||
r = rowIndex.get(b);
|
||||
c = colIndex.get(a);
|
||||
}
|
||||
if (r === undefined || c === undefined)
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-matrix-wiring",
|
||||
message: "Wiring is not a supported row/column matrix",
|
||||
ids: [id],
|
||||
},
|
||||
],
|
||||
};
|
||||
row.push(r);
|
||||
col.push(c);
|
||||
}
|
||||
const poses = new Map(selected.map((x) => [x.key.id, x]));
|
||||
const keymaps = p.map.layers.map((l) => ({
|
||||
name: l.name,
|
||||
keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode),
|
||||
}));
|
||||
const target: MockTarget = {
|
||||
format: "mock-target",
|
||||
version: "0.1.0",
|
||||
layout: order.map((id) => {
|
||||
const x = poses.get(id)!;
|
||||
return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h };
|
||||
}),
|
||||
idx: order,
|
||||
matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col },
|
||||
keymaps,
|
||||
mapping: order.map((id, i) => ({
|
||||
number: i,
|
||||
keyId: id,
|
||||
row: row[i]!,
|
||||
col: col[i]!,
|
||||
label: poses.get(id)!.key.labels?.find(Boolean) ?? "",
|
||||
})),
|
||||
};
|
||||
return { target, errors: [] };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FlatKey, Node, PhyFile, Transform } from "./phy";
|
||||
|
||||
/** Translate·Rotate のアフィン行列を返す。 */
|
||||
export function matrix(t: Transform): Affine {
|
||||
const a = (t.rotation * Math.PI) / 180,
|
||||
c = Math.cos(a),
|
||||
s = Math.sin(a);
|
||||
return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y };
|
||||
}
|
||||
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
|
||||
/** 左から右の順でなく、通常の行列積 left·right を返す。 */
|
||||
export function compose(left: Affine, right: Affine): Affine {
|
||||
return {
|
||||
a: left.a * right.a + left.c * right.b,
|
||||
b: left.b * right.a + left.d * right.b,
|
||||
c: left.a * right.c + left.c * right.d,
|
||||
d: left.b * right.c + left.d * right.d,
|
||||
e: left.a * right.e + left.c * right.f + left.e,
|
||||
f: left.b * right.e + left.d * right.f + left.f,
|
||||
};
|
||||
}
|
||||
/** 剛体変換の逆行列を返す。 */
|
||||
export function inverse(m: Affine): Affine {
|
||||
return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) };
|
||||
}
|
||||
/** 点に行列を適用する。 */
|
||||
export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } {
|
||||
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
|
||||
}
|
||||
/** PHY ノードのワールド行列を計算する。 */
|
||||
export function worldTransform(file: PhyFile, id: string): Affine {
|
||||
const walk = (nodes: Node[], parent: Affine): Affine | undefined => {
|
||||
for (const n of nodes) {
|
||||
const own = compose(parent, matrix(n.transform));
|
||||
if (n.id === id) return own;
|
||||
if (n.type === "cluster") {
|
||||
const r = walk(n.children, own);
|
||||
if (r) return r;
|
||||
}
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
const r = walk(v.children, own);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||
if (!found) throw new Error(`Unknown node: ${id}`);
|
||||
return found;
|
||||
}
|
||||
/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */
|
||||
export function flattenKeys(file: PhyFile, selection: Record<string, string> = {}): FlatKey[] {
|
||||
const out: FlatKey[] = [];
|
||||
const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => {
|
||||
for (const n of nodes) {
|
||||
const wm = compose(parent, matrix(n.transform)),
|
||||
p = [...path, n.id],
|
||||
absoluteRotation = parentRotation + n.transform.rotation;
|
||||
if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p });
|
||||
else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p);
|
||||
else if (n.type === "variantGroup") {
|
||||
const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default));
|
||||
if (!v) throw new Error(`Unknown variant for ${n.id}`);
|
||||
visit(v.children, wm, absoluteRotation, [...p, v.id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
/** Snapshot history for whole-project undo/redo. T-17. */
|
||||
export class ProjectHistory {
|
||||
private items: ProjectFile[];
|
||||
private cursor = 0;
|
||||
private pending = false;
|
||||
constructor(
|
||||
initial: ProjectFile,
|
||||
private readonly limit = 100,
|
||||
) {
|
||||
this.items = [structuredClone(initial)];
|
||||
}
|
||||
/** Adds one undoable snapshot unless a grouped edit is active 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. */
|
||||
begin(): void {
|
||||
if (this.pending) throw new Error("History group already active");
|
||||
this.items = this.items.slice(0, this.cursor + 1);
|
||||
this.items.push(structuredClone(this.items[this.cursor]!));
|
||||
this.cursor = this.items.length - 1;
|
||||
if (this.items.length > this.limit) {
|
||||
this.items.shift();
|
||||
this.cursor--;
|
||||
}
|
||||
this.pending = true;
|
||||
}
|
||||
/** Commits the final grouped snapshot as one undo step. T-17. */
|
||||
commit(value?: ProjectFile): void {
|
||||
if (!this.pending) throw new Error("No history group active");
|
||||
if (value) this.items[this.cursor] = structuredClone(value);
|
||||
if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) {
|
||||
this.items.pop();
|
||||
this.cursor--;
|
||||
}
|
||||
this.pending = false;
|
||||
}
|
||||
/** Replaces the current snapshot during a grouped drag. T-17. */
|
||||
replaceTop(value: ProjectFile): void {
|
||||
this.items[this.cursor] = structuredClone(value);
|
||||
}
|
||||
/** Moves to the previous snapshot. */
|
||||
undo(): ProjectFile {
|
||||
if (!this.canUndo) throw new Error("Nothing to undo");
|
||||
return structuredClone(this.items[--this.cursor]!);
|
||||
}
|
||||
/** Moves to the next snapshot. */
|
||||
redo(): ProjectFile {
|
||||
if (!this.canRedo) throw new Error("Nothing to redo");
|
||||
return structuredClone(this.items[++this.cursor]!);
|
||||
}
|
||||
get canUndo(): boolean {
|
||||
return this.cursor > 0;
|
||||
}
|
||||
get canRedo(): boolean {
|
||||
return this.cursor < this.items.length - 1;
|
||||
}
|
||||
/** Returns a copy of the current snapshot. */
|
||||
current(): ProjectFile {
|
||||
return structuredClone(this.items[this.cursor]!);
|
||||
}
|
||||
private add(value: ProjectFile) {
|
||||
this.items = this.items.slice(0, this.cursor + 1);
|
||||
this.items.push(structuredClone(value));
|
||||
this.cursor = this.items.length - 1;
|
||||
if (this.items.length > this.limit) {
|
||||
this.items.shift();
|
||||
this.cursor--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */
|
||||
export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string {
|
||||
return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`;
|
||||
}
|
||||
/** T-11: ノード種別ごとのIDを生成する。 */
|
||||
export function createNodeId(
|
||||
kind: "key" | "cluster" | "component" | "variantGroup" | "variant",
|
||||
uuid?: () => string,
|
||||
): string {
|
||||
return createId(
|
||||
{ key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind],
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PhyFile, Node, KeyNode } from "./phy";
|
||||
export type PhyReference = { source: string; checksum: string; [key: string]: unknown };
|
||||
export type IdxFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
order: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Collects keys in document order, including ghost keys and excluding decals. T-18. */
|
||||
export function eligibleKeyIds(phy: PhyFile): string[] {
|
||||
const ids: string[] = [];
|
||||
const visit = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "key") {
|
||||
if (!n.decal) ids.push(n.id);
|
||||
} else if (n.type === "cluster") visit(n.children);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
|
||||
});
|
||||
visit(phy.root);
|
||||
return ids;
|
||||
}
|
||||
/** Makes initial index order from KLE occurrence order. T-18. */
|
||||
export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile {
|
||||
const valid = new Set(eligibleKeyIds(phy));
|
||||
return {
|
||||
version: "0.1.0",
|
||||
phyReference: structuredClone(phyReference),
|
||||
order: keyOrder.filter((id) => valid.has(id)),
|
||||
};
|
||||
}
|
||||
/** Lists eligible PHY keys omitted from the saved order. T-18. */
|
||||
export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] {
|
||||
const registered = new Set(idx.order);
|
||||
return eligibleKeyIds(phy).filter((id) => !registered.has(id));
|
||||
}
|
||||
/** Registers new keys at the end without rebuilding existing order. T-18. */
|
||||
export function appendKeys(idx: IdxFile, ids: string[]): IdxFile {
|
||||
const out = structuredClone(idx);
|
||||
for (const id of ids) if (!out.order.includes(id)) out.order.push(id);
|
||||
return out;
|
||||
}
|
||||
/** Removes one key from index order. */
|
||||
export function excludeKey(idx: IdxFile, keyId: string): IdxFile {
|
||||
const o = structuredClone(idx);
|
||||
o.order = o.order.filter((x) => x !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Reorders the saved key list. */
|
||||
export function reorderKeys(idx: IdxFile, order: string[]): IdxFile {
|
||||
if (
|
||||
order.length !== idx.order.length ||
|
||||
new Set(order).size !== order.length ||
|
||||
order.some((x) => !idx.order.includes(x))
|
||||
)
|
||||
throw new Error("Order must contain every registered key exactly once");
|
||||
return { ...structuredClone(idx), order: [...order] };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||
|
||||
/** Small US keyboard display map for common QMK basic keys. T-23. */
|
||||
export const keycodeLabels: Record<string, string> = {
|
||||
KC_A: "A",
|
||||
KC_B: "B",
|
||||
KC_C: "C",
|
||||
KC_D: "D",
|
||||
KC_E: "E",
|
||||
KC_F: "F",
|
||||
KC_G: "G",
|
||||
KC_H: "H",
|
||||
KC_I: "I",
|
||||
KC_J: "J",
|
||||
KC_K: "K",
|
||||
KC_L: "L",
|
||||
KC_M: "M",
|
||||
KC_N: "N",
|
||||
KC_O: "O",
|
||||
KC_P: "P",
|
||||
KC_Q: "Q",
|
||||
KC_R: "R",
|
||||
KC_S: "S",
|
||||
KC_T: "T",
|
||||
KC_U: "U",
|
||||
KC_V: "V",
|
||||
KC_W: "W",
|
||||
KC_X: "X",
|
||||
KC_Y: "Y",
|
||||
KC_Z: "Z",
|
||||
KC_1: "1",
|
||||
KC_2: "2",
|
||||
KC_3: "3",
|
||||
KC_4: "4",
|
||||
KC_5: "5",
|
||||
KC_6: "6",
|
||||
KC_7: "7",
|
||||
KC_8: "8",
|
||||
KC_9: "9",
|
||||
KC_0: "0",
|
||||
KC_SPC: "Space",
|
||||
KC_ENTER: "Enter",
|
||||
KC_ESC: "Escape",
|
||||
KC_TAB: "Tab",
|
||||
KC_MINUS: "-",
|
||||
KC_EQUAL: "=",
|
||||
KC_LBRC: "[",
|
||||
KC_RBRC: "]",
|
||||
KC_BSLS: "\\",
|
||||
KC_SCLN: ";",
|
||||
KC_QUOT: "'",
|
||||
KC_GRV: "`",
|
||||
KC_COMM: ",",
|
||||
KC_DOT: ".",
|
||||
KC_SLSH: "/",
|
||||
};
|
||||
|
||||
const qmkCodeByName = new Map(
|
||||
qmkBasicKeycodes.flatMap((entry) =>
|
||||
[entry.key, ...entry.aliases].map((name) => [name, entry.key] as const),
|
||||
),
|
||||
);
|
||||
|
||||
/** Looks up the existing US display label, resolving a QMK alias to its formal name. */
|
||||
export function getKeycodeLabel(code: string): string | undefined {
|
||||
const formalName = qmkCodeByName.get(code);
|
||||
if (!formalName) return keycodeLabels[code];
|
||||
return (
|
||||
keycodeLabels[code] ??
|
||||
keycodeLabels[formalName] ??
|
||||
qmkBasicKeycodes
|
||||
.find((entry) => entry.key === formalName)
|
||||
?.aliases.map((alias) => keycodeLabels[alias])
|
||||
.find((label): label is string => label !== undefined)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { PhyReference } from "./idx";
|
||||
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||
export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown };
|
||||
export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown };
|
||||
export type MapFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
layers: MapLayer[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ParsedKeycode = {
|
||||
kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown";
|
||||
code: string;
|
||||
layer?: string;
|
||||
inner?: ParsedKeycode;
|
||||
};
|
||||
const codesFor = (groups: QmkKeyGroup[]) =>
|
||||
new Set(
|
||||
qmkBasicKeycodes
|
||||
.filter((keycode) => groups.includes(keycode.group))
|
||||
.flatMap(({ key, aliases }) => [key, ...aliases]),
|
||||
);
|
||||
type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"];
|
||||
const codesForKey = (key: string) => {
|
||||
const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key);
|
||||
return new Set(entry ? [entry.key, ...entry.aliases] : []);
|
||||
};
|
||||
const basic = codesFor(["basic"]);
|
||||
const modifiers = codesFor(["modifiers"]);
|
||||
const noneCodes = codesForKey("KC_NO");
|
||||
const transparentCodes = codesForKey("KC_TRANSPARENT");
|
||||
/** Parses supported QMK keycode syntax without evaluation. T-22. */
|
||||
export function parseKeycode(s: string): ParsedKeycode {
|
||||
const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/);
|
||||
if (m) return { kind: "layer", code: m[1]!, layer: m[2]! };
|
||||
const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/);
|
||||
if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) };
|
||||
const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/);
|
||||
if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) };
|
||||
if (noneCodes.has(s)) return { kind: "none", code: s };
|
||||
if (transparentCodes.has(s)) return { kind: "transparent", code: s };
|
||||
if (basic.has(s)) return { kind: "basic", code: s };
|
||||
if (modifiers.has(s)) return { kind: "modifier", code: s };
|
||||
return { kind: "unknown", code: s };
|
||||
}
|
||||
|
||||
function simpleEditableKeycode(parsed: ParsedKeycode): boolean {
|
||||
return (
|
||||
parsed.kind === "basic" ||
|
||||
parsed.kind === "modifier" ||
|
||||
parsed.kind === "none" ||
|
||||
parsed.kind === "transparent"
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether a keycode uses syntax editable under T-22. */
|
||||
export function isEditableKeycode(keycode: string): boolean {
|
||||
const parsed = parseKeycode(keycode);
|
||||
if (parsed.kind === "unknown") return false;
|
||||
if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner);
|
||||
if (parsed.kind === "layer")
|
||||
return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner));
|
||||
return true;
|
||||
}
|
||||
/** Creates a MAP with one empty Base layer. T-22. */
|
||||
export function createMap(ref: PhyReference): MapFile {
|
||||
return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] };
|
||||
}
|
||||
/** Adds a uniquely named layer. T-22. */
|
||||
export function addLayer(map: MapFile, name: string): MapFile {
|
||||
if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists");
|
||||
const o = structuredClone(map);
|
||||
o.layers.push({ name, keymap: [] });
|
||||
return o;
|
||||
}
|
||||
/** Renames a layer and rewrites encoded layer references. T-22. */
|
||||
export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile {
|
||||
if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists");
|
||||
const o = structuredClone(map);
|
||||
const l = o.layers.find((x) => x.name === oldName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
l.name = newName;
|
||||
for (const x of o.layers)
|
||||
for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName);
|
||||
return o;
|
||||
}
|
||||
function rewriteLayerRef(s: string, oldName: string, newName: string): string {
|
||||
const p = parseKeycode(s);
|
||||
if (p.kind === "layer" && p.layer === oldName) {
|
||||
if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`;
|
||||
return `${p.code}(${newName})`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
/** Moves a layer to a new array position. */
|
||||
export function reorderLayer(map: MapFile, name: string, index: number): MapFile {
|
||||
const o = structuredClone(map),
|
||||
i = o.layers.findIndex((x) => x.name === name);
|
||||
if (i < 0) throw new Error("Unknown layer");
|
||||
const [l] = o.layers.splice(i, 1);
|
||||
o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!);
|
||||
return o;
|
||||
}
|
||||
/** Deletes an unreferenced layer; references block deletion. T-22. */
|
||||
export function deleteLayer(map: MapFile, name: string): MapFile {
|
||||
if (map.layers.length < 2) throw new Error("At least one layer is required");
|
||||
if (
|
||||
map.layers.some((l) =>
|
||||
l.keymap.some((a) => {
|
||||
const p = parseKeycode(a.keycode);
|
||||
return p.kind === "layer" && p.layer === name;
|
||||
}),
|
||||
)
|
||||
)
|
||||
throw new Error("Layer is referenced");
|
||||
const o = structuredClone(map);
|
||||
o.layers = o.layers.filter((l) => l.name !== name);
|
||||
return o;
|
||||
}
|
||||
/** Assigns a keycode to a key, replacing an existing assignment. T-22. */
|
||||
export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile {
|
||||
if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`);
|
||||
const parsed = parseKeycode(keycode);
|
||||
if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer))
|
||||
throw new Error(`Unknown MAP layer reference: ${parsed.layer}`);
|
||||
const o = structuredClone(map),
|
||||
l = o.layers.find((x) => x.name === layerName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
const i = l.keymap.findIndex((a) => a.keyId === keyId),
|
||||
old = i < 0 ? undefined : l.keymap[i],
|
||||
a = { ...old, keyId, keycode };
|
||||
if (i < 0) l.keymap.push(a);
|
||||
else l.keymap[i] = a;
|
||||
return o;
|
||||
}
|
||||
/** Removes a key assignment. */
|
||||
export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile {
|
||||
const o = structuredClone(map),
|
||||
l = o.layers.find((x) => x.name === layerName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
l.keymap = l.keymap.filter((a) => a.keyId !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Lists keys not assigned in a layer. T-22. */
|
||||
export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] {
|
||||
const l = map.layers.find((x) => x.name === layerName);
|
||||
const assigned = new Set(l?.keymap.map((x) => x.keyId));
|
||||
return keyIds.filter((x) => !assigned?.has(x));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */
|
||||
export type Point = { x: number; y: number };
|
||||
export type Transform = { x: number; y: number; rotation: number };
|
||||
export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number };
|
||||
export type Profile = {
|
||||
id: string;
|
||||
name: string;
|
||||
unitSize: Point;
|
||||
visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number };
|
||||
};
|
||||
export type KleCompatibility = Record<string, unknown>;
|
||||
export type KeyNode = {
|
||||
type: "key";
|
||||
id: string;
|
||||
profile: string;
|
||||
transform: Transform;
|
||||
size: Size;
|
||||
switchOffset?: Point;
|
||||
partGroup?: string;
|
||||
orientation?: 0 | 90 | 180 | 270;
|
||||
perKeyLED?: boolean;
|
||||
decal?: boolean;
|
||||
ghost?: boolean;
|
||||
labels?: string[];
|
||||
kleCompatibility?: KleCompatibility;
|
||||
};
|
||||
export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string };
|
||||
export type ClusterNode = {
|
||||
type: "cluster";
|
||||
id: string;
|
||||
name?: string;
|
||||
transform: Transform;
|
||||
children: Node[];
|
||||
};
|
||||
export type Variant = { type: "variant"; id: string; name: string; children: Node[] };
|
||||
export type VariantGroupNode = {
|
||||
type: "variantGroup";
|
||||
id: string;
|
||||
name: string;
|
||||
transform: Transform;
|
||||
default: string;
|
||||
children: Variant[];
|
||||
};
|
||||
export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode;
|
||||
export type PhyFile = {
|
||||
version: string;
|
||||
metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string };
|
||||
profiles: Profile[];
|
||||
root: Node[];
|
||||
kleCompatibility?: KleCompatibility;
|
||||
};
|
||||
|
||||
export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] };
|
||||
@@ -0,0 +1,491 @@
|
||||
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 };
|
||||
function containers(f: PhyFile): Map<string, Container> {
|
||||
const m = new Map<string, Container>();
|
||||
const rec = (ns: Node[], w: typeof ident, angle: number) => {
|
||||
for (const n of ns) {
|
||||
m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle });
|
||||
const nw = compose(w, matrix(n.transform));
|
||||
const nextAngle = angle + n.transform.rotation;
|
||||
if (n.type === "cluster") rec(n.children, nw, nextAngle);
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle });
|
||||
rec(v.children, nw, nextAngle);
|
||||
}
|
||||
}
|
||||
};
|
||||
rec(f.root, ident, 0);
|
||||
return m;
|
||||
}
|
||||
function find(f: PhyFile, id: string): Node {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
return n;
|
||||
}
|
||||
function world(f: PhyFile, id: string): typeof ident {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
return compose(c!.parentWorld, matrix(n.transform));
|
||||
}
|
||||
function worldRotation(f: PhyFile, id: string): number {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n || !c) throw new Error(`Unknown node: ${id}`);
|
||||
return c.parentRotation + n.transform.rotation;
|
||||
}
|
||||
function mutable(f: PhyFile): PhyFile {
|
||||
return structuredClone(f);
|
||||
}
|
||||
function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
|
||||
return createNodeId(kind, gen);
|
||||
}
|
||||
/** 新しいPHYを返す。T-11。 */
|
||||
export function addKey(
|
||||
f: PhyFile,
|
||||
at: Transform,
|
||||
size: Size,
|
||||
profile = f.profiles[0]!.id,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f);
|
||||
o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||
return o;
|
||||
}
|
||||
/** 新しいPHYを返す。T-11。 */
|
||||
export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f);
|
||||
o.root.push({
|
||||
type: "component",
|
||||
id: nodeId("component", gen),
|
||||
transform: { ...at },
|
||||
...(partGroup === undefined ? {} : { partGroup }),
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
/** 指定コンテナへキーを追加する。 */
|
||||
export function addKeyToParent(
|
||||
f: PhyFile,
|
||||
parentId: string | null,
|
||||
at: Transform,
|
||||
size: Size,
|
||||
profile = f.profiles[0]!.id,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||
return o;
|
||||
}
|
||||
|
||||
/** 指定コンテナへ部品を追加する。 */
|
||||
export function addComponentToParent(
|
||||
f: PhyFile,
|
||||
parentId: string | null,
|
||||
at: Transform,
|
||||
partGroup?: string,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.push({
|
||||
type: "component",
|
||||
id: nodeId("component", gen),
|
||||
transform: { ...at },
|
||||
...(partGroup ? { partGroup } : {}),
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
/** root / cluster / variant の指定先にノード群を挿入する。 */
|
||||
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
target = parentNodes(o, parentId);
|
||||
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
|
||||
return o;
|
||||
}
|
||||
|
||||
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
||||
if (!parentId) return f.root;
|
||||
// variantのIDは containers に子の入れ物として載るので、find ではノードとして引けない
|
||||
const n = containers(f)
|
||||
.get(parentId)
|
||||
?.nodes.find((x) => x.id === parentId);
|
||||
if (n?.type === "cluster") return n.children;
|
||||
if (n?.type === "variantGroup") throw new Error("Choose a variant as the parent");
|
||||
return findVariant(f, parentId).children;
|
||||
}
|
||||
/** ローカル座標の差分でノードを移動する。 */
|
||||
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
n.transform.x += dx;
|
||||
n.transform.y += dy;
|
||||
return o;
|
||||
}
|
||||
/** ノードを回転する。 */
|
||||
export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
n.transform.rotation += delta;
|
||||
return o;
|
||||
}
|
||||
/** キーのサイズを変更する。 */
|
||||
export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
if (n.type !== "key") throw new Error(`${id} is not a key`);
|
||||
n.size = { ...size };
|
||||
return o;
|
||||
}
|
||||
/** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */
|
||||
export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile {
|
||||
if (ids.length < 1) throw new Error("Select at least one node");
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
first = cs.get(ids[0]!);
|
||||
if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes))
|
||||
throw new Error("Selected nodes must be siblings");
|
||||
const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!);
|
||||
if (selected.some((n) => !n)) throw new Error("Unknown selection");
|
||||
const angles = selected.map((n) => world(o, n.id));
|
||||
const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation);
|
||||
const angle = selectedAngles[0]!;
|
||||
const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9);
|
||||
const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }),
|
||||
basisInverse = inverse(basis);
|
||||
const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!));
|
||||
if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f }));
|
||||
const localOrigins = shapePoints.map((p) => apply(basisInverse, p));
|
||||
const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) };
|
||||
const origin = apply(basis, min);
|
||||
const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 });
|
||||
const local = compose(inverse(first.parentWorld), wt);
|
||||
const id = nodeId("cluster", gen),
|
||||
cluster: ClusterNode = {
|
||||
type: "cluster",
|
||||
id,
|
||||
name,
|
||||
transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation },
|
||||
children: [],
|
||||
};
|
||||
const inv = inverse(wt);
|
||||
cluster.children = selected.map((n) => {
|
||||
const w = world(o, n.id),
|
||||
rel = compose(inv, w);
|
||||
return {
|
||||
...n,
|
||||
transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) },
|
||||
} as Node;
|
||||
});
|
||||
const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b);
|
||||
const insert = indices[0]!;
|
||||
first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id)));
|
||||
first.nodes.splice(insert, 0, cluster);
|
||||
return o;
|
||||
}
|
||||
/** clusterの子を同じ親へ戻し、絶対変換を保つ。 */
|
||||
export function uncluster(f: PhyFile, id: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
c = cs.get(id),
|
||||
cluster = c?.nodes.find((n) => n.id === id);
|
||||
if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||
const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation;
|
||||
const cw = compose(c!.parentWorld, matrix(cluster.transform)),
|
||||
converted = cluster.children.map((n) => {
|
||||
const w = compose(cw, matrix(n.transform)),
|
||||
rel = compose(inverse(c!.parentWorld), w);
|
||||
return {
|
||||
...n,
|
||||
transform: {
|
||||
x: rel.e,
|
||||
y: rel.f,
|
||||
rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation,
|
||||
},
|
||||
} as Node;
|
||||
});
|
||||
const index = c!.nodes.indexOf(cluster);
|
||||
c!.nodes.splice(index, 1, ...converted);
|
||||
return o;
|
||||
}
|
||||
/** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */
|
||||
export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
src = cs.get(id),
|
||||
n = src?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
const oldW = world(o, id),
|
||||
oldRotation = worldRotation(o, id);
|
||||
let target: Node[] = o.root,
|
||||
parentW = ident,
|
||||
parentRotation = 0;
|
||||
if (newParentId) {
|
||||
const p = find(o, newParentId);
|
||||
if (p.type !== "cluster")
|
||||
throw new Error("Target parent must be a cluster; use reparentToVariant for variant children");
|
||||
if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant");
|
||||
target = p.children;
|
||||
parentW = world(o, p.id);
|
||||
parentRotation = worldRotation(o, p.id);
|
||||
}
|
||||
const oldIndex = src!.nodes.indexOf(n),
|
||||
sameParent = target === src!.nodes;
|
||||
src!.nodes.splice(oldIndex, 1);
|
||||
const rel = compose(inverse(parentW), oldW);
|
||||
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||
let at = index ?? target.length;
|
||||
if (sameParent && index !== undefined && oldIndex < index) at--;
|
||||
target.splice(Math.max(0, Math.min(at, target.length)), 0, n);
|
||||
return o;
|
||||
}
|
||||
/** variant内へ移動するreparent。T-27。 */
|
||||
export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
src = cs.get(id),
|
||||
n = src?.nodes.find((x) => x.id === id);
|
||||
const v = findVariant(o, variantId);
|
||||
if (!n || !src) throw new Error(`Unknown node: ${id}`);
|
||||
if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant");
|
||||
const old = world(o, id),
|
||||
oldRotation = worldRotation(o, id),
|
||||
parent = srcVariantWorld(o, variantId),
|
||||
rel = compose(inverse(parent), old);
|
||||
const parentRotation = srcVariantRotation(o, variantId);
|
||||
src.nodes.splice(src.nodes.indexOf(n), 1);
|
||||
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||
v.children.splice(index ?? v.children.length, 0, n);
|
||||
return o;
|
||||
}
|
||||
function containsNode(n: Node, id: string): boolean {
|
||||
if (n.id === id) return true;
|
||||
if (n.type === "cluster") return n.children.some((c) => containsNode(c, id));
|
||||
if (n.type === "variantGroup")
|
||||
return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id)));
|
||||
return false;
|
||||
}
|
||||
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
|
||||
if (n.type === "key") {
|
||||
return keyCorners(n.size).map((point) => apply(worldMatrix, point));
|
||||
}
|
||||
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||
if (n.type === "cluster")
|
||||
return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))));
|
||||
const selected = n.children.find((variant) => variant.id === n.default);
|
||||
return selected
|
||||
? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))))
|
||||
: [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||
}
|
||||
function findVariant(f: PhyFile, id: string): Variant {
|
||||
for (const n of f.root) {
|
||||
const r = walk(n, id);
|
||||
if (r) return r;
|
||||
}
|
||||
throw new Error(`Unknown variant: ${id}`);
|
||||
}
|
||||
function walk(n: Node, id: string): Variant | undefined {
|
||||
if (n.type === "variantGroup") {
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return v;
|
||||
for (const c of v.children) {
|
||||
const r = walk(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === "cluster")
|
||||
for (const c of n.children) {
|
||||
const r = walk(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function srcVariantWorld(f: PhyFile, id: string): typeof ident {
|
||||
const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => {
|
||||
for (const n of ns) {
|
||||
const nw = compose(w, matrix(n.transform));
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return nw;
|
||||
const r = rec(v.children, nw);
|
||||
if (r) return r;
|
||||
}
|
||||
if (n.type === "cluster") {
|
||||
const r = rec(n.children, nw);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const x = rec(f.root, ident);
|
||||
if (!x) throw new Error(`Unknown variant ${id}`);
|
||||
return x;
|
||||
}
|
||||
function srcVariantRotation(f: PhyFile, id: string): number {
|
||||
const rec = (nodes: Node[], parentRotation: number): number | undefined => {
|
||||
for (const n of nodes) {
|
||||
const angle = parentRotation + n.transform.rotation;
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return angle;
|
||||
const found = rec(v.children, angle);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
if (n.type === "cluster") {
|
||||
const found = rec(n.children, angle);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = rec(f.root, 0);
|
||||
if (found === undefined) throw new Error(`Unknown variant ${id}`);
|
||||
return found;
|
||||
}
|
||||
/** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */
|
||||
export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
if (n.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||
const c = containers(o).get(id)!,
|
||||
oldRotation = c.parentRotation + n.transform.rotation,
|
||||
old = compose(c.parentWorld, matrix(n.transform)),
|
||||
next = compose(c.parentWorld, matrix(frame)),
|
||||
correction = compose(inverse(next), old);
|
||||
n.children = n.children.map((ch) => {
|
||||
const t = compose(correction, matrix(ch.transform));
|
||||
return {
|
||||
...ch,
|
||||
transform: {
|
||||
x: t.e,
|
||||
y: t.f,
|
||||
rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation),
|
||||
},
|
||||
} as Node;
|
||||
});
|
||||
n.transform = { ...frame };
|
||||
return o;
|
||||
}
|
||||
/** VariantGroupを作成する。T-11。 */
|
||||
export function createVariantGroup(
|
||||
f: PhyFile,
|
||||
name: string,
|
||||
at: Transform,
|
||||
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
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) => ({
|
||||
type: "variant" as const,
|
||||
id: nodeId("variant", gen),
|
||||
name: x.name,
|
||||
children: structuredClone(x.children ?? []),
|
||||
}));
|
||||
parentNodes(o, parentId).push({
|
||||
type: "variantGroup",
|
||||
id: nodeId("variantGroup", gen),
|
||||
name,
|
||||
transform: { ...at },
|
||||
default: children[0]!.id,
|
||||
children,
|
||||
});
|
||||
return o;
|
||||
}
|
||||
/** Variantを追加する。T-11。 */
|
||||
export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||
g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] });
|
||||
return o;
|
||||
}
|
||||
/** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */
|
||||
export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||
if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants");
|
||||
const i = g.children.findIndex((v) => v.id === variantId);
|
||||
if (i < 0) throw new Error("Unknown variant");
|
||||
g.children.splice(i, 1);
|
||||
if (g.default === variantId) g.default = g.children[0]!.id;
|
||||
return o;
|
||||
}
|
||||
/** default Variantを変更する。 */
|
||||
export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId))
|
||||
throw new Error("Invalid group or variant");
|
||||
g.default = variantId;
|
||||
return o;
|
||||
}
|
||||
/** ノードを削除する。 */
|
||||
export function deleteNode(f: PhyFile, id: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
c = containers(o).get(id);
|
||||
if (!c) throw new Error(`Unknown node: ${id}`);
|
||||
const i = c.nodes.findIndex((n) => n.id === id);
|
||||
c.nodes.splice(i, 1);
|
||||
return o;
|
||||
}
|
||||
/** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */
|
||||
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id),
|
||||
copy = cloneNodesWithNewIds([n], gen)[0]!;
|
||||
copy.transform.x += 0.25;
|
||||
copy.transform.y += 0.25;
|
||||
const c = containers(o).get(id)!;
|
||||
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
|
||||
return o;
|
||||
}
|
||||
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
|
||||
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
|
||||
const copies = structuredClone(nodes);
|
||||
const remap = new Map<string, string>();
|
||||
const renew = (x: Node) => {
|
||||
const oldId = x.id;
|
||||
x.id = nodeId(x.type, gen);
|
||||
remap.set(oldId, x.id);
|
||||
if (x.type === "cluster") x.children.forEach(renew);
|
||||
if (x.type === "variantGroup")
|
||||
for (const v of x.children) {
|
||||
const oldVariantId = v.id;
|
||||
v.id = nodeId("variant", gen);
|
||||
remap.set(oldVariantId, v.id);
|
||||
v.children.forEach(renew);
|
||||
}
|
||||
};
|
||||
copies.forEach(renew);
|
||||
const remapDefaults = (x: Node) => {
|
||||
if (x.type === "cluster") x.children.forEach(remapDefaults);
|
||||
if (x.type === "variantGroup") {
|
||||
x.default = remap.get(x.default) ?? x.default;
|
||||
x.children.forEach((v) => v.children.forEach(remapDefaults));
|
||||
}
|
||||
};
|
||||
copies.forEach(remapDefaults);
|
||||
return copies;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PhyFile } from "./phy";
|
||||
import type { PhyReference } from "./idx";
|
||||
import { sha256Jcs } from "../io/json";
|
||||
/** Computes the project-local PHY reference checksum. T-12. */
|
||||
export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise<PhyReference> {
|
||||
return { source, checksum: await sha256Jcs(phy) };
|
||||
}
|
||||
/** Checks whether a stored checksum matches PHY. T-12. */
|
||||
export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise<boolean> {
|
||||
return ref.checksum === (await sha256Jcs(phy));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PhyFile, Node } from "./phy";
|
||||
import { createId } from "./ids";
|
||||
import { importKle } from "../io/kleImport";
|
||||
import { sha256Jcs } from "../io/json";
|
||||
import { createIdx, type IdxFile } from "./idx";
|
||||
import { createElc, type ElcFile } from "./elc";
|
||||
import { createMap, type MapFile } from "./map";
|
||||
export type MatrixOutputSettings = {
|
||||
rowPins: string[];
|
||||
colPins: string[];
|
||||
kind?: "matrix" | "charlieplexing" | "direct";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ProjectFile = {
|
||||
format: "beyond-kle-mock-project";
|
||||
version: string;
|
||||
projectId: string;
|
||||
phy: PhyFile;
|
||||
elc: ElcFile;
|
||||
idx: IdxFile;
|
||||
map: MapFile;
|
||||
output: { variantSelection: Record<string, string>; matrix?: MatrixOutputSettings; [key: string]: unknown };
|
||||
ui?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Creates default variant selections from PHY. T-10/T-20. */
|
||||
export function defaultVariantSelection(phy: PhyFile): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children);
|
||||
if (n.type === "variantGroup") {
|
||||
out[n.id] = n.default;
|
||||
const v = n.children.find((x) => x.id === n.default);
|
||||
if (v) walk(v.children);
|
||||
}
|
||||
});
|
||||
walk(phy.root);
|
||||
return out;
|
||||
}
|
||||
/** Creates an empty project with default reference values. T-10/T-13. */
|
||||
export async function createProject(phy: PhyFile, gen?: () => string): Promise<ProjectFile> {
|
||||
const ref = { source: "project:phy", checksum: await sha256Jcs(phy) };
|
||||
return {
|
||||
format: "beyond-kle-mock-project",
|
||||
version: "0.1.0",
|
||||
projectId: createId("project-", gen),
|
||||
phy: structuredClone(phy),
|
||||
elc: createElc(ref),
|
||||
idx: createIdx(phy, [], ref),
|
||||
map: createMap(ref),
|
||||
output: { variantSelection: defaultVariantSelection(phy) },
|
||||
};
|
||||
}
|
||||
/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */
|
||||
export async function createProjectFromKle(
|
||||
input: unknown,
|
||||
options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {},
|
||||
): Promise<ProjectFile> {
|
||||
const { phy, keyOrder } = importKle(input, options);
|
||||
const p = await createProject(phy, options.projectIdGenerator);
|
||||
p.idx = createIdx(phy, keyOrder, p.idx.phyReference);
|
||||
return p;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import { flattenKeys } from "./geometry";
|
||||
import type { Node } from "./phy";
|
||||
import { eligibleKeyIds, unregisteredKeys } from "./idx";
|
||||
import { parseKeycode } from "./map";
|
||||
import { validateSchema } from "../io/validateSchema";
|
||||
export type ValidationIssue = {
|
||||
severity: "blocksSave" | "blocksOutput" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
ids?: string[];
|
||||
};
|
||||
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
|
||||
export function validateProject(p: ProjectFile): ValidationIssue[] {
|
||||
const out: ValidationIssue[] = [];
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||
for (const e of validateSchema(layer, p[layer]))
|
||||
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
|
||||
if (p.format !== "beyond-kle-mock-project")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "project-format",
|
||||
message: "Unsupported ProjectFile format",
|
||||
path: "/format",
|
||||
});
|
||||
if (p.version !== "0.1.0")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: "Unsupported ProjectFile version",
|
||||
path: "/version",
|
||||
});
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||
if (p[layer].version !== "0.1.0")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: `Unsupported ${layer} version`,
|
||||
path: `/${layer}/version`,
|
||||
});
|
||||
const ids = new Map<string, string[]>();
|
||||
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
|
||||
const walk = (ns: Node[], path: string) =>
|
||||
ns.forEach((n, i) => {
|
||||
const q = `${path}/${i}`;
|
||||
add(n.id, q);
|
||||
if (n.type === "cluster") walk(n.children, q + "/children");
|
||||
if (n.type === "variantGroup") {
|
||||
if (!n.children.some((v) => v.id === n.default))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-default-variant",
|
||||
message: "VariantGroup default does not reference a child",
|
||||
path: q + "/default",
|
||||
ids: [n.id, n.default],
|
||||
});
|
||||
for (const v of n.children) {
|
||||
add(v.id, q + "/children");
|
||||
walk(v.children, q + `/children/${v.id}/children`);
|
||||
}
|
||||
}
|
||||
});
|
||||
walk(p.phy.root, "/phy/root");
|
||||
for (const [id, paths] of ids)
|
||||
if (paths.length > 1)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-id",
|
||||
message: "Duplicate PHY node ID",
|
||||
ids: [id],
|
||||
path: paths[1],
|
||||
});
|
||||
const nodes = new Set(ids.keys()),
|
||||
keys = new Set(eligibleKeyIds(p.phy));
|
||||
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
|
||||
if (!valid.has(id))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-reference",
|
||||
message: `Unknown reference: ${id}`,
|
||||
path,
|
||||
ids: [id],
|
||||
});
|
||||
};
|
||||
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
|
||||
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
|
||||
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
|
||||
p.map.layers.forEach((l, i) =>
|
||||
l.keymap.forEach((a, j) => {
|
||||
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
|
||||
const keycode = parseKeycode(a.keycode);
|
||||
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-map-layer-reference",
|
||||
message: `Unknown MAP layer reference: ${keycode.layer}`,
|
||||
path: `/map/layers/${i}/keymap/${j}/keycode`,
|
||||
ids: [l.name, keycode.layer ?? ""],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const providerIds = new Map<string, number>();
|
||||
p.elc.providers.forEach((provider, index) => {
|
||||
const first = providerIds.get(provider.id);
|
||||
if (first !== undefined)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-provider-id",
|
||||
message: "Provider IDs must be unique",
|
||||
path: `/elc/providers/${index}/id`,
|
||||
ids: [provider.id],
|
||||
});
|
||||
else providerIds.set(provider.id, index);
|
||||
if (!provider.id || provider.id.includes("."))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "invalid-provider-id",
|
||||
message: "Provider IDs must be nonempty and cannot contain '.'",
|
||||
path: `/elc/providers/${index}/id`,
|
||||
ids: [provider.id],
|
||||
});
|
||||
});
|
||||
const chainIds = new Set<string>();
|
||||
p.elc.ledChains?.forEach((chain, index) => {
|
||||
if (chainIds.has(chain.id))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-led-chain-id",
|
||||
message: "LED chain IDs must be unique",
|
||||
path: `/elc/ledChains/${index}/id`,
|
||||
ids: [chain.id],
|
||||
});
|
||||
chainIds.add(chain.id);
|
||||
});
|
||||
const wiredKeys = new Set<string>();
|
||||
p.elc.keys.forEach((wiring, index) => {
|
||||
if (wiredKeys.has(wiring.key))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-key-wiring",
|
||||
message: "A key may have only one ELC wiring entry",
|
||||
path: `/elc/keys/${index}/key`,
|
||||
ids: [wiring.key],
|
||||
});
|
||||
wiredKeys.add(wiring.key);
|
||||
});
|
||||
|
||||
const validatePinRef = (pin: string, path: string) => {
|
||||
if (pin === "VCC" || pin === "GND") return;
|
||||
const parts = pin.split(".");
|
||||
const providerId = parts[0];
|
||||
const pinName = parts[1];
|
||||
if (parts.length !== 2 || !providerId || !pinName) {
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "invalid-pin-reference",
|
||||
message: `Malformed PinRef: ${pin}`,
|
||||
path,
|
||||
ids: [pin],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-pin-provider",
|
||||
message: `Unknown PinRef provider: ${providerId}`,
|
||||
path,
|
||||
ids: [providerId, pin],
|
||||
});
|
||||
return;
|
||||
}
|
||||
let valid = true;
|
||||
if (provider.kind === "analog-mux")
|
||||
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
|
||||
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
|
||||
if (!valid)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "unknown-provider-pin",
|
||||
message: `Unknown pin ${pinName} on provider ${providerId}`,
|
||||
path,
|
||||
ids: [providerId, pinName],
|
||||
});
|
||||
};
|
||||
p.elc.providers.forEach((provider, index) => {
|
||||
if (provider.kind === "analog-mux") {
|
||||
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
|
||||
validatePinRef(provider.common, `/elc/providers/${index}/common`);
|
||||
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
|
||||
}
|
||||
});
|
||||
p.elc.keys.forEach((wiring, index) =>
|
||||
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
|
||||
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
|
||||
),
|
||||
);
|
||||
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
|
||||
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
|
||||
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
|
||||
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
|
||||
const checkProfiles = (ns: Node[]) =>
|
||||
ns.forEach((n, i) => {
|
||||
if (n.type === "key" && !profileIds.has(n.profile))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-profile",
|
||||
message: `Unknown profile: ${n.profile}`,
|
||||
path: `/phy/key/${n.id}/profile`,
|
||||
ids: [n.id, n.profile],
|
||||
});
|
||||
if (n.type === "cluster") checkProfiles(n.children);
|
||||
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
|
||||
});
|
||||
checkProfiles(p.phy.root);
|
||||
for (const [i, pr] of p.phy.profiles.entries()) {
|
||||
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "positive-unit-size",
|
||||
message: "unitSize values must be positive",
|
||||
path: `/phy/profiles/${i}/unitSize`,
|
||||
});
|
||||
}
|
||||
const keyWalk = (ns: Node[]) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "positive-key-size",
|
||||
message: "Key size values must be positive",
|
||||
path: `/phy/key/${n.id}/size`,
|
||||
ids: [n.id],
|
||||
});
|
||||
if (n.type === "cluster") keyWalk(n.children);
|
||||
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
|
||||
});
|
||||
keyWalk(p.phy.root);
|
||||
const unreg = unregisteredKeys(p.phy, p.idx);
|
||||
for (const id of unreg)
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "idx-unregistered",
|
||||
message: "Eligible key is not in IDX order",
|
||||
ids: [id],
|
||||
});
|
||||
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
|
||||
if (new Set(profileSizes).size > 1)
|
||||
out.push({
|
||||
severity: "warning",
|
||||
code: "unit-size-mismatch",
|
||||
message: "Profile unitSize values differ; geometry editing is restricted",
|
||||
path: "/phy/profiles",
|
||||
});
|
||||
let active: string[] = [];
|
||||
try {
|
||||
active = flattenKeys(p.phy, p.output.variantSelection)
|
||||
.filter((x) => !x.key.decal)
|
||||
.map((x) => x.key.id);
|
||||
} catch (e) {
|
||||
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
|
||||
}
|
||||
for (const id of active) {
|
||||
if (!p.elc.keys.some((k) => k.key === id))
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unwired-key",
|
||||
message: "Selected key has no wiring",
|
||||
ids: [id],
|
||||
});
|
||||
for (const l of p.map.layers)
|
||||
if (!l.keymap.some((a) => a.keyId === id))
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unassigned-key",
|
||||
message: `Selected key is not assigned in layer ${l.name}`,
|
||||
ids: [id, l.name],
|
||||
});
|
||||
}
|
||||
const branches = new Map<string, Array<[string, string]>>();
|
||||
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "key") branches.set(n.id, branch);
|
||||
if (n.type === "cluster") exclusivity(n.children, branch);
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
|
||||
});
|
||||
exclusivity(p.phy.root, []);
|
||||
const mutuallyExclusive = (a: string, b: string) => {
|
||||
for (const [group, variant] of branches.get(a) ?? [])
|
||||
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
|
||||
return false;
|
||||
};
|
||||
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
|
||||
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
|
||||
for (let i = 0; i < contacts.length; i++)
|
||||
for (let j = i + 1; j < contacts.length; j++) {
|
||||
const a = contacts[i]!,
|
||||
b = contacts[j]!;
|
||||
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
|
||||
const ap = a.diode
|
||||
? a.diode === "a-to-b"
|
||||
? [a.terminals.a, a.terminals.b]
|
||||
: [a.terminals.b, a.terminals.a]
|
||||
: null;
|
||||
const bp = b.diode
|
||||
? b.diode === "a-to-b"
|
||||
? [b.terminals.a, b.terminals.b]
|
||||
: [b.terminals.b, b.terminals.a]
|
||||
: null;
|
||||
const same =
|
||||
ap && bp
|
||||
? ap[0] === bp[0] && ap[1] === bp[1]
|
||||
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
|
||||
if (same)
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "wiring-collision",
|
||||
message: "Non-exclusive keys share the same electrical connection",
|
||||
ids: [a.key, b.key],
|
||||
});
|
||||
}
|
||||
const layerNames = new Set<string>();
|
||||
for (const [i, l] of p.map.layers.entries()) {
|
||||
if (layerNames.has(l.name))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-layer-name",
|
||||
message: "Layer names must be unique",
|
||||
path: `/map/layers/${i}/name`,
|
||||
ids: [l.name],
|
||||
});
|
||||
layerNames.add(l.name);
|
||||
}
|
||||
for (const [li, l] of p.map.layers.entries()) {
|
||||
const assignments = new Map<string, number>();
|
||||
for (const [ai, a] of l.keymap.entries()) {
|
||||
const prev = assignments.get(a.keyId);
|
||||
if (prev !== undefined)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-map-key",
|
||||
message: "A key may occur only once in a layer keymap",
|
||||
path: `/map/layers/${li}/keymap/${ai}/keyId`,
|
||||
ids: [a.keyId, l.name],
|
||||
});
|
||||
assignments.set(a.keyId, ai);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** RFC 8785 JCS の UTF-8 対象文字列を生成する。T-12。 */
|
||||
export function canonicalize(value: unknown): string {
|
||||
return encode(value);
|
||||
}
|
||||
function encode(v: unknown): string {
|
||||
if (v === null) return "null";
|
||||
if (typeof v === "string") return JSON.stringify(v);
|
||||
if (typeof v === "boolean") return v ? "true" : "false";
|
||||
if (typeof v === "number") {
|
||||
if (!Number.isFinite(v)) throw new TypeError("JCS does not support non-finite numbers");
|
||||
return JSON.stringify(Object.is(v, -0) ? 0 : v);
|
||||
}
|
||||
if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`;
|
||||
if (typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
const keys = Object.keys(o).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${encode(o[k])}`).join(",")}}`;
|
||||
}
|
||||
throw new TypeError(`Unsupported JCS value: ${typeof v}`);
|
||||
}
|
||||
/** JCS正規化したUTF-8をSHA-256し、指定形式で返す。T-12。 */
|
||||
export async function sha256Jcs(value: unknown): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(canonicalize(value));
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||
return `sha256-jcs:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FlatKey, KleCompatibility, PhyFile } from "../domain/phy";
|
||||
import { flattenKeys } from "../domain/geometry";
|
||||
import { labelsToKle } from "./kleImport";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const defaults: KleCompatibility = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||
|
||||
/** PHYをバリアント選択に従ってKLE JSONへ平坦化する。座標は6桁丸め。T-20。 */
|
||||
export function exportKle(phy: PhyFile, variantSelection: Record<string, string> = {}): unknown[] {
|
||||
const keys = flattenKeys(phy, variantSelection);
|
||||
const order = [...keys].sort((a, b) => a.rotation - b.rotation || a.y - b.y || a.x - b.x);
|
||||
const centers = new Map<number, { x: number; y: number }>();
|
||||
for (const key of order) if (!centers.has(key.rotation)) centers.set(key.rotation, { x: key.x, y: key.y });
|
||||
const previous: KleCompatibility = { ...defaults };
|
||||
let previousGhost = false;
|
||||
const rows: unknown[] = [];
|
||||
|
||||
for (const flat of order) {
|
||||
const key = flat.key;
|
||||
const center = centers.get(flat.rotation)!;
|
||||
const rad = (flat.rotation * Math.PI) / 180;
|
||||
const dx = flat.x - center.x;
|
||||
const dy = flat.y - center.y;
|
||||
const localX = center.x + Math.cos(rad) * dx + Math.sin(rad) * dy;
|
||||
const localY = center.y - Math.sin(rad) * dx + Math.cos(rad) * dy;
|
||||
const compat = { ...defaults, ...(key.kleCompatibility ?? {}) };
|
||||
const cfg: Obj = {
|
||||
r: flat.rotation,
|
||||
rx: round(center.x),
|
||||
ry: round(center.y),
|
||||
x: round(localX - center.x),
|
||||
y: round(localY - center.y),
|
||||
};
|
||||
|
||||
for (const field of ["c", "t", "p", "a", "sm", "sb", "st"] as const) {
|
||||
const next = compat[field];
|
||||
if (!same(next, previous[field])) cfg[field] = structuredClone(next);
|
||||
previous[field] = structuredClone(next);
|
||||
}
|
||||
const oldOverride = previous.fa !== undefined ? "fa" : previous.f2 !== undefined ? "f2" : "none";
|
||||
const nextOverride = compat.fa !== undefined ? "fa" : compat.f2 !== undefined ? "f2" : "none";
|
||||
const fontChanged = !same(compat.f, previous.f);
|
||||
if (fontChanged || (nextOverride === "none" && oldOverride !== "none")) cfg.f = compat.f;
|
||||
if (nextOverride === "fa" && (oldOverride !== "fa" || !same(compat.fa, previous.fa)))
|
||||
cfg.fa = structuredClone(compat.fa);
|
||||
if (nextOverride === "f2" && (oldOverride !== "f2" || !same(compat.f2, previous.f2)))
|
||||
cfg.f2 = structuredClone(compat.f2);
|
||||
previous.f = structuredClone(compat.f);
|
||||
delete previous.f2;
|
||||
delete previous.fa;
|
||||
if (nextOverride === "fa") previous.fa = structuredClone(compat.fa);
|
||||
if (nextOverride === "f2") previous.f2 = structuredClone(compat.f2);
|
||||
|
||||
if (key.ghost !== previousGhost) {
|
||||
cfg.g = key.ghost === true;
|
||||
previousGhost = key.ghost === true;
|
||||
}
|
||||
if (key.decal) cfg.d = true;
|
||||
if (compat.l === true) cfg.l = true;
|
||||
if (compat.n === true) cfg.n = true;
|
||||
|
||||
const w = key.size.w;
|
||||
const h = key.size.h;
|
||||
if (w !== 1) cfg.w = round(w);
|
||||
if (h !== 1) cfg.h = round(h);
|
||||
if (key.size.x2 !== undefined && key.size.x2 !== 0) cfg.x2 = round(key.size.x2);
|
||||
if (key.size.y2 !== undefined && key.size.y2 !== 0) cfg.y2 = round(key.size.y2);
|
||||
const w2 = key.size.w2 ?? w;
|
||||
const h2 = key.size.h2 ?? h;
|
||||
if (w2 !== w) cfg.w2 = round(w2);
|
||||
if (h2 !== h) cfg.h2 = round(h2);
|
||||
|
||||
rows.push([cfg, labelsToKle(key.labels, typeof compat.a === "number" ? compat.a : 4)]);
|
||||
}
|
||||
if (phy.kleCompatibility) rows.unshift(structuredClone(phy.kleCompatibility));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** KLE JSON文字列を生成する。 */
|
||||
export function stringifyKle(phy: PhyFile, selection: Record<string, string> = {}): string {
|
||||
return JSON.stringify(exportKle(phy, selection));
|
||||
}
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { KleCompatibility, KeyNode, PhyFile } from "../domain/phy";
|
||||
import { createNodeId } from "../domain/ids";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
type Atom = string | Obj;
|
||||
|
||||
// Output slot -> newline-field index. These fixed tables reproduce KLE's a=0..7 layout.
|
||||
const labelMap: readonly (readonly (number | null)[])[] = [
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
|
||||
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
|
||||
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
|
||||
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
|
||||
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
|
||||
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
|
||||
[null, null, null, null, 0, null, null, null, null, null, 4, null],
|
||||
];
|
||||
|
||||
const fileDefaults: Obj = {
|
||||
backcolor: "#eeeeee",
|
||||
name: "",
|
||||
author: "",
|
||||
notes: "",
|
||||
background: null,
|
||||
radii: "",
|
||||
switchMount: "",
|
||||
switchBrand: "",
|
||||
switchType: "",
|
||||
};
|
||||
const keyDefaults: Obj = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||
|
||||
function isObj(value: unknown): value is Obj {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function num(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
function labelsFromKle(serialized: string, alignment: number): string[] {
|
||||
const fields = serialized.split("\n");
|
||||
if (fields.length > 12) throw new Error("A KLE label cannot exceed 12 slots");
|
||||
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||
const labels = Array<string>(12).fill("");
|
||||
map.forEach((field, slot) => {
|
||||
if (field !== null && field < fields.length) labels[slot] = fields[field]!;
|
||||
});
|
||||
while (labels.at(-1) === "") labels.pop();
|
||||
return labels;
|
||||
}
|
||||
function oneShotArray(state: Obj, key: string): void {
|
||||
delete state[key];
|
||||
}
|
||||
|
||||
/** KLEの厳密JSON(またはVIA layouts.keymap)を新規PHYへ取り込む。T-7/T-18/T-20。 */
|
||||
export function importKle(
|
||||
input: unknown,
|
||||
options: { idGenerator?: () => string; name?: string } = {},
|
||||
): { phy: PhyFile; keyOrder: string[] } {
|
||||
let data: unknown = input;
|
||||
if (isObj(data)) {
|
||||
const layouts = data.layouts;
|
||||
if (isObj(layouts) && Array.isArray(layouts.keymap)) data = layouts.keymap;
|
||||
else throw new Error("Expected KLE array or VIA layouts.keymap");
|
||||
}
|
||||
if (!Array.isArray(data)) throw new Error("KLE input must be a JSON array");
|
||||
const rows = [...data] as unknown[];
|
||||
let rawMeta: Obj = {};
|
||||
if (rows.length && isObj(rows[0])) rawMeta = rows.shift() as Obj;
|
||||
|
||||
const gen = options.idGenerator ?? (() => globalThis.crypto.randomUUID());
|
||||
const state: Obj = { ...keyDefaults };
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rotation = 0;
|
||||
let rotationX = 0;
|
||||
let rotationY = 0;
|
||||
const root: KeyNode[] = [];
|
||||
const keyOrder: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!Array.isArray(row)) throw new Error("KLE rows must be arrays");
|
||||
cursorX = rotationX;
|
||||
for (const atom of row as Atom[]) {
|
||||
if (typeof atom === "string") {
|
||||
const w = num(state.w, 1);
|
||||
const h = num(state.h, 1);
|
||||
cursorX += num(state.x, 0);
|
||||
cursorY += num(state.y, 0);
|
||||
const radians = (rotation * Math.PI) / 180;
|
||||
const dx = cursorX - rotationX;
|
||||
const dy = cursorY - rotationY;
|
||||
const x = rotationX + Math.cos(radians) * dx - Math.sin(radians) * dy;
|
||||
const y = rotationY + Math.sin(radians) * dx + Math.cos(radians) * dy;
|
||||
const a = num(state.a, 4);
|
||||
const id = createNodeId("key", gen);
|
||||
const labels = labelsFromKle(atom, a);
|
||||
const compatibility: KleCompatibility = {
|
||||
c: structuredClone(state.c),
|
||||
t: structuredClone(state.t),
|
||||
p: structuredClone(state.p),
|
||||
a,
|
||||
f: num(state.f, 3),
|
||||
...(state.f2 === undefined ? {} : { f2: structuredClone(state.f2) }),
|
||||
...(state.fa === undefined ? {} : { fa: structuredClone(state.fa) }),
|
||||
l: state.l === true,
|
||||
n: state.n === true,
|
||||
sm: structuredClone(state.sm),
|
||||
sb: structuredClone(state.sb),
|
||||
st: structuredClone(state.st),
|
||||
};
|
||||
const w2 = num(state.w2, 0) || w;
|
||||
const h2 = num(state.h2, 0) || h;
|
||||
const key: KeyNode = {
|
||||
type: "key",
|
||||
id,
|
||||
profile: "default",
|
||||
transform: { x, y, rotation },
|
||||
size: {
|
||||
w,
|
||||
h,
|
||||
...(num(state.x2, 0) === 0 ? {} : { x2: num(state.x2, 0) }),
|
||||
...(num(state.y2, 0) === 0 ? {} : { y2: num(state.y2, 0) }),
|
||||
...(w2 === w ? {} : { w2 }),
|
||||
...(h2 === h ? {} : { h2 }),
|
||||
},
|
||||
labels,
|
||||
kleCompatibility: compatibility,
|
||||
};
|
||||
if (state.d === true) key.decal = true;
|
||||
if (state.g === true) key.ghost = true;
|
||||
root.push(key);
|
||||
if (!key.decal) keyOrder.push(id);
|
||||
|
||||
cursorX += w;
|
||||
for (const field of ["w", "h", "x", "y", "x2", "y2", "w2", "h2", "d", "l", "n"])
|
||||
oneShotArray(state, field);
|
||||
} else if (isObj(atom)) {
|
||||
for (const [key, value] of Object.entries(atom)) {
|
||||
if (key === "r") rotation = num(value, rotation);
|
||||
else if (key === "rx") {
|
||||
rotationX = num(value, rotationX);
|
||||
cursorX = rotationX;
|
||||
cursorY = rotationY;
|
||||
} else if (key === "ry") {
|
||||
rotationY = num(value, rotationY);
|
||||
cursorX = rotationX;
|
||||
cursorY = rotationY;
|
||||
} else if (key === "f") {
|
||||
state.f = value;
|
||||
delete state.f2;
|
||||
delete state.fa;
|
||||
} else if (key === "f2") {
|
||||
state.f2 = value;
|
||||
delete state.fa;
|
||||
} else if (key === "fa") {
|
||||
state.fa = structuredClone(value);
|
||||
delete state.f2;
|
||||
} else state[key] = structuredClone(value);
|
||||
}
|
||||
} else throw new Error("KLE row entries must be strings or objects");
|
||||
}
|
||||
cursorY += 1;
|
||||
}
|
||||
|
||||
const metadata = { ...fileDefaults, ...structuredClone(rawMeta) };
|
||||
const phy: PhyFile = {
|
||||
version: "0.1.0",
|
||||
metadata: { ...(options.name ? { name: options.name } : {}) },
|
||||
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||
root,
|
||||
kleCompatibility: metadata,
|
||||
};
|
||||
return { phy, keyOrder };
|
||||
}
|
||||
|
||||
/** JSON文字列を厳密にパースして KLE を取り込む。 */
|
||||
export function parseKleJson(
|
||||
json: string,
|
||||
options: { idGenerator?: () => string; name?: string } = {},
|
||||
): ReturnType<typeof importKle> {
|
||||
return importKle(JSON.parse(json) as unknown, options);
|
||||
}
|
||||
|
||||
/** 正準12欄のラベルをKLEのa別固定欄順に戻す。 */
|
||||
export function labelsToKle(labels: string[] | undefined, alignment: number): string {
|
||||
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||
const fields = Array<string>(12).fill("");
|
||||
map.forEach((sourceSlot, slot) => {
|
||||
if (sourceSlot !== null) fields[sourceSlot] = labels?.[slot] ?? "";
|
||||
});
|
||||
while (fields.at(-1) === "") fields.pop();
|
||||
return fields.join("\n");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
import { validateProject, type ValidationIssue } from "../domain/validate";
|
||||
import { makePhyReference, phyReferenceMatches } from "../domain/phyReference";
|
||||
import { validateSchema, type LayerName } from "./validateSchema";
|
||||
|
||||
export type ProjectReadResult = { project?: ProjectFile; errors: ValidationIssue[] };
|
||||
export type ProjectSaveResult = { json?: string; errors: ValidationIssue[] };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Validates project and output container shapes before semantic checks. T-10/T-16. */
|
||||
function validateProjectShape(value: unknown): ValidationIssue[] {
|
||||
const errors: ValidationIssue[] = [];
|
||||
if (!isRecord(value))
|
||||
return [{ severity: "blocksSave", code: "schema", message: "Expected a ProjectFile object", path: "/" }];
|
||||
const required = ["format", "version", "projectId", "phy", "idx", "elc", "map", "output"];
|
||||
for (const field of required)
|
||||
if (!(field in value))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: `Missing required ProjectFile field: ${field}`,
|
||||
path: `/${field}`,
|
||||
});
|
||||
if (value.format !== "beyond-kle-mock-project")
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Invalid ProjectFile format",
|
||||
path: "/format",
|
||||
});
|
||||
if (typeof value.version !== "string")
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/version" });
|
||||
if (typeof value.projectId !== "string")
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/projectId" });
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const) {
|
||||
if (!(layer in value)) continue;
|
||||
for (const issue of validateSchema(layer, value[layer]))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: issue.message,
|
||||
path: `/${layer}${issue.path === "/" ? "" : issue.path}`,
|
||||
});
|
||||
}
|
||||
const output = value.output;
|
||||
if (!isRecord(output)) {
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected an object", path: "/output" });
|
||||
} else {
|
||||
if (!isRecord(output.variantSelection))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an object of variant IDs",
|
||||
path: "/output/variantSelection",
|
||||
});
|
||||
else
|
||||
for (const [key, variant] of Object.entries(output.variantSelection))
|
||||
if (typeof variant !== "string")
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected a variant ID string",
|
||||
path: `/output/variantSelection/${key}`,
|
||||
});
|
||||
if (output.matrix !== undefined) {
|
||||
if (!isRecord(output.matrix))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an object",
|
||||
path: "/output/matrix",
|
||||
});
|
||||
else {
|
||||
for (const field of ["rowPins", "colPins"] as const) {
|
||||
const pins = output.matrix[field];
|
||||
if (!Array.isArray(pins) || pins.some((pin) => typeof pin !== "string"))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an array of pin references",
|
||||
path: `/output/matrix/${field}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
output.matrix.kind !== undefined &&
|
||||
!["matrix", "charlieplexing", "direct"].includes(String(output.matrix.kind))
|
||||
)
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Unknown output scan mode",
|
||||
path: "/output/matrix/kind",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Serializes a project and refreshes PHY references unless T-16 blocks saving. T-12/T-13. */
|
||||
export async function saveProject(project: ProjectFile, pretty = false): Promise<ProjectSaveResult> {
|
||||
const shapeErrors = validateProjectShape(project);
|
||||
if (shapeErrors.length) return { errors: shapeErrors };
|
||||
const errors = validateProject(project);
|
||||
if (errors.some((issue) => issue.severity === "blocksSave")) return { errors };
|
||||
|
||||
const p = structuredClone(project);
|
||||
const ref = await makePhyReference(p.phy);
|
||||
for (const layer of ["idx", "elc", "map"] as const)
|
||||
p[layer].phyReference = { ...p[layer].phyReference, ...ref };
|
||||
return { json: JSON.stringify(p, null, pretty ? 2 : undefined), errors };
|
||||
}
|
||||
|
||||
/** Parses a project, checking all schemas before semantic validation and retaining unknown fields. T-13/T-14. */
|
||||
export async function loadProject(json: string): Promise<ProjectReadResult> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(json) as unknown;
|
||||
} catch (e) {
|
||||
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||
}
|
||||
const errors = validateProjectShape(data);
|
||||
if (errors.length) return { errors };
|
||||
|
||||
const project = data as ProjectFile;
|
||||
const semantic = validateProject(project);
|
||||
const unsupportedVersion = semantic.filter((issue) => issue.code === "version");
|
||||
if (unsupportedVersion.length) return { errors: semantic };
|
||||
for (const layer of ["idx", "elc", "map"] as const)
|
||||
if (!(await phyReferenceMatches(project.phy, project[layer].phyReference)))
|
||||
semantic.push({
|
||||
severity: "warning",
|
||||
code: "phy-reference-mismatch",
|
||||
message: `${layer} PHY checksum does not match the current PHY`,
|
||||
path: `/${layer}/phyReference`,
|
||||
});
|
||||
return { project, errors: semantic };
|
||||
}
|
||||
|
||||
/** Writes a single layer as JSON. */
|
||||
export function writeLayer(layer: LayerName, value: unknown, pretty = false): string {
|
||||
const errors = validateSchema(layer, value);
|
||||
if (errors.length) throw new Error(errors.map((issue) => `${issue.path} ${issue.message}`).join("; "));
|
||||
return JSON.stringify(value, null, pretty ? 2 : undefined);
|
||||
}
|
||||
|
||||
/** Reads and schema-validates one layer file. */
|
||||
export function readLayer(layer: LayerName, json: string): { value?: unknown; errors: ValidationIssue[] } {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(json) as unknown;
|
||||
} catch (e) {
|
||||
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||
}
|
||||
const errors = validateSchema(layer, value).map((issue) => ({
|
||||
severity: "blocksSave" as const,
|
||||
code: "schema",
|
||||
message: issue.message,
|
||||
path: issue.path,
|
||||
}));
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"version" in value &&
|
||||
(value as { version?: unknown }).version !== "0.1.0"
|
||||
)
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: "Unsupported layer version",
|
||||
path: "/version",
|
||||
});
|
||||
return errors.length ? { errors } : { value, errors: [] };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/elc-layer.json",
|
||||
"title": "ELC Layer - Electrical Wiring",
|
||||
"description": "Wiring between provider pins and key terminals",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "providers", "keys"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/provider" }
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/keyWiring" }
|
||||
},
|
||||
"ledChains": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/ledChain" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"pinRef": {
|
||||
"type": "string",
|
||||
"pattern": "^(VCC|GND|[^.]+\\.[^.]+)$",
|
||||
"description": "\"<provider id>.<pin name>\", or the reserved power rails VCC / GND"
|
||||
},
|
||||
"provider": {
|
||||
"oneOf": [{ "$ref": "#/$defs/mcuProvider" }, { "$ref": "#/$defs/analogMuxProvider" }]
|
||||
},
|
||||
"mcuProvider": {
|
||||
"type": "object",
|
||||
"required": ["id", "kind", "part"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"kind": { "const": "mcu" },
|
||||
"part": {
|
||||
"type": "string",
|
||||
"description": "Part number. Used to interpret pin names"
|
||||
}
|
||||
}
|
||||
},
|
||||
"analogMuxProvider": {
|
||||
"type": "object",
|
||||
"required": ["id", "kind", "channels", "select", "common"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"kind": { "const": "analog-mux" },
|
||||
"channels": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Number of logical pins provided. Pin names are \"0\"..\"channels-1\""
|
||||
},
|
||||
"select": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/pinRef" },
|
||||
"description": "Select pins, least significant bit first"
|
||||
},
|
||||
"common": { "$ref": "#/$defs/pinRef" },
|
||||
"enable": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
},
|
||||
"keyWiring": {
|
||||
"oneOf": [{ "$ref": "#/$defs/contactKey" }, { "$ref": "#/$defs/analogKey" }]
|
||||
},
|
||||
"contactKey": {
|
||||
"type": "object",
|
||||
"required": ["key", "detect", "terminals"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"detect": { "const": "contact" },
|
||||
"terminals": {
|
||||
"type": "object",
|
||||
"description": "Non-polar. a and b carry no ordering meaning",
|
||||
"required": ["a", "b"],
|
||||
"properties": {
|
||||
"a": { "$ref": "#/$defs/pinRef" },
|
||||
"b": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
},
|
||||
"diode": {
|
||||
"type": "string",
|
||||
"enum": ["a-to-b", "b-to-a"],
|
||||
"description": "Anode side comes first. Omitted means no diode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"analogKey": {
|
||||
"type": "object",
|
||||
"required": ["key", "detect", "terminals"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"detect": { "const": "analog" },
|
||||
"terminals": {
|
||||
"type": "object",
|
||||
"required": ["out", "vcc", "gnd"],
|
||||
"properties": {
|
||||
"out": { "$ref": "#/$defs/pinRef" },
|
||||
"vcc": { "$ref": "#/$defs/pinRef" },
|
||||
"gnd": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledChain": {
|
||||
"type": "object",
|
||||
"required": ["id", "data", "order"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"data": { "$ref": "#/$defs/pinRef" },
|
||||
"order": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "PHY node IDs. The position in the array is the LED number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/idx-layer.json",
|
||||
"title": "IDX Layer - Key Identification",
|
||||
"description": "Ordered PHY key IDs across all variants. Output key numbers are contiguous positions after filtering by variant selection",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "order"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"description": "Each PHY key has its own entry, including mutually exclusive keys wired to the same terminals. Variant selection does not modify this list",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/map-layer.json",
|
||||
"title": "MAP Layer - Keymap Configuration",
|
||||
"description": "Key code assignments and layers",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "layers"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"layers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/layer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"layer": {
|
||||
"type": "object",
|
||||
"required": ["name", "keymap"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Layer identifier, unique within the MAP. Layer order is the array order"
|
||||
},
|
||||
"keymap": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/keyAssignment"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyAssignment": {
|
||||
"type": "object",
|
||||
"required": ["keyId", "keycode"],
|
||||
"properties": {
|
||||
"keyId": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"keycode": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/phy-layer.json",
|
||||
"title": "PHY Layer - Physical Key Layout",
|
||||
"description": "Physical positioning and sizing of keys",
|
||||
"type": "object",
|
||||
"required": ["version", "metadata", "profiles", "root"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Schema version (semver)"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"created": { "type": "string", "format": "date-time" },
|
||||
"modified": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"kleCompatibility": {
|
||||
"type": "object",
|
||||
"description": "File-level opaque KLE fields kept for round-trip output (switchMount, switchBrand, switchType, backcolor, radii, background, css). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profile"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"description": "Placement relative to the parent: rotate by rotation around the local origin, then translate by (x, y)",
|
||||
"required": ["x", "y", "rotation"],
|
||||
"properties": {
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"rotation": { "type": "number", "description": "Degrees" }
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "unitSize"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"unitSize": {
|
||||
"type": "object",
|
||||
"description": "Millimeters per 1U",
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "number", "minimum": 0 },
|
||||
"y": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"visualOffset": {
|
||||
"type": "object",
|
||||
"description": "Key top rectangle, in millimeters: inward distance from each edge of the outline, plus corner radius",
|
||||
"required": ["top", "right", "bottom", "left", "radius"],
|
||||
"properties": {
|
||||
"top": { "type": "number" },
|
||||
"right": { "type": "number" },
|
||||
"bottom": { "type": "number" },
|
||||
"left": { "type": "number" },
|
||||
"radius": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"key": {
|
||||
"type": "object",
|
||||
"required": ["type", "id", "profile", "transform", "size"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "key"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique key identifier"
|
||||
},
|
||||
"profile": {
|
||||
"type": "string",
|
||||
"description": "Reference to profile ID"
|
||||
},
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"size": {
|
||||
"type": "object",
|
||||
"description": "Local size. Not affected by the parent transform",
|
||||
"required": ["w", "h"],
|
||||
"properties": {
|
||||
"w": { "type": "number", "minimum": 0 },
|
||||
"h": { "type": "number", "minimum": 0 },
|
||||
"x2": {
|
||||
"type": "number",
|
||||
"description": "Second rectangle for non-rectangular keys, relative to the local origin"
|
||||
},
|
||||
"y2": { "type": "number" },
|
||||
"w2": { "type": "number", "minimum": 0 },
|
||||
"h2": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"switchOffset": {
|
||||
"type": "object",
|
||||
"description": "Switch position, offset from the center of the (w, h) rectangle. Key local coordinates, unit U. Defaults to 0",
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"partGroup": {
|
||||
"type": "string",
|
||||
"description": "Label grouping keys that use the same part. Not interpreted by Beyond KLE"
|
||||
},
|
||||
"orientation": {
|
||||
"type": "integer",
|
||||
"enum": [0, 90, 180, 270],
|
||||
"default": 0,
|
||||
"description": "Switch orientation in the key local coordinates"
|
||||
},
|
||||
"perKeyLED": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether a per-key LED is mounted"
|
||||
},
|
||||
"decal": { "type": "boolean", "default": false },
|
||||
"ghost": { "type": "boolean", "default": false },
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Reference information. Used for matching on KLE JSON import"
|
||||
},
|
||||
"kleCompatibility": {
|
||||
"type": "object",
|
||||
"description": "Opaque KLE fields kept for round-trip output (c, t, p, a, f, f2, fa, l, n, sm, sb, st). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"type": "object",
|
||||
"description": "Non-key part placed by the designer (LED, OLED, encoder, screw hole, ...)",
|
||||
"required": ["type", "id", "transform"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "component"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"partGroup": {
|
||||
"type": "string",
|
||||
"description": "Names starting with @ are reserved for editor rendering"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variantGroup": {
|
||||
"type": "object",
|
||||
"description": "Mutually exclusive layout alternatives. Children are variants only",
|
||||
"required": ["type", "id", "name", "transform", "default", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "variantGroup"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Heading shown for the choice in the editor"
|
||||
},
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"default": {
|
||||
"type": "string",
|
||||
"description": "id of the variant used when a single layout must be chosen"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"description": "Display order",
|
||||
"items": { "$ref": "#/$defs/variant" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"variant": {
|
||||
"type": "object",
|
||||
"description": "One alternative. Appears only directly under a variantGroup. Carries no transform",
|
||||
"required": ["type", "id", "name", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "variant"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "May be empty",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster": {
|
||||
"type": "object",
|
||||
"required": ["type", "id", "transform", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "cluster"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Ajv2020 from "ajv/dist/2020";
|
||||
import addFormats from "ajv-formats";
|
||||
import phySchema from "./schemas/phy.schema.json";
|
||||
import idxSchema from "./schemas/idx.schema.json";
|
||||
import elcSchema from "./schemas/elc.schema.json";
|
||||
import mapSchema from "./schemas/map.schema.json";
|
||||
|
||||
export type LayerName = "phy" | "idx" | "elc" | "map";
|
||||
export type SchemaIssue = { path: string; message: string };
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
const validators = {
|
||||
phy: ajv.compile(phySchema),
|
||||
idx: ajv.compile(idxSchema),
|
||||
elc: ajv.compile(elcSchema),
|
||||
map: ajv.compile(mapSchema),
|
||||
};
|
||||
/** Validates one layer against its draft 2020-12 schema. */
|
||||
export function validateSchema(layer: LayerName, value: unknown): SchemaIssue[] {
|
||||
const valid = validators[layer](value);
|
||||
return valid
|
||||
? []
|
||||
: (validators[layer].errors ?? []).map((e) => ({
|
||||
path: `${e.instancePath || "/"}${e.params && "missingProperty" in e.params ? `/${e.params.missingProperty}` : ""}`,
|
||||
message: e.message ?? "Schema validation failed",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./ui/App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
|
||||
import { createProjectFromKle } from "../domain/project";
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
import { ProjectHistory } from "../domain/history";
|
||||
import { validateProject } from "../domain/validate";
|
||||
import { saveProject, loadProject } from "../io/projectFile";
|
||||
import { exportKle } from "../io/kleExport";
|
||||
import { defaultLayout } from "./defaultLayout";
|
||||
import type { Dispatch } from "./types";
|
||||
import PhyEditor from "./phy/PhyEditor.vue";
|
||||
import IdxEditor from "./idx/IdxEditor.vue";
|
||||
import ElcPanel from "./panels/ElcPanel.vue";
|
||||
import MapPanel from "./panels/MapPanel.vue";
|
||||
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
||||
const project = shallowRef<ProjectFile | null>(null),
|
||||
activeTab = ref("PHY"),
|
||||
errorText = ref("");
|
||||
const importInput = ref<HTMLInputElement>(),
|
||||
openInput = ref<HTMLInputElement>();
|
||||
let history: ProjectHistory | null = null;
|
||||
const epoch = ref(0);
|
||||
const mismatch = computed(
|
||||
() =>
|
||||
!!project.value &&
|
||||
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
|
||||
);
|
||||
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
|
||||
severity = ["blocksSave", "blocksOutput", "warning"] as const;
|
||||
const canUndo = computed(() => {
|
||||
epoch.value;
|
||||
return !!history?.canUndo;
|
||||
}),
|
||||
canRedo = computed(() => {
|
||||
epoch.value;
|
||||
return !!history?.canRedo;
|
||||
});
|
||||
const dispatch: Dispatch = (label, update) => {
|
||||
if (!project.value || mismatch.value) return;
|
||||
const next = update(project.value);
|
||||
project.value = next;
|
||||
history?.push(next);
|
||||
epoch.value++;
|
||||
errorText.value = "";
|
||||
};
|
||||
function beginHistory() {
|
||||
if (history) {
|
||||
history.begin();
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function commitHistory() {
|
||||
if (history && project.value) {
|
||||
history.commit(project.value);
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function undo() {
|
||||
if (history?.canUndo) {
|
||||
project.value = history.undo();
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function redo() {
|
||||
if (history?.canRedo) {
|
||||
project.value = history.redo();
|
||||
epoch.value++;
|
||||
}
|
||||
}
|
||||
function globalKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
e.shiftKey ? redo() : undo();
|
||||
} else if (mod && e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
} else if (mod && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
}
|
||||
async function initialize() {
|
||||
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
epoch.value++;
|
||||
}
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
window.addEventListener("keydown", globalKey);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", globalKey));
|
||||
async function readFile(file: File, mode: "kle" | "project") {
|
||||
try {
|
||||
const raw = JSON.parse(await file.text()) as unknown;
|
||||
let p: ProjectFile;
|
||||
if (mode === "kle") p = await createProjectFromKle(raw, { name: file.name });
|
||||
else {
|
||||
const r = await loadProject(JSON.stringify(raw));
|
||||
if (!r.project) throw Error(r.errors.map((x) => x.message).join("; "));
|
||||
p = r.project;
|
||||
errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
epoch.value++;
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function download(name: string, text: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
async function save() {
|
||||
if (!project.value) return;
|
||||
const r = await saveProject(project.value, true);
|
||||
if (r.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, r.json);
|
||||
else errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
function exportSelected() {
|
||||
if (project.value)
|
||||
download(
|
||||
"layout.kle.json",
|
||||
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main v-if="project" class="app-shell" :class="{ locked: mismatch }">
|
||||
<header class="toolbar">
|
||||
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
|
||||
><input
|
||||
ref="importInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
@change="
|
||||
(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) void readFile(f, 'kle');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
"
|
||||
/><button @click="openInput?.click()">プロジェクトを開く</button
|
||||
><input
|
||||
ref="openInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
@change="
|
||||
(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) void readFile(f, 'project');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
"
|
||||
/><button @click="save">プロジェクトを保存</button
|
||||
><button @click="exportSelected">KLE JSONを出力</button
|
||||
><button :disabled="!canUndo" @click="undo">Undo</button
|
||||
><button :disabled="!canRedo" @click="redo">Redo</button
|
||||
><span v-if="errorText" class="error">{{ errorText }}</span>
|
||||
</header>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
|
||||
:key="t"
|
||||
:class="{ active: activeTab === t }"
|
||||
@click="activeTab = t"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</nav>
|
||||
<PhyEditor
|
||||
v-if="activeTab === 'PHY'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
:mismatch="mismatch"
|
||||
:begin-history="beginHistory"
|
||||
:commit-history="commitHistory"
|
||||
@error="errorText = $event"
|
||||
/>
|
||||
<IdxEditor
|
||||
v-else-if="activeTab === 'IDX'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
@error="errorText = $event"
|
||||
/>
|
||||
<section v-else class="other-panel">
|
||||
<ElcPanel v-if="activeTab === 'ELC'" :project="project" :dispatch="dispatch" /><MapPanel
|
||||
v-else-if="activeTab === 'MAP'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
:editable="!mismatch"
|
||||
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
|
||||
</section>
|
||||
<aside class="validation pane">
|
||||
<h2>検証結果</h2>
|
||||
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
|
||||
保存できない状態です。blocksSave の問題を解消してください。
|
||||
</p>
|
||||
<div class="issues">
|
||||
<section v-for="s in severity" :key="s">
|
||||
<h3>
|
||||
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
|
||||
</h3>
|
||||
<div v-for="(issue, i) in issues.filter((x) => x.severity === s)" :key="i" class="issue">
|
||||
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
|
||||
</div>
|
||||
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
14px system-ui,
|
||||
sans-serif;
|
||||
color: #20242b;
|
||||
background: #edf0f4;
|
||||
}
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 52px 42px minmax(300px, 1fr) 180px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.toolbar,
|
||||
.tabs,
|
||||
.pane {
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px;
|
||||
}
|
||||
.toolbar strong {
|
||||
margin-right: auto;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
.tabs button.active {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.other-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
.validation {
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
}
|
||||
.validation h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.issues {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.issues section {
|
||||
min-width: 240px;
|
||||
}
|
||||
.issues h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.issue {
|
||||
margin: 3px 0;
|
||||
background: #f4f6f9;
|
||||
padding: 5px;
|
||||
}
|
||||
.issue small {
|
||||
display: block;
|
||||
color: #667;
|
||||
}
|
||||
.muted {
|
||||
color: #778;
|
||||
}
|
||||
.warning,
|
||||
.error {
|
||||
color: #9a4c00;
|
||||
}
|
||||
.error {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
// 選択構成の平坦な配置を読み取り専用で描く。IDX・ELC・MAP・Generatorのパネルで共有する。
|
||||
import { computed } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import type { FlatKey, PhyFile } from "../../domain/phy";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
phy: PhyFile;
|
||||
selection: Record<string, string>;
|
||||
/** キーの中央に出す文字列。省略時は先頭のラベル */
|
||||
keyText?: (keyId: string) => string;
|
||||
/** キーに付けるCSSクラス(状態の色分け) */
|
||||
keyClass?: (keyId: string) => string | undefined;
|
||||
selectedIds?: string[];
|
||||
unit?: number;
|
||||
}>(),
|
||||
{ selectedIds: () => [], unit: 48 },
|
||||
);
|
||||
const emit = defineEmits<{ keyClick: [keyId: string, event: MouseEvent] }>();
|
||||
|
||||
const keys = computed<FlatKey[]>(() => flattenKeys(props.phy, props.selection));
|
||||
|
||||
// 回転を含めた四隅から表示範囲を求める
|
||||
const viewBox = computed(() => {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const k of keys.value) {
|
||||
const r = (k.rotation * Math.PI) / 180,
|
||||
c = Math.cos(r),
|
||||
s = Math.sin(r);
|
||||
const { w, h } = k.key.size;
|
||||
for (const [lx, ly] of [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
] as const) {
|
||||
const x = k.x + lx * c - ly * s,
|
||||
y = k.y + lx * s + ly * c;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
if (!isFinite(minX)) return "0 0 4 2";
|
||||
const pad = 0.25;
|
||||
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
|
||||
});
|
||||
const width = computed(() => {
|
||||
const parts = viewBox.value.split(" ").map(Number);
|
||||
return (parts[2] ?? 4) * props.unit;
|
||||
});
|
||||
|
||||
function text(k: FlatKey): string {
|
||||
return props.keyText ? props.keyText(k.key.id) : (k.key.labels?.find((l) => l) ?? "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="keyboard-view" :viewBox="viewBox" :width="width" data-testid="keyboard-view">
|
||||
<g
|
||||
v-for="k in keys"
|
||||
:key="k.key.id"
|
||||
:transform="`translate(${k.x},${k.y}) rotate(${k.rotation})`"
|
||||
:class="[
|
||||
'kv-key',
|
||||
keyClass?.(k.key.id),
|
||||
{ selected: selectedIds.includes(k.key.id), decal: k.key.decal, ghost: k.key.ghost },
|
||||
]"
|
||||
:data-key-id="k.key.id"
|
||||
@click="emit('keyClick', k.key.id, $event)"
|
||||
>
|
||||
<rect x="0.03" y="0.03" :width="k.key.size.w - 0.06" :height="k.key.size.h - 0.06" rx="0.08" />
|
||||
<rect
|
||||
v-if="k.key.size.w2 !== undefined || k.key.size.h2 !== undefined"
|
||||
:x="(k.key.size.x2 ?? 0) + 0.03"
|
||||
:y="(k.key.size.y2 ?? 0) + 0.03"
|
||||
:width="(k.key.size.w2 ?? k.key.size.w) - 0.06"
|
||||
:height="(k.key.size.h2 ?? k.key.size.h) - 0.06"
|
||||
rx="0.08"
|
||||
/>
|
||||
<text :x="k.key.size.w / 2" :y="k.key.size.h / 2" text-anchor="middle" dominant-baseline="central">
|
||||
{{ text(k) }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keyboard-view {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
.kv-key {
|
||||
cursor: pointer;
|
||||
}
|
||||
.kv-key rect {
|
||||
fill: #f3f4f6;
|
||||
stroke: #6b7280;
|
||||
stroke-width: 0.02;
|
||||
}
|
||||
.kv-key text {
|
||||
font-size: 0.22px;
|
||||
fill: #111827;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kv-key.selected rect {
|
||||
stroke: #2563eb;
|
||||
stroke-width: 0.06;
|
||||
}
|
||||
.kv-key.decal rect {
|
||||
stroke-dasharray: 0.06 0.04;
|
||||
fill: none;
|
||||
}
|
||||
.kv-key.ghost {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.kv-key.warn rect {
|
||||
fill: #fde68a;
|
||||
}
|
||||
.kv-key.error rect {
|
||||
fill: #fecaca;
|
||||
}
|
||||
.kv-key.ok rect {
|
||||
fill: #d1fae5;
|
||||
}
|
||||
.kv-key.muted rect {
|
||||
fill: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Small 4x4 starter layout, kept in src/ui rather than the test fixtures. */
|
||||
export const defaultLayout: unknown[] = Array.from({ length: 4 }, () => ["", "", "", ""]);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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"];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user