Compare commits
6
Commits
0838487f4a
...
c250efd8ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c250efd8ae | ||
|
|
754b1d5d85 | ||
|
|
afd9af1914 | ||
|
|
3749f267b5 | ||
|
|
c180642fa8 | ||
|
|
2321c068f9 |
@@ -0,0 +1,85 @@
|
|||||||
|
# 二乗マトリクスの一般知識(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 キーボードの回路: <https://blog.ikejima.org/make/keyboard/2019/12/14/keyboard-circuit.html>
|
||||||
|
(二乗マトリクスの初出。ゴーストはダイオード2つ分なので部品の組み合わせに注意、1キー2ダイオードが要るかも、と書いている)
|
||||||
|
- ikeji 2022 変則マトリクスキーボード設計: <https://blog.ikejima.org/make/keyboard/2022/12/07/atypical-matrix-design.html>
|
||||||
|
- gachiham QMK 0.19.2で改良二乗マトリクス: <https://gachiham.hatenablog.com/entry/2022/12/17/000000>
|
||||||
|
(9ピン72キーから63キー。rows/colsに同じピンを並べ、MATRIX_MASKEDを使う)
|
||||||
|
- Cheena でかいキーボード設計法: <https://note.com/cheena/n/n846846c3efce>(低電力改良二乗マトリクス)
|
||||||
|
- ZMK kscan: <https://zmk.dev/docs/config/kscan>
|
||||||
|
- ZMK ドライバ: <https://raw.githubusercontent.com/zmkfirmware/zmk/main/app/module/drivers/kscan/kscan_gpio_charlieplex.c>
|
||||||
|
|
||||||
|
## 捨てた記述
|
||||||
|
|
||||||
|
- 二乗マトリクスを e3w2q 氏の発案とした記憶は誤り。初出は ikeji 2019。
|
||||||
+108
-20
@@ -1,41 +1,129 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const canvasKeys = (page: Page) => page.locator('[data-testid="idx-canvas"] .key');
|
||||||
|
const numberOf = (page: Page, i: number) => canvasKeys(page).nth(i).getByTestId("idx-number");
|
||||||
|
|
||||||
|
/** Drags key `from` onto key `to`, releasing at `fraction` of the target's width. */
|
||||||
|
async function dragKey(page: Page, from: number, to: number, fraction: number) {
|
||||||
|
const a = (await canvasKeys(page).nth(from).locator("rect").boundingBox())!;
|
||||||
|
const b = (await canvasKeys(page).nth(to).locator("rect").boundingBox())!;
|
||||||
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2, { steps: 3 });
|
||||||
|
await page.mouse.move(b.x + b.width * fraction, b.y + b.height / 2, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
|
await expect(canvasKeys(page)).toHaveCount(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => {
|
test("drops at the edge of a key insert before or after it, and undo restores", async ({ page }) => {
|
||||||
|
await dragKey(page, 0, 3, 0.9);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("3");
|
||||||
|
await expect(numberOf(page, 3)).toHaveText("2");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("0");
|
||||||
|
await dragKey(page, 0, 1, 0.1);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("3");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 3)).toHaveText("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a drop in the middle of a key swaps the two and leaves the others", async ({ page }) => {
|
||||||
|
await dragKey(page, 0, 5, 0.5);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("5");
|
||||||
|
await expect(numberOf(page, 5)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("1");
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key added in PHY is registered at the end and can be dragged to the front", async ({ page }) => {
|
||||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "キーを足す" }).click();
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id");
|
|
||||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
const row = page.getByTestId(`idx-row-${keyId}`);
|
await expect(numberOf(page, 16)).toHaveText("16");
|
||||||
await expect(row).toBeVisible();
|
await dragKey(page, 16, 0, 0.1);
|
||||||
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
|
await expect(numberOf(page, 16)).toHaveText("0");
|
||||||
await expect(previewKey).toHaveText("16");
|
await expect(numberOf(page, 0)).toHaveText("1");
|
||||||
|
|
||||||
await row.getByRole("button", { name: `${keyId}を上へ` }).click();
|
|
||||||
await expect(previewKey).toHaveText("15");
|
|
||||||
await page.getByRole("button", { name: "Undo" }).click();
|
|
||||||
await expect(previewKey).toHaveText("16");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({
|
test("the variant tree switches the shown configuration without touching other keys' order", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||||
const keys = page.locator('[data-node-type="key"]');
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
const firstId = await keys.nth(0).getAttribute("data-node-id");
|
const firstId = await keys.nth(0).getAttribute("data-node-id");
|
||||||
const secondId = await keys.nth(1).getAttribute("data-node-id");
|
|
||||||
await keys.nth(0).click();
|
await keys.nth(0).click();
|
||||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
|
const optionA = page.locator(".tree").getByRole("button", { name: /Option A/ });
|
||||||
await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" });
|
const optionB = page.locator(".tree").getByRole("button", { name: /Option B/ });
|
||||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
|
await expect(optionA).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
|
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(1);
|
||||||
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
|
await optionB.click();
|
||||||
await expect(remaining).toHaveCount(1);
|
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");
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-1
@@ -336,7 +336,8 @@ test("削除確認は参照を列挙し、実行後はIDXからも消える", as
|
|||||||
await confirm.getByRole("button", { name: "削除", exact: true }).click();
|
await confirm.getByRole("button", { name: "削除", exact: true }).click();
|
||||||
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
|
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0);
|
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("idx-register-all")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
|
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
|
||||||
@@ -740,3 +741,31 @@ test("スイッチ・スタビの十字はトグルで出し、スタビ候補
|
|||||||
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||||
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
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,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 } },
|
||||||
|
];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy";
|
import type { ClusterNode, KeyNode, Node, PhyFile, Size, Transform, Variant, VariantGroupNode } from "./phy";
|
||||||
import { apply, compose, inverse, matrix } from "./geometry";
|
import { apply, compose, inverse, matrix } from "./geometry";
|
||||||
import { createNodeId } from "./ids";
|
import { createNodeId } from "./ids";
|
||||||
|
import { keyCorners } from "./keyShape";
|
||||||
|
|
||||||
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||||
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
|
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
|
||||||
@@ -50,7 +51,7 @@ function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
|
|||||||
export function addKey(
|
export function addKey(
|
||||||
f: PhyFile,
|
f: PhyFile,
|
||||||
at: Transform,
|
at: Transform,
|
||||||
size: { w: number; h: number },
|
size: Size,
|
||||||
profile = f.profiles[0]!.id,
|
profile = f.profiles[0]!.id,
|
||||||
gen?: () => string,
|
gen?: () => string,
|
||||||
): PhyFile {
|
): PhyFile {
|
||||||
@@ -75,7 +76,7 @@ export function addKeyToParent(
|
|||||||
f: PhyFile,
|
f: PhyFile,
|
||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
at: Transform,
|
at: Transform,
|
||||||
size: { w: number; h: number },
|
size: Size,
|
||||||
profile = f.profiles[0]!.id,
|
profile = f.profiles[0]!.id,
|
||||||
gen?: () => string,
|
gen?: () => string,
|
||||||
): PhyFile {
|
): PhyFile {
|
||||||
@@ -275,25 +276,7 @@ function containsNode(n: Node, id: string): boolean {
|
|||||||
}
|
}
|
||||||
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
|
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
|
||||||
if (n.type === "key") {
|
if (n.type === "key") {
|
||||||
const points = [
|
return keyCorners(n.size).map((point) => apply(worldMatrix, point));
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: n.size.w, y: 0 },
|
|
||||||
{ x: n.size.w, y: n.size.h },
|
|
||||||
{ x: 0, y: n.size.h },
|
|
||||||
];
|
|
||||||
if (
|
|
||||||
n.size.w2 !== undefined ||
|
|
||||||
n.size.h2 !== undefined ||
|
|
||||||
n.size.x2 !== undefined ||
|
|
||||||
n.size.y2 !== undefined
|
|
||||||
) {
|
|
||||||
const x = n.size.x2 ?? 0;
|
|
||||||
const y = n.size.y2 ?? 0;
|
|
||||||
const w = n.size.w2 ?? n.size.w;
|
|
||||||
const h = n.size.h2 ?? n.size.h;
|
|
||||||
points.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h });
|
|
||||||
}
|
|
||||||
return points.map((point) => apply(worldMatrix, point));
|
|
||||||
}
|
}
|
||||||
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
|
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||||
if (n.type === "cluster")
|
if (n.type === "cluster")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Point, Size } from "./phy";
|
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. */
|
/** 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 };
|
export type StabilizerCandidate = { id: string; label: string; offset: number };
|
||||||
@@ -35,12 +36,9 @@ const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const EPS = 1e-9;
|
const EPS = 1e-9;
|
||||||
/** Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet. */
|
|
||||||
function irregular(size: Size): boolean {
|
|
||||||
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
|
|
||||||
}
|
|
||||||
export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
|
export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
|
||||||
if (irregular(size)) return [];
|
// Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet.
|
||||||
|
if (hasSecondRect(size)) return [];
|
||||||
const length = Math.max(size.w, size.h);
|
const length = Math.max(size.w, size.h);
|
||||||
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
|
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { flattenKeys } from "../domain/geometry";
|
||||||
|
import type { Placement } from "../domain/idxImport";
|
||||||
|
import { importKle } from "./kleImport";
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
const num = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback);
|
||||||
|
|
||||||
|
/** Result of reading an existing definition. `layouts` lists the choices when one must be picked. */
|
||||||
|
export type LayoutRead = { placements: Placement[]; layouts?: string[]; layout?: string };
|
||||||
|
|
||||||
|
/** QMK keyboard.json/info.json layout names, or undefined when the input is not a QMK definition. */
|
||||||
|
export function qmkLayoutNames(input: unknown): string[] | undefined {
|
||||||
|
if (!isObj(input) || !isObj(input.layouts) || Array.isArray(input.layouts.keymap)) return undefined;
|
||||||
|
const names = Object.entries(input.layouts)
|
||||||
|
.filter(([, v]) => isObj(v) && Array.isArray(v.layout))
|
||||||
|
.map(([name]) => name);
|
||||||
|
return names.length ? names : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads key placements, in definition order, from QMK `layouts.<NAME>.layout[]`, a VIA definition's
|
||||||
|
* `layouts.keymap`, or KLE JSON. KLE and VIA go through importKle so coordinates match the project.
|
||||||
|
* QMK r/rx/ry are read with the KLE meaning. Without `layout`, a QMK file with several layouts
|
||||||
|
* returns only the names. T-31.
|
||||||
|
*/
|
||||||
|
export function readLayoutPlacements(input: unknown, layout?: string): LayoutRead {
|
||||||
|
const names = qmkLayoutNames(input);
|
||||||
|
if (names) {
|
||||||
|
const name = layout ?? (names.length === 1 ? names[0] : undefined);
|
||||||
|
if (!name) return { placements: [], layouts: names };
|
||||||
|
const entries = ((input as { layouts: Obj }).layouts[name] as { layout: unknown[] }).layout;
|
||||||
|
return { placements: entries.filter(isObj).map(qmkPlacement), layouts: names, layout: name };
|
||||||
|
}
|
||||||
|
const { phy, keyOrder } = importKle(input);
|
||||||
|
const flat = new Map(flattenKeys(phy).map((k) => [k.key.id, k]));
|
||||||
|
const placements = keyOrder.flatMap((id) => {
|
||||||
|
const k = flat.get(id);
|
||||||
|
return k && !k.key.decal ? [{ x: k.x, y: k.y, rotation: k.rotation }] : [];
|
||||||
|
});
|
||||||
|
return { placements };
|
||||||
|
}
|
||||||
|
|
||||||
|
function qmkPlacement(e: Obj): Placement {
|
||||||
|
const x = num(e.x),
|
||||||
|
y = num(e.y),
|
||||||
|
r = num(e.r),
|
||||||
|
rx = num(e.rx),
|
||||||
|
ry = num(e.ry);
|
||||||
|
const a = (r * Math.PI) / 180,
|
||||||
|
c = Math.cos(a),
|
||||||
|
s = Math.sin(a);
|
||||||
|
return { x: rx + (x - rx) * c - (y - ry) * s, y: ry + (x - rx) * s + (y - ry) * c, rotation: r };
|
||||||
|
}
|
||||||
+8
-6
@@ -9,7 +9,7 @@ import { exportKle } from "../io/kleExport";
|
|||||||
import { defaultLayout } from "./defaultLayout";
|
import { defaultLayout } from "./defaultLayout";
|
||||||
import type { Dispatch } from "./types";
|
import type { Dispatch } from "./types";
|
||||||
import PhyEditor from "./phy/PhyEditor.vue";
|
import PhyEditor from "./phy/PhyEditor.vue";
|
||||||
import IdxPanel from "./panels/IdxPanel.vue";
|
import IdxEditor from "./idx/IdxEditor.vue";
|
||||||
import ElcPanel from "./panels/ElcPanel.vue";
|
import ElcPanel from "./panels/ElcPanel.vue";
|
||||||
import MapPanel from "./panels/MapPanel.vue";
|
import MapPanel from "./panels/MapPanel.vue";
|
||||||
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
||||||
@@ -186,12 +186,14 @@ function exportSelected() {
|
|||||||
:commit-history="commitHistory"
|
:commit-history="commitHistory"
|
||||||
@error="errorText = $event"
|
@error="errorText = $event"
|
||||||
/>
|
/>
|
||||||
|
<IdxEditor
|
||||||
|
v-else-if="activeTab === 'IDX'"
|
||||||
|
:project="project"
|
||||||
|
:dispatch="dispatch"
|
||||||
|
@error="errorText = $event"
|
||||||
|
/>
|
||||||
<section v-else class="other-panel">
|
<section v-else class="other-panel">
|
||||||
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
|
<ElcPanel v-if="activeTab === 'ELC'" :project="project" :dispatch="dispatch" /><MapPanel
|
||||||
v-else-if="activeTab === 'ELC'"
|
|
||||||
:project="project"
|
|
||||||
:dispatch="dispatch"
|
|
||||||
/><MapPanel
|
|
||||||
v-else-if="activeTab === 'MAP'"
|
v-else-if="activeTab === 'MAP'"
|
||||||
:project="project"
|
:project="project"
|
||||||
:dispatch="dispatch"
|
:dispatch="dispatch"
|
||||||
|
|||||||
@@ -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"];
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
import type { FlatKey } from "../../domain/phy";
|
||||||
|
import { dropAction, hitKey, keyCenter, type DropAction } from "./logic";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type Options = {
|
||||||
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
pan: Ref<Point>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
keys: () => FlatKey[];
|
||||||
|
registered: () => ReadonlySet<string>;
|
||||||
|
previewing: () => boolean;
|
||||||
|
onDrop: (source: string, action: DropAction) => void;
|
||||||
|
onPreviewPick: (id: string) => void;
|
||||||
|
};
|
||||||
|
/** Pointer travel (px) before a press on a key becomes a drag. */
|
||||||
|
const DRAG_START = 4;
|
||||||
|
|
||||||
|
/** Canvas pointer handling: click/Shift+click selection, dragging a key to reorder, range selection, pan. T-31. */
|
||||||
|
export function useIdxPointer(o: Options) {
|
||||||
|
const drag = ref<{ id: string; point: Point; action?: DropAction } | null>(null);
|
||||||
|
const band = ref<{ start: Point; end: Point } | null>(null);
|
||||||
|
const spaceHeld = ref(false);
|
||||||
|
let press: { id?: string; client: Point; add: boolean; moved: boolean; pan?: Point } | null = null;
|
||||||
|
|
||||||
|
function world(event: PointerEvent): Point {
|
||||||
|
const svg = o.svg.value!;
|
||||||
|
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM()!.inverse());
|
||||||
|
return { x: (local.x - o.pan.value.x) / o.zoom.value, y: (local.y - o.pan.value.y) / o.zoom.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function begin(event: PointerEvent, id?: string) {
|
||||||
|
const add = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||||
|
const panning = event.button === 1 || spaceHeld.value;
|
||||||
|
if (event.button !== 0 && !panning) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!panning && id && o.previewing()) {
|
||||||
|
o.onPreviewPick(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
o.svg.value?.setPointerCapture?.(event.pointerId);
|
||||||
|
press = {
|
||||||
|
id: panning ? undefined : id,
|
||||||
|
client: { x: event.clientX, y: event.clientY },
|
||||||
|
add,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
if (panning) press.pan = { ...o.pan.value };
|
||||||
|
else if (!id && !o.previewing()) band.value = { start: world(event), end: world(event) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent) {
|
||||||
|
if (!press) return;
|
||||||
|
const dx = event.clientX - press.client.x,
|
||||||
|
dy = event.clientY - press.client.y;
|
||||||
|
if (!press.moved && Math.hypot(dx, dy) < DRAG_START) return;
|
||||||
|
press.moved = true;
|
||||||
|
if (press.pan) {
|
||||||
|
// viewBox matches the SVG's pixel size, so client deltas are viewBox deltas
|
||||||
|
o.pan.value = { x: press.pan.x + dx, y: press.pan.y + dy };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = world(event);
|
||||||
|
if (press.id) {
|
||||||
|
const action = dropAction(press.id, hitKey(o.keys(), point), o.registered());
|
||||||
|
drag.value = { id: press.id, point, action };
|
||||||
|
} else if (band.value) band.value = { ...band.value, end: point };
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
|
if (!press) return;
|
||||||
|
const { id, add, moved } = press;
|
||||||
|
if (id && !moved) {
|
||||||
|
o.selected.value = add
|
||||||
|
? o.selected.value.includes(id)
|
||||||
|
? o.selected.value.filter((x) => x !== id)
|
||||||
|
: [...o.selected.value, id]
|
||||||
|
: [id];
|
||||||
|
} else if (id && drag.value?.action) o.onDrop(id, drag.value.action);
|
||||||
|
else if (!id && !press.pan) {
|
||||||
|
const b = band.value;
|
||||||
|
if (!moved || !b) {
|
||||||
|
if (!add) o.selected.value = [];
|
||||||
|
} else {
|
||||||
|
const [x0, x1] = [Math.min(b.start.x, b.end.x), Math.max(b.start.x, b.end.x)];
|
||||||
|
const [y0, y1] = [Math.min(b.start.y, b.end.y), Math.max(b.start.y, b.end.y)];
|
||||||
|
const inside = o
|
||||||
|
.keys()
|
||||||
|
.filter((k) => {
|
||||||
|
const c = keyCenter(k);
|
||||||
|
return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1;
|
||||||
|
})
|
||||||
|
.map((k) => k.key.id);
|
||||||
|
o.selected.value = add ? [...new Set([...o.selected.value, ...inside])] : inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
press = null;
|
||||||
|
drag.value = null;
|
||||||
|
band.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { drag, band, spaceHeld, begin, move, end, cancel };
|
||||||
|
}
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from "vue";
|
|
||||||
import { flattenKeys } from "../../domain/geometry";
|
|
||||||
import type { Node, VariantGroupNode } from "../../domain/phy";
|
|
||||||
import { appendKeys, excludeKey, reorderKeys, unregisteredKeys } from "../../domain/idx";
|
|
||||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
|
||||||
import type { Dispatch } from "../types";
|
|
||||||
import KeyboardView from "../common/KeyboardView.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
|
||||||
const selectedKey = ref("");
|
|
||||||
const selection = computed(() => ({
|
|
||||||
...defaultVariantSelection(props.project.phy),
|
|
||||||
...props.project.output.variantSelection,
|
|
||||||
}));
|
|
||||||
const groups = computed(() => {
|
|
||||||
const out: VariantGroupNode[] = [];
|
|
||||||
const walk = (nodes: Node[]) =>
|
|
||||||
nodes.forEach((n) => {
|
|
||||||
if (n.type === "cluster") walk(n.children);
|
|
||||||
if (n.type === "variantGroup") {
|
|
||||||
out.push(n);
|
|
||||||
n.children.forEach((v) => walk(v.children));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
walk(props.project.phy.root);
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
type Entry = { id: string; label: string; path: string; pathIds: string[] };
|
|
||||||
const entries = computed<Entry[]>(() => {
|
|
||||||
const out: Entry[] = [];
|
|
||||||
const walk = (nodes: Node[], path: string[]) =>
|
|
||||||
nodes.forEach((n) => {
|
|
||||||
if (n.type === "cluster") walk(n.children, [...path, n.name || n.id]);
|
|
||||||
else if (n.type === "key") {
|
|
||||||
if (!n.decal)
|
|
||||||
out.push({
|
|
||||||
id: n.id,
|
|
||||||
label: n.labels?.find(Boolean) || "(ラベルなし)",
|
|
||||||
path: path.join(" / ") || "ルート",
|
|
||||||
pathIds: path,
|
|
||||||
});
|
|
||||||
} else if (n.type === "variantGroup") {
|
|
||||||
n.children.forEach((v) => walk(v.children, [...path, `${n.name} / ${v.name}`]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
walk(props.project.phy.root, []);
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
const byId = computed(() => new Map(entries.value.map((e) => [e.id, e])));
|
|
||||||
const unregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
|
|
||||||
const displayKeys = computed(() =>
|
|
||||||
flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal),
|
|
||||||
);
|
|
||||||
const outputNumbers = computed(() => {
|
|
||||||
const active = new Set(displayKeys.value.map((k) => k.key.id));
|
|
||||||
return new Map(props.project.idx.order.filter((id) => active.has(id)).map((id, index) => [id, index]));
|
|
||||||
});
|
|
||||||
const rowEntries = computed(() =>
|
|
||||||
props.project.idx.order.map(
|
|
||||||
(id) => byId.value.get(id) ?? { id, label: "(PHYに存在しないキー)", path: "不明", pathIds: [] },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
function setVariant(groupId: string, variantId: string) {
|
|
||||||
props.dispatch("IDX出力構成変更", (p) => ({
|
|
||||||
...p,
|
|
||||||
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [groupId]: variantId } },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
function register(id: string) {
|
|
||||||
props.dispatch("IDXキー登録", (p) => ({ ...p, idx: appendKeys(p.idx, [id]) }));
|
|
||||||
}
|
|
||||||
function exclude(id: string) {
|
|
||||||
props.dispatch("IDXキー除外", (p) => ({ ...p, idx: excludeKey(p.idx, id) }));
|
|
||||||
}
|
|
||||||
function move(id: string, delta: number) {
|
|
||||||
const i = props.project.idx.order.indexOf(id),
|
|
||||||
j = i + delta;
|
|
||||||
if (j < 0 || j >= props.project.idx.order.length) return;
|
|
||||||
const order = [...props.project.idx.order];
|
|
||||||
[order[i], order[j]] = [order[j]!, order[i]!];
|
|
||||||
props.dispatch("IDX順序変更", (p) => ({ ...p, idx: reorderKeys(p.idx, order) }));
|
|
||||||
}
|
|
||||||
function registerAll() {
|
|
||||||
props.dispatch("IDX未登録キーを登録", (p) => ({
|
|
||||||
...p,
|
|
||||||
idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="idx-panel">
|
|
||||||
<h2>Key Indexer</h2>
|
|
||||||
<div class="idx-layout">
|
|
||||||
<div class="idx-list">
|
|
||||||
<div class="idx-head">
|
|
||||||
<h3>IDX order</h3>
|
|
||||||
<button data-testid="idx-register-all" :disabled="!unregistered.size" @click="registerAll">
|
|
||||||
未登録をすべて登録
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p>選択構成での出力番号をプレビューします。</p>
|
|
||||||
<ol>
|
|
||||||
<li
|
|
||||||
v-for="(entry, i) in rowEntries"
|
|
||||||
:key="entry.id"
|
|
||||||
:data-testid="`idx-row-${entry.id}`"
|
|
||||||
:class="{ chosen: selectedKey === entry.id }"
|
|
||||||
>
|
|
||||||
<span class="idx-number" :aria-label="`保存順 ${i}`">保存順 {{ i }}</span
|
|
||||||
><b>{{ entry.id }}</b
|
|
||||||
><span>{{ entry.label }}</span
|
|
||||||
><small>{{ entry.path }}</small
|
|
||||||
><span>{{
|
|
||||||
outputNumbers.has(entry.id) ? `出力 ${outputNumbers.get(entry.id)}` : "出力対象外"
|
|
||||||
}}</span>
|
|
||||||
<button :aria-label="`${entry.id}を上へ`" :disabled="i === 0" @click="move(entry.id, -1)">
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:aria-label="`${entry.id}を下へ`"
|
|
||||||
:disabled="i === rowEntries.length - 1"
|
|
||||||
@click="move(entry.id, 1)"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button :aria-label="`${entry.id}を除外`" @click="exclude(entry.id)">除外</button>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<h3>全バリアントのキー</h3>
|
|
||||||
<ul class="idx-all-keys">
|
|
||||||
<li
|
|
||||||
v-for="entry in entries"
|
|
||||||
:key="entry.id"
|
|
||||||
:data-testid="`idx-key-${entry.id}`"
|
|
||||||
:class="{ chosen: selectedKey === entry.id, unregistered: unregistered.has(entry.id) }"
|
|
||||||
>
|
|
||||||
<b>{{ entry.id }}</b> · {{ entry.label }} <small>— {{ entry.path }}</small>
|
|
||||||
<strong v-if="unregistered.has(entry.id)">未登録</strong>
|
|
||||||
<span v-else-if="outputNumbers.has(entry.id)" class="idx-preview"
|
|
||||||
>出力 {{ outputNumbers.get(entry.id) }}</span
|
|
||||||
>
|
|
||||||
<span v-else class="idx-preview muted">選択構成外</span>
|
|
||||||
<button v-if="unregistered.has(entry.id)" @click="register(entry.id)">登録</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<aside class="idx-preview-pane">
|
|
||||||
<h3>出力構成</h3>
|
|
||||||
<label v-for="group in groups" :key="group.id"
|
|
||||||
>{{ group.name }}
|
|
||||||
<select
|
|
||||||
:aria-label="`${group.name} 出力バリアント`"
|
|
||||||
:value="selection[group.id] ?? group.default"
|
|
||||||
@change="setVariant(group.id, ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option v-for="variant in group.children" :key="variant.id" :value="variant.id">
|
|
||||||
{{ variant.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<KeyboardView
|
|
||||||
:phy="project.phy"
|
|
||||||
:selection="selection"
|
|
||||||
:selected-ids="selectedKey ? [selectedKey] : []"
|
|
||||||
:key-text="(id) => (outputNumbers.has(id) ? String(outputNumbers.get(id)) : '')"
|
|
||||||
@key-click="(id) => (selectedKey = id)"
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.idx-panel {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 10px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #cbd1da;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.idx-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(440px, 1.2fr) minmax(320px, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
.idx-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.idx-panel h2 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
.idx-panel h3 {
|
|
||||||
margin: 12px 0 6px;
|
|
||||||
}
|
|
||||||
.idx-panel ol,
|
|
||||||
.idx-all-keys {
|
|
||||||
padding-left: 24px;
|
|
||||||
}
|
|
||||||
.idx-panel li {
|
|
||||||
padding: 5px;
|
|
||||||
margin: 3px 0;
|
|
||||||
}
|
|
||||||
.idx-panel ol li {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 86px minmax(95px, auto) 1fr 1fr auto auto auto auto;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.idx-panel small {
|
|
||||||
color: #586273;
|
|
||||||
}
|
|
||||||
.idx-number {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.idx-all-keys {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.idx-all-keys li {
|
|
||||||
border-bottom: 1px solid #e3e6eb;
|
|
||||||
}
|
|
||||||
.idx-panel .chosen {
|
|
||||||
background: #dce9ff;
|
|
||||||
}
|
|
||||||
.idx-all-keys .unregistered {
|
|
||||||
background: #fff1d6;
|
|
||||||
}
|
|
||||||
.idx-all-keys strong {
|
|
||||||
color: #9a4c00;
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
.idx-preview-pane {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
.idx-preview-pane label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
.idx-preview {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #1769d2;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #778;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Node } from "../../domain/phy";
|
import type { Node } from "../../domain/phy";
|
||||||
|
import { keyPath } from "./logic";
|
||||||
defineOptions({ name: "NodeSvg" });
|
defineOptions({ name: "NodeSvg" });
|
||||||
defineProps<{
|
defineProps<{
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
@@ -27,7 +28,13 @@ const emit = defineEmits<{
|
|||||||
v-if="n.type === 'key'"
|
v-if="n.type === 'key'"
|
||||||
class="node"
|
class="node"
|
||||||
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||||
><rect
|
><path
|
||||||
|
v-if="keyPath(n.size)"
|
||||||
|
:d="keyPath(n.size)"
|
||||||
|
class="key-face"
|
||||||
|
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
|
||||||
|
/><rect
|
||||||
|
v-else
|
||||||
:width="n.size.w"
|
:width="n.size.w"
|
||||||
:height="n.size.h"
|
:height="n.size.h"
|
||||||
rx=".06"
|
rx=".06"
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const {
|
|||||||
tabItem,
|
tabItem,
|
||||||
chooseVariant,
|
chooseVariant,
|
||||||
keyAdd,
|
keyAdd,
|
||||||
|
presetAdd,
|
||||||
partAdd,
|
partAdd,
|
||||||
makeCluster,
|
makeCluster,
|
||||||
ungroup,
|
ungroup,
|
||||||
@@ -337,7 +338,8 @@ function onFocus(_id: string) {
|
|||||||
:move-targets="moveTargets"
|
:move-targets="moveTargets"
|
||||||
:focus="focus"
|
:focus="focus"
|
||||||
:crumbs="focus ? focusCrumbs(focus) : []"
|
:crumbs="focus ? focusCrumbs(focus) : []"
|
||||||
@key-add="keyAdd"
|
@key-add="keyAdd()"
|
||||||
|
@preset-add="presetAdd"
|
||||||
@part-add="partAdd"
|
@part-add="partAdd"
|
||||||
@cluster="makeCluster"
|
@cluster="makeCluster"
|
||||||
@ungroup="ungroup"
|
@ungroup="ungroup"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import type { Node, Transform } from "../../domain/phy";
|
import type { Node, Transform } from "../../domain/phy";
|
||||||
import type { StabilizerCandidate } from "../../domain/stabilizers";
|
import type { StabilizerCandidate } from "../../domain/stabilizers";
|
||||||
|
import { hasSecondRect } from "../../domain/keyShape";
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@@ -26,6 +27,20 @@ function setSwitchOffset(axis: "x" | "y", value: number) {
|
|||||||
else n.switchOffset = offset;
|
else n.switchOffset = offset;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 異形キーは2つ目の矩形(x2, y2, w2, h2)を持つ。オンにしたときはKLEの既定(0, 0, w, h)から始める。T-32。
|
||||||
|
const irregular = computed(() => props.item?.type === "key" && hasSecondRect(props.item.size));
|
||||||
|
function setIrregular(on: boolean) {
|
||||||
|
emit("update", props.item!.id, (n) => {
|
||||||
|
if (n.type !== "key") return;
|
||||||
|
if (on) n.size = { ...n.size, x2: 0, y2: 0, w2: n.size.w, h2: n.size.h };
|
||||||
|
else n.size = { w: n.size.w, h: n.size.h };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setSecondRect(field: "x2" | "y2" | "w2" | "h2", value: number) {
|
||||||
|
emit("update", props.item!.id, (n) => {
|
||||||
|
if (n.type === "key") n.size[field] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
function toggleStabilizer(id: string, on: boolean) {
|
function toggleStabilizer(id: string, on: boolean) {
|
||||||
const current = props.stabilizers?.selected ?? [];
|
const current = props.stabilizers?.selected ?? [];
|
||||||
emit(
|
emit(
|
||||||
@@ -113,6 +128,47 @@ const frameR = ref(0);
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
/></label>
|
/></label>
|
||||||
|
<label class="check"
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="irregular"
|
||||||
|
@change="setIrregular(($event.target as HTMLInputElement).checked)"
|
||||||
|
/>異形キー</label
|
||||||
|
>
|
||||||
|
<template v-if="irregular && item.type === 'key'">
|
||||||
|
<label
|
||||||
|
>X2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.x2 ?? 0"
|
||||||
|
@change="setSecondRect('x2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Y2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.y2 ?? 0"
|
||||||
|
@change="setSecondRect('y2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Width2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.w2 ?? item.size.w"
|
||||||
|
@change="setSecondRect('w2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Height2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.h2 ?? item.size.h"
|
||||||
|
@change="setSecondRect('h2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
</template>
|
||||||
<label
|
<label
|
||||||
>switchOffset X
|
>switchOffset X
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -23,17 +23,25 @@ const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<g class="selection-layer" pointer-events="none">
|
<g class="selection-layer" pointer-events="none">
|
||||||
<rect
|
<template v-for="frame in frames" :key="frame.id">
|
||||||
v-for="frame in frames"
|
<path
|
||||||
:key="frame.id"
|
v-if="frame.path"
|
||||||
:data-sel-id="frame.id"
|
:data-sel-id="frame.id"
|
||||||
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
|
:class="['sel', { candidate: frame.candidate }]"
|
||||||
:transform="affineText(frame.transform)"
|
:transform="affineText(frame.transform)"
|
||||||
:x="frame.box.x"
|
:d="frame.path"
|
||||||
:y="frame.box.y"
|
/>
|
||||||
:width="frame.box.width"
|
<rect
|
||||||
:height="frame.box.height"
|
v-else
|
||||||
/>
|
:data-sel-id="frame.id"
|
||||||
|
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
|
||||||
|
:transform="affineText(frame.transform)"
|
||||||
|
:x="frame.box.x"
|
||||||
|
:y="frame.box.y"
|
||||||
|
:width="frame.box.width"
|
||||||
|
:height="frame.box.height"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<circle
|
<circle
|
||||||
v-for="frame in origins"
|
v-for="frame in origins"
|
||||||
:key="`origin-${frame.id}`"
|
:key="`origin-${frame.id}`"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mismatch: boolean;
|
mismatch: boolean;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
@@ -11,6 +12,7 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
keyAdd: [];
|
keyAdd: [];
|
||||||
|
presetAdd: [presetId: string];
|
||||||
partAdd: [group: string];
|
partAdd: [group: string];
|
||||||
cluster: [];
|
cluster: [];
|
||||||
ungroup: [];
|
ungroup: [];
|
||||||
@@ -28,6 +30,11 @@ const emit = defineEmits<{
|
|||||||
holdMode: [];
|
holdMode: [];
|
||||||
mounts: [];
|
mounts: [];
|
||||||
}>();
|
}>();
|
||||||
|
function addPreset(e: Event) {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
emit("presetAdd", select.value);
|
||||||
|
select.value = "";
|
||||||
|
}
|
||||||
function addPart(e: Event) {
|
function addPart(e: Event) {
|
||||||
const select = e.target as HTMLSelectElement;
|
const select = e.target as HTMLSelectElement;
|
||||||
emit("partAdd", select.value);
|
emit("partAdd", select.value);
|
||||||
@@ -40,6 +47,12 @@ function addPart(e: Event) {
|
|||||||
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
|
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
|
||||||
+⌨
|
+⌨
|
||||||
</button>
|
</button>
|
||||||
|
<select aria-label="異形キーを足す" :disabled="mismatch" @change="addPreset">
|
||||||
|
<option value="">異形キーを足す</option>
|
||||||
|
<option v-for="preset in IRREGULAR_KEY_PRESETS" :key="preset.id" :value="preset.id">
|
||||||
|
{{ preset.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
|
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
|
||||||
<option value="">部品を足す</option>
|
<option value="">部品を足す</option>
|
||||||
<option>@led</option>
|
<option>@led</option>
|
||||||
|
|||||||
+13
-19
@@ -2,6 +2,7 @@ import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
|
|||||||
import type { ProjectFile } from "../../domain/project";
|
import type { ProjectFile } from "../../domain/project";
|
||||||
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
|
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
|
||||||
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
|
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
|
||||||
|
import { hasSecondRect, keyCorners, keyOutline } from "../../domain/keyShape";
|
||||||
|
|
||||||
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
|
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
|
||||||
const parent = new Map<string, string | null>(),
|
const parent = new Map<string, string | null>(),
|
||||||
@@ -200,25 +201,7 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
|
|||||||
items.forEach((n) => {
|
items.forEach((n) => {
|
||||||
const w = compose(parent, matrix(n.transform));
|
const w = compose(parent, matrix(n.transform));
|
||||||
if (n.type === "key") {
|
if (n.type === "key") {
|
||||||
const corners = [
|
points.push(...keyCorners(n.size).map((p) => apply(w, p)));
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: n.size.w, y: 0 },
|
|
||||||
{ x: n.size.w, y: n.size.h },
|
|
||||||
{ x: 0, y: n.size.h },
|
|
||||||
];
|
|
||||||
if (
|
|
||||||
n.size.x2 !== undefined ||
|
|
||||||
n.size.y2 !== undefined ||
|
|
||||||
n.size.w2 !== undefined ||
|
|
||||||
n.size.h2 !== undefined
|
|
||||||
) {
|
|
||||||
const x = n.size.x2 ?? 0,
|
|
||||||
y = n.size.y2 ?? 0,
|
|
||||||
ww = n.size.w2 ?? n.size.w,
|
|
||||||
hh = n.size.h2 ?? n.size.h;
|
|
||||||
corners.push({ x, y }, { x: x + ww, y }, { x: x + ww, y: y + hh }, { x, y: y + hh });
|
|
||||||
}
|
|
||||||
points.push(...corners.map((p) => apply(w, p)));
|
|
||||||
} else if (n.type === "component") {
|
} else if (n.type === "component") {
|
||||||
const c = apply(w, { x: 0, y: 0 });
|
const c = apply(w, { x: 0, y: 0 });
|
||||||
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
|
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
|
||||||
@@ -232,11 +215,21 @@ export function localBounds(nodes: Node[], variants: Record<string, string> = {}
|
|||||||
return obbAabb(points);
|
return obbAabb(points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SVG path of a non-rectangular key's outline; undefined for plain keys, which are drawn as rounded rects. */
|
||||||
|
export function keyPath(size: KeyNode["size"]): string | undefined {
|
||||||
|
if (!hasSecondRect(size)) return undefined;
|
||||||
|
return keyOutline(size)
|
||||||
|
.map((loop) => `M ${loop.map((p) => `${p.x} ${p.y}`).join(" L ")} Z`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export type SelectionFrame = {
|
export type SelectionFrame = {
|
||||||
id: string;
|
id: string;
|
||||||
type: Node["type"];
|
type: Node["type"];
|
||||||
transform: Affine;
|
transform: Affine;
|
||||||
box: Box;
|
box: Box;
|
||||||
|
/** Outline of a non-rectangular key, drawn instead of `box`. */
|
||||||
|
path?: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
candidate: boolean;
|
candidate: boolean;
|
||||||
};
|
};
|
||||||
@@ -258,6 +251,7 @@ export function selectionFrames(
|
|||||||
type: node.type,
|
type: node.type,
|
||||||
transform,
|
transform,
|
||||||
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
|
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
|
||||||
|
path: keyPath(node.size),
|
||||||
selected: selected.includes(node.id),
|
selected: selected.includes(node.id),
|
||||||
candidate: candidate === node.id,
|
candidate: candidate === node.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { nextTick, onUnmounted, ref, type Ref } from "vue";
|
|||||||
import type { ProjectFile } from "../../domain/project";
|
import type { ProjectFile } from "../../domain/project";
|
||||||
import type { KeyNode, Node } from "../../domain/phy";
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
import { apply, worldTransform } from "../../domain/geometry";
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
|
import { keyCorners } from "../../domain/keyShape";
|
||||||
import { moveNode } from "../../domain/phyCommands";
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
import { normalizeSelection, snap } from "./logic";
|
import { normalizeSelection, snap } from "./logic";
|
||||||
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
|
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
|
||||||
@@ -303,12 +304,7 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
for (const item of options.keys()) {
|
for (const item of options.keys()) {
|
||||||
if (!scope.has(item.key.id)) continue;
|
if (!scope.has(item.key.id)) continue;
|
||||||
const transform = worldTransform(options.project().phy, item.key.id);
|
const transform = worldTransform(options.project().phy, item.key.id);
|
||||||
const corners = [
|
const corners = keyCorners(item.key.size).map((point) => apply(transform, point));
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: item.key.size.w, y: 0 },
|
|
||||||
{ x: item.key.size.w, y: item.key.size.h },
|
|
||||||
{ x: 0, y: item.key.size.h },
|
|
||||||
].map((point) => apply(transform, point));
|
|
||||||
const xs = corners.map((point) => point.x);
|
const xs = corners.map((point) => point.x);
|
||||||
const ys = corners.map((point) => point.y);
|
const ys = corners.map((point) => point.y);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { ProjectFile } from "../../domain/project";
|
import type { ProjectFile } from "../../domain/project";
|
||||||
import type { KeyNode, Node, VariantGroupNode } from "../../domain/phy";
|
import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
|
||||||
import {
|
import {
|
||||||
addComponentToParent,
|
addComponentToParent,
|
||||||
addVariant as addVariantCommand,
|
addVariant as addVariantCommand,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
||||||
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
||||||
import { newRowPosition, rowEndPosition } from "../../domain/rows";
|
import { newRowPosition, rowEndPosition } from "../../domain/rows";
|
||||||
|
import { IRREGULAR_KEY_PRESETS, keyOrigin } from "../../domain/keyShape";
|
||||||
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
@@ -126,7 +127,7 @@ export function usePhyCommands(options: Options) {
|
|||||||
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
|
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyAdd() {
|
function keyAdd(size: Size = { w: 1, h: 1 }) {
|
||||||
const chosen = options.node(options.selected.value.at(-1) || "");
|
const chosen = options.node(options.selected.value.at(-1) || "");
|
||||||
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
|
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
|
||||||
// like an empty selection inside the focus.
|
// like an empty selection inside the focus.
|
||||||
@@ -149,15 +150,23 @@ export function usePhyCommands(options: Options) {
|
|||||||
: chosen?.type === "component"
|
: chosen?.type === "component"
|
||||||
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
|
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
|
||||||
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
|
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
|
||||||
|
// The key's bounding box, not its origin, goes to the found position (T-32).
|
||||||
|
const offset = keyOrigin(size);
|
||||||
let id = "";
|
let id = "";
|
||||||
options.dispatch("キー追加", (project) => {
|
options.dispatch("キー追加", (project) => {
|
||||||
const phy = addKeyToParent(project.phy, parent, { x, y, rotation: 0 }, { w: 1, h: 1 });
|
const at = { x: x - offset.x, y: y - offset.y, rotation: 0 };
|
||||||
|
const phy = addKeyToParent(project.phy, parent, at, size);
|
||||||
id = addedId(project.phy, phy);
|
id = addedId(project.phy, phy);
|
||||||
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
||||||
});
|
});
|
||||||
if (id) options.selected.value = [id];
|
if (id) options.selected.value = [id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function presetAdd(presetId: string) {
|
||||||
|
const preset = IRREGULAR_KEY_PRESETS.find((item) => item.id === presetId);
|
||||||
|
if (preset) keyAdd(preset.size);
|
||||||
|
}
|
||||||
|
|
||||||
function insertionParent() {
|
function insertionParent() {
|
||||||
const focusId = options.focus.value;
|
const focusId = options.focus.value;
|
||||||
if (!focusId) return null;
|
if (!focusId) return null;
|
||||||
@@ -383,6 +392,7 @@ export function usePhyCommands(options: Options) {
|
|||||||
tabItem,
|
tabItem,
|
||||||
chooseVariant,
|
chooseVariant,
|
||||||
keyAdd,
|
keyAdd,
|
||||||
|
presetAdd,
|
||||||
partAdd,
|
partAdd,
|
||||||
makeCluster,
|
makeCluster,
|
||||||
ungroup,
|
ungroup,
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
insertAtGap,
|
||||||
|
insertNextTo,
|
||||||
|
keyVariantGroups,
|
||||||
|
orderSegments,
|
||||||
|
renumber,
|
||||||
|
swapKeys,
|
||||||
|
} from "../src/domain/idxOrder";
|
||||||
|
import type { OrderContext } from "../src/domain/idxOrder";
|
||||||
|
import {
|
||||||
|
assignLines,
|
||||||
|
keyRect,
|
||||||
|
matrixSequence,
|
||||||
|
scanKeys,
|
||||||
|
splitHalves,
|
||||||
|
type ScanOptions,
|
||||||
|
} from "../src/domain/idxScan";
|
||||||
|
import { matchPlacements } from "../src/domain/idxImport";
|
||||||
|
import { readLayoutPlacements } from "../src/io/layoutImport";
|
||||||
|
import { importKle } from "../src/io/kleImport";
|
||||||
|
import { flattenKeys } from "../src/domain/geometry";
|
||||||
|
import type { KeyRect } from "../src/domain/rows";
|
||||||
|
import type { FlatKey, PhyFile } from "../src/domain/phy";
|
||||||
|
|
||||||
|
const sample = (path: string) =>
|
||||||
|
JSON.parse(readFileSync(new URL(`../../samples/${path}`, import.meta.url), "utf8")) as unknown;
|
||||||
|
const gen = () => {
|
||||||
|
let i = 0;
|
||||||
|
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Context where `hidden` keys are in group g (with every `grouped` visible key) and the rest are common. */
|
||||||
|
const ctx = (visible: string[], grouped: string[] = []): OrderContext => ({
|
||||||
|
visible: new Set(visible),
|
||||||
|
groups: new Map(grouped.map((id) => [id, ["g"]])),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX order editing", () => {
|
||||||
|
it("keeps the ISO Enter next to the ANSI Enter when inserting after it", () => {
|
||||||
|
// a = ANSI Enter (shown), h = ISO Enter (hidden), b = common key
|
||||||
|
const c = ctx(["a", "b", "x"], ["a", "h"]);
|
||||||
|
expect(orderSegments(["a", "h", "b"], c)).toEqual([["a", "h"], ["b"]]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "a", true)).toEqual(["a", "h", "x", "b"]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "b", false)).toEqual(["a", "h", "x", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches hidden keys forward only to a key sharing their group", () => {
|
||||||
|
const c = ctx(["p", "q"], ["h1", "h2", "q"]);
|
||||||
|
expect(orderSegments(["p", "h1", "h2", "q"], c)).toEqual([["p"], ["h1", "h2", "q"]]);
|
||||||
|
// shares with neither: stays with the key before
|
||||||
|
expect(orderSegments(["p", "z", "q"], ctx(["p", "q"]))).toEqual([["p", "z"], ["q"]]);
|
||||||
|
// shares with both: stays with the key before
|
||||||
|
expect(orderSegments(["p", "h", "q"], ctx(["p", "q"], ["p", "h", "q"]))).toEqual([["p", "h"], ["q"]]);
|
||||||
|
// once a hidden key goes forward, the rest of the run follows to keep the order
|
||||||
|
const mixed = {
|
||||||
|
visible: new Set(["p", "q"]),
|
||||||
|
groups: new Map([
|
||||||
|
["h1", ["g"]],
|
||||||
|
["q", ["g"]],
|
||||||
|
["h2", ["k"]],
|
||||||
|
["p", ["k"]],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
expect(orderSegments(["p", "h1", "h2", "q"], mixed)).toEqual([["p"], ["h1", "h2", "q"]]);
|
||||||
|
// leading hidden keys attach to the first visible key, trailing ones to the last
|
||||||
|
expect(orderSegments(["z", "p", "q", "y"], ctx(["p", "q"]))).toEqual([
|
||||||
|
["z", "p"],
|
||||||
|
["q", "y"],
|
||||||
|
]);
|
||||||
|
expect(orderSegments(["z"], ctx([]))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a key between two others like a list move", () => {
|
||||||
|
const c = ctx(["k0", "k1", "k2", "k3", "k4", "k5"]);
|
||||||
|
const order = ["k0", "k1", "k2", "k3", "k4", "k5"];
|
||||||
|
// n = 4 dropped between i = 1 and i + 1 = 2: lands on 2, 2..3 shift back
|
||||||
|
expect(insertNextTo(order, c, "k4", "k1", true)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k4", "k2", false)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
|
||||||
|
// n = 1 dropped between i = 3 and 4: lands on 3, 2..3 shift forward
|
||||||
|
expect(insertNextTo(order, c, "k1", "k3", true)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k1", "k4", false)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k1", "k1", true)).toEqual(order);
|
||||||
|
expect(insertAtGap(order, c, "k0", 99)).toEqual(["k1", "k2", "k3", "k4", "k5", "k0"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves hidden keys in place when their visible key moves away", () => {
|
||||||
|
const c = ctx(["a", "b", "c"], ["a", "h"]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "c"], c, "a", "c", true)).toEqual(["h", "b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers an unregistered key at the drop position", () => {
|
||||||
|
const c = ctx(["a", "b", "new"]);
|
||||||
|
expect(insertNextTo(["a", "b"], c, "new", "b", false)).toEqual(["a", "new", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps only the two keys", () => {
|
||||||
|
expect(swapKeys(["a", "h", "b", "c"], "a", "c")).toEqual(["c", "h", "b", "a"]);
|
||||||
|
expect(() => swapKeys(["a"], "a", "zz")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renumbers a sequence from the position of its first key, moving attached hidden keys along", () => {
|
||||||
|
const c = ctx(["a", "b", "c", "d"], ["b", "h"]);
|
||||||
|
const order = ["a", "b", "h", "c", "d"];
|
||||||
|
expect(renumber(order, c, ["d", "b"])).toEqual(["a", "c", "d", "b", "h"]);
|
||||||
|
expect(renumber(order, c, ["b", "a"])).toEqual(["b", "h", "a", "c", "d"]);
|
||||||
|
expect(renumber(order, c, ["c", "a"])).toEqual(["b", "h", "c", "a", "d"]);
|
||||||
|
// unregistered keys are registered inside the block; an all-new sequence goes to the end
|
||||||
|
expect(renumber(["a", "b"], ctx(["a", "b", "n"]), ["n", "b"])).toEqual(["a", "n", "b"]);
|
||||||
|
expect(renumber(["a"], ctx(["a", "n"]), ["n"])).toEqual(["a", "n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never duplicates or drops keys", () => {
|
||||||
|
const c = ctx(["a", "b", "c", "d"], ["a", "h1", "c", "h2"]);
|
||||||
|
const order = ["h1", "a", "b", "h2", "c", "d", "z"];
|
||||||
|
const hiddenOf = (o: string[]) => o.filter((id) => !c.visible.has(id));
|
||||||
|
const moved = renumber(order, c, ["d", "c", "b", "a"]);
|
||||||
|
expect([...moved].sort()).toEqual([...order].sort());
|
||||||
|
// hidden keys travel with their visible key here
|
||||||
|
expect(moved).toEqual(["d", "z", "h2", "c", "b", "h1", "a"]);
|
||||||
|
for (const r of [
|
||||||
|
insertNextTo(order, c, "a", "d", true),
|
||||||
|
insertNextTo(order, c, "d", "a", false),
|
||||||
|
swapKeys(order, "b", "d"),
|
||||||
|
]) {
|
||||||
|
expect([...r].sort()).toEqual([...order].sort());
|
||||||
|
expect(hiddenOf(r)).toEqual(hiddenOf(order));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists VariantGroup ancestors including nested groups", () => {
|
||||||
|
const { phy } = importKle(sample("kle/macropad-4x4.json"), { idGenerator: gen() });
|
||||||
|
const [a, b] = phy.root as Extract<PhyFile["root"][number], { type: "key" }>[];
|
||||||
|
const nested: PhyFile = {
|
||||||
|
...phy,
|
||||||
|
root: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "outer",
|
||||||
|
name: "o",
|
||||||
|
default: "v1",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "v1",
|
||||||
|
name: "1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "inner",
|
||||||
|
name: "i",
|
||||||
|
default: "v2",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [{ type: "variant", id: "v2", name: "2", children: [a!] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
b!,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const groups = keyVariantGroups(nested);
|
||||||
|
expect(groups.get(a!.id)).toEqual(["outer", "inner"]);
|
||||||
|
expect(groups.get(b!.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
|
||||||
|
const options = (o: Partial<ScanOptions>): ScanOptions => ({
|
||||||
|
kind: "rows",
|
||||||
|
direction: "normal",
|
||||||
|
left: "normal",
|
||||||
|
right: "normal",
|
||||||
|
offsets: new Map(),
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX preset scans", () => {
|
||||||
|
// Two 2x2 halves with a 2U gap
|
||||||
|
const split = [r("l00", 0, 0), r("l01", 1, 0), r("l10", 0, 1), r("l11", 1, 1)].concat([
|
||||||
|
r("r00", 4, 0),
|
||||||
|
r("r01", 5, 0),
|
||||||
|
r("r10", 4, 1),
|
||||||
|
r("r11", 5, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("scans rows, columns, halves and alternating halves", () => {
|
||||||
|
expect(scanKeys(split, options({})).sequence).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l01",
|
||||||
|
"r00",
|
||||||
|
"r01",
|
||||||
|
"l10",
|
||||||
|
"l11",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ direction: "reversed" })).sequence.slice(0, 4)).toEqual([
|
||||||
|
"r01",
|
||||||
|
"r00",
|
||||||
|
"l01",
|
||||||
|
"l00",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ kind: "columns" })).sequence.slice(0, 4)).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l10",
|
||||||
|
"l01",
|
||||||
|
"l11",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ kind: "columns", direction: "reversed" })).sequence.slice(0, 2)).toEqual(
|
||||||
|
["r01", "r11"],
|
||||||
|
);
|
||||||
|
const halves = scanKeys(split, options({ kind: "halves", right: "reversed" }));
|
||||||
|
expect(halves.sequence).toEqual(["l00", "l01", "l10", "l11", "r01", "r00", "r11", "r10"]);
|
||||||
|
expect(halves.boundary).toBe(3);
|
||||||
|
expect(scanKeys(split, options({ kind: "alternating" })).sequence).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l01",
|
||||||
|
"r00",
|
||||||
|
"r01",
|
||||||
|
"l10",
|
||||||
|
"l11",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a board without a gap at the middle of its x range", () => {
|
||||||
|
const halves = splitHalves([r("a", 0, 0), r("b", 1, 0), r("c", 2, 0), r("d", 3, 0, 2)]);
|
||||||
|
expect(halves.boundary).toBe(2.5);
|
||||||
|
expect([...halves.left]).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts the row assignment of one column", () => {
|
||||||
|
// column-staggered: the outer column sits a full row lower and is detected one row off
|
||||||
|
const keys = [r("o0", 0, 1), r("o1", 0, 2), r("a0", 1, 0), r("a1", 1, 1), r("a2", 1, 2)];
|
||||||
|
const before = assignLines(keys, "row", new Map());
|
||||||
|
expect(before.lines).toEqual([["a0"], ["o0", "a1"], ["o1", "a2"]]);
|
||||||
|
const outer = before.crossLines.findIndex((col) => col.includes("o0"));
|
||||||
|
const after = assignLines(keys, "row", new Map([[outer, -1]]));
|
||||||
|
expect(after.lines).toEqual([["o0", "a0"], ["o1", "a1"], ["a2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bounding box of rotated keys", () => {
|
||||||
|
const rotated = keyRect({
|
||||||
|
key: {
|
||||||
|
type: "key",
|
||||||
|
id: "k",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 2, h: 1 },
|
||||||
|
},
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 90,
|
||||||
|
path: [],
|
||||||
|
});
|
||||||
|
expect(rotated.x).toBeCloseTo(-1);
|
||||||
|
expect(rotated.w).toBeCloseTo(1);
|
||||||
|
expect(rotated.h).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders by matrix row then col and leaves unwired keys at the end", () => {
|
||||||
|
const positions = new Map([
|
||||||
|
["a", { row: 1, col: 0 }],
|
||||||
|
["b", { row: 0, col: 2 }],
|
||||||
|
["c", { row: 0, col: 1 }],
|
||||||
|
]);
|
||||||
|
expect(matrixSequence(["x", "a", "b", "y", "c"], positions)).toEqual(["c", "b", "a", "x", "y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX import from existing definitions", () => {
|
||||||
|
const shown = (phy: PhyFile): FlatKey[] => flattenKeys(phy).filter((k) => !k.key.decal);
|
||||||
|
|
||||||
|
it("matches every key of the KLE the project came from, in KLE order", () => {
|
||||||
|
for (const file of ["kle/macropad-4x4.json", "kle/cr0wn60.json"]) {
|
||||||
|
const { phy, keyOrder } = importKle(sample(file), { idGenerator: gen() });
|
||||||
|
const result = matchPlacements(readLayoutPlacements(sample(file)).placements, shown(phy));
|
||||||
|
expect(result.unmatched, file).toBe(0);
|
||||||
|
expect(result.ids).toEqual(keyOrder.filter((id) => shown(phy).some((k) => k.key.id === id)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the VIA definition of cr0wn60 against its KLE", () => {
|
||||||
|
const { phy } = importKle(sample("kle/cr0wn60.json"), { idGenerator: gen() });
|
||||||
|
const keys = shown(phy);
|
||||||
|
const result = matchPlacements(readLayoutPlacements(sample("via/cr0wn60.json")).placements, keys);
|
||||||
|
expect(result.ids.length).toBe(keys.length);
|
||||||
|
expect(new Set(result.ids).size).toBe(result.ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads QMK layouts, asks for a name when there are several, and applies rotation", () => {
|
||||||
|
const kle = [
|
||||||
|
[{ r: 30, rx: 2, ry: 1, x: 0.5 }, "A"],
|
||||||
|
[{ x: -1 }, "B"],
|
||||||
|
];
|
||||||
|
const { phy } = importKle(kle, { idGenerator: gen() });
|
||||||
|
const keys = shown(phy);
|
||||||
|
const qmk = {
|
||||||
|
layouts: {
|
||||||
|
LAYOUT_a: {
|
||||||
|
layout: [
|
||||||
|
{ matrix: [0, 1], x: 1, y: 2, r: 30, rx: 2, ry: 1 },
|
||||||
|
{ matrix: [0, 0], x: 2.5, y: 1, r: 30, rx: 2, ry: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
LAYOUT_b: { layout: [{ matrix: [0, 0], x: 0, y: 0 }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(readLayoutPlacements(qmk)).toEqual({ placements: [], layouts: ["LAYOUT_a", "LAYOUT_b"] });
|
||||||
|
const read = readLayoutPlacements(qmk, "LAYOUT_a");
|
||||||
|
const result = matchPlacements(read.placements, keys);
|
||||||
|
expect(result.unmatched).toBe(0);
|
||||||
|
const label = (id: string) => keys.find((k) => k.key.id === id)!.key.labels?.find(Boolean);
|
||||||
|
expect(result.ids.map(label)).toEqual(["B", "A"]);
|
||||||
|
expect(matchPlacements(readLayoutPlacements(qmk, "LAYOUT_b").placements, keys).unmatched).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
IRREGULAR_KEY_PRESETS,
|
||||||
|
hasSecondRect,
|
||||||
|
keyOrigin,
|
||||||
|
keyOutline,
|
||||||
|
keyRects,
|
||||||
|
} from "../src/domain/keyShape";
|
||||||
|
import { keyPath } from "../src/ui/phy/logic";
|
||||||
|
|
||||||
|
const isoEnter = IRREGULAR_KEY_PRESETS.find((p) => p.id === "iso-enter")!.size;
|
||||||
|
const asSet = (loop: { x: number; y: number }[]) => loop.map((p) => `${p.x},${p.y}`).sort();
|
||||||
|
|
||||||
|
describe("key shape", () => {
|
||||||
|
it("fills missing second-rectangle fields with KLE's defaults", () => {
|
||||||
|
expect(hasSecondRect({ w: 1, h: 1 })).toBe(false);
|
||||||
|
expect(keyRects({ w: 1, h: 1 })).toEqual([{ x: 0, y: 0, w: 1, h: 1 }]);
|
||||||
|
expect(keyRects({ w: 1.25, h: 1, w2: 1.75 })).toEqual([
|
||||||
|
{ x: 0, y: 0, w: 1.25, h: 1 },
|
||||||
|
{ x: 0, y: 0, w: 1.75, h: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("traces the ISO Enter as one clockwise loop of six corners", () => {
|
||||||
|
const loops = keyOutline(isoEnter);
|
||||||
|
expect(loops).toHaveLength(1);
|
||||||
|
expect(asSet(loops[0]!)).toEqual(
|
||||||
|
asSet([
|
||||||
|
{ x: -0.25, y: 0 },
|
||||||
|
{ x: 1.25, y: 0 },
|
||||||
|
{ x: 1.25, y: 2 },
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: -0.25, y: 1 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// Shoelace area is positive for a clockwise loop on a y-down screen.
|
||||||
|
const loop = loops[0]!;
|
||||||
|
const area = loop.reduce((sum, p, i) => {
|
||||||
|
const q = loop[(i + 1) % loop.length]!;
|
||||||
|
return sum + (p.x * q.y - q.x * p.y) / 2;
|
||||||
|
}, 0);
|
||||||
|
expect(area).toBeCloseTo(1.25 * 2 + 0.25);
|
||||||
|
});
|
||||||
|
it("traces a rectangle-in-rectangle as the outer rectangle", () => {
|
||||||
|
expect(keyOutline({ w: 1, h: 1, w2: 2 }).map(asSet)).toEqual([
|
||||||
|
asSet([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("puts the bounding box's top-left where the key is placed", () => {
|
||||||
|
expect(keyOrigin(isoEnter)).toEqual({ x: -0.25, y: 0 });
|
||||||
|
expect(keyOrigin({ w: 1, h: 1 })).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
it("draws plain keys as rects and irregular ones as a path", () => {
|
||||||
|
expect(keyPath({ w: 1, h: 1 })).toBeUndefined();
|
||||||
|
expect(keyPath(isoEnter)).toMatch(/^M .* Z$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,6 +121,57 @@ ISO Enter・BAE等の異形キー(`w2`・`h2`・`x2`・`y2` のいずれかを
|
|||||||
|
|
||||||
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
覆すときに影響する箇所: `mock/src/ui/phy/`、6の行判定の関数とそのテスト。
|
||||||
|
|
||||||
|
### T-31 IDX Editorの作り直し
|
||||||
|
|
||||||
|
`ui/panels/IdxPanel.vue` を捨て、`ui/idx/` に一から作る。入力は [idx-editor-feedback.md](idx-editor-feedback.md)「方向が合意できたもの」。
|
||||||
|
`order` の操作(挿入・入れ替え・連番の並べ直し・走査・取り込みの照合)は `domain/` に純粋関数として置いてテストし、行と列の判定は `domain/rows.ts` を変えずに使う。
|
||||||
|
`ui/phy/viewport.ts` の純粋関数は使ってよいが、`ui/phy/` は変えない。
|
||||||
|
理由: 所感は画面の操作の作り直しで、`IdxFile` の形と `domain/idx.ts` は変える理由がない。
|
||||||
|
|
||||||
|
所感が定めていない部分は、下のとおり仮に決める。
|
||||||
|
|
||||||
|
- 表示する構成: エディタ内の状態として持ち、開いたときは `output.variantSelection`(欠けたグループは既定)から始める。
|
||||||
|
切り替えても `output` は書き換えず、Undoにも積まない。
|
||||||
|
- 画面: 左にバリアントのツリー、中央にキャンバス、右に並べ替え(プリセット・取り込み・未登録の登録)と選択中のキーの情報。
|
||||||
|
キーには選んだ構成での出力番号を出す。未登録のキーは番号を出さず、未登録と分かる見た目にする。並びのリストは置かない。
|
||||||
|
- 選択: クリックで1つ、Shift/Ctrl+クリックで追加・解除、何もない場所からのドラッグで範囲選択、Escと何もない場所のクリックで解除。
|
||||||
|
- ドロップの判定: ドロップ先のキーの幅を4等分し、左端の1/4は「そのキーの前へ挿入」、右端の1/4は「後へ挿入」、中央は入れ替え。
|
||||||
|
回転したキーはキーの向きで測る。ドラッグ中は挿入位置の線か入れ替えの印を出す。何もない場所へのドロップは取り消し。
|
||||||
|
ドラッグで動かすのは押したキー1つだけ。未登録のキーをドロップすると、その位置に登録する(入れ替えはしない)。
|
||||||
|
- 隠れたキーのくっつき先: 前後で最も近い表示中のキーを候補にし、VariantGroupの祖先を共有するほうへ付ける。
|
||||||
|
両方と共有する、どちらとも共有しない場合は手前に付ける。手前がなければ後ろに付ける。
|
||||||
|
表示中のキーに挟まれた隠れたキーの並びは、後ろに付くキーが出たら、そこから先をすべて後ろに付ける(順序を保つため)。
|
||||||
|
- 連番の並べ直し(プリセット・取り込みの共通操作): 渡された列のキーを、列の先頭キーがあった位置から連続して置く。
|
||||||
|
列に入らない表示中のキーは、互いの順序を保つ。列に未登録のキーがあれば登録する。
|
||||||
|
隠れたキーは、くっついている表示中のキーと一緒に動く(ドラッグでの移動と異なる)。
|
||||||
|
- 左右の半身: 判定した列のうち、隣り合う列の隙間が最も大きい所で分ける。最大の隙間が0.5U未満なら、範囲の中央のx座標で分ける。
|
||||||
|
回転したキーは、外接矩形で行と列を判定する。
|
||||||
|
- 向き: 行優先・列優先は全体に1つ(通常 / 左右逆)。半身ごと・行ごとに左右交互は、左手側と右手側に別々に指定する。
|
||||||
|
マトリクス順は向きを持たず、row→colの順に並べ、ELCで位置が決まらないキーは今の順序のまま末尾に置く。
|
||||||
|
- プレビュー: 行で走査する方式は行を線で結んで色分けし、列を選んで↑/↓で行の割り当てをずらす。
|
||||||
|
列優先は列を結び、行を選んで←/→でずらす。仮の出力番号と半身の境界も出す。Escで取り消し、適用で1回のUndo単位にする。
|
||||||
|
- 取り込み(QMK `keyboard.json` の `layout[]`、VIA定義の `layouts.keymap`、KLE JSON): 各キーの位置・回転を、表示中のキーの位置・回転と照合する(1対1、許容差0.01U)。
|
||||||
|
照合できたキーの列を連番の並べ直しに渡し、照合できなかった数を表示する。QMKで複数のレイアウトがあれば選ばせる。
|
||||||
|
|
||||||
|
覆すときに影響する箇所: `mock/src/ui/idx/`、`mock/src/domain/` の IDX の並べ替え関数とそのテスト、`mock/e2e/idx.spec.ts`。
|
||||||
|
|
||||||
|
### T-32 PHY Editorでの異形キー
|
||||||
|
|
||||||
|
PHY Editorで、2つ目の矩形(`x2`・`y2`・`w2`・`h2`)を持つキーを描き、編集し、追加できるようにする。
|
||||||
|
理由: データモデル・KLEの読み書きは2つ目の矩形を扱えるのに、キャンバスは主矩形しか描かず、編集手段もなかった。
|
||||||
|
|
||||||
|
- 描画: 2つの矩形の和の外形を1つの輪郭で描く。選択枠も同じ外形にする。当たり判定と範囲選択は両方の矩形で取る。
|
||||||
|
2つ目の矩形を持たないキーは今までどおり角丸の矩形で描く。
|
||||||
|
- 編集: プロパティ欄に「異形キー」のチェックを置き、オンのときだけ2つ目の矩形の欄(X2・Y2・Width2・Height2)を出す。
|
||||||
|
オンにしたときの初期値はKLEの既定(0, 0, w, h)。オフにすると4つとも消す。Width・Heightを変えても2つ目の矩形は変えない。
|
||||||
|
- 追加: ツールバーの「キーを足す」(1U)はそのまま残し、隣に「異形キーを足す」のselectを置く。
|
||||||
|
候補は `domain/` のプリセット表から出し、今はISO Enter(KLEのプリセットと同じ `w1.25 h2`、`x2 -0.25 w2 1.5 h2 1`)だけを置く。
|
||||||
|
プリセットを表に足せば、selectの項目も増える。ショートカットのキー追加は1Uのまま。
|
||||||
|
- 追加位置: 1Uキーと同じ規則(T-30 単位6)で求めた位置に、キーの外接矩形の左上を合わせる。ISO Enterは原点が外接矩形の0.25U右になる。
|
||||||
|
- スイッチ位置・スタビ: 変えない。スイッチは主矩形の中心(`switchOffset` 0)、スタビ候補は置かない(T-30 単位8)。
|
||||||
|
|
||||||
|
覆すときに影響する箇所: `mock/src/domain/keyShape.ts` とそのテスト、`mock/src/ui/phy/` の描画・選択枠・プロパティ欄・ツールバー、`mock/e2e/phy.spec.ts`。
|
||||||
|
|
||||||
## B. 明示的な未決事項
|
## B. 明示的な未決事項
|
||||||
|
|
||||||
### 未確認事項
|
### 未確認事項
|
||||||
|
|||||||
@@ -14,6 +14,33 @@
|
|||||||
- 倍マトリクスでは、同じrow/colの組が編集中に重なってよい。ダイオードの向きで区別するので衝突にはならない(T-25)。
|
- 倍マトリクスでは、同じrow/colの組が編集中に重なってよい。ダイオードの向きで区別するので衝突にはならない(T-25)。
|
||||||
重なった場合も保存は止めない(T-16)。
|
重なった場合も保存は止めない(T-16)。
|
||||||
|
|
||||||
|
### 倍マトリクス
|
||||||
|
|
||||||
|
- 倍マトリクスモードは配線補助の設定であり、ボードの属性ではない。ELCには保存しない(ELCはトポロジを区分として持たない)。
|
||||||
|
- 途中保存したプロジェクトで設定を復元できるよう、プロジェクト内のエディタの状態として保存する。
|
||||||
|
- Generatorの出力設定を作るときの初期値に使える。常には連動させない(走査方式を決めるのは出力設定)。
|
||||||
|
倍マトリクスの出力はGeneratorが未対応(T-19)なので、効くのは対応後。
|
||||||
|
- rowモード/colモードのどちらで作業中かは保存しない。
|
||||||
|
- エディタはrow/colやCOL2ROWの概念を持たない。順/逆の解釈はGeneratorが行う。エディタ内の規約は次の2つだけ。
|
||||||
|
- rowモードでなぞると端子a、colモードでなぞると端子bに書く。
|
||||||
|
- なぞったときの既定の向きは `a-to-b`。
|
||||||
|
- 倍マトリクスモードでなぞったとき、同じピンの組を持つキーが既にあれば、新しいキーの向きを反転する。
|
||||||
|
- 排他なキー(レイアウトバリアント)との重なりは数えない。排他キーは同じ組を同じ向きで共有するのが正しい。
|
||||||
|
- 判定は両端子が揃った時点で行う。rowモードとcolモードに分けてなぞる場合は、後から付いた端子の時点。
|
||||||
|
- 配線し直すキー自身の旧配線は数えない。
|
||||||
|
- 両方の向きが既に埋まっている場合は反転せず、衝突として警告する。なぞる操作は止めない(T-16)。
|
||||||
|
- モードの切り替えでは既存の配線を書き換えない。モードはこれからなぞる操作の補助だけを決める。
|
||||||
|
- 切り替えた時点では「どちらが二個目か」を決める順序がない(なぞった順はELCに残らない)。
|
||||||
|
- 既存の重なりの振り分けは、切り替えとは別の一回限りのコマンドにする。
|
||||||
|
- 倍から通常へ戻しても配線は変えない。向きの不揃いは検証で拾う。
|
||||||
|
- 向きを変える操作を2つ持つ。どちらも、選択中のキーの向きが混ざっていても、相方を選んでいてもいなくてもよい。
|
||||||
|
- 相方と交換: 選んだキーの(順序のない)ピンの組を重複なく集め、その組を持つ全キーの向きを反転する。
|
||||||
|
組ごとに1回だけ反転するので、相方を同時に選んでいても二重に反転しない。排他キーも一緒に反転する。
|
||||||
|
相方のいないキーは単に反転する。
|
||||||
|
- このキーだけ反転: 選んだキーだけを反転する。同じ向きで衝突している組は、交換では解消できないため。
|
||||||
|
- 向きは三角形のオーバーレイで見せる。ダイオード記号ではないのでカソードの棒は付けず、`a→b` の向きだけを表す。
|
||||||
|
相方のいるキーといないキーの区別も見えるようにする(塗り/白抜きなど)。
|
||||||
|
|
||||||
### ピン提供者
|
### ピン提供者
|
||||||
|
|
||||||
- ボード(Pro Micro互換など、MCUを載せた開発基板)も提供者として扱いたい。多くの利用者はチップではなくボードを使う。
|
- ボード(Pro Micro互換など、MCUを載せた開発基板)も提供者として扱いたい。多くの利用者はチップではなくボードを使う。
|
||||||
@@ -27,9 +54,9 @@
|
|||||||
|
|
||||||
## 要検討
|
## 要検討
|
||||||
|
|
||||||
- 倍マトリクスの向きの指定方法(選択範囲だけダイオードの向きを逆にする、など)。
|
|
||||||
- 二乗マトリクス(同じピンがrowにもcolにも現れ、向きはダイオードだけで区別する)の操作。通常のマトリクスと同じ操作で足りるか。
|
- 二乗マトリクス(同じピンがrowにもcolにも現れ、向きはダイオードだけで区別する)の操作。通常のマトリクスと同じ操作で足りるか。
|
||||||
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
|
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
|
||||||
|
倍マトリクスの操作(自動反転・交換)が使えそうだが、いったん別に検討する。統合は両方の検討後に、矛盾がないかを確認して行う。
|
||||||
- directの操作。row/colの指定がなく、片方の端子は電源レールになる。
|
- directの操作。row/colの指定がなく、片方の端子は電源レールになる。
|
||||||
- mux・シフトレジスタを使う場合の操作。rowかcolの一方を親の提供者の論理ピンにする形になるか。
|
- mux・シフトレジスタを使う場合の操作。rowかcolの一方を親の提供者の論理ピンにする形になるか。
|
||||||
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
|
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
|
||||||
@@ -38,6 +65,12 @@
|
|||||||
- ピン名の候補の一覧が「今使えるピン」と誤解される問題。ダイアログに分けるなど。ボードを提供者にすれば、外に出ていないピンは候補から消える。
|
- ピン名の候補の一覧が「今使えるピン」と誤解される問題。ダイアログに分けるなど。ボードを提供者にすれば、外に出ていないピンは候補から消える。
|
||||||
- GPIOに直結したLED(インジケータなど)。仕様にないので `backlog/README.md` のELC-P-4とした。
|
- GPIOに直結したLED(インジケータなど)。仕様にないので `backlog/README.md` のELC-P-4とした。
|
||||||
|
|
||||||
|
## ELC Editorの範囲外
|
||||||
|
|
||||||
|
- Generatorのプレビューが倍マトリクスを「未対応」にできない。T-19の検査は両端子が `rowPins`/`colPins` に分かれているかだけを見て、
|
||||||
|
向きが揃っているかを見ない。倍マトリクスの配線は検査を通り、同じ(row, col)を持つキーが2つある設定を出力してしまう。
|
||||||
|
単純なマトリクスに限る(ELC-P-3)なら、向きが揃っていることも検査条件に要る。
|
||||||
|
|
||||||
## モックの実装上の問題
|
## モックの実装上の問題
|
||||||
|
|
||||||
作り直しで解消する前提で、原因の記録だけ残す。
|
作り直しで解消する前提で、原因の記録だけ残す。
|
||||||
|
|||||||
Reference in New Issue
Block a user