Compare commits
13
Commits
master
...
2ecc535c16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ecc535c16 | ||
|
|
101ffb73d1 | ||
|
|
3894df8069 | ||
|
|
c5ac41b1cc | ||
|
|
f604ca4043 | ||
|
|
8f6e74758e | ||
|
|
91f21dc191 | ||
|
|
f03858daa9 | ||
|
|
ef55ff9c81 | ||
|
|
c23513bae7 | ||
|
|
7695d0f51d | ||
|
|
fccbc28bee | ||
|
|
937fc27dbd |
@@ -0,0 +1,4 @@
|
||||
// VS Codeのユーザー設定(markdownlint.config)と同じ規則にそろえる
|
||||
{
|
||||
"MD007": { "indent": 4 }
|
||||
}
|
||||
@@ -127,6 +127,9 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
||||
- ドキュメントを更新するときは、上のルール1〜5のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
|
||||
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
|
||||
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
|
||||
- Markdownをコミットする前に、変更したファイルへ `npx markdownlint-cli2 --fix <files>` をかける。
|
||||
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
|
||||
規則は `.markdownlint.jsonc`。変更していないファイルの指摘は直さない。
|
||||
|
||||
## 未確認事項
|
||||
|
||||
|
||||
@@ -86,6 +86,15 @@
|
||||
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
|
||||
全方式で走査手順が一意に決まることの証明は求めない。
|
||||
|
||||
### ELC-P-4 GPIOに直結したLEDの表し方
|
||||
|
||||
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「LEDの識別番号」
|
||||
- 未決事項: インジケータなど、チェーンに入らずマイコンのピンで直接点灯するLEDをELCにどう持つか。
|
||||
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
|
||||
- 保留できる理由: チェーン方式(WS2812系)のLEDとキーの配線は、直結LEDと独立に扱える。
|
||||
- 再開条件: インジケータLEDの出力に対応するとき、またはLED配線の編集画面の仕様を作るとき。
|
||||
- 完了条件: 実在の基板で直結LEDの配線を確認し、ELCでの表し方と、ファームウェア出力に必要な情報を定める。
|
||||
|
||||
## IDX
|
||||
|
||||
### IDX-P-1 単一のorderから既存コミュニティレイアウトの並びを得られる範囲
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1 @@
|
||||
{ "printWidth": 110 }
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function projectJson(page: import("@playwright/test").Page) {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(file);
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
async function loadProject(page: import("@playwright/test").Page, project: unknown) {
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "case.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(project)),
|
||||
});
|
||||
}
|
||||
|
||||
test("provider, key terminals and diode edit wiring and show collisions", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await expect(page.getByText(/MCU · mcu · RP2040/)).toBeVisible();
|
||||
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
const first = view.locator(".kv-key").nth(0);
|
||||
await first.click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("15");
|
||||
await expect(first).toHaveClass(/ok/);
|
||||
await expect(first).toContainText("GP0/GP1");
|
||||
|
||||
await view.locator(".kv-key").nth(1).click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||
await expect(view.locator(".kv-key.error")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("batch wiring applies selected keys in row-major PHY order", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
const views = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
const batchView = views.nth(1);
|
||||
await batchView.locator(".kv-key").nth(0).click();
|
||||
await batchView.locator(".kv-key").nth(1).click();
|
||||
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
await expect(page.getByTestId("batch-preview")).toBeVisible();
|
||||
await expect(page.getByTestId("batch-preview").locator("tbody tr").nth(0)).toContainText("MCU.GP0");
|
||||
await page.getByTestId("batch-confirm").click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("14");
|
||||
await expect(views.first().locator(".kv-key").nth(0)).toContainText("GP0/GP1");
|
||||
await expect(views.first().locator(".kv-key").nth(1)).toContainText("GP0/GP2");
|
||||
});
|
||||
|
||||
test("batch wiring previews overwritten assignments and blocks insufficient capacity", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||
await view.locator(".kv-key").first().click();
|
||||
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP5");
|
||||
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP6");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
const batchView = page.locator(".wiring-box [data-testid=keyboard-view]").nth(1);
|
||||
for (let i = 0; i < 3; i++) await batchView.locator(".kv-key").nth(i).click();
|
||||
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
await expect(page.getByTestId("batch-capacity-error")).toBeVisible();
|
||||
await expect(page.getByTestId("batch-confirm")).toBeDisabled();
|
||||
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||
await page.getByTestId("batch-wire").click();
|
||||
const rows = page.getByTestId("batch-preview").locator("tbody tr");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(rows.first()).toContainText("上書き");
|
||||
});
|
||||
|
||||
test("diode direction is part of collision identity and unknown MCU parts accept free pin names", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
let source = await projectJson(page);
|
||||
const [k1, k2] = source.idx.order;
|
||||
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||
source.elc.keys = [
|
||||
{ key: k1, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "a-to-b" },
|
||||
{ key: k2, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "b-to-a" },
|
||||
];
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false })).toHaveCount(0);
|
||||
source.elc.keys[1].diode = "a-to-b";
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("provider-id").fill("ALT");
|
||||
await page.locator("input[list=mcu-parts]").fill("Unknown-MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await expect(page.getByText("未知の品番はピン名を自由入力します。")).toBeVisible();
|
||||
const key = page.locator(".wiring-box .kv-key").nth(2);
|
||||
await key.click();
|
||||
await page.getByLabel("a端子自由入力").fill("ALT.PIN_X");
|
||||
await page.getByLabel("b端子自由入力").fill("ALT.PIN_Y");
|
||||
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||
await expect(page.getByTestId("unwired-count")).toHaveText("13");
|
||||
});
|
||||
|
||||
test("LED chain candidates include only per-key LED keys and @led parts across all variants", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const source = await projectJson(page);
|
||||
const keys = [] as any[];
|
||||
const walk = (nodes: any[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "key") keys.push(n);
|
||||
else if (n.type === "cluster") walk(n.children);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v: any) => walk(v.children));
|
||||
});
|
||||
walk(source.phy.root);
|
||||
keys[0].perKeyLED = true;
|
||||
keys[1].perKeyLED = false;
|
||||
source.phy.root.push({
|
||||
type: "component",
|
||||
id: "cmp-test-led",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
partGroup: "@led",
|
||||
});
|
||||
source.phy.root.push({
|
||||
type: "component",
|
||||
id: "cmp-test-other",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
partGroup: "@screw",
|
||||
});
|
||||
await loadProject(page, source);
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByLabel("チェーンID").fill("LED1");
|
||||
await page.getByLabel("データピン").fill("MCU.DATA");
|
||||
await page.getByRole("button", { name: "作成" }).click();
|
||||
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[0].id}"]`)).toHaveCount(1);
|
||||
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-led"]')).toHaveCount(1);
|
||||
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[1].id}"]`)).toHaveCount(0);
|
||||
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-other"]')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
test("generates the 4x4 preview and blocks output when a wire is removed", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(file);
|
||||
const source = JSON.parse(await (await import("node:fs/promises")).readFile(file, "utf8"));
|
||||
const keyIds: string[] = source.idx.order;
|
||||
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||
source.elc.keys = keyIds.map((key, i) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: `MCU.GP${Math.floor(i / 4)}`, b: `MCU.GP${4 + (i % 4)}` },
|
||||
diode: "a-to-b",
|
||||
}));
|
||||
source.map.layers[0].keymap = keyIds.map((key, i) => ({ keyId: key, keycode: `KC_${i % 10}` }));
|
||||
const uploadProject = async (obj: unknown) => {
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooserPromise
|
||||
).setFiles({
|
||||
name: "fixture.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(obj)),
|
||||
});
|
||||
};
|
||||
await uploadProject(source);
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await page.getByTestId("row-pin-select").selectOption(`MCU.GP${i}`);
|
||||
await page.getByRole("button", { name: "row追加" }).click();
|
||||
await page.getByTestId("col-pin-select").selectOption(`MCU.GP${i + 4}`);
|
||||
await page.getByRole("button", { name: "col追加" }).click();
|
||||
}
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.getByTestId("mapping-count")).toHaveText("16行");
|
||||
const rows = page.locator(".preview tbody tr");
|
||||
await expect(rows).toHaveCount(16);
|
||||
await expect(rows.nth(0).locator("td").nth(2)).toHaveText("0");
|
||||
await expect(rows.nth(0).locator("td").nth(3)).toHaveText("0");
|
||||
await expect(rows.nth(4).locator("td").nth(2)).toHaveText("1");
|
||||
await expect(rows.nth(4).locator("td").nth(3)).toHaveText("0");
|
||||
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.locator(".wiring-box [data-testid=keyboard-view] .kv-key").first().click();
|
||||
await page.getByRole("button", { name: "配線解除" }).click();
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.locator(".errors").getByText("unwired-key", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("project undo invalidates a result generated from the newer project state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "ELC" }).click();
|
||||
await page.getByTestId("provider-id").fill("MCU");
|
||||
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||
await page.getByRole("button", { name: "Generator" }).click();
|
||||
await page.getByTestId("generate-button").click();
|
||||
await expect(page.locator(".errors")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(".errors")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Redo" }).click();
|
||||
await expect(page.locator(".errors")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
});
|
||||
|
||||
test("register unindexed key, reorder its output number, and undo", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||
await page.getByRole("button", { name: "キー追加" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
|
||||
const unregisteredRow = page.locator(".idx-all-keys li.unregistered").first();
|
||||
const keyId = (await unregisteredRow.locator("b").innerText()).trim();
|
||||
await unregisteredRow.getByRole("button", { name: "登録" }).click();
|
||||
const row = page.getByTestId(`idx-row-${keyId}`);
|
||||
await expect(row).toBeVisible();
|
||||
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
|
||||
await expect(previewKey).toHaveText("16");
|
||||
|
||||
await row.getByRole("button", { name: `${keyId}を上へ` }).click();
|
||||
await expect(previewKey).toHaveText("15");
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(previewKey).toHaveText("16");
|
||||
});
|
||||
|
||||
test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||
const treeKeys = page.locator(".tree-node").filter({ hasText: "キー ·" });
|
||||
const firstId = await treeKeys.nth(0).getAttribute("data-tree-id");
|
||||
const secondId = await treeKeys.nth(1).getAttribute("data-tree-id");
|
||||
await treeKeys.nth(0).click();
|
||||
await treeKeys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
|
||||
await page.getByLabel("Layout 出力バリアント").selectOption({ label: "Option B" });
|
||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
|
||||
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
|
||||
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
|
||||
await expect(remaining).toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
});
|
||||
|
||||
test("assign codes and rename referenced layers safely", async ({ page }) => {
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("レイヤー名を入力してください");
|
||||
await page.getByLabel("追加するレイヤー名").fill("Base");
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("already exists");
|
||||
|
||||
const keys = page.locator('[data-testid="keyboard-view"] [data-key-id]');
|
||||
const firstId = await keys.nth(0).getAttribute("data-key-id");
|
||||
const secondId = await keys.nth(1).getAttribute("data-key-id");
|
||||
await keys.nth(0).click();
|
||||
await page.getByLabel("キーコード").fill("KC_A");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${firstId}"] text`)).toContainText("KC_A");
|
||||
|
||||
await page.getByLabel("追加するレイヤー名").fill("Nav");
|
||||
await page.getByTestId("map-add-layer").click();
|
||||
await expect(page.getByRole("tab", { name: "Nav" })).toHaveAttribute("aria-selected", "true");
|
||||
await keys.nth(1).click();
|
||||
await page.getByLabel("キーコード").fill("MO(Nav)");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Nav)");
|
||||
|
||||
await page.getByLabel("新しい名前").fill("Navigation");
|
||||
await page.getByTestId("map-rename-layer").click();
|
||||
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Navigation)");
|
||||
await page.getByTestId("map-delete-layer").click();
|
||||
await expect(page.getByRole("alert")).toContainText("referenced");
|
||||
});
|
||||
|
||||
test("assignment click creates one undo step and unknown existing codes stay read-only", async ({ page }) => {
|
||||
const key = page.locator('[data-testid="keyboard-view"] [data-key-id]').first();
|
||||
const id = await key.getAttribute("data-key-id");
|
||||
await key.click();
|
||||
await page.getByLabel("キーコード").fill("KC_A");
|
||||
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||
await expect(page.locator(`[data-key-id="${id}"] text`)).toContainText("KC_A");
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(page.locator(`[data-key-id="${id}"] text`)).not.toContainText("KC_A");
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filePath);
|
||||
const file = await readFile(filePath, "utf8");
|
||||
const source = JSON.parse(file);
|
||||
source.map.layers[0].keymap = [{ keyId: id, keycode: "KC_UNKNOWN_VENDOR_CODE" }];
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "unknown.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(source)),
|
||||
});
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await page.locator(`[data-testid="keyboard-view"] [data-key-id="${id}"]`).click();
|
||||
await expect(page.getByRole("status")).toContainText("未知のキーコード");
|
||||
await expect(page.getByRole("button", { name: "割り当て", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "割り当て解除" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("MAP editing controls are disabled when profile unit sizes differ", async ({ page }) => {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filePath);
|
||||
const source = JSON.parse(await readFile(filePath, "utf8"));
|
||||
source.phy.profiles.push({
|
||||
...source.phy.profiles[0],
|
||||
id: "alternate",
|
||||
name: "Alternate",
|
||||
unitSize: { x: 18, y: 18 },
|
||||
});
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "unit-mismatch.bkle.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(source)),
|
||||
});
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-rename-layer")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-delete-layer")).toBeDisabled();
|
||||
await expect(page.getByLabel("キーコード")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
});
|
||||
|
||||
test("drag snaps to quarter units and undo/redo", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const before = await key.getAttribute("transform");
|
||||
const box = await key.boundingBox();
|
||||
if (!box) throw new Error("Key has no bounds");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2 + 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const moved = await key.getAttribute("transform");
|
||||
expect(moved).not.toBe(before);
|
||||
const x = Number(moved?.match(/translate\(([-\d.]+)/)?.[1]);
|
||||
expect(x / 0.25).toBeCloseTo(Math.round(x / 0.25), 6);
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(key).toHaveAttribute("transform", before!);
|
||||
await page.getByRole("button", { name: "Redo" }).click();
|
||||
await expect(key).toHaveAttribute("transform", moved!);
|
||||
});
|
||||
|
||||
test("dragging inside a rotated cluster tracks pointer movement", async ({ page }) => {
|
||||
const treeNodes = page.locator("[data-tree-id]");
|
||||
await treeNodes.nth(0).click();
|
||||
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスター化" }).click();
|
||||
const cluster = page.locator('[data-node-type="cluster"]').first();
|
||||
const clusterId = await cluster.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${clusterId}"]`).click();
|
||||
const rotation = page.getByLabel("Rotation");
|
||||
await rotation.fill("30");
|
||||
await rotation.dispatchEvent("change");
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const box = await key.boundingBox();
|
||||
if (!box) throw new Error("Key bounds missing");
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
const before = { ...start };
|
||||
const localBefore = await key.getAttribute("transform");
|
||||
const matrix = await key.evaluate((el) => {
|
||||
const m = (el.parentElement as unknown as SVGGElement).getScreenCTM()!;
|
||||
return [m.a, m.b, m.c, m.d] as const;
|
||||
});
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 32, start.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const after = await key.boundingBox();
|
||||
if (!after) throw new Error("Key bounds missing");
|
||||
const screenDx = after.x + after.width / 2 - before.x;
|
||||
const localAfter = await key.getAttribute("transform");
|
||||
const parse = (value: string | null) => {
|
||||
const match = value?.match(/translate\(([-\d.]+),([-\d.]+)\)/);
|
||||
return { x: Number(match?.[1]), y: Number(match?.[2]) };
|
||||
};
|
||||
const delta = {
|
||||
x: parse(localAfter).x - parse(localBefore).x,
|
||||
y: parse(localAfter).y - parse(localBefore).y,
|
||||
};
|
||||
expect(Math.abs(screenDx - (matrix[0] * delta.x + matrix[2] * delta.y))).toBeLessThan(4);
|
||||
expect(
|
||||
Math.abs(after.y + after.height / 2 - before.y - (matrix[1] * delta.x + matrix[3] * delta.y)),
|
||||
).toBeLessThan(4);
|
||||
expect(Math.abs(screenDx - 32)).toBeLessThan(15);
|
||||
});
|
||||
|
||||
test("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => {
|
||||
const positions = async (): Promise<Record<string, [number, number]>> =>
|
||||
page.locator('[data-node-type="key"]').evaluateAll((els) =>
|
||||
Object.fromEntries(
|
||||
els.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return [el.getAttribute("data-node-id"), [r.x + r.width / 2, r.y + r.height / 2]];
|
||||
}),
|
||||
),
|
||||
) as Promise<Record<string, [number, number]>>;
|
||||
const before = await positions();
|
||||
const treeNodes = page.locator("[data-tree-id]");
|
||||
const targetId = await page.locator('[data-node-type="key"]').first().getAttribute("data-node-id");
|
||||
await treeNodes.nth(0).click();
|
||||
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスター化" }).click();
|
||||
const clustered = await positions();
|
||||
for (const [id, point] of Object.entries(before)) {
|
||||
expect(clustered[id]?.[0]).toBeCloseTo(point[0], 0);
|
||||
expect(clustered[id]?.[1]).toBeCloseTo(point[1], 0);
|
||||
}
|
||||
|
||||
const target = page.locator(`[data-node-id="${targetId}"]`).first();
|
||||
const box = await target.boundingBox();
|
||||
if (!box) throw new Error("Target key bounds missing");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const after = await positions();
|
||||
for (const id of Object.keys(before)) {
|
||||
if (id === targetId) continue;
|
||||
expect(after[id]?.[0]).toBeCloseTo(before[id]![0]!, 0);
|
||||
expect(after[id]?.[1]).toBeCloseTo(before[id]![1]!, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("variant deletion uses the reference review and removes IDX references", async ({ page }) => {
|
||||
const initialKey = page.locator('[data-node-type="key"]').first();
|
||||
const keyId = await initialKey.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${keyId}"]`).click();
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
const controls = page.locator(".canvas-head label").last();
|
||||
await controls.locator("button").first().click(); // Add a third Variant so deletion can proceed.
|
||||
await controls.locator("button").last().click(); // Request deletion of the selected/default Variant.
|
||||
await expect(page.locator(".confirm-box")).toContainText("削除される参照");
|
||||
await expect(page.locator(".confirm-box")).toContainText("idx");
|
||||
await page.getByRole("button", { name: "参照と一緒に削除" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(page.getByTestId(`idx-row-${keyId}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("multi-key rotation is one undo step", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const ids = [
|
||||
await keys.nth(0).getAttribute("data-node-id"),
|
||||
await keys.nth(1).getAttribute("data-node-id"),
|
||||
];
|
||||
const before = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
const tree = page.locator("[data-tree-id]");
|
||||
await tree.nth(0).click();
|
||||
await tree.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択を+15°回転" }).click();
|
||||
const after = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
expect(after).not.toEqual(before);
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
const undone = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
expect(undone).toEqual(before);
|
||||
});
|
||||
|
||||
test("unsupported versions are rejected; save-blocked projects stay open and mismatches block panel edits", async ({
|
||||
page,
|
||||
}) => {
|
||||
const first = page.locator('[data-node-type="key"]').first();
|
||||
const firstId = await first.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${firstId}"]`).click();
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filepath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filepath);
|
||||
const fs = await import("node:fs/promises");
|
||||
const original = JSON.parse(await fs.readFile(filepath, "utf8"));
|
||||
const load = async (value: unknown, name: string) => {
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooserPromise
|
||||
).setFiles({
|
||||
name,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(value)),
|
||||
});
|
||||
};
|
||||
await load({ ...original, version: "0.2.0" }, "unsupported.bkle.json");
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
await expect(page.locator(".error")).toContainText("Unsupported ProjectFile version");
|
||||
|
||||
const duplicate = structuredClone(original);
|
||||
duplicate.idx.order[0] = "missing-key-reference";
|
||||
await load(duplicate, "missing-reference.bkle.json");
|
||||
await expect(page.getByTestId("save-blocked")).toBeVisible();
|
||||
|
||||
const mismatch = structuredClone(original);
|
||||
mismatch.phy.profiles.push({ ...mismatch.phy.profiles[0], id: "other", unitSize: { x: 2, y: 2 } });
|
||||
await load(mismatch, "unit-mismatch.bkle.json");
|
||||
await expect(page.getByText("unitSize が揃っていないPHYは編集できません。", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Layout").selectOption({ label: "Option B" });
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(15);
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await expect(page.getByLabel("追加するレイヤー名")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
|
||||
await expect(page.getByRole("tab", { name: "Blocked" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("project download can be loaded back", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const box = await key.boundingBox();
|
||||
if (!box) throw new Error("Key bounds missing");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 16, box.y + box.height / 2, { steps: 2 });
|
||||
await page.mouse.up();
|
||||
const savedTransform = await key.getAttribute("transform");
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filepath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filepath);
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles(filepath);
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
await expect(page.locator('[data-node-type="key"]').first()).toHaveAttribute("transform", savedTransform!);
|
||||
await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("KLE import creates sixty visible keys", async ({ page }) => {
|
||||
const kle = JSON.parse(await readFile(path.resolve(process.cwd(), "../samples/kle/cr0wn60.json"), "utf8"));
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({
|
||||
name: "cr0wn60.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(kle)),
|
||||
});
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head><meta charset="UTF-8" /><title>Beyond KLE Mock</title></head>
|
||||
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "beyond-kle-mock",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"vue": "^3.5.43"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"prettier": "^3.9.9",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^5.0.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
use: { browserName: "chromium", headless: true, baseURL: "http://127.0.0.1:5173" },
|
||||
webServer: {
|
||||
command: "npx vite --host 127.0.0.1",
|
||||
url: "http://127.0.0.1:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Generated
+991
@@ -0,0 +1,991 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
ajv-formats:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(ajv@8.20.0)
|
||||
vue:
|
||||
specifier: ^3.5.43
|
||||
version: 3.5.43(typescript@6.0.3)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
'@types/node':
|
||||
specifier: ^26.6.2
|
||||
version: 26.6.2
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.9
|
||||
version: 6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))
|
||||
prettier:
|
||||
specifier: ^3.9.9
|
||||
version: 3.9.9
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1(@types/node@26.6.2)
|
||||
vitest:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2))
|
||||
vue-tsc:
|
||||
specifier: ^3.3.11
|
||||
version: 3.3.11(typescript@6.0.3)
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@oxc-project/types@0.151.0':
|
||||
resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||
resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.11':
|
||||
resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||
resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||
resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||
resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||
resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||
resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||
resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||
resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||
resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@26.6.2':
|
||||
resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.9':
|
||||
resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/mocker@5.0.1':
|
||||
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/spy@5.0.1':
|
||||
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||
|
||||
'@volar/source-map@2.4.28':
|
||||
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
||||
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
|
||||
|
||||
'@vue/language-core@3.3.11':
|
||||
resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==}
|
||||
|
||||
'@vue/reactivity@3.5.43':
|
||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||
|
||||
'@vue/runtime-core@3.5.43':
|
||||
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
||||
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
||||
|
||||
'@vue/server-renderer@3.5.43':
|
||||
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
||||
|
||||
'@vue/shared@3.5.43':
|
||||
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
alien-signals@3.2.1:
|
||||
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@2.3.2:
|
||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.8:
|
||||
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.33.0:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@1.4.2:
|
||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||
|
||||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
obug@2.2.1:
|
||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.63.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.63.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prettier@3.9.9:
|
||||
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.2.11:
|
||||
resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
tinybench@6.1.4:
|
||||
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.9.0:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
vite@8.3.1:
|
||||
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@5.0.1:
|
||||
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.1
|
||||
'@vitest/browser-preview': 5.0.1
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.1
|
||||
'@vitest/coverage-v8': 5.0.1
|
||||
'@vitest/ui': 5.0.1
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.2.0:
|
||||
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
|
||||
|
||||
vue-tsc@3.3.11:
|
||||
resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
vue@3.5.43:
|
||||
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@oxc-project/types@0.151.0': {}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@26.6.2':
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
vue: 3.5.43(typescript@6.0.3)
|
||||
|
||||
'@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.1
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
optionalDependencies:
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
|
||||
'@vitest/spy@5.0.1': {}
|
||||
|
||||
'@volar/language-core@2.4.28':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.28
|
||||
|
||||
'@volar/source-map@2.4.28': {}
|
||||
|
||||
'@volar/typescript@2.4.28':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.2.0
|
||||
|
||||
'@vue/compiler-core@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/shared': 3.5.43
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/compiler-sfc@3.5.43':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@vue/compiler-core': 3.5.43
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.28
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/language-core@3.3.11':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
alien-signals: 3.2.1
|
||||
muggle-string: 0.4.1
|
||||
path-browserify: 1.0.1
|
||||
picomatch: 4.0.7
|
||||
|
||||
'@vue/reactivity@3.5.43':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-core@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/runtime-dom@3.5.43':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.43
|
||||
'@vue/runtime-core': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.43':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
|
||||
'@vue/shared@3.5.43': {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.8
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
alien-signals@3.2.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
es-module-lexer@2.3.2: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.8: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.7):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.33.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.33.0
|
||||
lightningcss-darwin-arm64: 1.33.0
|
||||
lightningcss-darwin-x64: 1.33.0
|
||||
lightningcss-freebsd-x64: 1.33.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.33.0
|
||||
lightningcss-linux-arm64-gnu: 1.33.0
|
||||
lightningcss-linux-arm64-musl: 1.33.0
|
||||
lightningcss-linux-x64-gnu: 1.33.0
|
||||
lightningcss-linux-x64-musl: 1.33.0
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
magic-string@1.4.2:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
playwright-core@1.63.0: {}
|
||||
|
||||
playwright@1.63.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prettier@3.9.9: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
rolldown@1.2.11:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.151.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.11
|
||||
'@rolldown/binding-android-arm64': 1.2.11
|
||||
'@rolldown/binding-darwin-arm64': 1.2.11
|
||||
'@rolldown/binding-darwin-x64': 1.2.11
|
||||
'@rolldown/binding-freebsd-x64': 1.2.11
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.11
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.11
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.11
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.11
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.11
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.11
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.11
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
tinybench@6.1.4: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@8.9.0: {}
|
||||
|
||||
vite@8.3.1(@types/node@26.6.2):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.2.11
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 1.4.2
|
||||
obug: 2.2.1
|
||||
picomatch: 4.0.7
|
||||
std-env: 4.2.0
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.1(@types/node@26.6.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.2
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.2.0: {}
|
||||
|
||||
vue-tsc@3.3.11(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
'@vue/language-core': 3.3.11
|
||||
typescript: 6.0.3
|
||||
|
||||
vue@3.5.43(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
'@vue/compiler-sfc': 3.5.43
|
||||
'@vue/runtime-dom': 3.5.43
|
||||
'@vue/server-renderer': 3.5.43
|
||||
'@vue/shared': 3.5.43
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import type { Node, Variant, VariantGroupNode } from "./phy";
|
||||
|
||||
export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string };
|
||||
type DeleteTarget =
|
||||
{ kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode };
|
||||
|
||||
function descendants(node: Node | Variant): string[] {
|
||||
const ids = [node.id];
|
||||
if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||
if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant));
|
||||
if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */
|
||||
export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] {
|
||||
const target = findTarget(p.phy.root, nodeId);
|
||||
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||
const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||
const out: CascadeReference[] = [];
|
||||
p.idx.order.forEach((id) => {
|
||||
if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id });
|
||||
});
|
||||
p.elc.keys.forEach((w, i) => {
|
||||
if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key });
|
||||
});
|
||||
p.elc.ledChains?.forEach((chain) =>
|
||||
chain.order.forEach((id) => {
|
||||
if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id });
|
||||
}),
|
||||
);
|
||||
p.map.layers.forEach((layer) =>
|
||||
layer.keymap.forEach((assignment) => {
|
||||
if (ids.has(assignment.keyId))
|
||||
out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId });
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */
|
||||
export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile {
|
||||
const o = structuredClone(p);
|
||||
const target = findTarget(o.phy.root, nodeId);
|
||||
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||
const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||
removeTarget(o.phy.root, target);
|
||||
|
||||
o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id));
|
||||
o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key));
|
||||
if (o.elc.ledChains)
|
||||
o.elc.ledChains = o.elc.ledChains.map((chain) => ({
|
||||
...chain,
|
||||
order: chain.order.filter((id) => !deletedIds.has(id)),
|
||||
}));
|
||||
o.map.layers = o.map.layers.map((layer) => ({
|
||||
...layer,
|
||||
keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)),
|
||||
}));
|
||||
|
||||
if (target.kind === "variant") {
|
||||
if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id;
|
||||
if (o.output.variantSelection[target.group.id] === nodeId)
|
||||
o.output.variantSelection[target.group.id] = target.group.default;
|
||||
}
|
||||
pruneVariantSelection(o);
|
||||
return o;
|
||||
}
|
||||
|
||||
function findTarget(nodes: Node[], id: string): DeleteTarget | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { kind: "node", node };
|
||||
if (node.type === "cluster") {
|
||||
const found = findTarget(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (node.type === "variantGroup")
|
||||
for (const variant of node.children) {
|
||||
if (variant.id === id) return { kind: "variant", variant, group: node };
|
||||
const found = findTarget(variant.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removeTarget(nodes: Node[], target: DeleteTarget): void {
|
||||
const id = target.kind === "node" ? target.node.id : target.variant.id;
|
||||
if (target.kind === "variant") {
|
||||
const { group, variant } = target;
|
||||
if (group.children.length <= 2)
|
||||
throw new Error("Cannot delete a variant leaving fewer than two variants");
|
||||
group.children.splice(group.children.indexOf(variant), 1);
|
||||
if (group.default === id) group.default = group.children[0]!.id;
|
||||
return;
|
||||
}
|
||||
const index = nodes.findIndex((node) => node.id === id);
|
||||
if (index >= 0) {
|
||||
nodes.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.type === "cluster") removeTarget(node.children, target);
|
||||
if (node.type === "variantGroup")
|
||||
for (const variant of node.children) removeTarget(variant.children, target);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneVariantSelection(project: ProjectFile): void {
|
||||
const valid = new Map<string, { ids: Set<string>; defaultId: string }>();
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === "cluster") walk(node.children);
|
||||
if (node.type === "variantGroup") {
|
||||
valid.set(node.id, {
|
||||
ids: new Set(node.children.map((variant) => variant.id)),
|
||||
defaultId: node.default,
|
||||
});
|
||||
node.children.forEach((variant) => walk(variant.children));
|
||||
}
|
||||
});
|
||||
walk(project.phy.root);
|
||||
for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) {
|
||||
const group = valid.get(groupId);
|
||||
if (!group) delete project.output.variantSelection[groupId];
|
||||
else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { PhyReference } from "./idx";
|
||||
export type PinRef = string;
|
||||
export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown };
|
||||
export type AnalogMuxProvider = {
|
||||
id: string;
|
||||
kind: "analog-mux";
|
||||
channels: number;
|
||||
select: PinRef[];
|
||||
common: PinRef;
|
||||
enable?: PinRef;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ContactWiring = {
|
||||
key: string;
|
||||
detect: "contact";
|
||||
terminals: { a: PinRef; b: PinRef };
|
||||
diode?: "a-to-b" | "b-to-a";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type AnalogWiring = {
|
||||
key: string;
|
||||
detect: "analog";
|
||||
terminals: { out: PinRef; vcc: PinRef; gnd: PinRef };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type KeyWiring = ContactWiring | AnalogWiring;
|
||||
export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown };
|
||||
export type ElcFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
providers: (McuProvider | AnalogMuxProvider)[];
|
||||
keys: KeyWiring[];
|
||||
ledChains?: LedChain[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */
|
||||
export function mcuPinCandidates(provider: McuProvider): string[] {
|
||||
return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : [];
|
||||
}
|
||||
/** Creates an empty ELC file. */
|
||||
export function createElc(ref: PhyReference): ElcFile {
|
||||
return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] };
|
||||
}
|
||||
/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */
|
||||
export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile {
|
||||
if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'");
|
||||
const o = structuredClone(elc);
|
||||
const i = o.providers.findIndex((x) => x.id === p.id);
|
||||
if (i < 0) o.providers.push(structuredClone(p));
|
||||
else {
|
||||
const old = o.providers[i]!;
|
||||
const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]);
|
||||
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||
o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as
|
||||
McuProvider | AnalogMuxProvider;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
/** Removes a provider and its dependent references. */
|
||||
export function removeProvider(elc: ElcFile, id: string): ElcFile {
|
||||
const o = structuredClone(elc),
|
||||
removed = new Set([id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const p of o.providers)
|
||||
if (p.kind === "analog-mux" && !removed.has(p.id)) {
|
||||
const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])];
|
||||
if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) {
|
||||
removed.add(p.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + "."));
|
||||
o.providers = o.providers.filter((p) => !removed.has(p.id));
|
||||
o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v)));
|
||||
if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data));
|
||||
return o;
|
||||
}
|
||||
/** Adds or edits terminal and diode configuration for a key. */
|
||||
export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
const i = o.keys.findIndex((x) => x.key === w.key);
|
||||
if (i < 0) o.keys.push(structuredClone(w));
|
||||
else {
|
||||
const old = o.keys[i]!;
|
||||
const known = new Set(["key", "detect", "terminals", "diode"]);
|
||||
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||
const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]);
|
||||
const terminalExtensions = Object.fromEntries(
|
||||
Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)),
|
||||
);
|
||||
const updated = {
|
||||
...extensions,
|
||||
...(old.detect === w.detect ? old : {}),
|
||||
...w,
|
||||
terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals },
|
||||
} as KeyWiring;
|
||||
if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode;
|
||||
o.keys[i] = updated;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
/** Removes wiring for a key. */
|
||||
export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
o.keys = o.keys.filter((x) => x.key !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Returns keys without any ELC entry. */
|
||||
export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] {
|
||||
const wired = new Set(elc.keys.map((k) => k.key));
|
||||
return keyIds.filter((k) => !wired.has(k));
|
||||
}
|
||||
/** Creates an LED chain with an independent ordered node list. T-11. */
|
||||
export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
o.ledChains ??= [];
|
||||
if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID");
|
||||
o.ledChains.push({ id, data, order: [] });
|
||||
return o;
|
||||
}
|
||||
/** Replaces LED order while preserving the chain's other fields. */
|
||||
export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile {
|
||||
const o = structuredClone(elc);
|
||||
const c = o.ledChains?.find((x) => x.id === id);
|
||||
if (!c) throw new Error("Unknown LED chain");
|
||||
c.order = [...order];
|
||||
return o;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc";
|
||||
|
||||
/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */
|
||||
export function wireMatrixKeys(
|
||||
elc: ElcFile,
|
||||
keyIds: string[],
|
||||
rowPins: string[],
|
||||
colPins: string[],
|
||||
diode?: "a-to-b" | "b-to-a",
|
||||
): ElcFile {
|
||||
if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required");
|
||||
if (keyIds.length > rowPins.length * colPins.length)
|
||||
throw new Error("Matrix does not have enough row/column positions");
|
||||
let next = elc;
|
||||
keyIds.forEach((key, i) => {
|
||||
const wiring: ContactWiring = {
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! },
|
||||
...(diode ? { diode } : {}),
|
||||
};
|
||||
// Keep unknown extension fields on an existing ELC entry, just like normal edits.
|
||||
next = setKeyWiring(next, wiring);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import { flattenKeys } from "./geometry";
|
||||
import { validateProject, type ValidationIssue } from "./validate";
|
||||
export type MockTarget = {
|
||||
format: "mock-target";
|
||||
version: "0.1.0";
|
||||
layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>;
|
||||
idx: string[];
|
||||
matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] };
|
||||
keymaps: Array<{ name: string; keycodes: string[] }>;
|
||||
mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>;
|
||||
};
|
||||
export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] };
|
||||
/** Generates preview output after validation, without modifying the project. T-2/T-19. */
|
||||
export function generateMockTarget(p: ProjectFile): GeneratorResult {
|
||||
const errors = validateProject(p).filter(
|
||||
(x) => x.severity === "blocksOutput" || x.severity === "blocksSave",
|
||||
);
|
||||
if (!p.output.matrix)
|
||||
errors.push({
|
||||
severity: "blocksOutput",
|
||||
code: "matrix-settings-missing",
|
||||
message: "Matrix rowPins and colPins must be configured",
|
||||
});
|
||||
if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix")
|
||||
errors.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-scan-mode",
|
||||
message: `${p.output.matrix.kind} scan mode is not supported`,
|
||||
});
|
||||
if (errors.length) return { errors };
|
||||
const selected = flattenKeys(p.phy, p.output.variantSelection),
|
||||
active = new Set(selected.map((x) => x.key.id)),
|
||||
order = p.idx.order.filter((id) => active.has(id)),
|
||||
matrix = p.output.matrix!;
|
||||
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
|
||||
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
|
||||
const wiring = new Map(p.elc.keys.map((x) => [x.key, x]));
|
||||
const row: number[] = [],
|
||||
col: number[] = [];
|
||||
for (const id of order) {
|
||||
const w = wiring.get(id);
|
||||
if (!w || w.detect !== "contact")
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-detection",
|
||||
message: "Only contact matrix wiring is supported",
|
||||
ids: [id],
|
||||
},
|
||||
],
|
||||
};
|
||||
const a = w.terminals.a,
|
||||
b = w.terminals.b;
|
||||
let r: number | undefined, c: number | undefined;
|
||||
if (rowIndex.has(a) && colIndex.has(b)) {
|
||||
r = rowIndex.get(a);
|
||||
c = colIndex.get(b);
|
||||
} else if (rowIndex.has(b) && colIndex.has(a)) {
|
||||
r = rowIndex.get(b);
|
||||
c = colIndex.get(a);
|
||||
}
|
||||
if (r === undefined || c === undefined)
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
severity: "blocksOutput",
|
||||
code: "unsupported-matrix-wiring",
|
||||
message: "Wiring is not a supported row/column matrix",
|
||||
ids: [id],
|
||||
},
|
||||
],
|
||||
};
|
||||
row.push(r);
|
||||
col.push(c);
|
||||
}
|
||||
const poses = new Map(selected.map((x) => [x.key.id, x]));
|
||||
const keymaps = p.map.layers.map((l) => ({
|
||||
name: l.name,
|
||||
keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode),
|
||||
}));
|
||||
const target: MockTarget = {
|
||||
format: "mock-target",
|
||||
version: "0.1.0",
|
||||
layout: order.map((id) => {
|
||||
const x = poses.get(id)!;
|
||||
return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h };
|
||||
}),
|
||||
idx: order,
|
||||
matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col },
|
||||
keymaps,
|
||||
mapping: order.map((id, i) => ({
|
||||
number: i,
|
||||
keyId: id,
|
||||
row: row[i]!,
|
||||
col: col[i]!,
|
||||
label: poses.get(id)!.key.labels?.find(Boolean) ?? "",
|
||||
})),
|
||||
};
|
||||
return { target, errors: [] };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FlatKey, Node, PhyFile, Transform } from "./phy";
|
||||
|
||||
/** Translate·Rotate のアフィン行列を返す。 */
|
||||
export function matrix(t: Transform): Affine {
|
||||
const a = (t.rotation * Math.PI) / 180,
|
||||
c = Math.cos(a),
|
||||
s = Math.sin(a);
|
||||
return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y };
|
||||
}
|
||||
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
|
||||
/** 左から右の順でなく、通常の行列積 left·right を返す。 */
|
||||
export function compose(left: Affine, right: Affine): Affine {
|
||||
return {
|
||||
a: left.a * right.a + left.c * right.b,
|
||||
b: left.b * right.a + left.d * right.b,
|
||||
c: left.a * right.c + left.c * right.d,
|
||||
d: left.b * right.c + left.d * right.d,
|
||||
e: left.a * right.e + left.c * right.f + left.e,
|
||||
f: left.b * right.e + left.d * right.f + left.f,
|
||||
};
|
||||
}
|
||||
/** 剛体変換の逆行列を返す。 */
|
||||
export function inverse(m: Affine): Affine {
|
||||
return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) };
|
||||
}
|
||||
/** 点に行列を適用する。 */
|
||||
export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } {
|
||||
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
|
||||
}
|
||||
/** PHY ノードのワールド行列を計算する。 */
|
||||
export function worldTransform(file: PhyFile, id: string): Affine {
|
||||
const walk = (nodes: Node[], parent: Affine): Affine | undefined => {
|
||||
for (const n of nodes) {
|
||||
const own = compose(parent, matrix(n.transform));
|
||||
if (n.id === id) return own;
|
||||
if (n.type === "cluster") {
|
||||
const r = walk(n.children, own);
|
||||
if (r) return r;
|
||||
}
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
const r = walk(v.children, own);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||
if (!found) throw new Error(`Unknown node: ${id}`);
|
||||
return found;
|
||||
}
|
||||
/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */
|
||||
export function flattenKeys(file: PhyFile, selection: Record<string, string> = {}): FlatKey[] {
|
||||
const out: FlatKey[] = [];
|
||||
const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => {
|
||||
for (const n of nodes) {
|
||||
const wm = compose(parent, matrix(n.transform)),
|
||||
p = [...path, n.id],
|
||||
absoluteRotation = parentRotation + n.transform.rotation;
|
||||
if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p });
|
||||
else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p);
|
||||
else if (n.type === "variantGroup") {
|
||||
const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default));
|
||||
if (!v) throw new Error(`Unknown variant for ${n.id}`);
|
||||
visit(v.children, wm, absoluteRotation, [...p, v.id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
/** Snapshot history for whole-project undo/redo. T-17. */
|
||||
export class ProjectHistory {
|
||||
private items: ProjectFile[];
|
||||
private cursor = 0;
|
||||
private pending = false;
|
||||
constructor(
|
||||
initial: ProjectFile,
|
||||
private readonly limit = 100,
|
||||
) {
|
||||
this.items = [structuredClone(initial)];
|
||||
}
|
||||
/** Adds one undoable snapshot unless a grouped edit is active. */
|
||||
push(value: ProjectFile): void {
|
||||
if (this.pending) {
|
||||
this.replaceTop(value);
|
||||
return;
|
||||
}
|
||||
this.add(value);
|
||||
}
|
||||
/** Starts grouping subsequent pushes into a single operation. T-17. */
|
||||
begin(): void {
|
||||
if (this.pending) throw new Error("History group already active");
|
||||
this.items = this.items.slice(0, this.cursor + 1);
|
||||
this.items.push(structuredClone(this.items[this.cursor]!));
|
||||
this.cursor = this.items.length - 1;
|
||||
if (this.items.length > this.limit) {
|
||||
this.items.shift();
|
||||
this.cursor--;
|
||||
}
|
||||
this.pending = true;
|
||||
}
|
||||
/** Commits the final grouped snapshot as one undo step. T-17. */
|
||||
commit(value?: ProjectFile): void {
|
||||
if (!this.pending) throw new Error("No history group active");
|
||||
if (value) this.items[this.cursor] = structuredClone(value);
|
||||
if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) {
|
||||
this.items.pop();
|
||||
this.cursor--;
|
||||
}
|
||||
this.pending = false;
|
||||
}
|
||||
/** Replaces the current snapshot during a grouped drag. T-17. */
|
||||
replaceTop(value: ProjectFile): void {
|
||||
this.items[this.cursor] = structuredClone(value);
|
||||
}
|
||||
/** Moves to the previous snapshot. */
|
||||
undo(): ProjectFile {
|
||||
if (!this.canUndo) throw new Error("Nothing to undo");
|
||||
return structuredClone(this.items[--this.cursor]!);
|
||||
}
|
||||
/** Moves to the next snapshot. */
|
||||
redo(): ProjectFile {
|
||||
if (!this.canRedo) throw new Error("Nothing to redo");
|
||||
return structuredClone(this.items[++this.cursor]!);
|
||||
}
|
||||
get canUndo(): boolean {
|
||||
return this.cursor > 0;
|
||||
}
|
||||
get canRedo(): boolean {
|
||||
return this.cursor < this.items.length - 1;
|
||||
}
|
||||
/** Returns a copy of the current snapshot. */
|
||||
current(): ProjectFile {
|
||||
return structuredClone(this.items[this.cursor]!);
|
||||
}
|
||||
private add(value: ProjectFile) {
|
||||
this.items = this.items.slice(0, this.cursor + 1);
|
||||
this.items.push(structuredClone(value));
|
||||
this.cursor = this.items.length - 1;
|
||||
if (this.items.length > this.limit) {
|
||||
this.items.shift();
|
||||
this.cursor--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */
|
||||
export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string {
|
||||
return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`;
|
||||
}
|
||||
/** T-11: ノード種別ごとのIDを生成する。 */
|
||||
export function createNodeId(
|
||||
kind: "key" | "cluster" | "component" | "variantGroup" | "variant",
|
||||
uuid?: () => string,
|
||||
): string {
|
||||
return createId(
|
||||
{ key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind],
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PhyFile, Node, KeyNode } from "./phy";
|
||||
export type PhyReference = { source: string; checksum: string; [key: string]: unknown };
|
||||
export type IdxFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
order: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Collects keys in document order, including ghost keys and excluding decals. T-18. */
|
||||
export function eligibleKeyIds(phy: PhyFile): string[] {
|
||||
const ids: string[] = [];
|
||||
const visit = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "key") {
|
||||
if (!n.decal) ids.push(n.id);
|
||||
} else if (n.type === "cluster") visit(n.children);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
|
||||
});
|
||||
visit(phy.root);
|
||||
return ids;
|
||||
}
|
||||
/** Makes initial index order from KLE occurrence order. T-18. */
|
||||
export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile {
|
||||
const valid = new Set(eligibleKeyIds(phy));
|
||||
return {
|
||||
version: "0.1.0",
|
||||
phyReference: structuredClone(phyReference),
|
||||
order: keyOrder.filter((id) => valid.has(id)),
|
||||
};
|
||||
}
|
||||
/** Lists eligible PHY keys omitted from the saved order. T-18. */
|
||||
export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] {
|
||||
const registered = new Set(idx.order);
|
||||
return eligibleKeyIds(phy).filter((id) => !registered.has(id));
|
||||
}
|
||||
/** Registers new keys at the end without rebuilding existing order. T-18. */
|
||||
export function appendKeys(idx: IdxFile, ids: string[]): IdxFile {
|
||||
const out = structuredClone(idx);
|
||||
for (const id of ids) if (!out.order.includes(id)) out.order.push(id);
|
||||
return out;
|
||||
}
|
||||
/** Removes one key from index order. */
|
||||
export function excludeKey(idx: IdxFile, keyId: string): IdxFile {
|
||||
const o = structuredClone(idx);
|
||||
o.order = o.order.filter((x) => x !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Reorders the saved key list. */
|
||||
export function reorderKeys(idx: IdxFile, order: string[]): IdxFile {
|
||||
if (
|
||||
order.length !== idx.order.length ||
|
||||
new Set(order).size !== order.length ||
|
||||
order.some((x) => !idx.order.includes(x))
|
||||
)
|
||||
throw new Error("Order must contain every registered key exactly once");
|
||||
return { ...structuredClone(idx), order: [...order] };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||
|
||||
/** Small US keyboard display map for common QMK basic keys. T-23. */
|
||||
export const keycodeLabels: Record<string, string> = {
|
||||
KC_A: "A",
|
||||
KC_B: "B",
|
||||
KC_C: "C",
|
||||
KC_D: "D",
|
||||
KC_E: "E",
|
||||
KC_F: "F",
|
||||
KC_G: "G",
|
||||
KC_H: "H",
|
||||
KC_I: "I",
|
||||
KC_J: "J",
|
||||
KC_K: "K",
|
||||
KC_L: "L",
|
||||
KC_M: "M",
|
||||
KC_N: "N",
|
||||
KC_O: "O",
|
||||
KC_P: "P",
|
||||
KC_Q: "Q",
|
||||
KC_R: "R",
|
||||
KC_S: "S",
|
||||
KC_T: "T",
|
||||
KC_U: "U",
|
||||
KC_V: "V",
|
||||
KC_W: "W",
|
||||
KC_X: "X",
|
||||
KC_Y: "Y",
|
||||
KC_Z: "Z",
|
||||
KC_1: "1",
|
||||
KC_2: "2",
|
||||
KC_3: "3",
|
||||
KC_4: "4",
|
||||
KC_5: "5",
|
||||
KC_6: "6",
|
||||
KC_7: "7",
|
||||
KC_8: "8",
|
||||
KC_9: "9",
|
||||
KC_0: "0",
|
||||
KC_SPC: "Space",
|
||||
KC_ENTER: "Enter",
|
||||
KC_ESC: "Escape",
|
||||
KC_TAB: "Tab",
|
||||
KC_MINUS: "-",
|
||||
KC_EQUAL: "=",
|
||||
KC_LBRC: "[",
|
||||
KC_RBRC: "]",
|
||||
KC_BSLS: "\\",
|
||||
KC_SCLN: ";",
|
||||
KC_QUOT: "'",
|
||||
KC_GRV: "`",
|
||||
KC_COMM: ",",
|
||||
KC_DOT: ".",
|
||||
KC_SLSH: "/",
|
||||
};
|
||||
|
||||
const qmkCodeByName = new Map(
|
||||
qmkBasicKeycodes.flatMap((entry) =>
|
||||
[entry.key, ...entry.aliases].map((name) => [name, entry.key] as const),
|
||||
),
|
||||
);
|
||||
|
||||
/** Looks up the existing US display label, resolving a QMK alias to its formal name. */
|
||||
export function getKeycodeLabel(code: string): string | undefined {
|
||||
const formalName = qmkCodeByName.get(code);
|
||||
if (!formalName) return keycodeLabels[code];
|
||||
return (
|
||||
keycodeLabels[code] ??
|
||||
keycodeLabels[formalName] ??
|
||||
qmkBasicKeycodes
|
||||
.find((entry) => entry.key === formalName)
|
||||
?.aliases.map((alias) => keycodeLabels[alias])
|
||||
.find((label): label is string => label !== undefined)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { PhyReference } from "./idx";
|
||||
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||
export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown };
|
||||
export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown };
|
||||
export type MapFile = {
|
||||
version: string;
|
||||
phyReference: PhyReference;
|
||||
layers: MapLayer[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ParsedKeycode = {
|
||||
kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown";
|
||||
code: string;
|
||||
layer?: string;
|
||||
inner?: ParsedKeycode;
|
||||
};
|
||||
const codesFor = (groups: QmkKeyGroup[]) =>
|
||||
new Set(
|
||||
qmkBasicKeycodes
|
||||
.filter((keycode) => groups.includes(keycode.group))
|
||||
.flatMap(({ key, aliases }) => [key, ...aliases]),
|
||||
);
|
||||
type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"];
|
||||
const codesForKey = (key: string) => {
|
||||
const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key);
|
||||
return new Set(entry ? [entry.key, ...entry.aliases] : []);
|
||||
};
|
||||
const basic = codesFor(["basic"]);
|
||||
const modifiers = codesFor(["modifiers"]);
|
||||
const noneCodes = codesForKey("KC_NO");
|
||||
const transparentCodes = codesForKey("KC_TRANSPARENT");
|
||||
/** Parses supported QMK keycode syntax without evaluation. T-22. */
|
||||
export function parseKeycode(s: string): ParsedKeycode {
|
||||
const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/);
|
||||
if (m) return { kind: "layer", code: m[1]!, layer: m[2]! };
|
||||
const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/);
|
||||
if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) };
|
||||
const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/);
|
||||
if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) };
|
||||
if (noneCodes.has(s)) return { kind: "none", code: s };
|
||||
if (transparentCodes.has(s)) return { kind: "transparent", code: s };
|
||||
if (basic.has(s)) return { kind: "basic", code: s };
|
||||
if (modifiers.has(s)) return { kind: "modifier", code: s };
|
||||
return { kind: "unknown", code: s };
|
||||
}
|
||||
|
||||
function simpleEditableKeycode(parsed: ParsedKeycode): boolean {
|
||||
return (
|
||||
parsed.kind === "basic" ||
|
||||
parsed.kind === "modifier" ||
|
||||
parsed.kind === "none" ||
|
||||
parsed.kind === "transparent"
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether a keycode uses syntax editable under T-22. */
|
||||
export function isEditableKeycode(keycode: string): boolean {
|
||||
const parsed = parseKeycode(keycode);
|
||||
if (parsed.kind === "unknown") return false;
|
||||
if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner);
|
||||
if (parsed.kind === "layer")
|
||||
return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner));
|
||||
return true;
|
||||
}
|
||||
/** Creates a MAP with one empty Base layer. T-22. */
|
||||
export function createMap(ref: PhyReference): MapFile {
|
||||
return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] };
|
||||
}
|
||||
/** Adds a uniquely named layer. T-22. */
|
||||
export function addLayer(map: MapFile, name: string): MapFile {
|
||||
if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists");
|
||||
const o = structuredClone(map);
|
||||
o.layers.push({ name, keymap: [] });
|
||||
return o;
|
||||
}
|
||||
/** Renames a layer and rewrites encoded layer references. T-22. */
|
||||
export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile {
|
||||
if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists");
|
||||
const o = structuredClone(map);
|
||||
const l = o.layers.find((x) => x.name === oldName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
l.name = newName;
|
||||
for (const x of o.layers)
|
||||
for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName);
|
||||
return o;
|
||||
}
|
||||
function rewriteLayerRef(s: string, oldName: string, newName: string): string {
|
||||
const p = parseKeycode(s);
|
||||
if (p.kind === "layer" && p.layer === oldName) {
|
||||
if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`;
|
||||
return `${p.code}(${newName})`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
/** Moves a layer to a new array position. */
|
||||
export function reorderLayer(map: MapFile, name: string, index: number): MapFile {
|
||||
const o = structuredClone(map),
|
||||
i = o.layers.findIndex((x) => x.name === name);
|
||||
if (i < 0) throw new Error("Unknown layer");
|
||||
const [l] = o.layers.splice(i, 1);
|
||||
o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!);
|
||||
return o;
|
||||
}
|
||||
/** Deletes an unreferenced layer; references block deletion. T-22. */
|
||||
export function deleteLayer(map: MapFile, name: string): MapFile {
|
||||
if (map.layers.length < 2) throw new Error("At least one layer is required");
|
||||
if (
|
||||
map.layers.some((l) =>
|
||||
l.keymap.some((a) => {
|
||||
const p = parseKeycode(a.keycode);
|
||||
return p.kind === "layer" && p.layer === name;
|
||||
}),
|
||||
)
|
||||
)
|
||||
throw new Error("Layer is referenced");
|
||||
const o = structuredClone(map);
|
||||
o.layers = o.layers.filter((l) => l.name !== name);
|
||||
return o;
|
||||
}
|
||||
/** Assigns a keycode to a key, replacing an existing assignment. T-22. */
|
||||
export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile {
|
||||
if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`);
|
||||
const parsed = parseKeycode(keycode);
|
||||
if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer))
|
||||
throw new Error(`Unknown MAP layer reference: ${parsed.layer}`);
|
||||
const o = structuredClone(map),
|
||||
l = o.layers.find((x) => x.name === layerName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
const i = l.keymap.findIndex((a) => a.keyId === keyId),
|
||||
old = i < 0 ? undefined : l.keymap[i],
|
||||
a = { ...old, keyId, keycode };
|
||||
if (i < 0) l.keymap.push(a);
|
||||
else l.keymap[i] = a;
|
||||
return o;
|
||||
}
|
||||
/** Removes a key assignment. */
|
||||
export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile {
|
||||
const o = structuredClone(map),
|
||||
l = o.layers.find((x) => x.name === layerName);
|
||||
if (!l) throw new Error("Unknown layer");
|
||||
l.keymap = l.keymap.filter((a) => a.keyId !== keyId);
|
||||
return o;
|
||||
}
|
||||
/** Lists keys not assigned in a layer. T-22. */
|
||||
export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] {
|
||||
const l = map.layers.find((x) => x.name === layerName);
|
||||
const assigned = new Set(l?.keymap.map((x) => x.keyId));
|
||||
return keyIds.filter((x) => !assigned?.has(x));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */
|
||||
export type Point = { x: number; y: number };
|
||||
export type Transform = { x: number; y: number; rotation: number };
|
||||
export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number };
|
||||
export type Profile = {
|
||||
id: string;
|
||||
name: string;
|
||||
unitSize: Point;
|
||||
visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number };
|
||||
};
|
||||
export type KleCompatibility = Record<string, unknown>;
|
||||
export type KeyNode = {
|
||||
type: "key";
|
||||
id: string;
|
||||
profile: string;
|
||||
transform: Transform;
|
||||
size: Size;
|
||||
switchOffset?: Point;
|
||||
partGroup?: string;
|
||||
orientation?: 0 | 90 | 180 | 270;
|
||||
perKeyLED?: boolean;
|
||||
decal?: boolean;
|
||||
ghost?: boolean;
|
||||
labels?: string[];
|
||||
kleCompatibility?: KleCompatibility;
|
||||
};
|
||||
export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string };
|
||||
export type ClusterNode = {
|
||||
type: "cluster";
|
||||
id: string;
|
||||
name?: string;
|
||||
transform: Transform;
|
||||
children: Node[];
|
||||
};
|
||||
export type Variant = { type: "variant"; id: string; name: string; children: Node[] };
|
||||
export type VariantGroupNode = {
|
||||
type: "variantGroup";
|
||||
id: string;
|
||||
name: string;
|
||||
transform: Transform;
|
||||
default: string;
|
||||
children: Variant[];
|
||||
};
|
||||
export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode;
|
||||
export type PhyFile = {
|
||||
version: string;
|
||||
metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string };
|
||||
profiles: Profile[];
|
||||
root: Node[];
|
||||
kleCompatibility?: KleCompatibility;
|
||||
};
|
||||
|
||||
export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] };
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy";
|
||||
import { apply, compose, inverse, matrix } from "./geometry";
|
||||
import { createNodeId } from "./ids";
|
||||
|
||||
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
|
||||
function containers(f: PhyFile): Map<string, Container> {
|
||||
const m = new Map<string, Container>();
|
||||
const rec = (ns: Node[], w: typeof ident, angle: number) => {
|
||||
for (const n of ns) {
|
||||
m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle });
|
||||
const nw = compose(w, matrix(n.transform));
|
||||
const nextAngle = angle + n.transform.rotation;
|
||||
if (n.type === "cluster") rec(n.children, nw, nextAngle);
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle });
|
||||
rec(v.children, nw, nextAngle);
|
||||
}
|
||||
}
|
||||
};
|
||||
rec(f.root, ident, 0);
|
||||
return m;
|
||||
}
|
||||
function find(f: PhyFile, id: string): Node {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
return n;
|
||||
}
|
||||
function world(f: PhyFile, id: string): typeof ident {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
return compose(c!.parentWorld, matrix(n.transform));
|
||||
}
|
||||
function worldRotation(f: PhyFile, id: string): number {
|
||||
const c = containers(f).get(id);
|
||||
const n = c?.nodes.find((x) => x.id === id);
|
||||
if (!n || !c) throw new Error(`Unknown node: ${id}`);
|
||||
return c.parentRotation + n.transform.rotation;
|
||||
}
|
||||
function mutable(f: PhyFile): PhyFile {
|
||||
return structuredClone(f);
|
||||
}
|
||||
function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
|
||||
return createNodeId(kind, gen);
|
||||
}
|
||||
/** 新しいPHYを返す。T-11。 */
|
||||
export function addKey(
|
||||
f: PhyFile,
|
||||
at: Transform,
|
||||
size: { w: number; h: number },
|
||||
profile = f.profiles[0]!.id,
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f);
|
||||
o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||
return o;
|
||||
}
|
||||
/** 新しいPHYを返す。T-11。 */
|
||||
export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f);
|
||||
o.root.push({
|
||||
type: "component",
|
||||
id: nodeId("component", gen),
|
||||
transform: { ...at },
|
||||
...(partGroup === undefined ? {} : { partGroup }),
|
||||
});
|
||||
return o;
|
||||
}
|
||||
/** ローカル座標の差分でノードを移動する。 */
|
||||
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
n.transform.x += dx;
|
||||
n.transform.y += dy;
|
||||
return o;
|
||||
}
|
||||
/** ノードを回転する。 */
|
||||
export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
n.transform.rotation += delta;
|
||||
return o;
|
||||
}
|
||||
/** キーのサイズを変更する。 */
|
||||
export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
if (n.type !== "key") throw new Error(`${id} is not a key`);
|
||||
n.size = { ...size };
|
||||
return o;
|
||||
}
|
||||
/** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */
|
||||
export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile {
|
||||
if (ids.length < 1) throw new Error("Select at least one node");
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
first = cs.get(ids[0]!);
|
||||
if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes))
|
||||
throw new Error("Selected nodes must be siblings");
|
||||
const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!);
|
||||
if (selected.some((n) => !n)) throw new Error("Unknown selection");
|
||||
const angles = selected.map((n) => world(o, n.id));
|
||||
const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation);
|
||||
const angle = selectedAngles[0]!;
|
||||
const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9);
|
||||
const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }),
|
||||
basisInverse = inverse(basis);
|
||||
const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!));
|
||||
if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f }));
|
||||
const localOrigins = shapePoints.map((p) => apply(basisInverse, p));
|
||||
const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) };
|
||||
const origin = apply(basis, min);
|
||||
const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 });
|
||||
const local = compose(inverse(first.parentWorld), wt);
|
||||
const id = nodeId("cluster", gen),
|
||||
cluster: ClusterNode = {
|
||||
type: "cluster",
|
||||
id,
|
||||
name,
|
||||
transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation },
|
||||
children: [],
|
||||
};
|
||||
const inv = inverse(wt);
|
||||
cluster.children = selected.map((n) => {
|
||||
const w = world(o, n.id),
|
||||
rel = compose(inv, w);
|
||||
return {
|
||||
...n,
|
||||
transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) },
|
||||
} as Node;
|
||||
});
|
||||
const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b);
|
||||
const insert = indices[0]!;
|
||||
first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id)));
|
||||
first.nodes.splice(insert, 0, cluster);
|
||||
return o;
|
||||
}
|
||||
/** clusterの子を同じ親へ戻し、絶対変換を保つ。 */
|
||||
export function uncluster(f: PhyFile, id: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
c = cs.get(id),
|
||||
cluster = c?.nodes.find((n) => n.id === id);
|
||||
if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||
const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation;
|
||||
const cw = compose(c!.parentWorld, matrix(cluster.transform)),
|
||||
converted = cluster.children.map((n) => {
|
||||
const w = compose(cw, matrix(n.transform)),
|
||||
rel = compose(inverse(c!.parentWorld), w);
|
||||
return {
|
||||
...n,
|
||||
transform: {
|
||||
x: rel.e,
|
||||
y: rel.f,
|
||||
rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation,
|
||||
},
|
||||
} as Node;
|
||||
});
|
||||
const index = c!.nodes.indexOf(cluster);
|
||||
c!.nodes.splice(index, 1, ...converted);
|
||||
return o;
|
||||
}
|
||||
/** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */
|
||||
export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
src = cs.get(id),
|
||||
n = src?.nodes.find((x) => x.id === id);
|
||||
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||
const oldW = world(o, id),
|
||||
oldRotation = worldRotation(o, id);
|
||||
let target: Node[] = o.root,
|
||||
parentW = ident,
|
||||
parentRotation = 0;
|
||||
if (newParentId) {
|
||||
const p = find(o, newParentId);
|
||||
if (p.type !== "cluster")
|
||||
throw new Error("Target parent must be a cluster; use reparentToVariant for variant children");
|
||||
if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant");
|
||||
target = p.children;
|
||||
parentW = world(o, p.id);
|
||||
parentRotation = worldRotation(o, p.id);
|
||||
}
|
||||
const oldIndex = src!.nodes.indexOf(n),
|
||||
sameParent = target === src!.nodes;
|
||||
src!.nodes.splice(oldIndex, 1);
|
||||
const rel = compose(inverse(parentW), oldW);
|
||||
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||
let at = index ?? target.length;
|
||||
if (sameParent && index !== undefined && oldIndex < index) at--;
|
||||
target.splice(Math.max(0, Math.min(at, target.length)), 0, n);
|
||||
return o;
|
||||
}
|
||||
/** variant内へ移動するreparent。T-27。 */
|
||||
export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile {
|
||||
const o = mutable(f),
|
||||
cs = containers(o),
|
||||
src = cs.get(id),
|
||||
n = src?.nodes.find((x) => x.id === id);
|
||||
const v = findVariant(o, variantId);
|
||||
if (!n || !src) throw new Error(`Unknown node: ${id}`);
|
||||
if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant");
|
||||
const old = world(o, id),
|
||||
oldRotation = worldRotation(o, id),
|
||||
parent = srcVariantWorld(o, variantId),
|
||||
rel = compose(inverse(parent), old);
|
||||
const parentRotation = srcVariantRotation(o, variantId);
|
||||
src.nodes.splice(src.nodes.indexOf(n), 1);
|
||||
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||
v.children.splice(index ?? v.children.length, 0, n);
|
||||
return o;
|
||||
}
|
||||
function containsNode(n: Node, id: string): boolean {
|
||||
if (n.id === id) return true;
|
||||
if (n.type === "cluster") return n.children.some((c) => containsNode(c, id));
|
||||
if (n.type === "variantGroup")
|
||||
return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id)));
|
||||
return false;
|
||||
}
|
||||
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
|
||||
if (n.type === "key") {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: n.size.w, y: 0 },
|
||||
{ x: n.size.w, y: n.size.h },
|
||||
{ x: 0, y: n.size.h },
|
||||
];
|
||||
if (
|
||||
n.size.w2 !== undefined ||
|
||||
n.size.h2 !== undefined ||
|
||||
n.size.x2 !== undefined ||
|
||||
n.size.y2 !== undefined
|
||||
) {
|
||||
const x = n.size.x2 ?? 0;
|
||||
const y = n.size.y2 ?? 0;
|
||||
const w = n.size.w2 ?? n.size.w;
|
||||
const h = n.size.h2 ?? n.size.h;
|
||||
points.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h });
|
||||
}
|
||||
return points.map((point) => apply(worldMatrix, point));
|
||||
}
|
||||
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||
if (n.type === "cluster")
|
||||
return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))));
|
||||
const selected = n.children.find((variant) => variant.id === n.default);
|
||||
return selected
|
||||
? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))))
|
||||
: [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||
}
|
||||
function findVariant(f: PhyFile, id: string): Variant {
|
||||
for (const n of f.root) {
|
||||
const r = walk(n, id);
|
||||
if (r) return r;
|
||||
}
|
||||
throw new Error(`Unknown variant: ${id}`);
|
||||
}
|
||||
function walk(n: Node, id: string): Variant | undefined {
|
||||
if (n.type === "variantGroup") {
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return v;
|
||||
for (const c of v.children) {
|
||||
const r = walk(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n.type === "cluster")
|
||||
for (const c of n.children) {
|
||||
const r = walk(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function srcVariantWorld(f: PhyFile, id: string): typeof ident {
|
||||
const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => {
|
||||
for (const n of ns) {
|
||||
const nw = compose(w, matrix(n.transform));
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return nw;
|
||||
const r = rec(v.children, nw);
|
||||
if (r) return r;
|
||||
}
|
||||
if (n.type === "cluster") {
|
||||
const r = rec(n.children, nw);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const x = rec(f.root, ident);
|
||||
if (!x) throw new Error(`Unknown variant ${id}`);
|
||||
return x;
|
||||
}
|
||||
function srcVariantRotation(f: PhyFile, id: string): number {
|
||||
const rec = (nodes: Node[], parentRotation: number): number | undefined => {
|
||||
for (const n of nodes) {
|
||||
const angle = parentRotation + n.transform.rotation;
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return angle;
|
||||
const found = rec(v.children, angle);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
if (n.type === "cluster") {
|
||||
const found = rec(n.children, angle);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const found = rec(f.root, 0);
|
||||
if (found === undefined) throw new Error(`Unknown variant ${id}`);
|
||||
return found;
|
||||
}
|
||||
/** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */
|
||||
export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id);
|
||||
if (n.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||
const c = containers(o).get(id)!,
|
||||
oldRotation = c.parentRotation + n.transform.rotation,
|
||||
old = compose(c.parentWorld, matrix(n.transform)),
|
||||
next = compose(c.parentWorld, matrix(frame)),
|
||||
correction = compose(inverse(next), old);
|
||||
n.children = n.children.map((ch) => {
|
||||
const t = compose(correction, matrix(ch.transform));
|
||||
return {
|
||||
...ch,
|
||||
transform: {
|
||||
x: t.e,
|
||||
y: t.f,
|
||||
rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation),
|
||||
},
|
||||
} as Node;
|
||||
});
|
||||
n.transform = { ...frame };
|
||||
return o;
|
||||
}
|
||||
/** VariantGroupを作成する。T-11。 */
|
||||
export function createVariantGroup(
|
||||
f: PhyFile,
|
||||
name: string,
|
||||
at: Transform,
|
||||
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||
gen?: () => string,
|
||||
): PhyFile {
|
||||
const o = mutable(f),
|
||||
children = variants.map((x) => ({
|
||||
type: "variant" as const,
|
||||
id: nodeId("variant", gen),
|
||||
name: x.name,
|
||||
children: structuredClone(x.children ?? []),
|
||||
}));
|
||||
o.root.push({
|
||||
type: "variantGroup",
|
||||
id: nodeId("variantGroup", gen),
|
||||
name,
|
||||
transform: { ...at },
|
||||
default: children[0]!.id,
|
||||
children,
|
||||
});
|
||||
return o;
|
||||
}
|
||||
/** Variantを追加する。T-11。 */
|
||||
export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||
g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] });
|
||||
return o;
|
||||
}
|
||||
/** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */
|
||||
export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||
if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants");
|
||||
const i = g.children.findIndex((v) => v.id === variantId);
|
||||
if (i < 0) throw new Error("Unknown variant");
|
||||
g.children.splice(i, 1);
|
||||
if (g.default === variantId) g.default = g.children[0]!.id;
|
||||
return o;
|
||||
}
|
||||
/** default Variantを変更する。 */
|
||||
export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
g = find(o, groupId);
|
||||
if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId))
|
||||
throw new Error("Invalid group or variant");
|
||||
g.default = variantId;
|
||||
return o;
|
||||
}
|
||||
/** ノードを削除する。 */
|
||||
export function deleteNode(f: PhyFile, id: string): PhyFile {
|
||||
const o = mutable(f),
|
||||
c = containers(o).get(id);
|
||||
if (!c) throw new Error(`Unknown node: ${id}`);
|
||||
const i = c.nodes.findIndex((n) => n.id === id);
|
||||
c.nodes.splice(i, 1);
|
||||
return o;
|
||||
}
|
||||
/** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */
|
||||
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
|
||||
const o = mutable(f),
|
||||
n = find(o, id),
|
||||
copy = structuredClone(n);
|
||||
const renew = (x: Node) => {
|
||||
x.id = nodeId(x.type, gen);
|
||||
if (x.type === "cluster") x.children.forEach(renew);
|
||||
if (x.type === "variantGroup")
|
||||
for (const v of x.children) {
|
||||
v.id = nodeId("variant", gen);
|
||||
v.children.forEach(renew);
|
||||
}
|
||||
};
|
||||
renew(copy);
|
||||
copy.transform.x += 0.25;
|
||||
copy.transform.y += 0.25;
|
||||
const c = containers(o).get(id)!;
|
||||
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
|
||||
return o;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PhyFile } from "./phy";
|
||||
import type { PhyReference } from "./idx";
|
||||
import { sha256Jcs } from "../io/json";
|
||||
/** Computes the project-local PHY reference checksum. T-12. */
|
||||
export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise<PhyReference> {
|
||||
return { source, checksum: await sha256Jcs(phy) };
|
||||
}
|
||||
/** Checks whether a stored checksum matches PHY. T-12. */
|
||||
export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise<boolean> {
|
||||
return ref.checksum === (await sha256Jcs(phy));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PhyFile, Node } from "./phy";
|
||||
import { createId } from "./ids";
|
||||
import { importKle } from "../io/kleImport";
|
||||
import { sha256Jcs } from "../io/json";
|
||||
import { createIdx, type IdxFile } from "./idx";
|
||||
import { createElc, type ElcFile } from "./elc";
|
||||
import { createMap, type MapFile } from "./map";
|
||||
export type MatrixOutputSettings = {
|
||||
rowPins: string[];
|
||||
colPins: string[];
|
||||
kind?: "matrix" | "charlieplexing" | "direct";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export type ProjectFile = {
|
||||
format: "beyond-kle-mock-project";
|
||||
version: string;
|
||||
projectId: string;
|
||||
phy: PhyFile;
|
||||
elc: ElcFile;
|
||||
idx: IdxFile;
|
||||
map: MapFile;
|
||||
output: { variantSelection: Record<string, string>; matrix?: MatrixOutputSettings; [key: string]: unknown };
|
||||
ui?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Creates default variant selections from PHY. T-10/T-20. */
|
||||
export function defaultVariantSelection(phy: PhyFile): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children);
|
||||
if (n.type === "variantGroup") {
|
||||
out[n.id] = n.default;
|
||||
const v = n.children.find((x) => x.id === n.default);
|
||||
if (v) walk(v.children);
|
||||
}
|
||||
});
|
||||
walk(phy.root);
|
||||
return out;
|
||||
}
|
||||
/** Creates an empty project with default reference values. T-10/T-13. */
|
||||
export async function createProject(phy: PhyFile, gen?: () => string): Promise<ProjectFile> {
|
||||
const ref = { source: "project:phy", checksum: await sha256Jcs(phy) };
|
||||
return {
|
||||
format: "beyond-kle-mock-project",
|
||||
version: "0.1.0",
|
||||
projectId: createId("project-", gen),
|
||||
phy: structuredClone(phy),
|
||||
elc: createElc(ref),
|
||||
idx: createIdx(phy, [], ref),
|
||||
map: createMap(ref),
|
||||
output: { variantSelection: defaultVariantSelection(phy) },
|
||||
};
|
||||
}
|
||||
/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */
|
||||
export async function createProjectFromKle(
|
||||
input: unknown,
|
||||
options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {},
|
||||
): Promise<ProjectFile> {
|
||||
const { phy, keyOrder } = importKle(input, options);
|
||||
const p = await createProject(phy, options.projectIdGenerator);
|
||||
p.idx = createIdx(phy, keyOrder, p.idx.phyReference);
|
||||
return p;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
import type { ProjectFile } from "./project";
|
||||
import { flattenKeys } from "./geometry";
|
||||
import type { Node } from "./phy";
|
||||
import { eligibleKeyIds, unregisteredKeys } from "./idx";
|
||||
import { parseKeycode } from "./map";
|
||||
import { validateSchema } from "../io/validateSchema";
|
||||
export type ValidationIssue = {
|
||||
severity: "blocksSave" | "blocksOutput" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
ids?: string[];
|
||||
};
|
||||
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
|
||||
export function validateProject(p: ProjectFile): ValidationIssue[] {
|
||||
const out: ValidationIssue[] = [];
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||
for (const e of validateSchema(layer, p[layer]))
|
||||
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
|
||||
if (p.format !== "beyond-kle-mock-project")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "project-format",
|
||||
message: "Unsupported ProjectFile format",
|
||||
path: "/format",
|
||||
});
|
||||
if (p.version !== "0.1.0")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: "Unsupported ProjectFile version",
|
||||
path: "/version",
|
||||
});
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||
if (p[layer].version !== "0.1.0")
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: `Unsupported ${layer} version`,
|
||||
path: `/${layer}/version`,
|
||||
});
|
||||
const ids = new Map<string, string[]>();
|
||||
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
|
||||
const walk = (ns: Node[], path: string) =>
|
||||
ns.forEach((n, i) => {
|
||||
const q = `${path}/${i}`;
|
||||
add(n.id, q);
|
||||
if (n.type === "cluster") walk(n.children, q + "/children");
|
||||
if (n.type === "variantGroup") {
|
||||
if (!n.children.some((v) => v.id === n.default))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-default-variant",
|
||||
message: "VariantGroup default does not reference a child",
|
||||
path: q + "/default",
|
||||
ids: [n.id, n.default],
|
||||
});
|
||||
for (const v of n.children) {
|
||||
add(v.id, q + "/children");
|
||||
walk(v.children, q + `/children/${v.id}/children`);
|
||||
}
|
||||
}
|
||||
});
|
||||
walk(p.phy.root, "/phy/root");
|
||||
for (const [id, paths] of ids)
|
||||
if (paths.length > 1)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-id",
|
||||
message: "Duplicate PHY node ID",
|
||||
ids: [id],
|
||||
path: paths[1],
|
||||
});
|
||||
const nodes = new Set(ids.keys()),
|
||||
keys = new Set(eligibleKeyIds(p.phy));
|
||||
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
|
||||
if (!valid.has(id))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-reference",
|
||||
message: `Unknown reference: ${id}`,
|
||||
path,
|
||||
ids: [id],
|
||||
});
|
||||
};
|
||||
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
|
||||
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
|
||||
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
|
||||
p.map.layers.forEach((l, i) =>
|
||||
l.keymap.forEach((a, j) => {
|
||||
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
|
||||
const keycode = parseKeycode(a.keycode);
|
||||
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-map-layer-reference",
|
||||
message: `Unknown MAP layer reference: ${keycode.layer}`,
|
||||
path: `/map/layers/${i}/keymap/${j}/keycode`,
|
||||
ids: [l.name, keycode.layer ?? ""],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const providerIds = new Map<string, number>();
|
||||
p.elc.providers.forEach((provider, index) => {
|
||||
const first = providerIds.get(provider.id);
|
||||
if (first !== undefined)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-provider-id",
|
||||
message: "Provider IDs must be unique",
|
||||
path: `/elc/providers/${index}/id`,
|
||||
ids: [provider.id],
|
||||
});
|
||||
else providerIds.set(provider.id, index);
|
||||
if (!provider.id || provider.id.includes("."))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "invalid-provider-id",
|
||||
message: "Provider IDs must be nonempty and cannot contain '.'",
|
||||
path: `/elc/providers/${index}/id`,
|
||||
ids: [provider.id],
|
||||
});
|
||||
});
|
||||
const chainIds = new Set<string>();
|
||||
p.elc.ledChains?.forEach((chain, index) => {
|
||||
if (chainIds.has(chain.id))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-led-chain-id",
|
||||
message: "LED chain IDs must be unique",
|
||||
path: `/elc/ledChains/${index}/id`,
|
||||
ids: [chain.id],
|
||||
});
|
||||
chainIds.add(chain.id);
|
||||
});
|
||||
const wiredKeys = new Set<string>();
|
||||
p.elc.keys.forEach((wiring, index) => {
|
||||
if (wiredKeys.has(wiring.key))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-key-wiring",
|
||||
message: "A key may have only one ELC wiring entry",
|
||||
path: `/elc/keys/${index}/key`,
|
||||
ids: [wiring.key],
|
||||
});
|
||||
wiredKeys.add(wiring.key);
|
||||
});
|
||||
|
||||
const validatePinRef = (pin: string, path: string) => {
|
||||
if (pin === "VCC" || pin === "GND") return;
|
||||
const parts = pin.split(".");
|
||||
const providerId = parts[0];
|
||||
const pinName = parts[1];
|
||||
if (parts.length !== 2 || !providerId || !pinName) {
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "invalid-pin-reference",
|
||||
message: `Malformed PinRef: ${pin}`,
|
||||
path,
|
||||
ids: [pin],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-pin-provider",
|
||||
message: `Unknown PinRef provider: ${providerId}`,
|
||||
path,
|
||||
ids: [providerId, pin],
|
||||
});
|
||||
return;
|
||||
}
|
||||
let valid = true;
|
||||
if (provider.kind === "analog-mux")
|
||||
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
|
||||
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
|
||||
if (!valid)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "unknown-provider-pin",
|
||||
message: `Unknown pin ${pinName} on provider ${providerId}`,
|
||||
path,
|
||||
ids: [providerId, pinName],
|
||||
});
|
||||
};
|
||||
p.elc.providers.forEach((provider, index) => {
|
||||
if (provider.kind === "analog-mux") {
|
||||
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
|
||||
validatePinRef(provider.common, `/elc/providers/${index}/common`);
|
||||
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
|
||||
}
|
||||
});
|
||||
p.elc.keys.forEach((wiring, index) =>
|
||||
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
|
||||
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
|
||||
),
|
||||
);
|
||||
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
|
||||
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
|
||||
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
|
||||
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
|
||||
const checkProfiles = (ns: Node[]) =>
|
||||
ns.forEach((n, i) => {
|
||||
if (n.type === "key" && !profileIds.has(n.profile))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "missing-profile",
|
||||
message: `Unknown profile: ${n.profile}`,
|
||||
path: `/phy/key/${n.id}/profile`,
|
||||
ids: [n.id, n.profile],
|
||||
});
|
||||
if (n.type === "cluster") checkProfiles(n.children);
|
||||
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
|
||||
});
|
||||
checkProfiles(p.phy.root);
|
||||
for (const [i, pr] of p.phy.profiles.entries()) {
|
||||
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "positive-unit-size",
|
||||
message: "unitSize values must be positive",
|
||||
path: `/phy/profiles/${i}/unitSize`,
|
||||
});
|
||||
}
|
||||
const keyWalk = (ns: Node[]) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "positive-key-size",
|
||||
message: "Key size values must be positive",
|
||||
path: `/phy/key/${n.id}/size`,
|
||||
ids: [n.id],
|
||||
});
|
||||
if (n.type === "cluster") keyWalk(n.children);
|
||||
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
|
||||
});
|
||||
keyWalk(p.phy.root);
|
||||
const unreg = unregisteredKeys(p.phy, p.idx);
|
||||
for (const id of unreg)
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "idx-unregistered",
|
||||
message: "Eligible key is not in IDX order",
|
||||
ids: [id],
|
||||
});
|
||||
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
|
||||
if (new Set(profileSizes).size > 1)
|
||||
out.push({
|
||||
severity: "warning",
|
||||
code: "unit-size-mismatch",
|
||||
message: "Profile unitSize values differ; geometry editing is restricted",
|
||||
path: "/phy/profiles",
|
||||
});
|
||||
let active: string[] = [];
|
||||
try {
|
||||
active = flattenKeys(p.phy, p.output.variantSelection)
|
||||
.filter((x) => !x.key.decal)
|
||||
.map((x) => x.key.id);
|
||||
} catch (e) {
|
||||
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
|
||||
}
|
||||
for (const id of active) {
|
||||
if (!p.elc.keys.some((k) => k.key === id))
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unwired-key",
|
||||
message: "Selected key has no wiring",
|
||||
ids: [id],
|
||||
});
|
||||
for (const l of p.map.layers)
|
||||
if (!l.keymap.some((a) => a.keyId === id))
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "unassigned-key",
|
||||
message: `Selected key is not assigned in layer ${l.name}`,
|
||||
ids: [id, l.name],
|
||||
});
|
||||
}
|
||||
const branches = new Map<string, Array<[string, string]>>();
|
||||
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "key") branches.set(n.id, branch);
|
||||
if (n.type === "cluster") exclusivity(n.children, branch);
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
|
||||
});
|
||||
exclusivity(p.phy.root, []);
|
||||
const mutuallyExclusive = (a: string, b: string) => {
|
||||
for (const [group, variant] of branches.get(a) ?? [])
|
||||
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
|
||||
return false;
|
||||
};
|
||||
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
|
||||
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
|
||||
for (let i = 0; i < contacts.length; i++)
|
||||
for (let j = i + 1; j < contacts.length; j++) {
|
||||
const a = contacts[i]!,
|
||||
b = contacts[j]!;
|
||||
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
|
||||
const ap = a.diode
|
||||
? a.diode === "a-to-b"
|
||||
? [a.terminals.a, a.terminals.b]
|
||||
: [a.terminals.b, a.terminals.a]
|
||||
: null;
|
||||
const bp = b.diode
|
||||
? b.diode === "a-to-b"
|
||||
? [b.terminals.a, b.terminals.b]
|
||||
: [b.terminals.b, b.terminals.a]
|
||||
: null;
|
||||
const same =
|
||||
ap && bp
|
||||
? ap[0] === bp[0] && ap[1] === bp[1]
|
||||
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
|
||||
if (same)
|
||||
out.push({
|
||||
severity: "blocksOutput",
|
||||
code: "wiring-collision",
|
||||
message: "Non-exclusive keys share the same electrical connection",
|
||||
ids: [a.key, b.key],
|
||||
});
|
||||
}
|
||||
const layerNames = new Set<string>();
|
||||
for (const [i, l] of p.map.layers.entries()) {
|
||||
if (layerNames.has(l.name))
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-layer-name",
|
||||
message: "Layer names must be unique",
|
||||
path: `/map/layers/${i}/name`,
|
||||
ids: [l.name],
|
||||
});
|
||||
layerNames.add(l.name);
|
||||
}
|
||||
for (const [li, l] of p.map.layers.entries()) {
|
||||
const assignments = new Map<string, number>();
|
||||
for (const [ai, a] of l.keymap.entries()) {
|
||||
const prev = assignments.get(a.keyId);
|
||||
if (prev !== undefined)
|
||||
out.push({
|
||||
severity: "blocksSave",
|
||||
code: "duplicate-map-key",
|
||||
message: "A key may occur only once in a layer keymap",
|
||||
path: `/map/layers/${li}/keymap/${ai}/keyId`,
|
||||
ids: [a.keyId, l.name],
|
||||
});
|
||||
assignments.set(a.keyId, ai);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** RFC 8785 JCS の UTF-8 対象文字列を生成する。T-12。 */
|
||||
export function canonicalize(value: unknown): string {
|
||||
return encode(value);
|
||||
}
|
||||
function encode(v: unknown): string {
|
||||
if (v === null) return "null";
|
||||
if (typeof v === "string") return JSON.stringify(v);
|
||||
if (typeof v === "boolean") return v ? "true" : "false";
|
||||
if (typeof v === "number") {
|
||||
if (!Number.isFinite(v)) throw new TypeError("JCS does not support non-finite numbers");
|
||||
return JSON.stringify(Object.is(v, -0) ? 0 : v);
|
||||
}
|
||||
if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`;
|
||||
if (typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
const keys = Object.keys(o).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${encode(o[k])}`).join(",")}}`;
|
||||
}
|
||||
throw new TypeError(`Unsupported JCS value: ${typeof v}`);
|
||||
}
|
||||
/** JCS正規化したUTF-8をSHA-256し、指定形式で返す。T-12。 */
|
||||
export async function sha256Jcs(value: unknown): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(canonicalize(value));
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||
return `sha256-jcs:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FlatKey, KleCompatibility, PhyFile } from "../domain/phy";
|
||||
import { flattenKeys } from "../domain/geometry";
|
||||
import { labelsToKle } from "./kleImport";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const defaults: KleCompatibility = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||
|
||||
/** PHYをバリアント選択に従ってKLE JSONへ平坦化する。座標は6桁丸め。T-20。 */
|
||||
export function exportKle(phy: PhyFile, variantSelection: Record<string, string> = {}): unknown[] {
|
||||
const keys = flattenKeys(phy, variantSelection);
|
||||
const order = [...keys].sort((a, b) => a.rotation - b.rotation || a.y - b.y || a.x - b.x);
|
||||
const centers = new Map<number, { x: number; y: number }>();
|
||||
for (const key of order) if (!centers.has(key.rotation)) centers.set(key.rotation, { x: key.x, y: key.y });
|
||||
const previous: KleCompatibility = { ...defaults };
|
||||
let previousGhost = false;
|
||||
const rows: unknown[] = [];
|
||||
|
||||
for (const flat of order) {
|
||||
const key = flat.key;
|
||||
const center = centers.get(flat.rotation)!;
|
||||
const rad = (flat.rotation * Math.PI) / 180;
|
||||
const dx = flat.x - center.x;
|
||||
const dy = flat.y - center.y;
|
||||
const localX = center.x + Math.cos(rad) * dx + Math.sin(rad) * dy;
|
||||
const localY = center.y - Math.sin(rad) * dx + Math.cos(rad) * dy;
|
||||
const compat = { ...defaults, ...(key.kleCompatibility ?? {}) };
|
||||
const cfg: Obj = {
|
||||
r: flat.rotation,
|
||||
rx: round(center.x),
|
||||
ry: round(center.y),
|
||||
x: round(localX - center.x),
|
||||
y: round(localY - center.y),
|
||||
};
|
||||
|
||||
for (const field of ["c", "t", "p", "a", "sm", "sb", "st"] as const) {
|
||||
const next = compat[field];
|
||||
if (!same(next, previous[field])) cfg[field] = structuredClone(next);
|
||||
previous[field] = structuredClone(next);
|
||||
}
|
||||
const oldOverride = previous.fa !== undefined ? "fa" : previous.f2 !== undefined ? "f2" : "none";
|
||||
const nextOverride = compat.fa !== undefined ? "fa" : compat.f2 !== undefined ? "f2" : "none";
|
||||
const fontChanged = !same(compat.f, previous.f);
|
||||
if (fontChanged || (nextOverride === "none" && oldOverride !== "none")) cfg.f = compat.f;
|
||||
if (nextOverride === "fa" && (oldOverride !== "fa" || !same(compat.fa, previous.fa)))
|
||||
cfg.fa = structuredClone(compat.fa);
|
||||
if (nextOverride === "f2" && (oldOverride !== "f2" || !same(compat.f2, previous.f2)))
|
||||
cfg.f2 = structuredClone(compat.f2);
|
||||
previous.f = structuredClone(compat.f);
|
||||
delete previous.f2;
|
||||
delete previous.fa;
|
||||
if (nextOverride === "fa") previous.fa = structuredClone(compat.fa);
|
||||
if (nextOverride === "f2") previous.f2 = structuredClone(compat.f2);
|
||||
|
||||
if (key.ghost !== previousGhost) {
|
||||
cfg.g = key.ghost === true;
|
||||
previousGhost = key.ghost === true;
|
||||
}
|
||||
if (key.decal) cfg.d = true;
|
||||
if (compat.l === true) cfg.l = true;
|
||||
if (compat.n === true) cfg.n = true;
|
||||
|
||||
const w = key.size.w;
|
||||
const h = key.size.h;
|
||||
if (w !== 1) cfg.w = round(w);
|
||||
if (h !== 1) cfg.h = round(h);
|
||||
if (key.size.x2 !== undefined && key.size.x2 !== 0) cfg.x2 = round(key.size.x2);
|
||||
if (key.size.y2 !== undefined && key.size.y2 !== 0) cfg.y2 = round(key.size.y2);
|
||||
const w2 = key.size.w2 ?? w;
|
||||
const h2 = key.size.h2 ?? h;
|
||||
if (w2 !== w) cfg.w2 = round(w2);
|
||||
if (h2 !== h) cfg.h2 = round(h2);
|
||||
|
||||
rows.push([cfg, labelsToKle(key.labels, typeof compat.a === "number" ? compat.a : 4)]);
|
||||
}
|
||||
if (phy.kleCompatibility) rows.unshift(structuredClone(phy.kleCompatibility));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** KLE JSON文字列を生成する。 */
|
||||
export function stringifyKle(phy: PhyFile, selection: Record<string, string> = {}): string {
|
||||
return JSON.stringify(exportKle(phy, selection));
|
||||
}
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { KleCompatibility, KeyNode, PhyFile } from "../domain/phy";
|
||||
import { createNodeId } from "../domain/ids";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
type Atom = string | Obj;
|
||||
|
||||
// Output slot -> newline-field index. These fixed tables reproduce KLE's a=0..7 layout.
|
||||
const labelMap: readonly (readonly (number | null)[])[] = [
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
|
||||
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
|
||||
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
|
||||
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
|
||||
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
|
||||
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
|
||||
[null, null, null, null, 0, null, null, null, null, null, 4, null],
|
||||
];
|
||||
|
||||
const fileDefaults: Obj = {
|
||||
backcolor: "#eeeeee",
|
||||
name: "",
|
||||
author: "",
|
||||
notes: "",
|
||||
background: null,
|
||||
radii: "",
|
||||
switchMount: "",
|
||||
switchBrand: "",
|
||||
switchType: "",
|
||||
};
|
||||
const keyDefaults: Obj = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||
|
||||
function isObj(value: unknown): value is Obj {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function num(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
function labelsFromKle(serialized: string, alignment: number): string[] {
|
||||
const fields = serialized.split("\n");
|
||||
if (fields.length > 12) throw new Error("A KLE label cannot exceed 12 slots");
|
||||
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||
const labels = Array<string>(12).fill("");
|
||||
map.forEach((field, slot) => {
|
||||
if (field !== null && field < fields.length) labels[slot] = fields[field]!;
|
||||
});
|
||||
while (labels.at(-1) === "") labels.pop();
|
||||
return labels;
|
||||
}
|
||||
function oneShotArray(state: Obj, key: string): void {
|
||||
delete state[key];
|
||||
}
|
||||
|
||||
/** KLEの厳密JSON(またはVIA layouts.keymap)を新規PHYへ取り込む。T-7/T-18/T-20。 */
|
||||
export function importKle(
|
||||
input: unknown,
|
||||
options: { idGenerator?: () => string; name?: string } = {},
|
||||
): { phy: PhyFile; keyOrder: string[] } {
|
||||
let data: unknown = input;
|
||||
if (isObj(data)) {
|
||||
const layouts = data.layouts;
|
||||
if (isObj(layouts) && Array.isArray(layouts.keymap)) data = layouts.keymap;
|
||||
else throw new Error("Expected KLE array or VIA layouts.keymap");
|
||||
}
|
||||
if (!Array.isArray(data)) throw new Error("KLE input must be a JSON array");
|
||||
const rows = [...data] as unknown[];
|
||||
let rawMeta: Obj = {};
|
||||
if (rows.length && isObj(rows[0])) rawMeta = rows.shift() as Obj;
|
||||
|
||||
const gen = options.idGenerator ?? (() => globalThis.crypto.randomUUID());
|
||||
const state: Obj = { ...keyDefaults };
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rotation = 0;
|
||||
let rotationX = 0;
|
||||
let rotationY = 0;
|
||||
const root: KeyNode[] = [];
|
||||
const keyOrder: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!Array.isArray(row)) throw new Error("KLE rows must be arrays");
|
||||
cursorX = rotationX;
|
||||
for (const atom of row as Atom[]) {
|
||||
if (typeof atom === "string") {
|
||||
const w = num(state.w, 1);
|
||||
const h = num(state.h, 1);
|
||||
cursorX += num(state.x, 0);
|
||||
cursorY += num(state.y, 0);
|
||||
const radians = (rotation * Math.PI) / 180;
|
||||
const dx = cursorX - rotationX;
|
||||
const dy = cursorY - rotationY;
|
||||
const x = rotationX + Math.cos(radians) * dx - Math.sin(radians) * dy;
|
||||
const y = rotationY + Math.sin(radians) * dx + Math.cos(radians) * dy;
|
||||
const a = num(state.a, 4);
|
||||
const id = createNodeId("key", gen);
|
||||
const labels = labelsFromKle(atom, a);
|
||||
const compatibility: KleCompatibility = {
|
||||
c: structuredClone(state.c),
|
||||
t: structuredClone(state.t),
|
||||
p: structuredClone(state.p),
|
||||
a,
|
||||
f: num(state.f, 3),
|
||||
...(state.f2 === undefined ? {} : { f2: structuredClone(state.f2) }),
|
||||
...(state.fa === undefined ? {} : { fa: structuredClone(state.fa) }),
|
||||
l: state.l === true,
|
||||
n: state.n === true,
|
||||
sm: structuredClone(state.sm),
|
||||
sb: structuredClone(state.sb),
|
||||
st: structuredClone(state.st),
|
||||
};
|
||||
const w2 = num(state.w2, 0) || w;
|
||||
const h2 = num(state.h2, 0) || h;
|
||||
const key: KeyNode = {
|
||||
type: "key",
|
||||
id,
|
||||
profile: "default",
|
||||
transform: { x, y, rotation },
|
||||
size: {
|
||||
w,
|
||||
h,
|
||||
...(num(state.x2, 0) === 0 ? {} : { x2: num(state.x2, 0) }),
|
||||
...(num(state.y2, 0) === 0 ? {} : { y2: num(state.y2, 0) }),
|
||||
...(w2 === w ? {} : { w2 }),
|
||||
...(h2 === h ? {} : { h2 }),
|
||||
},
|
||||
labels,
|
||||
kleCompatibility: compatibility,
|
||||
};
|
||||
if (state.d === true) key.decal = true;
|
||||
if (state.g === true) key.ghost = true;
|
||||
root.push(key);
|
||||
if (!key.decal) keyOrder.push(id);
|
||||
|
||||
cursorX += w;
|
||||
for (const field of ["w", "h", "x", "y", "x2", "y2", "w2", "h2", "d", "l", "n"])
|
||||
oneShotArray(state, field);
|
||||
} else if (isObj(atom)) {
|
||||
for (const [key, value] of Object.entries(atom)) {
|
||||
if (key === "r") rotation = num(value, rotation);
|
||||
else if (key === "rx") {
|
||||
rotationX = num(value, rotationX);
|
||||
cursorX = rotationX;
|
||||
cursorY = rotationY;
|
||||
} else if (key === "ry") {
|
||||
rotationY = num(value, rotationY);
|
||||
cursorX = rotationX;
|
||||
cursorY = rotationY;
|
||||
} else if (key === "f") {
|
||||
state.f = value;
|
||||
delete state.f2;
|
||||
delete state.fa;
|
||||
} else if (key === "f2") {
|
||||
state.f2 = value;
|
||||
delete state.fa;
|
||||
} else if (key === "fa") {
|
||||
state.fa = structuredClone(value);
|
||||
delete state.f2;
|
||||
} else state[key] = structuredClone(value);
|
||||
}
|
||||
} else throw new Error("KLE row entries must be strings or objects");
|
||||
}
|
||||
cursorY += 1;
|
||||
}
|
||||
|
||||
const metadata = { ...fileDefaults, ...structuredClone(rawMeta) };
|
||||
const phy: PhyFile = {
|
||||
version: "0.1.0",
|
||||
metadata: { ...(options.name ? { name: options.name } : {}) },
|
||||
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||
root,
|
||||
kleCompatibility: metadata,
|
||||
};
|
||||
return { phy, keyOrder };
|
||||
}
|
||||
|
||||
/** JSON文字列を厳密にパースして KLE を取り込む。 */
|
||||
export function parseKleJson(
|
||||
json: string,
|
||||
options: { idGenerator?: () => string; name?: string } = {},
|
||||
): ReturnType<typeof importKle> {
|
||||
return importKle(JSON.parse(json) as unknown, options);
|
||||
}
|
||||
|
||||
/** 正準12欄のラベルをKLEのa別固定欄順に戻す。 */
|
||||
export function labelsToKle(labels: string[] | undefined, alignment: number): string {
|
||||
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||
const fields = Array<string>(12).fill("");
|
||||
map.forEach((sourceSlot, slot) => {
|
||||
if (sourceSlot !== null) fields[sourceSlot] = labels?.[slot] ?? "";
|
||||
});
|
||||
while (fields.at(-1) === "") fields.pop();
|
||||
return fields.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
import { validateProject, type ValidationIssue } from "../domain/validate";
|
||||
import { makePhyReference, phyReferenceMatches } from "../domain/phyReference";
|
||||
import { validateSchema, type LayerName } from "./validateSchema";
|
||||
|
||||
export type ProjectReadResult = { project?: ProjectFile; errors: ValidationIssue[] };
|
||||
export type ProjectSaveResult = { json?: string; errors: ValidationIssue[] };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Validates project and output container shapes before semantic checks. T-10/T-16. */
|
||||
function validateProjectShape(value: unknown): ValidationIssue[] {
|
||||
const errors: ValidationIssue[] = [];
|
||||
if (!isRecord(value))
|
||||
return [{ severity: "blocksSave", code: "schema", message: "Expected a ProjectFile object", path: "/" }];
|
||||
const required = ["format", "version", "projectId", "phy", "idx", "elc", "map", "output"];
|
||||
for (const field of required)
|
||||
if (!(field in value))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: `Missing required ProjectFile field: ${field}`,
|
||||
path: `/${field}`,
|
||||
});
|
||||
if (value.format !== "beyond-kle-mock-project")
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Invalid ProjectFile format",
|
||||
path: "/format",
|
||||
});
|
||||
if (typeof value.version !== "string")
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/version" });
|
||||
if (typeof value.projectId !== "string")
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/projectId" });
|
||||
for (const layer of ["phy", "idx", "elc", "map"] as const) {
|
||||
if (!(layer in value)) continue;
|
||||
for (const issue of validateSchema(layer, value[layer]))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: issue.message,
|
||||
path: `/${layer}${issue.path === "/" ? "" : issue.path}`,
|
||||
});
|
||||
}
|
||||
const output = value.output;
|
||||
if (!isRecord(output)) {
|
||||
errors.push({ severity: "blocksSave", code: "schema", message: "Expected an object", path: "/output" });
|
||||
} else {
|
||||
if (!isRecord(output.variantSelection))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an object of variant IDs",
|
||||
path: "/output/variantSelection",
|
||||
});
|
||||
else
|
||||
for (const [key, variant] of Object.entries(output.variantSelection))
|
||||
if (typeof variant !== "string")
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected a variant ID string",
|
||||
path: `/output/variantSelection/${key}`,
|
||||
});
|
||||
if (output.matrix !== undefined) {
|
||||
if (!isRecord(output.matrix))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an object",
|
||||
path: "/output/matrix",
|
||||
});
|
||||
else {
|
||||
for (const field of ["rowPins", "colPins"] as const) {
|
||||
const pins = output.matrix[field];
|
||||
if (!Array.isArray(pins) || pins.some((pin) => typeof pin !== "string"))
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Expected an array of pin references",
|
||||
path: `/output/matrix/${field}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
output.matrix.kind !== undefined &&
|
||||
!["matrix", "charlieplexing", "direct"].includes(String(output.matrix.kind))
|
||||
)
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "schema",
|
||||
message: "Unknown output scan mode",
|
||||
path: "/output/matrix/kind",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Serializes a project and refreshes PHY references unless T-16 blocks saving. T-12/T-13. */
|
||||
export async function saveProject(project: ProjectFile, pretty = false): Promise<ProjectSaveResult> {
|
||||
const shapeErrors = validateProjectShape(project);
|
||||
if (shapeErrors.length) return { errors: shapeErrors };
|
||||
const errors = validateProject(project);
|
||||
if (errors.some((issue) => issue.severity === "blocksSave")) return { errors };
|
||||
|
||||
const p = structuredClone(project);
|
||||
const ref = await makePhyReference(p.phy);
|
||||
for (const layer of ["idx", "elc", "map"] as const)
|
||||
p[layer].phyReference = { ...p[layer].phyReference, ...ref };
|
||||
return { json: JSON.stringify(p, null, pretty ? 2 : undefined), errors };
|
||||
}
|
||||
|
||||
/** Parses a project, checking all schemas before semantic validation and retaining unknown fields. T-13/T-14. */
|
||||
export async function loadProject(json: string): Promise<ProjectReadResult> {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(json) as unknown;
|
||||
} catch (e) {
|
||||
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||
}
|
||||
const errors = validateProjectShape(data);
|
||||
if (errors.length) return { errors };
|
||||
|
||||
const project = data as ProjectFile;
|
||||
const semantic = validateProject(project);
|
||||
const unsupportedVersion = semantic.filter((issue) => issue.code === "version");
|
||||
if (unsupportedVersion.length) return { errors: semantic };
|
||||
for (const layer of ["idx", "elc", "map"] as const)
|
||||
if (!(await phyReferenceMatches(project.phy, project[layer].phyReference)))
|
||||
semantic.push({
|
||||
severity: "warning",
|
||||
code: "phy-reference-mismatch",
|
||||
message: `${layer} PHY checksum does not match the current PHY`,
|
||||
path: `/${layer}/phyReference`,
|
||||
});
|
||||
return { project, errors: semantic };
|
||||
}
|
||||
|
||||
/** Writes a single layer as JSON. */
|
||||
export function writeLayer(layer: LayerName, value: unknown, pretty = false): string {
|
||||
const errors = validateSchema(layer, value);
|
||||
if (errors.length) throw new Error(errors.map((issue) => `${issue.path} ${issue.message}`).join("; "));
|
||||
return JSON.stringify(value, null, pretty ? 2 : undefined);
|
||||
}
|
||||
|
||||
/** Reads and schema-validates one layer file. */
|
||||
export function readLayer(layer: LayerName, json: string): { value?: unknown; errors: ValidationIssue[] } {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(json) as unknown;
|
||||
} catch (e) {
|
||||
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||
}
|
||||
const errors = validateSchema(layer, value).map((issue) => ({
|
||||
severity: "blocksSave" as const,
|
||||
code: "schema",
|
||||
message: issue.message,
|
||||
path: issue.path,
|
||||
}));
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"version" in value &&
|
||||
(value as { version?: unknown }).version !== "0.1.0"
|
||||
)
|
||||
errors.push({
|
||||
severity: "blocksSave",
|
||||
code: "version",
|
||||
message: "Unsupported layer version",
|
||||
path: "/version",
|
||||
});
|
||||
return errors.length ? { errors } : { value, errors: [] };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/elc-layer.json",
|
||||
"title": "ELC Layer - Electrical Wiring",
|
||||
"description": "Wiring between provider pins and key terminals",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "providers", "keys"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/provider" }
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/keyWiring" }
|
||||
},
|
||||
"ledChains": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/ledChain" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"pinRef": {
|
||||
"type": "string",
|
||||
"pattern": "^(VCC|GND|[^.]+\\.[^.]+)$",
|
||||
"description": "\"<provider id>.<pin name>\", or the reserved power rails VCC / GND"
|
||||
},
|
||||
"provider": {
|
||||
"oneOf": [{ "$ref": "#/$defs/mcuProvider" }, { "$ref": "#/$defs/analogMuxProvider" }]
|
||||
},
|
||||
"mcuProvider": {
|
||||
"type": "object",
|
||||
"required": ["id", "kind", "part"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"kind": { "const": "mcu" },
|
||||
"part": {
|
||||
"type": "string",
|
||||
"description": "Part number. Used to interpret pin names"
|
||||
}
|
||||
}
|
||||
},
|
||||
"analogMuxProvider": {
|
||||
"type": "object",
|
||||
"required": ["id", "kind", "channels", "select", "common"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"kind": { "const": "analog-mux" },
|
||||
"channels": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Number of logical pins provided. Pin names are \"0\"..\"channels-1\""
|
||||
},
|
||||
"select": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/pinRef" },
|
||||
"description": "Select pins, least significant bit first"
|
||||
},
|
||||
"common": { "$ref": "#/$defs/pinRef" },
|
||||
"enable": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
},
|
||||
"keyWiring": {
|
||||
"oneOf": [{ "$ref": "#/$defs/contactKey" }, { "$ref": "#/$defs/analogKey" }]
|
||||
},
|
||||
"contactKey": {
|
||||
"type": "object",
|
||||
"required": ["key", "detect", "terminals"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"detect": { "const": "contact" },
|
||||
"terminals": {
|
||||
"type": "object",
|
||||
"description": "Non-polar. a and b carry no ordering meaning",
|
||||
"required": ["a", "b"],
|
||||
"properties": {
|
||||
"a": { "$ref": "#/$defs/pinRef" },
|
||||
"b": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
},
|
||||
"diode": {
|
||||
"type": "string",
|
||||
"enum": ["a-to-b", "b-to-a"],
|
||||
"description": "Anode side comes first. Omitted means no diode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"analogKey": {
|
||||
"type": "object",
|
||||
"required": ["key", "detect", "terminals"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"detect": { "const": "analog" },
|
||||
"terminals": {
|
||||
"type": "object",
|
||||
"required": ["out", "vcc", "gnd"],
|
||||
"properties": {
|
||||
"out": { "$ref": "#/$defs/pinRef" },
|
||||
"vcc": { "$ref": "#/$defs/pinRef" },
|
||||
"gnd": { "$ref": "#/$defs/pinRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledChain": {
|
||||
"type": "object",
|
||||
"required": ["id", "data", "order"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"data": { "$ref": "#/$defs/pinRef" },
|
||||
"order": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "PHY node IDs. The position in the array is the LED number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/idx-layer.json",
|
||||
"title": "IDX Layer - Key Identification",
|
||||
"description": "Ordered PHY key IDs across all variants. Output key numbers are contiguous positions after filtering by variant selection",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "order"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"description": "Each PHY key has its own entry, including mutually exclusive keys wired to the same terminals. Variant selection does not modify this list",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/map-layer.json",
|
||||
"title": "MAP Layer - Keymap Configuration",
|
||||
"description": "Key code assignments and layers",
|
||||
"type": "object",
|
||||
"required": ["version", "phyReference", "layers"],
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"phyReference": {
|
||||
"type": "object",
|
||||
"required": ["source", "checksum"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"checksum": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"layers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/layer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"layer": {
|
||||
"type": "object",
|
||||
"required": ["name", "keymap"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Layer identifier, unique within the MAP. Layer order is the array order"
|
||||
},
|
||||
"keymap": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/keyAssignment"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyAssignment": {
|
||||
"type": "object",
|
||||
"required": ["keyId", "keycode"],
|
||||
"properties": {
|
||||
"keyId": {
|
||||
"type": "string",
|
||||
"description": "Reference to PHY layer key ID"
|
||||
},
|
||||
"keycode": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://beyond-kle.org/schemas/phy-layer.json",
|
||||
"title": "PHY Layer - Physical Key Layout",
|
||||
"description": "Physical positioning and sizing of keys",
|
||||
"type": "object",
|
||||
"required": ["version", "metadata", "profiles", "root"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||
"description": "Schema version (semver)"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"created": { "type": "string", "format": "date-time" },
|
||||
"modified": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"kleCompatibility": {
|
||||
"type": "object",
|
||||
"description": "File-level opaque KLE fields kept for round-trip output (switchMount, switchBrand, switchType, backcolor, radii, background, css). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/profile"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"description": "Placement relative to the parent: rotate by rotation around the local origin, then translate by (x, y)",
|
||||
"required": ["x", "y", "rotation"],
|
||||
"properties": {
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"rotation": { "type": "number", "description": "Degrees" }
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "unitSize"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"unitSize": {
|
||||
"type": "object",
|
||||
"description": "Millimeters per 1U",
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "number", "minimum": 0 },
|
||||
"y": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"visualOffset": {
|
||||
"type": "object",
|
||||
"description": "Key top rectangle, in millimeters: inward distance from each edge of the outline, plus corner radius",
|
||||
"required": ["top", "right", "bottom", "left", "radius"],
|
||||
"properties": {
|
||||
"top": { "type": "number" },
|
||||
"right": { "type": "number" },
|
||||
"bottom": { "type": "number" },
|
||||
"left": { "type": "number" },
|
||||
"radius": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"key": {
|
||||
"type": "object",
|
||||
"required": ["type", "id", "profile", "transform", "size"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "key"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique key identifier"
|
||||
},
|
||||
"profile": {
|
||||
"type": "string",
|
||||
"description": "Reference to profile ID"
|
||||
},
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"size": {
|
||||
"type": "object",
|
||||
"description": "Local size. Not affected by the parent transform",
|
||||
"required": ["w", "h"],
|
||||
"properties": {
|
||||
"w": { "type": "number", "minimum": 0 },
|
||||
"h": { "type": "number", "minimum": 0 },
|
||||
"x2": {
|
||||
"type": "number",
|
||||
"description": "Second rectangle for non-rectangular keys, relative to the local origin"
|
||||
},
|
||||
"y2": { "type": "number" },
|
||||
"w2": { "type": "number", "minimum": 0 },
|
||||
"h2": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"switchOffset": {
|
||||
"type": "object",
|
||||
"description": "Switch position, offset from the center of the (w, h) rectangle. Key local coordinates, unit U. Defaults to 0",
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"partGroup": {
|
||||
"type": "string",
|
||||
"description": "Label grouping keys that use the same part. Not interpreted by Beyond KLE"
|
||||
},
|
||||
"orientation": {
|
||||
"type": "integer",
|
||||
"enum": [0, 90, 180, 270],
|
||||
"default": 0,
|
||||
"description": "Switch orientation in the key local coordinates"
|
||||
},
|
||||
"perKeyLED": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether a per-key LED is mounted"
|
||||
},
|
||||
"decal": { "type": "boolean", "default": false },
|
||||
"ghost": { "type": "boolean", "default": false },
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Reference information. Used for matching on KLE JSON import"
|
||||
},
|
||||
"kleCompatibility": {
|
||||
"type": "object",
|
||||
"description": "Opaque KLE fields kept for round-trip output (c, t, p, a, f, f2, fa, l, n, sm, sb, st). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"type": "object",
|
||||
"description": "Non-key part placed by the designer (LED, OLED, encoder, screw hole, ...)",
|
||||
"required": ["type", "id", "transform"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "component"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"partGroup": {
|
||||
"type": "string",
|
||||
"description": "Names starting with @ are reserved for editor rendering"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variantGroup": {
|
||||
"type": "object",
|
||||
"description": "Mutually exclusive layout alternatives. Children are variants only",
|
||||
"required": ["type", "id", "name", "transform", "default", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "variantGroup"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Heading shown for the choice in the editor"
|
||||
},
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"default": {
|
||||
"type": "string",
|
||||
"description": "id of the variant used when a single layout must be chosen"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"description": "Display order",
|
||||
"items": { "$ref": "#/$defs/variant" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"variant": {
|
||||
"type": "object",
|
||||
"description": "One alternative. Appears only directly under a variantGroup. Carries no transform",
|
||||
"required": ["type", "id", "name", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "variant"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "May be empty",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster": {
|
||||
"type": "object",
|
||||
"required": ["type", "id", "transform", "children"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "cluster"
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"transform": { "$ref": "#/$defs/transform" },
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/key" },
|
||||
{ "$ref": "#/$defs/cluster" },
|
||||
{ "$ref": "#/$defs/component" },
|
||||
{ "$ref": "#/$defs/variantGroup" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Ajv2020 from "ajv/dist/2020";
|
||||
import addFormats from "ajv-formats";
|
||||
import phySchema from "./schemas/phy.schema.json";
|
||||
import idxSchema from "./schemas/idx.schema.json";
|
||||
import elcSchema from "./schemas/elc.schema.json";
|
||||
import mapSchema from "./schemas/map.schema.json";
|
||||
|
||||
export type LayerName = "phy" | "idx" | "elc" | "map";
|
||||
export type SchemaIssue = { path: string; message: string };
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
const validators = {
|
||||
phy: ajv.compile(phySchema),
|
||||
idx: ajv.compile(idxSchema),
|
||||
elc: ajv.compile(elcSchema),
|
||||
map: ajv.compile(mapSchema),
|
||||
};
|
||||
/** Validates one layer against its draft 2020-12 schema. */
|
||||
export function validateSchema(layer: LayerName, value: unknown): SchemaIssue[] {
|
||||
const valid = validators[layer](value);
|
||||
return valid
|
||||
? []
|
||||
: (validators[layer].errors ?? []).map((e) => ({
|
||||
path: `${e.instancePath || "/"}${e.params && "missingProperty" in e.params ? `/${e.params.missingProperty}` : ""}`,
|
||||
message: e.message ?? "Schema validation failed",
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./ui/App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,904 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
|
||||
import { createProjectFromKle } from "../domain/project";
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
import type { Node, KeyNode, Transform, VariantGroupNode } from "../domain/phy";
|
||||
import { flattenKeys } from "../domain/geometry";
|
||||
import {
|
||||
addKey,
|
||||
addComponent,
|
||||
moveNode,
|
||||
resizeKey,
|
||||
rotateNode,
|
||||
clusterNodes,
|
||||
uncluster,
|
||||
reparent,
|
||||
reparentToVariant,
|
||||
duplicateNode,
|
||||
createVariantGroup,
|
||||
addVariant,
|
||||
setDefaultVariant,
|
||||
reframe,
|
||||
} from "../domain/phyCommands";
|
||||
import { ProjectHistory } from "../domain/history";
|
||||
import { validateProject } from "../domain/validate";
|
||||
import { listDeleteReferences, deleteNodeCascade } from "../domain/deleteCascade";
|
||||
import { saveProject, loadProject } from "../io/projectFile";
|
||||
import { exportKle } from "../io/kleExport";
|
||||
import { defaultLayout } from "./defaultLayout";
|
||||
import type { Dispatch } from "./types";
|
||||
import PhyTree from "./phy/PhyTree.vue";
|
||||
import PhyCanvas from "./phy/PhyCanvas.vue";
|
||||
import { parentPoint, snappedDelta } from "./phy/dragMath";
|
||||
import IdxPanel from "./panels/IdxPanel.vue";
|
||||
import ElcPanel from "./panels/ElcPanel.vue";
|
||||
import MapPanel from "./panels/MapPanel.vue";
|
||||
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
||||
|
||||
const project = shallowRef<ProjectFile | null>(null);
|
||||
const activeTab = ref("PHY"),
|
||||
selection = ref<string[]>([]),
|
||||
variants = ref<Record<string, string>>({}),
|
||||
zoom = ref(1);
|
||||
const errorText = ref(""),
|
||||
pendingDelete = ref<{
|
||||
id: string;
|
||||
refs: ReturnType<typeof listDeleteReferences>;
|
||||
} | null>(null);
|
||||
const importInput = ref<HTMLInputElement>(),
|
||||
openInput = ref<HTMLInputElement>();
|
||||
let history: ProjectHistory | null = null;
|
||||
const historyEpoch = ref(0);
|
||||
let drag: {
|
||||
id: string;
|
||||
parent: SVGGElement;
|
||||
start: { x: number; y: number };
|
||||
origin: Transform;
|
||||
base: ProjectFile;
|
||||
} | null = null;
|
||||
const mismatch = computed(
|
||||
() =>
|
||||
!!project.value &&
|
||||
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
|
||||
);
|
||||
const issues = computed(() => (project.value ? validateProject(project.value) : []));
|
||||
const canUndo = computed(() => {
|
||||
historyEpoch.value;
|
||||
return !!history?.canUndo;
|
||||
}),
|
||||
canRedo = computed(() => {
|
||||
historyEpoch.value;
|
||||
return !!history?.canRedo;
|
||||
});
|
||||
const severity = ["blocksSave", "blocksOutput", "warning"] as const;
|
||||
const selectedNode = computed(() =>
|
||||
project.value && selection.value.length === 1
|
||||
? findNode(project.value.phy.root, selection.value[0]!)
|
||||
: null,
|
||||
);
|
||||
const keyCount = computed(() => (project.value ? flattenKeys(project.value.phy, variants.value).length : 0));
|
||||
const variantGroups = computed(() => {
|
||||
const groups: VariantGroupNode[] = [];
|
||||
if (project.value)
|
||||
visit(project.value.phy.root, (n) => {
|
||||
if (n.type === "variantGroup") groups.push(n);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
const dispatch: Dispatch = (label, update) => {
|
||||
if (!project.value) return;
|
||||
if (mismatch.value) return;
|
||||
const next = update(project.value);
|
||||
project.value = next;
|
||||
if (history) history.push(next);
|
||||
historyEpoch.value++;
|
||||
errorText.value = "";
|
||||
};
|
||||
function findNode(nodes: Node[], id: string): Node | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (n.type === "cluster") {
|
||||
const x = findNode(n.children, id);
|
||||
if (x) return x;
|
||||
}
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) {
|
||||
if (v.id === id) return n;
|
||||
const x = findNode(v.children, id);
|
||||
if (x) return x;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function visit(nodes: Node[], fn: (n: Node) => void) {
|
||||
for (const n of nodes) {
|
||||
fn(n);
|
||||
if (n.type === "cluster") visit(n.children, fn);
|
||||
if (n.type === "variantGroup") for (const v of n.children) visit(v.children, fn);
|
||||
}
|
||||
}
|
||||
function parents() {
|
||||
const out: { id: string; label: string }[] = [{ id: "", label: "root" }];
|
||||
if (project.value)
|
||||
visit(project.value.phy.root, (n) => {
|
||||
if (n.type === "cluster") out.push({ id: n.id, label: `クラスター ${n.name || n.id}` });
|
||||
if (n.type === "variantGroup")
|
||||
for (const v of n.children) out.push({ id: v.id, label: `${n.name} / ${v.name}` });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function setSelected(id: string, additive: boolean) {
|
||||
selection.value = additive
|
||||
? selection.value.includes(id)
|
||||
? selection.value.filter((x) => x !== id)
|
||||
: [...selection.value, id]
|
||||
: [id];
|
||||
const n = project.value ? findNode(project.value.phy.root, id) : null;
|
||||
if (n?.type === "cluster")
|
||||
promptValue.value = { x: n.transform.x, y: n.transform.y, r: n.transform.rotation };
|
||||
}
|
||||
async function initialize() {
|
||||
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...p.output.variantSelection };
|
||||
}
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
window.addEventListener("keydown", keyHandler);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", keyHandler));
|
||||
function keyHandler(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
e.shiftKey ? redo() : undo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
}
|
||||
function undo() {
|
||||
if (history?.canUndo) {
|
||||
project.value = history.undo();
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...project.value.output.variantSelection };
|
||||
selection.value = selection.value.filter(
|
||||
(id) => !!project.value && !!findNode(project.value.phy.root, id),
|
||||
);
|
||||
}
|
||||
}
|
||||
function redo() {
|
||||
if (history?.canRedo) {
|
||||
project.value = history.redo();
|
||||
historyEpoch.value++;
|
||||
variants.value = { ...project.value.output.variantSelection };
|
||||
}
|
||||
}
|
||||
async function readFile(file: File, mode: "project" | "kle") {
|
||||
try {
|
||||
const json = JSON.parse(await file.text()) as unknown;
|
||||
if (mode === "kle") {
|
||||
const p = await createProjectFromKle(json, { name: file.name });
|
||||
project.value = p;
|
||||
history = new ProjectHistory(p);
|
||||
variants.value = { ...p.output.variantSelection };
|
||||
selection.value = [];
|
||||
} else {
|
||||
const result = await loadProject(JSON.stringify(json));
|
||||
if (!result.project) throw Error(result.errors.map((x) => x.message).join("; "));
|
||||
project.value = result.project;
|
||||
history = new ProjectHistory(result.project);
|
||||
variants.value = { ...result.project.output.variantSelection };
|
||||
selection.value = [];
|
||||
errorText.value = result.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
historyEpoch.value++;
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function download(name: string, text: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
async function save() {
|
||||
if (project.value) {
|
||||
const result = await saveProject(project.value, true);
|
||||
if (result.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, result.json);
|
||||
else errorText.value = result.errors.map((x) => x.message).join("; ");
|
||||
}
|
||||
}
|
||||
function exportSelected() {
|
||||
if (project.value)
|
||||
download("layout.kle.json", JSON.stringify(exportKle(project.value.phy, variants.value), null, 2));
|
||||
}
|
||||
function nodeDown(event: PointerEvent, id: string, parent: SVGGElement) {
|
||||
if (mismatch.value || !project.value) return;
|
||||
const node = findNode(project.value.phy.root, id);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
const p = parentPoint(parent, event.clientX, event.clientY);
|
||||
history?.begin();
|
||||
historyEpoch.value++;
|
||||
drag = { id, parent, start: p, origin: { ...node.transform }, base: project.value };
|
||||
window.addEventListener("pointermove", movePointer);
|
||||
window.addEventListener("pointerup", endPointer, { once: true });
|
||||
}
|
||||
function movePointer(e: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const p = parentPoint(drag.parent, e.clientX, e.clientY),
|
||||
delta = snappedDelta(drag.start, p);
|
||||
const next = moveNode(drag.base.phy, drag.id, delta.x, delta.y);
|
||||
project.value = { ...drag.base, phy: next };
|
||||
history?.replaceTop(project.value);
|
||||
}
|
||||
function endPointer() {
|
||||
window.removeEventListener("pointermove", movePointer);
|
||||
if (project.value) history?.commit(project.value);
|
||||
historyEpoch.value++;
|
||||
drag = null;
|
||||
}
|
||||
function keyAdd() {
|
||||
if (!project.value) return;
|
||||
dispatch("キー追加", (p) => ({ ...p, phy: addKey(p.phy, { x: 0, y: 0, rotation: 0 }, { w: 1, h: 1 }) }));
|
||||
const n = project.value.phy.root.at(-1);
|
||||
if (n) selection.value = [n.id];
|
||||
}
|
||||
function componentAdd(group: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("部品追加", (p) => ({
|
||||
...p,
|
||||
phy: addComponent(p.phy, { x: 0, y: 0, rotation: 0 }, group || undefined),
|
||||
}));
|
||||
const n = project.value.phy.root.at(-1);
|
||||
if (n) selection.value = [n.id];
|
||||
}
|
||||
function updateTransform(field: keyof Transform, value: number) {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1 || !selectedNode.value) return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("変換変更", (p) => {
|
||||
const phy = structuredClone(p.phy),
|
||||
n = findNode(phy.root, id);
|
||||
if (n) n.transform[field] = value;
|
||||
return { ...p, phy };
|
||||
});
|
||||
}
|
||||
function increment(field: keyof Transform, step: number) {
|
||||
if (!selectedNode.value) return;
|
||||
const n = selectedNode.value;
|
||||
updateTransform(field, n.transform[field] + step);
|
||||
}
|
||||
function updateSize(field: "w" | "h", value: number) {
|
||||
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("キーサイズ変更", (p) => ({
|
||||
...p,
|
||||
phy: resizeKey(p.phy, id, { ...(findNode(p.phy.root, id) as KeyNode).size, [field]: value }),
|
||||
}));
|
||||
}
|
||||
function updateLabel(value: string) {
|
||||
if (mismatch.value || !project.value || !selectedNode.value || selectedNode.value.type !== "key") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("ラベル変更", (p) => {
|
||||
const phy = structuredClone(p.phy),
|
||||
n = findNode(phy.root, id);
|
||||
if (n && n.type === "key") n.labels = [value];
|
||||
return { ...p, phy };
|
||||
});
|
||||
}
|
||||
function rotateSelected() {
|
||||
if (mismatch.value || !project.value) return;
|
||||
const ids = [...selection.value].filter((id) => findNode(project.value!.phy.root, id));
|
||||
if (!ids.length) return;
|
||||
dispatch("回転", (p) => ({ ...p, phy: ids.reduce((phy, id) => rotateNode(phy, id, 15), p.phy) }));
|
||||
}
|
||||
function makeCluster() {
|
||||
if (!project.value || selection.value.length < 1) return;
|
||||
try {
|
||||
dispatch("クラスター化", (p) => ({ ...p, phy: clusterNodes(p.phy, selection.value) }));
|
||||
const n = project.value.phy.root.find((n) => n.type === "cluster");
|
||||
if (n) selection.value = [n.id];
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function ungroup() {
|
||||
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
|
||||
const id = selectedNode.value.id;
|
||||
dispatch("クラスター解除", (p) => ({ ...p, phy: uncluster(p.phy, id) }));
|
||||
selection.value = [];
|
||||
}
|
||||
function duplicate() {
|
||||
if (!project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
dispatch("複製", (p) => ({ ...p, phy: duplicateNode(p.phy, id) }));
|
||||
}
|
||||
function reparentTo(target: string) {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
try {
|
||||
dispatch("reparent", (p) => ({
|
||||
...p,
|
||||
phy: target.startsWith("v-")
|
||||
? reparentToVariant(p.phy, id, target)
|
||||
: reparent(p.phy, id, target || null),
|
||||
}));
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function createVariants() {
|
||||
if (!project.value || selection.value.length === 0) return;
|
||||
const first = findNode(project.value.phy.root, selection.value[0]!);
|
||||
if (!first) return;
|
||||
try {
|
||||
dispatch("VariantGroup作成", (p) => {
|
||||
let phy = createVariantGroup(p.phy, "Layout", { ...first.transform }, [
|
||||
{ name: "Option A" },
|
||||
{ name: "Option B" },
|
||||
]);
|
||||
const vg = phy.root.at(-1);
|
||||
if (vg?.type === "variantGroup") {
|
||||
for (const id of selection.value) phy = reparentToVariant(phy, id, vg.children[0]!.id);
|
||||
return {
|
||||
...p,
|
||||
phy,
|
||||
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [vg.id]: vg.default } },
|
||||
};
|
||||
}
|
||||
return { ...p, phy };
|
||||
});
|
||||
selection.value = [];
|
||||
if (project.value) variants.value = { ...project.value.output.variantSelection };
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function addVariantUi(groupId: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("Variant追加", (p) => ({
|
||||
...p,
|
||||
phy: addVariant(
|
||||
p.phy,
|
||||
groupId,
|
||||
`Option ${String.fromCharCode(65 + (findNode(p.phy.root, groupId) as Node & { children: Node[] }).children.length)}`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
function removeVariantUi(variantId: string) {
|
||||
if (!project.value) return;
|
||||
pendingDelete.value = { id: variantId, refs: listDeleteReferences(project.value, variantId) };
|
||||
}
|
||||
function changeDefault(groupId: string, id: string) {
|
||||
if (!project.value) return;
|
||||
dispatch("既定Variant変更", (p) => ({ ...p, phy: setDefaultVariant(p.phy, groupId, id) }));
|
||||
}
|
||||
function confirmDelete() {
|
||||
if (!pendingDelete.value || !project.value) return;
|
||||
const { id } = pendingDelete.value;
|
||||
try {
|
||||
dispatch("ノード削除", (p) => deleteNodeCascade(p, id));
|
||||
if (project.value) variants.value = { ...project.value.output.variantSelection };
|
||||
selection.value = [];
|
||||
pendingDelete.value = null;
|
||||
} catch (e) {
|
||||
errorText.value = String(e);
|
||||
}
|
||||
}
|
||||
function requestDelete() {
|
||||
if (mismatch.value || !project.value || selection.value.length !== 1) return;
|
||||
const id = selection.value[0]!;
|
||||
pendingDelete.value = { id, refs: listDeleteReferences(project.value, id) };
|
||||
}
|
||||
function changeFrame() {
|
||||
if (mismatch.value || !project.value || selectedNode.value?.type !== "cluster") return;
|
||||
const id = selectedNode.value.id;
|
||||
const x = Number(promptValue.value.x),
|
||||
y = Number(promptValue.value.y),
|
||||
rotation = Number(promptValue.value.r);
|
||||
dispatch("座標枠変更", (p) => ({ ...p, phy: reframe(p.phy, id, { x, y, rotation }) }));
|
||||
}
|
||||
const promptValue = ref({ x: 0, y: 0, r: 0 });
|
||||
function changeVariant(groupId: string, id: string) {
|
||||
variants.value = { ...variants.value, [groupId]: id };
|
||||
if (!mismatch.value)
|
||||
dispatch("表示構成変更", (p) => ({
|
||||
...p,
|
||||
output: { ...p.output, variantSelection: { ...variants.value } },
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell" :class="{ locked: mismatch }" v-if="project">
|
||||
<header class="toolbar">
|
||||
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
|
||||
><input
|
||||
ref="importInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
@change="
|
||||
(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) void readFile(f, 'kle');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
"
|
||||
/><button @click="openInput?.click()">プロジェクトを開く</button
|
||||
><input
|
||||
ref="openInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept=".bkle.json,.json,application/json"
|
||||
@change="
|
||||
(e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) void readFile(f, 'project');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
"
|
||||
/><button @click="save">プロジェクトを保存</button
|
||||
><button @click="exportSelected">KLE JSONを出力</button
|
||||
><button :disabled="!canUndo" @click="undo">Undo</button
|
||||
><button :disabled="!canRedo" @click="redo">Redo</button
|
||||
><span v-if="errorText" class="error">{{ errorText }}</span>
|
||||
</header>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
|
||||
:key="tab"
|
||||
:class="{ active: activeTab === tab }"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</nav>
|
||||
<section v-if="activeTab === 'PHY'" class="workspace">
|
||||
<aside class="tree pane">
|
||||
<h2>PHYの木</h2>
|
||||
<div class="commands">
|
||||
<button :disabled="mismatch" @click="keyAdd">キー追加</button
|
||||
><select
|
||||
id="part-group"
|
||||
:disabled="mismatch"
|
||||
@change="componentAdd(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">部品追加…</option>
|
||||
<option>@led</option>
|
||||
<option>@encoder</option>
|
||||
<option>@screw</option>
|
||||
<option>@oled</option>
|
||||
<option>custom</option></select
|
||||
><button :disabled="mismatch || selection.length === 0" @click="makeCluster">クラスター化</button
|
||||
><button :disabled="mismatch || selectedNode?.type !== 'cluster'" @click="ungroup">解除</button
|
||||
><button :disabled="mismatch || selection.length !== 1" @click="duplicate">複製</button
|
||||
><button :disabled="mismatch || selection.length !== 1" @click="requestDelete">削除</button
|
||||
><select
|
||||
:disabled="mismatch || selection.length !== 1"
|
||||
@change="reparentTo(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">reparent先…</option>
|
||||
<option v-for="p in parents()" :key="p.id" :value="p.id">{{ p.label }}</option></select
|
||||
><button :disabled="mismatch || selection.length === 0" @click="createVariants">
|
||||
選択からVariantGroup
|
||||
</button>
|
||||
</div>
|
||||
<PhyTree
|
||||
:nodes="project.phy.root"
|
||||
:selected="selection"
|
||||
@select="(id, add) => setSelected(id, add)"
|
||||
/>
|
||||
<div v-if="pendingDelete" class="confirm-box">
|
||||
<strong>削除の確認: {{ pendingDelete.id }}</strong>
|
||||
<p>削除される参照 {{ pendingDelete.refs.length }} 件 (T-15)</p>
|
||||
<ul>
|
||||
<li v-for="(r, i) in pendingDelete.refs" :key="i">
|
||||
{{ r.layer }} · {{ r.id }} · {{ r.location }}
|
||||
</li>
|
||||
</ul>
|
||||
<button :disabled="mismatch" @click="confirmDelete">参照と一緒に削除</button
|
||||
><button @click="pendingDelete = null">キャンセル</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="canvas-pane pane">
|
||||
<div class="canvas-head">
|
||||
<strong>レイアウト · {{ keyCount }} keys</strong
|
||||
><label>Zoom <input type="range" min="0.5" max="2" step="0.1" v-model.number="zoom" /></label
|
||||
><label v-for="n in variantGroups" :key="n.id"
|
||||
>{{ n.name }}
|
||||
<select
|
||||
:value="variants[n.id] ?? n.default"
|
||||
@change="changeVariant(n.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="v in n.children" :key="v.id" :value="v.id">{{ v.name }}</option></select
|
||||
><button :disabled="mismatch" @click="addVariantUi(n.id)">+</button
|
||||
><button :disabled="mismatch" @click="changeDefault(n.id, variants[n.id] ?? n.default)">
|
||||
default</button
|
||||
><button :disabled="mismatch" @click="removeVariantUi(variants[n.id] ?? n.default)">
|
||||
−
|
||||
</button></label
|
||||
>
|
||||
</div>
|
||||
<p v-if="mismatch" class="warning">unitSize が揃っていないPHYは編集できません。</p>
|
||||
<PhyCanvas
|
||||
:nodes="project.phy.root"
|
||||
:selected="selection"
|
||||
:variants="variants"
|
||||
:zoom="zoom"
|
||||
@select="(id, add) => setSelected(id, add)"
|
||||
@drag-start="nodeDown"
|
||||
/>
|
||||
</section>
|
||||
<aside class="properties pane">
|
||||
<h2>プロパティ</h2>
|
||||
<template v-if="selectedNode"
|
||||
><p>
|
||||
<b>{{ selectedNode.type }}</b
|
||||
><br /><small>{{ selectedNode.id }}</small>
|
||||
</p>
|
||||
<label
|
||||
>X
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.x"
|
||||
@change="updateTransform('x', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('x', 0.25)">±.25U</button></label
|
||||
><label
|
||||
>Y
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.y"
|
||||
@change="updateTransform('y', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('y', 0.25)">±.25U</button></label
|
||||
><label
|
||||
>Rotation
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.transform.rotation"
|
||||
@change="updateTransform('rotation', Number(($event.target as HTMLInputElement).value))"
|
||||
/><button @click="increment('rotation', 15)">±15°</button></label
|
||||
><button @click="rotateSelected">+15°回転</button
|
||||
><template v-if="selectedNode.type === 'key'"
|
||||
><label
|
||||
>Width
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.size.w"
|
||||
@change="updateSize('w', Number(($event.target as HTMLInputElement).value))" /></label
|
||||
><label
|
||||
>Height
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
:value="selectedNode.size.h"
|
||||
@change="updateSize('h', Number(($event.target as HTMLInputElement).value))" /></label
|
||||
><label
|
||||
>Label
|
||||
<input
|
||||
:value="selectedNode.labels?.filter(Boolean)[0]"
|
||||
@change="updateLabel(($event.target as HTMLInputElement).value)" /></label></template
|
||||
><template v-if="selectedNode.type === 'cluster'"
|
||||
><h3>座標枠変更</h3>
|
||||
<label>原点 X <input type="number" v-model.number="promptValue.x" /></label
|
||||
><label>原点 Y <input type="number" v-model.number="promptValue.y" /></label
|
||||
><label>角度 <input type="number" v-model.number="promptValue.r" /></label
|
||||
><button @click="changeFrame">適用</button></template
|
||||
></template
|
||||
>
|
||||
<template v-else-if="selection.length > 1">
|
||||
<p>{{ selection.length }}件を選択中</p>
|
||||
<button :disabled="mismatch" @click="rotateSelected">選択を+15°回転</button>
|
||||
</template>
|
||||
<p v-else>ノードを選択してください。Shift+クリックで複数選択。</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section v-else class="other-panel">
|
||||
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
|
||||
v-else-if="activeTab === 'ELC'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
/><MapPanel
|
||||
v-else-if="activeTab === 'MAP'"
|
||||
:project="project"
|
||||
:dispatch="dispatch"
|
||||
:editable="!mismatch"
|
||||
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
|
||||
</section>
|
||||
<div v-if="activeTab === 'PHY' && selectedNode" class="nudge-controls">
|
||||
<button :disabled="mismatch" @click="increment('x', -0.25)">X −.25U</button
|
||||
><button :disabled="mismatch" @click="increment('x', 0.25)">X +.25U</button
|
||||
><button :disabled="mismatch" @click="increment('y', -0.25)">Y −.25U</button
|
||||
><button :disabled="mismatch" @click="increment('y', 0.25)">Y +.25U</button
|
||||
><button :disabled="mismatch" @click="increment('rotation', -15)">回転 −15°</button
|
||||
><button :disabled="mismatch" @click="increment('rotation', 15)">回転 +15°</button>
|
||||
</div>
|
||||
<aside class="validation pane">
|
||||
<h2>検証結果</h2>
|
||||
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
|
||||
保存できない状態です。blocksSave の問題を解消してください。
|
||||
</p>
|
||||
<div class="issues">
|
||||
<section v-for="s in severity" :key="s">
|
||||
<h3>
|
||||
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
|
||||
</h3>
|
||||
<button
|
||||
v-for="(issue, i) in issues.filter((x) => x.severity === s)"
|
||||
:key="i"
|
||||
class="issue"
|
||||
@click="issue.ids?.[0] && setSelected(issue.ids[0], false)"
|
||||
>
|
||||
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
|
||||
</button>
|
||||
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
14px system-ui,
|
||||
sans-serif;
|
||||
color: #20242b;
|
||||
background: #edf0f4;
|
||||
}
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 52px 42px minmax(300px, 1fr) 180px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.app-shell.locked .properties {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.toolbar,
|
||||
.tabs,
|
||||
.pane,
|
||||
.placeholder {
|
||||
background: white;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px;
|
||||
}
|
||||
.toolbar strong {
|
||||
margin-right: auto;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
.tabs button.active {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.workspace {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
||||
gap: 8px;
|
||||
}
|
||||
.pane {
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.tree h2,
|
||||
.properties h2,
|
||||
.validation h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.commands button,
|
||||
.commands select {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tree-list {
|
||||
padding-left: 14px;
|
||||
list-style: none;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.tree-node {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.tree-node small {
|
||||
color: #778;
|
||||
}
|
||||
.tree-node.chosen {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.canvas-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.canvas-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.canvas-head label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.canvas {
|
||||
width: 1152px;
|
||||
height: 896px;
|
||||
min-height: 240px;
|
||||
flex: none;
|
||||
background-color: #fbfcfe;
|
||||
background-image:
|
||||
linear-gradient(#e7ebf1 1px, transparent 1px), linear-gradient(90deg, #e7ebf1 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
touch-action: none;
|
||||
}
|
||||
.phy-node {
|
||||
cursor: grab;
|
||||
}
|
||||
.phy-node:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.key-face {
|
||||
fill: #f8cc78;
|
||||
stroke: #5b4a2b;
|
||||
stroke-width: 0.025;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.secondary {
|
||||
fill: #ffe7ad;
|
||||
}
|
||||
.key-label {
|
||||
font-size: 0.13px;
|
||||
fill: #29251e;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.decal .key-face {
|
||||
fill: #fff;
|
||||
stroke-dasharray: 0.08 0.06;
|
||||
}
|
||||
.chosen > .key-face,
|
||||
.chosen > .part {
|
||||
stroke: #1769d2;
|
||||
stroke-width: 0.055;
|
||||
}
|
||||
.part {
|
||||
fill: #b4d5ef;
|
||||
stroke: #315d7a;
|
||||
stroke-width: 0.03;
|
||||
}
|
||||
.part-label {
|
||||
font-size: 0.12px;
|
||||
fill: #333;
|
||||
}
|
||||
.selection-box {
|
||||
fill: none;
|
||||
stroke: #1769d2;
|
||||
stroke-width: 0.025;
|
||||
stroke-dasharray: 0.04;
|
||||
}
|
||||
.group-origin {
|
||||
fill: #d33;
|
||||
pointer-events: none;
|
||||
}
|
||||
.properties label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.properties input {
|
||||
width: 105px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nudge-controls {
|
||||
position: fixed;
|
||||
right: 270px;
|
||||
bottom: 194px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100vw - 540px);
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #adb8c8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.validation {
|
||||
grid-row: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.issues {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.issues section {
|
||||
min-width: 240px;
|
||||
}
|
||||
.issues h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.issue {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
margin: 3px 0;
|
||||
border: 0;
|
||||
background: #f4f6f9;
|
||||
padding: 5px;
|
||||
}
|
||||
.issue small {
|
||||
display: block;
|
||||
color: #667;
|
||||
}
|
||||
.muted {
|
||||
color: #778;
|
||||
}
|
||||
.warning,
|
||||
.error {
|
||||
color: #9a4c00;
|
||||
}
|
||||
.error {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.confirm-box {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: #fff2dc;
|
||||
border: 1px solid #db9d39;
|
||||
}
|
||||
.confirm-box ul {
|
||||
max-height: 90px;
|
||||
overflow: auto;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.other-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 20px;
|
||||
}
|
||||
.canvas-pane .warning {
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
// 選択構成の平坦な配置を読み取り専用で描く。IDX・ELC・MAP・Generatorのパネルで共有する。
|
||||
import { computed } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import type { FlatKey, PhyFile } from "../../domain/phy";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
phy: PhyFile;
|
||||
selection: Record<string, string>;
|
||||
/** キーの中央に出す文字列。省略時は先頭のラベル */
|
||||
keyText?: (keyId: string) => string;
|
||||
/** キーに付けるCSSクラス(状態の色分け) */
|
||||
keyClass?: (keyId: string) => string | undefined;
|
||||
selectedIds?: string[];
|
||||
unit?: number;
|
||||
}>(),
|
||||
{ selectedIds: () => [], unit: 48 },
|
||||
);
|
||||
const emit = defineEmits<{ keyClick: [keyId: string, event: MouseEvent] }>();
|
||||
|
||||
const keys = computed<FlatKey[]>(() => flattenKeys(props.phy, props.selection));
|
||||
|
||||
// 回転を含めた四隅から表示範囲を求める
|
||||
const viewBox = computed(() => {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const k of keys.value) {
|
||||
const r = (k.rotation * Math.PI) / 180,
|
||||
c = Math.cos(r),
|
||||
s = Math.sin(r);
|
||||
const { w, h } = k.key.size;
|
||||
for (const [lx, ly] of [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
] as const) {
|
||||
const x = k.x + lx * c - ly * s,
|
||||
y = k.y + lx * s + ly * c;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
if (!isFinite(minX)) return "0 0 4 2";
|
||||
const pad = 0.25;
|
||||
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
|
||||
});
|
||||
const width = computed(() => {
|
||||
const parts = viewBox.value.split(" ").map(Number);
|
||||
return (parts[2] ?? 4) * props.unit;
|
||||
});
|
||||
|
||||
function text(k: FlatKey): string {
|
||||
return props.keyText ? props.keyText(k.key.id) : (k.key.labels?.find((l) => l) ?? "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="keyboard-view" :viewBox="viewBox" :width="width" data-testid="keyboard-view">
|
||||
<g
|
||||
v-for="k in keys"
|
||||
:key="k.key.id"
|
||||
:transform="`translate(${k.x},${k.y}) rotate(${k.rotation})`"
|
||||
:class="[
|
||||
'kv-key',
|
||||
keyClass?.(k.key.id),
|
||||
{ selected: selectedIds.includes(k.key.id), decal: k.key.decal, ghost: k.key.ghost },
|
||||
]"
|
||||
:data-key-id="k.key.id"
|
||||
@click="emit('keyClick', k.key.id, $event)"
|
||||
>
|
||||
<rect x="0.03" y="0.03" :width="k.key.size.w - 0.06" :height="k.key.size.h - 0.06" rx="0.08" />
|
||||
<rect
|
||||
v-if="k.key.size.w2 !== undefined || k.key.size.h2 !== undefined"
|
||||
:x="(k.key.size.x2 ?? 0) + 0.03"
|
||||
:y="(k.key.size.y2 ?? 0) + 0.03"
|
||||
:width="(k.key.size.w2 ?? k.key.size.w) - 0.06"
|
||||
:height="(k.key.size.h2 ?? k.key.size.h) - 0.06"
|
||||
rx="0.08"
|
||||
/>
|
||||
<text :x="k.key.size.w / 2" :y="k.key.size.h / 2" text-anchor="middle" dominant-baseline="central">
|
||||
{{ text(k) }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keyboard-view {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
.kv-key {
|
||||
cursor: pointer;
|
||||
}
|
||||
.kv-key rect {
|
||||
fill: #f3f4f6;
|
||||
stroke: #6b7280;
|
||||
stroke-width: 0.02;
|
||||
}
|
||||
.kv-key text {
|
||||
font-size: 0.22px;
|
||||
fill: #111827;
|
||||
pointer-events: none;
|
||||
}
|
||||
.kv-key.selected rect {
|
||||
stroke: #2563eb;
|
||||
stroke-width: 0.06;
|
||||
}
|
||||
.kv-key.decal rect {
|
||||
stroke-dasharray: 0.06 0.04;
|
||||
fill: none;
|
||||
}
|
||||
.kv-key.ghost {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.kv-key.warn rect {
|
||||
fill: #fde68a;
|
||||
}
|
||||
.kv-key.error rect {
|
||||
fill: #fecaca;
|
||||
}
|
||||
.kv-key.ok rect {
|
||||
fill: #d1fae5;
|
||||
}
|
||||
.kv-key.muted rect {
|
||||
fill: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Small 4x4 starter layout, kept in src/ui rather than the test fixtures. */
|
||||
export const defaultLayout: unknown[] = Array.from({ length: 4 }, () => ["", "", "", ""]);
|
||||
@@ -0,0 +1,564 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import {
|
||||
mcuPinCandidates,
|
||||
removeKeyWiring,
|
||||
setKeyWiring,
|
||||
setProvider,
|
||||
removeProvider,
|
||||
createLedChain,
|
||||
setLedOrder,
|
||||
} from "../../domain/elc";
|
||||
import type { ContactWiring, McuProvider, AnalogMuxProvider, PinRef } from "../../domain/elc";
|
||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||
import type { Node } from "../../domain/phy";
|
||||
import { validateProject } from "../../domain/validate";
|
||||
import { wireMatrixKeys } from "../../domain/elcMatrix";
|
||||
import type { Dispatch } from "../types";
|
||||
import KeyboardView from "../common/KeyboardView.vue";
|
||||
|
||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||
const variants = computed(() => ({
|
||||
...defaultVariantSelection(props.project.phy),
|
||||
...props.project.output.variantSelection,
|
||||
}));
|
||||
const keys = computed(() =>
|
||||
flattenKeys(props.project.phy, variants.value)
|
||||
.filter((x) => !x.key.decal)
|
||||
.map((x) => x.key),
|
||||
);
|
||||
const selectedKeyId = ref("");
|
||||
const selectedKey = computed(() => keys.value.find((x) => x.id === selectedKeyId.value));
|
||||
const wired = computed(() => new Map(props.project.elc.keys.map((x) => [x.key, x])));
|
||||
const collisionIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
validateProject(props.project)
|
||||
.filter((x) => x.code === "wiring-collision")
|
||||
.flatMap((x) => x.ids ?? []),
|
||||
),
|
||||
);
|
||||
const unwired = computed(() => keys.value.filter((k) => !wired.value.has(k.id)).map((k) => k.id));
|
||||
const wiring = ref({ a: "", b: "", diode: "" as "" | "a-to-b" | "b-to-a" });
|
||||
watch(selectedKeyId, (id) => {
|
||||
const current = props.project.elc.keys.find((x) => x.key === id);
|
||||
wiring.value =
|
||||
current?.detect === "contact"
|
||||
? { a: current.terminals.a, b: current.terminals.b, diode: current.diode ?? "" }
|
||||
: { a: "", b: "", diode: "" };
|
||||
});
|
||||
const providerId = ref("MCU"),
|
||||
providerPart = ref("RP2040"),
|
||||
providerError = ref("");
|
||||
const providerCandidates = computed(() => {
|
||||
const p = { id: providerId.value, kind: "mcu" as const, part: providerPart.value };
|
||||
return mcuPinCandidates(p).map((name) => `${p.id}.${name}`);
|
||||
});
|
||||
const pinRefs = computed(() => [
|
||||
"VCC",
|
||||
"GND",
|
||||
...props.project.elc.providers.flatMap((p) =>
|
||||
p.kind === "mcu"
|
||||
? mcuPinCandidates(p).map((pin) => `${p.id}.${pin}`)
|
||||
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
|
||||
),
|
||||
]);
|
||||
const providerRows = computed(() => props.project.elc.providers);
|
||||
const mux = ref({ id: "MUX", channels: 8, select: "", common: "", enable: "" });
|
||||
const chainId = ref("LED"),
|
||||
chainData = ref(""),
|
||||
selectedChainId = ref("");
|
||||
const batchKeyIds = ref<string[]>([]),
|
||||
batchRows = ref<string[]>([]),
|
||||
batchCols = ref<string[]>([]);
|
||||
const batchRow = ref(""),
|
||||
batchCol = ref(""),
|
||||
batchDiode = ref<"" | "a-to-b" | "b-to-a">("a-to-b");
|
||||
const batchPreviewOpen = ref(false),
|
||||
batchError = ref("");
|
||||
const chains = computed(() => props.project.elc.ledChains ?? []);
|
||||
const nodes = computed(() => {
|
||||
const result: Array<{ id: string; label: string }> = [];
|
||||
const visit = (ns: Node[]) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "key" && n.perKeyLED) result.push({ id: n.id, label: `${n.id} · per-key LED` });
|
||||
else if (n.type === "component" && n.partGroup === "@led")
|
||||
result.push({ id: n.id, label: `${n.id} · @led` });
|
||||
else if (n.type === "cluster") visit(n.children);
|
||||
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
|
||||
});
|
||||
visit(props.project.phy.root);
|
||||
return result;
|
||||
});
|
||||
const chain = computed(() => chains.value.find((x) => x.id === selectedChainId.value));
|
||||
const chainSelection = ref<string[]>([]);
|
||||
watch(selectedChainId, () => {
|
||||
chainSelection.value = [];
|
||||
});
|
||||
function clickKey(id: string) {
|
||||
selectedKeyId.value = id;
|
||||
if (chain.value && nodes.value.some((n) => n.id === id) && !chain.value.order.includes(id))
|
||||
setOrder([...chain.value.order, id]);
|
||||
}
|
||||
function keyClass(id: string) {
|
||||
return collisionIds.value.has(id) ? "error" : !wired.value.has(id) ? "warn" : "ok";
|
||||
}
|
||||
function commitWiring() {
|
||||
if (!selectedKeyId.value || !wiring.value.a || !wiring.value.b) return;
|
||||
const next: ContactWiring = {
|
||||
key: selectedKeyId.value,
|
||||
detect: "contact",
|
||||
terminals: { a: wiring.value.a, b: wiring.value.b },
|
||||
...(wiring.value.diode ? { diode: wiring.value.diode } : {}),
|
||||
};
|
||||
props.dispatch("接点配線", (p) => ({ ...p, elc: setKeyWiring(p.elc, next) }));
|
||||
}
|
||||
function removeWiring() {
|
||||
if (selectedKeyId.value)
|
||||
props.dispatch("配線解除", (p) => ({ ...p, elc: removeKeyWiring(p.elc, selectedKeyId.value) }));
|
||||
}
|
||||
function addMcu() {
|
||||
providerError.value = "";
|
||||
if (!providerId.value || providerId.value.includes(".")) {
|
||||
providerError.value = "IDは空欄にできず、'.'を含められません";
|
||||
return;
|
||||
}
|
||||
const p: McuProvider = { id: providerId.value, kind: "mcu", part: providerPart.value };
|
||||
props.dispatch("MCU追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
|
||||
}
|
||||
function editProvider(id: string) {
|
||||
const p = props.project.elc.providers.find((x) => x.id === id);
|
||||
if (p?.kind === "mcu") {
|
||||
providerId.value = p.id;
|
||||
providerPart.value = p.part;
|
||||
}
|
||||
}
|
||||
function addMux() {
|
||||
if (!mux.value.id || mux.value.id.includes(".")) {
|
||||
providerError.value = "IDは空欄にできず、'.'を含められません";
|
||||
return;
|
||||
}
|
||||
const p: AnalogMuxProvider = {
|
||||
id: mux.value.id,
|
||||
kind: "analog-mux",
|
||||
channels: Number(mux.value.channels),
|
||||
select: mux.value.select
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
common: mux.value.common,
|
||||
...(mux.value.enable ? { enable: mux.value.enable } : {}),
|
||||
};
|
||||
props.dispatch("analog-mux追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
|
||||
}
|
||||
function removeProviderUi(id: string) {
|
||||
props.dispatch("提供者削除", (p) => ({ ...p, elc: removeProvider(p.elc, id) }));
|
||||
}
|
||||
function makeChain() {
|
||||
if (!chainId.value || !chainData.value) return;
|
||||
try {
|
||||
props.dispatch("LEDチェーン作成", (p) => ({
|
||||
...p,
|
||||
elc: createLedChain(p.elc, chainId.value, chainData.value),
|
||||
}));
|
||||
selectedChainId.value = chainId.value;
|
||||
} catch (e) {
|
||||
providerError.value = String(e);
|
||||
}
|
||||
}
|
||||
function setOrder(order: string[]) {
|
||||
if (chain.value)
|
||||
props.dispatch("LED順序", (p) => ({ ...p, elc: setLedOrder(p.elc, chain.value!.id, order) }));
|
||||
}
|
||||
function moveLed(index: number, direction: number) {
|
||||
const order = [...(chain.value?.order ?? [])];
|
||||
const to = index + direction;
|
||||
if (to < 0 || to >= order.length) return;
|
||||
[order[index], order[to]] = [order[to]!, order[index]!];
|
||||
setOrder(order);
|
||||
}
|
||||
function removeLed(index: number) {
|
||||
const order = [...(chain.value?.order ?? [])];
|
||||
order.splice(index, 1);
|
||||
setOrder(order);
|
||||
}
|
||||
function toggleBatchKey(id: string) {
|
||||
batchKeyIds.value = batchKeyIds.value.includes(id)
|
||||
? batchKeyIds.value.filter((x) => x !== id)
|
||||
: [...batchKeyIds.value, id];
|
||||
}
|
||||
function moveBatchPin(kind: "row" | "col", index: number, delta: number) {
|
||||
const list = kind === "row" ? batchRows.value : batchCols.value;
|
||||
const nextIndex = index + delta;
|
||||
if (nextIndex < 0 || nextIndex >= list.length) return;
|
||||
[list[index], list[nextIndex]] = [list[nextIndex]!, list[index]!];
|
||||
}
|
||||
function batchWire() {
|
||||
if (!batchKeyIds.value.length || !batchRows.value.length || !batchCols.value.length) return;
|
||||
// Matrix assignment follows the visible PHY key order, regardless of click order.
|
||||
const orderedKeys = keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id));
|
||||
try {
|
||||
props.dispatch("一括配線", (p) => ({
|
||||
...p,
|
||||
elc: wireMatrixKeys(
|
||||
p.elc,
|
||||
orderedKeys,
|
||||
batchRows.value,
|
||||
batchCols.value,
|
||||
batchDiode.value || undefined,
|
||||
),
|
||||
}));
|
||||
batchError.value = "";
|
||||
batchPreviewOpen.value = false;
|
||||
} catch (e) {
|
||||
batchError.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
const batchOrderedKeys = computed(() =>
|
||||
keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id)),
|
||||
);
|
||||
const batchCapacityError = computed(
|
||||
() => batchOrderedKeys.value.length > batchRows.value.length * batchCols.value.length,
|
||||
);
|
||||
const batchPreview = computed(() =>
|
||||
batchOrderedKeys.value.map((key, i) => ({
|
||||
key,
|
||||
row: batchRows.value[Math.floor(i / batchCols.value.length)],
|
||||
col: batchCols.value[i % batchCols.value.length],
|
||||
overwrite: wired.value.has(key),
|
||||
})),
|
||||
);
|
||||
const ledCandidate = ref("");
|
||||
function addLedCandidate() {
|
||||
if (
|
||||
chain.value &&
|
||||
nodes.value.some((n) => n.id === ledCandidate.value) &&
|
||||
!chain.value.order.includes(ledCandidate.value)
|
||||
)
|
||||
setOrder([...chain.value.order, ledCandidate.value]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="elc-panel">
|
||||
<h2>Wiring Editor</h2>
|
||||
<div class="elc-grid">
|
||||
<section class="box">
|
||||
<h3>ピン提供者</h3>
|
||||
<ul>
|
||||
<li v-for="p in providerRows" :key="p.id">
|
||||
<button @click="editProvider(p.id)">
|
||||
{{ p.id }} · {{ p.kind }} · {{ p.kind === "mcu" ? p.part : `${p.channels} channels` }}
|
||||
</button>
|
||||
<button @click="removeProviderUi(p.id)">削除</button>
|
||||
<div v-if="p.kind === 'analog-mux'">
|
||||
select {{ p.select.join(", ") }} / common {{ p.common
|
||||
}}<span v-if="p.enable"> / enable {{ p.enable }}</span> (データ保持のみ)
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<label>ID <input v-model="providerId" data-testid="provider-id" /></label
|
||||
><label
|
||||
>MCU品番 <input v-model="providerPart" list="mcu-parts" /><datalist id="mcu-parts">
|
||||
<option>RP2040</option>
|
||||
</datalist></label
|
||||
><button @click="addMcu">MCUを追加・更新</button>
|
||||
<p v-if="providerCandidates.length">候補: {{ providerCandidates.join(", ") }}</p>
|
||||
<p v-else>未知の品番はピン名を自由入力します。</p>
|
||||
<hr />
|
||||
<h4>analog-mux (保持のみ)</h4>
|
||||
<label>ID <input v-model="mux.id" /></label
|
||||
><label>channels <input type="number" min="1" v-model.number="mux.channels" /></label
|
||||
><label>select (カンマ区切り) <input v-model="mux.select" /></label
|
||||
><label>common <input v-model="mux.common" /></label
|
||||
><label>enable <input v-model="mux.enable" /></label
|
||||
><button @click="addMux">analog-muxを追加・更新</button>
|
||||
<p v-if="providerError" class="error-text">{{ providerError }}</p>
|
||||
</section>
|
||||
<section class="box wiring-box">
|
||||
<h3>選択構成のキー配線</h3>
|
||||
<p>
|
||||
未配線: <b data-testid="unwired-count">{{ unwired.length }}</b> / {{ keys.length }}
|
||||
</p>
|
||||
<KeyboardView
|
||||
:phy="project.phy"
|
||||
:selection="variants"
|
||||
:selected-ids="selectedKeyId ? [selectedKeyId] : []"
|
||||
:key-class="keyClass"
|
||||
:key-text="
|
||||
(id) => {
|
||||
const w = wired.get(id);
|
||||
return w?.detect === 'contact'
|
||||
? `${w.terminals.a.split('.').at(-1)}/${w.terminals.b.split('.').at(-1)}`
|
||||
: w
|
||||
? 'analog'
|
||||
: id.slice(-3);
|
||||
}
|
||||
"
|
||||
@key-click="(id) => clickKey(id)"
|
||||
/>
|
||||
<p>
|
||||
色: <span class="warn-text">黄 未配線</span> · <span class="error-text">赤 衝突</span> · 緑 配線済み
|
||||
</p>
|
||||
<div v-if="selectedKey" class="edit-form">
|
||||
<h4>{{ selectedKey.id }} · 接点端子</h4>
|
||||
<label
|
||||
>a
|
||||
<select v-model="wiring.a">
|
||||
<option value="">選択…</option>
|
||||
<option v-for="pin in pinRefs" :key="`a${pin}`" :value="pin">{{ pin }}</option></select
|
||||
><input v-model="wiring.a" aria-label="a端子自由入力" placeholder="自由入力: provider.pin"
|
||||
/></label>
|
||||
<label
|
||||
>b
|
||||
<select v-model="wiring.b">
|
||||
<option value="">選択…</option>
|
||||
<option v-for="pin in pinRefs" :key="`b${pin}`" :value="pin">{{ pin }}</option></select
|
||||
><input v-model="wiring.b" aria-label="b端子自由入力" placeholder="自由入力: provider.pin"
|
||||
/></label>
|
||||
<label
|
||||
>ダイオード
|
||||
<select v-model="wiring.diode">
|
||||
<option value="">なし</option>
|
||||
<option value="a-to-b">a-to-b</option>
|
||||
<option value="b-to-a">b-to-a</option>
|
||||
</select></label
|
||||
><button @click="commitWiring">配線を保存</button><button @click="removeWiring">配線解除</button>
|
||||
</div>
|
||||
<p v-else>キーをクリックして端子を編集します。</p>
|
||||
<h4>未配線・衝突</h4>
|
||||
<p>未配線 ({{ unwired.length }}): {{ unwired.join(", ") || "なし" }}</p>
|
||||
<ul>
|
||||
<li
|
||||
v-for="issue in validateProject(project).filter((x) => x.code === 'wiring-collision')"
|
||||
:key="issue.ids?.join('-')"
|
||||
>
|
||||
{{ issue.code }}: {{ issue.ids?.join(" ↔ ") }}
|
||||
</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h4>一括配線</h4>
|
||||
<p>
|
||||
キーを選び、row/colピンの順序を指定して配線します。選択キーにはPHY表示順でrowを進め、各row内でcolを進めます。
|
||||
</p>
|
||||
<KeyboardView
|
||||
:phy="project.phy"
|
||||
:selection="variants"
|
||||
:selected-ids="batchKeyIds"
|
||||
:key-text="(id) => id.slice(-3)"
|
||||
@key-click="(id) => toggleBatchKey(id)"
|
||||
/>
|
||||
<p>選択キー: {{ batchKeyIds.length }}</p>
|
||||
<div class="batch-controls">
|
||||
<label
|
||||
>rowピン<select v-model="batchRow" data-testid="batch-row-select">
|
||||
<option value="">選択…</option>
|
||||
<option
|
||||
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
|
||||
:key="`br-${pin}`"
|
||||
:value="pin"
|
||||
>
|
||||
{{ pin }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
if (batchRow && !batchRows.includes(batchRow)) batchRows.push(batchRow);
|
||||
batchRow = '';
|
||||
"
|
||||
>
|
||||
rowへ追加
|
||||
</button>
|
||||
<ol>
|
||||
<li v-for="(pin, i) in batchRows" :key="`br-${i}-${pin}`">
|
||||
{{ i }}: {{ pin }} <button @click="moveBatchPin('row', i, -1)">↑</button
|
||||
><button @click="moveBatchPin('row', i, 1)">↓</button
|
||||
><button @click="batchRows.splice(i, 1)">除去</button>
|
||||
</li>
|
||||
</ol>
|
||||
<label
|
||||
>colピン<select v-model="batchCol" data-testid="batch-col-select">
|
||||
<option value="">選択…</option>
|
||||
<option
|
||||
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
|
||||
:key="`bc-${pin}`"
|
||||
:value="pin"
|
||||
>
|
||||
{{ pin }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<button
|
||||
@click="
|
||||
if (batchCol && !batchCols.includes(batchCol)) batchCols.push(batchCol);
|
||||
batchCol = '';
|
||||
"
|
||||
>
|
||||
colへ追加
|
||||
</button>
|
||||
<ol>
|
||||
<li v-for="(pin, i) in batchCols" :key="`bc-${i}-${pin}`">
|
||||
{{ i }}: {{ pin }} <button @click="moveBatchPin('col', i, -1)">↑</button
|
||||
><button @click="moveBatchPin('col', i, 1)">↓</button
|
||||
><button @click="batchCols.splice(i, 1)">除去</button>
|
||||
</li>
|
||||
</ol>
|
||||
<label
|
||||
>ダイオード<select v-model="batchDiode">
|
||||
<option value="">なし</option>
|
||||
<option value="a-to-b">a-to-b</option>
|
||||
<option value="b-to-a">b-to-a</option>
|
||||
</select></label
|
||||
>
|
||||
<button
|
||||
data-testid="batch-wire"
|
||||
:disabled="!batchKeyIds.length || !batchRows.length || !batchCols.length"
|
||||
@click="
|
||||
batchPreviewOpen = true;
|
||||
batchError = '';
|
||||
"
|
||||
>
|
||||
割当プレビュー
|
||||
</button>
|
||||
<p v-if="batchCapacityError" role="alert" data-testid="batch-capacity-error">
|
||||
容量不足: {{ batchOrderedKeys.length }} keys / {{ batchRows.length * batchCols.length }} positions
|
||||
</p>
|
||||
<p v-if="batchError" role="alert">{{ batchError }}</p>
|
||||
<section v-if="batchPreviewOpen" data-testid="batch-preview">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>キー</th>
|
||||
<th>rowピン</th>
|
||||
<th>colピン</th>
|
||||
<th>既存配線</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in batchPreview" :key="item.key">
|
||||
<td>{{ item.key }}</td>
|
||||
<td>{{ item.row }}</td>
|
||||
<td>{{ item.col }}</td>
|
||||
<td>{{ item.overwrite ? "上書き" : "新規" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button data-testid="batch-confirm" :disabled="batchCapacityError" @click="batchWire">
|
||||
この内容で確定
|
||||
</button>
|
||||
<button @click="batchPreviewOpen = false">キャンセル</button>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="box">
|
||||
<h3>LEDチェーン</h3>
|
||||
<label>チェーンID <input v-model="chainId" /></label
|
||||
><label>データピン <input v-model="chainData" list="pin-options" placeholder="provider.pin" /></label
|
||||
><datalist id="pin-options">
|
||||
<option v-for="pin in pinRefs" :key="pin">{{ pin }}</option></datalist
|
||||
><button @click="makeChain">作成</button>
|
||||
<label
|
||||
>編集するチェーン
|
||||
<select v-model="selectedChainId">
|
||||
<option value="">選択…</option>
|
||||
<option v-for="c in chains" :key="c.id" :value="c.id">{{ c.id }}</option>
|
||||
</select></label
|
||||
>
|
||||
<p>対象は全バリアントのper-key LEDキーと@led部品ノードです。</p>
|
||||
<label
|
||||
>追加するLED
|
||||
<select v-model="ledCandidate" data-testid="led-candidate">
|
||||
<option value="">選択…</option>
|
||||
<option v-for="n in nodes" :key="n.id" :value="n.id">{{ n.label }}</option>
|
||||
</select></label
|
||||
>
|
||||
<button data-testid="led-add" :disabled="!ledCandidate || !chain" @click="addLedCandidate">
|
||||
チェーン末尾へ追加
|
||||
</button>
|
||||
<KeyboardView
|
||||
v-if="chain"
|
||||
:phy="project.phy"
|
||||
:selection="variants"
|
||||
:selected-ids="chain.order"
|
||||
:key-text="(id) => String(chain!.order.indexOf(id) + 1 || '')"
|
||||
@key-click="
|
||||
(id) => {
|
||||
if (nodes.some((n) => n.id === id) && !chain!.order.includes(id))
|
||||
setOrder([...chain!.order, id]);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ol>
|
||||
<li v-for="(id, i) in chain?.order ?? []" :key="`${id}-${i}`">
|
||||
{{ i }} · {{ id }} <button @click="moveLed(i, -1)">↑</button
|
||||
><button @click="moveLed(i, 1)">↓</button><button @click="removeLed(i)">除去</button>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.elc-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.elc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) minmax(320px, 1.5fr) minmax(230px, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.box {
|
||||
padding: 10px;
|
||||
border: 1px solid #d2d7df;
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
}
|
||||
.box label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.box input,
|
||||
.box select {
|
||||
max-width: 170px;
|
||||
}
|
||||
.wiring-box {
|
||||
min-width: 0;
|
||||
}
|
||||
.edit-form {
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.edit-form label {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.batch-controls {
|
||||
padding: 8px;
|
||||
background: #f6f8fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.batch-controls ol {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.warn-text {
|
||||
color: #985c00;
|
||||
}
|
||||
.error-text {
|
||||
color: #b42318;
|
||||
}
|
||||
.warn :deep(rect) {
|
||||
fill: #fde68a;
|
||||
}
|
||||
.error :deep(rect) {
|
||||
fill: #fecaca;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import { mcuPinCandidates } from "../../domain/elc";
|
||||
import { generateMockTarget } from "../../domain/generator";
|
||||
import type { Dispatch } from "../types";
|
||||
import KeyboardView from "../common/KeyboardView.vue";
|
||||
|
||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||
const selection = computed(() => ({
|
||||
...defaultVariantSelection(props.project.phy),
|
||||
...props.project.output.variantSelection,
|
||||
}));
|
||||
const groups = computed(() => {
|
||||
const all: VariantGroupNode[] = [];
|
||||
const visit = (ns: Node[]) =>
|
||||
ns.forEach((n) => {
|
||||
if (n.type === "cluster") visit(n.children);
|
||||
else if (n.type === "variantGroup") {
|
||||
all.push(n);
|
||||
n.children.forEach((v) => visit(v.children));
|
||||
}
|
||||
});
|
||||
visit(props.project.phy.root);
|
||||
return all;
|
||||
});
|
||||
const pins = computed(() => [
|
||||
...new Set(
|
||||
props.project.elc.providers.flatMap((p) =>
|
||||
p.kind === "mcu"
|
||||
? mcuPinCandidates(p).map((x) => `${p.id}.${x}`)
|
||||
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const rowAdd = ref(""),
|
||||
colAdd = ref("");
|
||||
const result = ref<ReturnType<typeof generateMockTarget> | null>(null);
|
||||
watch(
|
||||
() => props.project,
|
||||
() => {
|
||||
result.value = null;
|
||||
},
|
||||
);
|
||||
const rows = computed(() => props.project.output.matrix?.rowPins ?? []),
|
||||
cols = computed(() => props.project.output.matrix?.colPins ?? []);
|
||||
const flatKeys = computed(() => flattenKeys(props.project.phy, selection.value).filter((x) => !x.key.decal));
|
||||
const mappingById = computed(() => new Map((result.value?.target?.mapping ?? []).map((m) => [m.keyId, m])));
|
||||
function setVariant(groupId: string, variantId: string) {
|
||||
props.dispatch("出力構成", (p) => ({
|
||||
...p,
|
||||
output: { ...p.output, variantSelection: { ...selection.value, [groupId]: variantId } },
|
||||
}));
|
||||
result.value = null;
|
||||
}
|
||||
function setPins(kind: "rowPins" | "colPins", value: string[]) {
|
||||
props.dispatch(kind === "rowPins" ? "rowピン順序" : "colピン順序", (p) => ({
|
||||
...p,
|
||||
output: {
|
||||
...p.output,
|
||||
matrix: {
|
||||
...p.output.matrix,
|
||||
kind: "matrix",
|
||||
rowPins: [...(p.output.matrix?.rowPins ?? [])],
|
||||
colPins: [...(p.output.matrix?.colPins ?? [])],
|
||||
[kind]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
result.value = null;
|
||||
}
|
||||
function addPin(kind: "rowPins" | "colPins", pin: string) {
|
||||
if (!pin) return;
|
||||
const current = kind === "rowPins" ? rows.value : cols.value;
|
||||
if (current.includes(pin)) return;
|
||||
setPins(kind, [...current, pin]);
|
||||
if (kind === "rowPins") rowAdd.value = "";
|
||||
else colAdd.value = "";
|
||||
}
|
||||
function movePin(kind: "rowPins" | "colPins", index: number, d: number) {
|
||||
const next = [...(kind === "rowPins" ? rows.value : cols.value)],
|
||||
to = index + d;
|
||||
if (to < 0 || to >= next.length) return;
|
||||
[next[index], next[to]] = [next[to]!, next[index]!];
|
||||
setPins(kind, next);
|
||||
}
|
||||
function removePin(kind: "rowPins" | "colPins", index: number) {
|
||||
const next = [...(kind === "rowPins" ? rows.value : cols.value)];
|
||||
next.splice(index, 1);
|
||||
setPins(kind, next);
|
||||
}
|
||||
function generate() {
|
||||
result.value = generateMockTarget(props.project);
|
||||
}
|
||||
function download() {
|
||||
if (!result.value?.target) return;
|
||||
const blob = new Blob([JSON.stringify(result.value.target, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "mock-target.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
function keyText(id: string) {
|
||||
const m = mappingById.value.get(id);
|
||||
return m ? `${m.number}\nr${m.row} c${m.col}` : id.slice(-3);
|
||||
}
|
||||
function keyClass(id: string) {
|
||||
return mappingById.value.has(id) ? "ok" : "muted";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="generator-panel">
|
||||
<h2>Generator · プレビュー</h2>
|
||||
<p class="notice">
|
||||
これは検証用の <code>mock-target.json</code> です。QMKにそのまま渡せる生成物ではありません。
|
||||
</p>
|
||||
<div class="settings">
|
||||
<section class="box">
|
||||
<h3>出力構成</h3>
|
||||
<label v-for="g in groups" :key="g.id"
|
||||
>{{ g.name }}
|
||||
<select
|
||||
:value="selection[g.id] ?? g.default"
|
||||
@change="setVariant(g.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="v in g.children" :key="v.id" :value="v.id">{{ v.name }}</option>
|
||||
</select></label
|
||||
>
|
||||
<p v-if="!groups.length">バリアントグループなし</p>
|
||||
</section>
|
||||
<section class="box">
|
||||
<h3>Matrix row / col</h3>
|
||||
<p>ELCで使うピン候補から順序を設定します。番号はリスト順です。</p>
|
||||
<h4>rowPins</h4>
|
||||
<ol>
|
||||
<li v-for="(pin, i) in rows" :key="`${pin}-${i}`">
|
||||
{{ i }}: {{ pin }} <button @click="movePin('rowPins', i, -1)">↑</button
|
||||
><button @click="movePin('rowPins', i, 1)">↓</button
|
||||
><button @click="removePin('rowPins', i)">削除</button>
|
||||
</li>
|
||||
</ol>
|
||||
<select v-model="rowAdd" data-testid="row-pin-select">
|
||||
<option value="">ピンを選択…</option>
|
||||
<option v-for="pin in pins" :key="`r${pin}`">{{ pin }}</option></select
|
||||
><button @click="addPin('rowPins', rowAdd)">row追加</button>
|
||||
<h4>colPins</h4>
|
||||
<ol>
|
||||
<li v-for="(pin, i) in cols" :key="`${pin}-${i}`">
|
||||
{{ i }}: {{ pin }} <button @click="movePin('colPins', i, -1)">↑</button
|
||||
><button @click="movePin('colPins', i, 1)">↓</button
|
||||
><button @click="removePin('colPins', i)">削除</button>
|
||||
</li>
|
||||
</ol>
|
||||
<select v-model="colAdd" data-testid="col-pin-select">
|
||||
<option value="">ピンを選択…</option>
|
||||
<option v-for="pin in pins" :key="`c${pin}`">{{ pin }}</option></select
|
||||
><button @click="addPin('colPins', colAdd)">col追加</button>
|
||||
<p v-if="!pins.length">ELCのピン提供者を先に追加してください。</p>
|
||||
</section>
|
||||
<section class="box preview">
|
||||
<h3>生成結果</h3>
|
||||
<button data-testid="generate-button" @click="generate">生成</button
|
||||
><span v-if="result?.target" data-testid="mapping-count"> {{ result.target.mapping.length }}行</span>
|
||||
<div v-if="result && !result.target" class="errors">
|
||||
<h4>生成を止めた理由 ({{ result.errors.length }})</h4>
|
||||
<ul>
|
||||
<li v-for="(e, i) in result.errors" :key="`${e.code}-${i}`">
|
||||
<b>{{ e.code }}</b> {{ e.message }} <small>{{ e.ids?.join(", ") }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="result?.target"
|
||||
><button data-testid="download-target" @click="download">mock-target.jsonをダウンロード</button>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>出力番号</th>
|
||||
<th>keyId</th>
|
||||
<th>row</th>
|
||||
<th>col</th>
|
||||
<th>ラベル</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in result.target.mapping" :key="m.keyId">
|
||||
<td>{{ m.number }}</td>
|
||||
<td>{{ m.keyId }}</td>
|
||||
<td>{{ m.row }}</td>
|
||||
<td>{{ m.col }}</td>
|
||||
<td>{{ m.label }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>レイヤー配列</h4>
|
||||
<section v-for="layer in result.target.keymaps" :key="layer.name">
|
||||
<b>{{ layer.name }}</b
|
||||
><code class="array">{{ JSON.stringify(layer.keycodes) }}</code>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
<section class="layout-box">
|
||||
<h3>選択構成 · 番号 / row,col</h3>
|
||||
<KeyboardView :phy="project.phy" :selection="selection" :key-text="keyText" :key-class="keyClass" />
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generator-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.notice {
|
||||
padding: 8px;
|
||||
background: #fff4d6;
|
||||
}
|
||||
.settings {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(380px, 1.5fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.box,
|
||||
.layout-box {
|
||||
padding: 10px;
|
||||
border: 1px solid #d2d7df;
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
}
|
||||
.box label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.box h3,
|
||||
.layout-box h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.preview th,
|
||||
.preview td {
|
||||
border: 1px solid #d8dde5;
|
||||
padding: 3px 5px;
|
||||
text-align: left;
|
||||
}
|
||||
.errors {
|
||||
color: #9b1c1c;
|
||||
}
|
||||
.array {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 5px 0 10px;
|
||||
}
|
||||
.layout-box {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.layout-box :deep(.kv-key text) {
|
||||
font-size: 0.16px;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||
import { appendKeys, excludeKey, reorderKeys, unregisteredKeys } from "../../domain/idx";
|
||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||
import type { Dispatch } from "../types";
|
||||
import KeyboardView from "../common/KeyboardView.vue";
|
||||
|
||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||
const selectedKey = ref("");
|
||||
const selection = computed(() => ({
|
||||
...defaultVariantSelection(props.project.phy),
|
||||
...props.project.output.variantSelection,
|
||||
}));
|
||||
const groups = computed(() => {
|
||||
const out: VariantGroupNode[] = [];
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children);
|
||||
if (n.type === "variantGroup") {
|
||||
out.push(n);
|
||||
n.children.forEach((v) => walk(v.children));
|
||||
}
|
||||
});
|
||||
walk(props.project.phy.root);
|
||||
return out;
|
||||
});
|
||||
type Entry = { id: string; label: string; path: string; pathIds: string[] };
|
||||
const entries = computed<Entry[]>(() => {
|
||||
const out: Entry[] = [];
|
||||
const walk = (nodes: Node[], path: string[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children, [...path, n.name || n.id]);
|
||||
else if (n.type === "key") {
|
||||
if (!n.decal)
|
||||
out.push({
|
||||
id: n.id,
|
||||
label: n.labels?.find(Boolean) || "(ラベルなし)",
|
||||
path: path.join(" / ") || "ルート",
|
||||
pathIds: path,
|
||||
});
|
||||
} else if (n.type === "variantGroup") {
|
||||
n.children.forEach((v) => walk(v.children, [...path, `${n.name} / ${v.name}`]));
|
||||
}
|
||||
});
|
||||
walk(props.project.phy.root, []);
|
||||
return out;
|
||||
});
|
||||
const byId = computed(() => new Map(entries.value.map((e) => [e.id, e])));
|
||||
const unregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
|
||||
const displayKeys = computed(() =>
|
||||
flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal),
|
||||
);
|
||||
const outputNumbers = computed(() => {
|
||||
const active = new Set(displayKeys.value.map((k) => k.key.id));
|
||||
return new Map(props.project.idx.order.filter((id) => active.has(id)).map((id, index) => [id, index]));
|
||||
});
|
||||
const rowEntries = computed(() =>
|
||||
props.project.idx.order.map(
|
||||
(id) => byId.value.get(id) ?? { id, label: "(PHYに存在しないキー)", path: "不明", pathIds: [] },
|
||||
),
|
||||
);
|
||||
function setVariant(groupId: string, variantId: string) {
|
||||
props.dispatch("IDX出力構成変更", (p) => ({
|
||||
...p,
|
||||
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [groupId]: variantId } },
|
||||
}));
|
||||
}
|
||||
function register(id: string) {
|
||||
props.dispatch("IDXキー登録", (p) => ({ ...p, idx: appendKeys(p.idx, [id]) }));
|
||||
}
|
||||
function exclude(id: string) {
|
||||
props.dispatch("IDXキー除外", (p) => ({ ...p, idx: excludeKey(p.idx, id) }));
|
||||
}
|
||||
function move(id: string, delta: number) {
|
||||
const i = props.project.idx.order.indexOf(id),
|
||||
j = i + delta;
|
||||
if (j < 0 || j >= props.project.idx.order.length) return;
|
||||
const order = [...props.project.idx.order];
|
||||
[order[i], order[j]] = [order[j]!, order[i]!];
|
||||
props.dispatch("IDX順序変更", (p) => ({ ...p, idx: reorderKeys(p.idx, order) }));
|
||||
}
|
||||
function registerAll() {
|
||||
props.dispatch("IDX未登録キーを登録", (p) => ({
|
||||
...p,
|
||||
idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)),
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="idx-panel">
|
||||
<h2>Key Indexer</h2>
|
||||
<div class="idx-layout">
|
||||
<div class="idx-list">
|
||||
<div class="idx-head">
|
||||
<h3>IDX order</h3>
|
||||
<button data-testid="idx-register-all" :disabled="!unregistered.size" @click="registerAll">
|
||||
未登録をすべて登録
|
||||
</button>
|
||||
</div>
|
||||
<p>選択構成での出力番号をプレビューします。</p>
|
||||
<ol>
|
||||
<li
|
||||
v-for="(entry, i) in rowEntries"
|
||||
:key="entry.id"
|
||||
:data-testid="`idx-row-${entry.id}`"
|
||||
:class="{ chosen: selectedKey === entry.id }"
|
||||
>
|
||||
<span class="idx-number" :aria-label="`保存順 ${i}`">保存順 {{ i }}</span
|
||||
><b>{{ entry.id }}</b
|
||||
><span>{{ entry.label }}</span
|
||||
><small>{{ entry.path }}</small
|
||||
><span>{{
|
||||
outputNumbers.has(entry.id) ? `出力 ${outputNumbers.get(entry.id)}` : "出力対象外"
|
||||
}}</span>
|
||||
<button :aria-label="`${entry.id}を上へ`" :disabled="i === 0" @click="move(entry.id, -1)">
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
:aria-label="`${entry.id}を下へ`"
|
||||
:disabled="i === rowEntries.length - 1"
|
||||
@click="move(entry.id, 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button :aria-label="`${entry.id}を除外`" @click="exclude(entry.id)">除外</button>
|
||||
</li>
|
||||
</ol>
|
||||
<h3>全バリアントのキー</h3>
|
||||
<ul class="idx-all-keys">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
:data-testid="`idx-key-${entry.id}`"
|
||||
:class="{ chosen: selectedKey === entry.id, unregistered: unregistered.has(entry.id) }"
|
||||
>
|
||||
<b>{{ entry.id }}</b> · {{ entry.label }} <small>— {{ entry.path }}</small>
|
||||
<strong v-if="unregistered.has(entry.id)">未登録</strong>
|
||||
<span v-else-if="outputNumbers.has(entry.id)" class="idx-preview"
|
||||
>出力 {{ outputNumbers.get(entry.id) }}</span
|
||||
>
|
||||
<span v-else class="idx-preview muted">選択構成外</span>
|
||||
<button v-if="unregistered.has(entry.id)" @click="register(entry.id)">登録</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="idx-preview-pane">
|
||||
<h3>出力構成</h3>
|
||||
<label v-for="group in groups" :key="group.id"
|
||||
>{{ group.name }}
|
||||
<select
|
||||
:aria-label="`${group.name} 出力バリアント`"
|
||||
:value="selection[group.id] ?? group.default"
|
||||
@change="setVariant(group.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="variant in group.children" :key="variant.id" :value="variant.id">
|
||||
{{ variant.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<KeyboardView
|
||||
:phy="project.phy"
|
||||
:selection="selection"
|
||||
:selected-ids="selectedKey ? [selectedKey] : []"
|
||||
:key-text="(id) => (outputNumbers.has(id) ? String(outputNumbers.get(id)) : '')"
|
||||
@key-click="(id) => (selectedKey = id)"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.idx-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.idx-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(440px, 1.2fr) minmax(320px, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.idx-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.idx-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.idx-panel h3 {
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.idx-panel ol,
|
||||
.idx-all-keys {
|
||||
padding-left: 24px;
|
||||
}
|
||||
.idx-panel li {
|
||||
padding: 5px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.idx-panel ol li {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(95px, auto) 1fr 1fr auto auto auto auto;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.idx-panel small {
|
||||
color: #586273;
|
||||
}
|
||||
.idx-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
.idx-all-keys {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.idx-all-keys li {
|
||||
border-bottom: 1px solid #e3e6eb;
|
||||
}
|
||||
.idx-panel .chosen {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.idx-all-keys .unregistered {
|
||||
background: #fff1d6;
|
||||
}
|
||||
.idx-all-keys strong {
|
||||
color: #9a4c00;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.idx-preview-pane {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
}
|
||||
.idx-preview-pane label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.idx-preview {
|
||||
font-size: 12px;
|
||||
color: #1769d2;
|
||||
}
|
||||
.muted {
|
||||
color: #778;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import {
|
||||
assignKey,
|
||||
deleteLayer,
|
||||
isEditableKeycode,
|
||||
parseKeycode,
|
||||
reorderLayer,
|
||||
renameLayer,
|
||||
unassignKey,
|
||||
unassignedKeys,
|
||||
addLayer,
|
||||
} from "../../domain/map";
|
||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||
import { getKeycodeLabel } from "../../domain/keycodeLabels";
|
||||
import { qmkBasicKeycodes } from "../../domain/qmkBasicKeycodes";
|
||||
import type { Dispatch } from "../types";
|
||||
import KeyboardView from "../common/KeyboardView.vue";
|
||||
|
||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch; editable?: boolean }>();
|
||||
const selectedLayer = ref("Base"),
|
||||
selectedKey = ref(""),
|
||||
draft = ref(""),
|
||||
newLayerName = ref(""),
|
||||
renameValue = ref(""),
|
||||
error = ref("");
|
||||
const selection = computed(() => ({
|
||||
...defaultVariantSelection(props.project.phy),
|
||||
...props.project.output.variantSelection,
|
||||
}));
|
||||
const keys = computed(() => flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal));
|
||||
const layer = computed(
|
||||
() => props.project.map.layers.find((x) => x.name === selectedLayer.value) ?? props.project.map.layers[0],
|
||||
);
|
||||
const keyLabels = computed(
|
||||
() => new Map(keys.value.map((x) => [x.key.id, x.key.labels?.find(Boolean) || x.key.id])),
|
||||
);
|
||||
const assignments = computed(() => new Map((layer.value?.keymap ?? []).map((a) => [a.keyId, a.keycode])));
|
||||
const selectedCode = computed(() => assignments.value.get(selectedKey.value) ?? "");
|
||||
const selectedCodeEditable = computed(() => !selectedCode.value || isEditableKeycode(selectedCode.value));
|
||||
const missing = computed(() =>
|
||||
unassignedKeys(
|
||||
props.project.map,
|
||||
layer.value?.name ?? "",
|
||||
keys.value.map((x) => x.key.id),
|
||||
),
|
||||
);
|
||||
const candidates = computed(() => {
|
||||
const keycodes = qmkBasicKeycodes
|
||||
.filter((entry) => entry.group === "basic" || entry.group === "modifiers" || entry.group === "internal")
|
||||
.map((entry) => entry.key);
|
||||
const modifierFunctions = ["LCTL", "LSFT", "LALT", "LGUI", "RCTL", "RSFT", "RALT", "RGUI"];
|
||||
const out = [...keycodes];
|
||||
for (const l of props.project.map.layers)
|
||||
for (const code of ["MO", "TG", "TO", "TT", "DF", "OSL"]) out.push(`${code}(${l.name})`);
|
||||
for (const l of props.project.map.layers) for (const kc of keycodes) out.push(`LT(${l.name}, ${kc})`);
|
||||
for (const mod of modifierFunctions) for (const kc of keycodes) out.push(`${mod}(${kc})`);
|
||||
return [...new Set(out)];
|
||||
});
|
||||
function fail(e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
function pickLayer(name: string) {
|
||||
selectedLayer.value = name;
|
||||
selectedKey.value = "";
|
||||
error.value = "";
|
||||
}
|
||||
function chooseKey(id: string) {
|
||||
selectedKey.value = id;
|
||||
draft.value = assignments.value.get(id) ?? "";
|
||||
error.value = "";
|
||||
}
|
||||
function saveAssignment() {
|
||||
if (props.editable === false || !selectedKey.value || !layer.value || !selectedCodeEditable.value) return;
|
||||
if (draft.value.trim() === selectedCode.value) return;
|
||||
try {
|
||||
props.dispatch("MAPキー割り当て", (p) => ({
|
||||
...p,
|
||||
map: assignKey(p.map, layer.value!.name, selectedKey.value, draft.value.trim()),
|
||||
}));
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
function add() {
|
||||
if (props.editable === false) return;
|
||||
if (!newLayerName.value.trim()) {
|
||||
error.value = "レイヤー名を入力してください。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
props.dispatch("MAPレイヤー追加", (p) => ({ ...p, map: addLayer(p.map, newLayerName.value.trim()) }));
|
||||
selectedLayer.value = newLayerName.value.trim();
|
||||
newLayerName.value = "";
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
function rename() {
|
||||
if (props.editable === false) return;
|
||||
if (!layer.value) return;
|
||||
if (!renameValue.value.trim()) {
|
||||
error.value = "新しいレイヤー名を入力してください。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
props.dispatch("MAPレイヤー改名", (p) => ({
|
||||
...p,
|
||||
map: renameLayer(p.map, layer.value!.name, renameValue.value.trim()),
|
||||
}));
|
||||
selectedLayer.value = renameValue.value.trim();
|
||||
renameValue.value = "";
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
function remove() {
|
||||
if (props.editable === false) return;
|
||||
if (!layer.value) return;
|
||||
try {
|
||||
props.dispatch("MAPレイヤー削除", (p) => ({ ...p, map: deleteLayer(p.map, layer.value!.name) }));
|
||||
selectedLayer.value = props.project.map.layers[0]?.name ?? "";
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
function move(delta: number) {
|
||||
if (props.editable === false) return;
|
||||
if (!layer.value) return;
|
||||
const i = props.project.map.layers.findIndex((x) => x.name === layer.value!.name),
|
||||
j = i + delta;
|
||||
if (j < 0 || j >= props.project.map.layers.length) return;
|
||||
props.dispatch("MAPレイヤー順序変更", (p) => ({ ...p, map: reorderLayer(p.map, layer.value!.name, j) }));
|
||||
}
|
||||
function unassign() {
|
||||
if (props.editable === false || !layer.value || !selectedKey.value || !selectedCodeEditable.value) return;
|
||||
props.dispatch("MAP割り当て解除", (p) => ({
|
||||
...p,
|
||||
map: unassignKey(p.map, layer.value!.name, selectedKey.value),
|
||||
}));
|
||||
draft.value = "";
|
||||
error.value = "";
|
||||
}
|
||||
function keyText(id: string) {
|
||||
const code = assignments.value.get(id);
|
||||
if (!code) return "";
|
||||
const parsed = parseKeycode(code);
|
||||
const label = parsed.kind === "basic" ? getKeycodeLabel(code) : undefined;
|
||||
return label ? `${code}\n${label}` : code;
|
||||
}
|
||||
function keyClass(id: string) {
|
||||
const code = assignments.value.get(id);
|
||||
if (!code) return "warn";
|
||||
return isEditableKeycode(code) ? undefined : "muted";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="map-panel">
|
||||
<h2>Keymap Designer</h2>
|
||||
<div class="map-columns">
|
||||
<aside class="map-layers">
|
||||
<h3>レイヤー</h3>
|
||||
<div class="layer-tabs" role="tablist">
|
||||
<button
|
||||
v-for="(item, i) in project.map.layers"
|
||||
:key="item.name"
|
||||
role="tab"
|
||||
:aria-selected="selectedLayer === item.name"
|
||||
:class="{ active: selectedLayer === item.name }"
|
||||
@click="pickLayer(item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
>追加する名前
|
||||
<input
|
||||
v-model="newLayerName"
|
||||
aria-label="追加するレイヤー名"
|
||||
:disabled="editable === false" /></label
|
||||
><button data-testid="map-add-layer" :disabled="editable === false" @click="add">追加</button>
|
||||
<label
|
||||
>新しい名前
|
||||
<input
|
||||
v-model="renameValue"
|
||||
aria-label="新しい名前"
|
||||
:placeholder="layer?.name"
|
||||
:disabled="editable === false" /></label
|
||||
><button data-testid="map-rename-layer" :disabled="editable === false" @click="rename">改名</button>
|
||||
<div>
|
||||
<button
|
||||
aria-label="レイヤーを上へ"
|
||||
:disabled="editable === false || !layer || project.map.layers[0]?.name === layer.name"
|
||||
@click="move(-1)"
|
||||
>
|
||||
↑</button
|
||||
><button
|
||||
aria-label="レイヤーを下へ"
|
||||
:disabled="editable === false || !layer || project.map.layers.at(-1)?.name === layer.name"
|
||||
@click="move(1)"
|
||||
>
|
||||
↓</button
|
||||
><button data-testid="map-delete-layer" :disabled="editable === false" @click="remove">削除</button>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="map-error">{{ error }}</p>
|
||||
</aside>
|
||||
<main class="map-editor">
|
||||
<h3>{{ layer?.name }} · キーマップ</h3>
|
||||
<KeyboardView
|
||||
:phy="project.phy"
|
||||
:selection="selection"
|
||||
:selected-ids="selectedKey ? [selectedKey] : []"
|
||||
:key-text="keyText"
|
||||
:key-class="keyClass"
|
||||
@key-click="(id) => chooseKey(id)"
|
||||
/>
|
||||
<div v-if="selectedKey" class="map-assignment">
|
||||
<strong>{{ keyLabels.get(selectedKey) }} · {{ selectedKey }}</strong>
|
||||
<label
|
||||
>キーコード
|
||||
<input
|
||||
v-model="draft"
|
||||
list="map-keycode-candidates"
|
||||
aria-label="キーコード"
|
||||
@keydown.enter.prevent="saveAssignment"
|
||||
:disabled="editable === false || !selectedCodeEditable"
|
||||
/></label>
|
||||
<datalist id="map-keycode-candidates">
|
||||
<option v-for="value in candidates" :key="value" :value="value" />
|
||||
</datalist>
|
||||
<button @click="saveAssignment" :disabled="editable === false || !selectedCodeEditable">
|
||||
割り当て</button
|
||||
><button @click="unassign" :disabled="editable === false || !selectedCodeEditable">
|
||||
割り当て解除
|
||||
</button>
|
||||
<p v-if="selectedCode && !selectedCodeEditable" role="status">
|
||||
未知のキーコードは保持し、表示のみです。
|
||||
</p>
|
||||
<p v-if="draft && !isEditableKeycode(draft.trim())" class="map-error">
|
||||
このキーコードは編集対象外です。
|
||||
</p>
|
||||
</div>
|
||||
<section class="map-missing">
|
||||
<h3>未割り当て · {{ missing.length }} keys</h3>
|
||||
<ul>
|
||||
<li v-for="id in missing" :key="id">
|
||||
<button @click="chooseKey(id)">{{ keyLabels.get(id) }} · {{ id }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.map-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.map-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(400px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.map-layers {
|
||||
border-right: 1px solid #d5dae2;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.map-layers h3,
|
||||
.map-editor h3 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.map-layers label,
|
||||
.map-assignment label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 9px 0;
|
||||
align-items: center;
|
||||
}
|
||||
.map-layers input {
|
||||
min-width: 0;
|
||||
width: 120px;
|
||||
}
|
||||
.layer-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.layer-tabs button.active {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.map-editor {
|
||||
min-width: 0;
|
||||
}
|
||||
.map-assignment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
padding: 8px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.map-assignment input {
|
||||
width: 240px;
|
||||
}
|
||||
.map-missing ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.map-error {
|
||||
color: #a21d16;
|
||||
background: #fff0ef;
|
||||
padding: 6px;
|
||||
}
|
||||
.map-panel :deep(.keyboard-view) {
|
||||
max-height: 48vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
import PhyNodeView from "./PhyNodeView.vue";
|
||||
defineProps<{ nodes: Node[]; selected: string[]; variants: Record<string, string>; zoom: number }>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: string, additive: boolean];
|
||||
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
|
||||
}>();
|
||||
function visible(n: Node, selection: Record<string, string>) {
|
||||
return n.type !== "variantGroup" || !!n.children.find((v) => v.id === (selection[n.id] ?? n.default));
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<svg
|
||||
class="canvas"
|
||||
data-testid="phy-canvas"
|
||||
:viewBox="`-64 -64 ${1152 / zoom} ${896 / zoom}`"
|
||||
width="1152"
|
||||
height="896"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
>
|
||||
<g class="layout" transform="scale(64)">
|
||||
<g v-for="n in nodes" :key="n.id">
|
||||
<PhyNodeView
|
||||
:node="n"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="visible(n, variants)"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node } from "../../domain/phy";
|
||||
const props = defineProps<{
|
||||
node: Node;
|
||||
selected: string[];
|
||||
variants: Record<string, string>;
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: string, additive: boolean];
|
||||
dragStart: [event: PointerEvent, id: string, parent: SVGGElement];
|
||||
}>();
|
||||
function children(n: Node): Node[] {
|
||||
if (n.type === "cluster") return n.children;
|
||||
if (n.type === "variantGroup")
|
||||
return n.children.find((v) => v.id === (props.variants[n.id] ?? n.default))?.children ?? [];
|
||||
return [];
|
||||
}
|
||||
function partShape(part?: string) {
|
||||
return part === "@led"
|
||||
? "led"
|
||||
: part === "@encoder"
|
||||
? "encoder"
|
||||
: part === "@screw"
|
||||
? "screw"
|
||||
: part === "@oled"
|
||||
? "oled"
|
||||
: "other";
|
||||
}
|
||||
function pointer(event: PointerEvent) {
|
||||
const parent = (event.currentTarget as SVGGElement).parentElement as unknown as SVGGElement;
|
||||
emit("dragStart", event, props.node.id, parent);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<g
|
||||
v-if="visible"
|
||||
class="phy-node"
|
||||
:class="[
|
||||
{
|
||||
chosen: selected.includes(node.id),
|
||||
ghost: node.type === 'key' && node.ghost,
|
||||
decal: node.type === 'key' && node.decal,
|
||||
},
|
||||
`node-${node.type}`,
|
||||
]"
|
||||
:data-node-id="node.id"
|
||||
:data-node-type="node.type"
|
||||
:transform="`translate(${node.transform.x},${node.transform.y}) rotate(${node.transform.rotation})`"
|
||||
@pointerdown.stop="pointer"
|
||||
@click.stop="emit('select', node.id, $event.shiftKey)"
|
||||
>
|
||||
<template v-if="node.type === 'key'"
|
||||
><rect
|
||||
class="key-face"
|
||||
x="0.04"
|
||||
y="0.04"
|
||||
:width="Math.max(0.1, node.size.w - 0.08)"
|
||||
:height="Math.max(0.1, node.size.h - 0.08)"
|
||||
rx="0.08"
|
||||
/><rect
|
||||
v-if="node.size.w2 || node.size.h2 || node.size.x2 || node.size.y2"
|
||||
class="key-face secondary"
|
||||
:x="node.size.x2 || 0"
|
||||
:y="node.size.y2 || 0"
|
||||
:width="node.size.w2 || node.size.w"
|
||||
:height="node.size.h2 || node.size.h"
|
||||
rx="0.06"
|
||||
/><text class="key-label" x="0.12" y="0.3">{{
|
||||
node.labels?.filter(Boolean).slice(0, 2).join(" / ")
|
||||
}}</text></template
|
||||
>
|
||||
<template v-else-if="node.type === 'component'"
|
||||
><circle v-if="partShape(node.partGroup) === 'led'" class="part led" r="0.25" /><circle
|
||||
v-else-if="partShape(node.partGroup) === 'screw'"
|
||||
class="part screw"
|
||||
r="0.18"
|
||||
/><rect
|
||||
v-else-if="partShape(node.partGroup) === 'oled'"
|
||||
class="part oled"
|
||||
x="-0.35"
|
||||
y="-0.22"
|
||||
width="0.7"
|
||||
height="0.44"
|
||||
/><g v-else-if="partShape(node.partGroup) === 'encoder'"
|
||||
><circle class="part encoder" r="0.27" /><path d="M-.1 -.18 L.12 0 -.1 .18" /></g
|
||||
><path v-else class="part" d="M0 -.22 L.22 0 0 .22 -.22 0 Z" /><text
|
||||
class="part-label"
|
||||
x="0.28"
|
||||
y="0.05"
|
||||
>{{ node.partGroup || "part" }}</text
|
||||
></template
|
||||
>
|
||||
<g v-else-if="node.type === 'cluster'" class="cluster-content"
|
||||
><PhyNodeView
|
||||
v-for="c in node.children"
|
||||
:key="c.id"
|
||||
:node="c"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="true"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/></g>
|
||||
<g v-else-if="node.type === 'variantGroup'" class="variant-content"
|
||||
><PhyNodeView
|
||||
v-for="c in children(node)"
|
||||
:key="c.id"
|
||||
:node="c"
|
||||
:selected="selected"
|
||||
:variants="variants"
|
||||
:visible="true"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
@drag-start="(event, id, parent) => emit('dragStart', event, id, parent)"
|
||||
/></g>
|
||||
<circle v-if="node.type === 'cluster' || node.type === 'variantGroup'" class="group-origin" r="0.07" />
|
||||
<rect
|
||||
v-if="selected.includes(node.id) && node.type !== 'key'"
|
||||
class="selection-box"
|
||||
x="-0.12"
|
||||
y="-0.12"
|
||||
width="0.24"
|
||||
height="0.24"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { Node, Variant } from "../../domain/phy";
|
||||
type Item = Node | Variant;
|
||||
defineProps<{ nodes: Item[]; selected: string[]; expanded?: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: string, additive: boolean] }>();
|
||||
function kind(n: Item) {
|
||||
switch (n.type) {
|
||||
case "variantGroup":
|
||||
return "VariantGroup";
|
||||
case "cluster":
|
||||
return "Cluster";
|
||||
case "component":
|
||||
return "部品";
|
||||
case "key":
|
||||
return "キー";
|
||||
case "variant":
|
||||
return "Variant";
|
||||
}
|
||||
}
|
||||
function title(n: Item) {
|
||||
switch (n.type) {
|
||||
case "key":
|
||||
return n.labels?.filter(Boolean)[0] || "無題キー";
|
||||
case "component":
|
||||
return n.partGroup || "部品";
|
||||
case "variantGroup":
|
||||
case "cluster":
|
||||
case "variant":
|
||||
return n.name || n.type;
|
||||
}
|
||||
}
|
||||
function children(n: Item): Item[] {
|
||||
return n.type === "cluster" || n.type === "variantGroup" || n.type === "variant" ? n.children : [];
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul class="tree-list">
|
||||
<li v-for="n in nodes" :key="n.id">
|
||||
<button
|
||||
class="tree-node"
|
||||
:class="{ chosen: selected.includes(n.id) }"
|
||||
:data-tree-id="n.id"
|
||||
@click="emit('select', n.id, $event.shiftKey)"
|
||||
>
|
||||
{{ kind(n) }} · {{ title(n) }} <small>{{ n.id }}</small></button
|
||||
><PhyTree
|
||||
v-if="children(n).length"
|
||||
:nodes="children(n)"
|
||||
:selected="selected"
|
||||
@select="(id, additive) => emit('select', id, additive)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Point = { x: number; y: number };
|
||||
export function snapFromStart(_start: number, delta: number, step = 0.25): number {
|
||||
return Math.round(delta / step) * step;
|
||||
}
|
||||
export function parentPoint(parent: SVGGraphicsElement, clientX: number, clientY: number): Point {
|
||||
const matrix = parent.getScreenCTM();
|
||||
if (!matrix) throw new Error("SVG parent has no screen transform");
|
||||
const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
export function snappedDelta(start: Point, current: Point): Point {
|
||||
return { x: snapFromStart(0, current.x - start.x), y: snapFromStart(0, current.y - start.y) };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { ProjectFile } from "../domain/project";
|
||||
export type Dispatch = (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createProjectFromKle } from "../src/domain/project";
|
||||
import { saveProject, loadProject } from "../src/io/projectFile";
|
||||
import { validateSchema } from "../src/io/validateSchema";
|
||||
import { appendKeys, unregisteredKeys } from "../src/domain/idx";
|
||||
import { assignKey, isEditableKeycode, parseKeycode, renameLayer, deleteLayer } from "../src/domain/map";
|
||||
import { getKeycodeLabel } from "../src/domain/keycodeLabels";
|
||||
import { qmkBasicKeycodes } from "../src/domain/qmkBasicKeycodes";
|
||||
import { generateMockTarget } from "../src/domain/generator";
|
||||
import { deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||
import { ProjectHistory } from "../src/domain/history";
|
||||
import { validateProject } from "../src/domain/validate";
|
||||
import { mcuPinCandidates } from "../src/domain/elc";
|
||||
const kle = JSON.parse(
|
||||
readFileSync(new URL("../../samples/kle/macropad-4x4.json", import.meta.url), "utf8"),
|
||||
) as unknown;
|
||||
const gen = (() => {
|
||||
let i = 0;
|
||||
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
||||
})();
|
||||
describe("domain project flow", () => {
|
||||
it("supports QMK basic keycode names and aliases while preserving entered spelling", async () => {
|
||||
const editable = [
|
||||
"KC_ENT",
|
||||
"KC_ENTER",
|
||||
"KC_ESCAPE",
|
||||
"KC_ESC",
|
||||
"KC_P0",
|
||||
"KC_KP_0",
|
||||
"KC_LCTL",
|
||||
"KC_LEFT_CTRL",
|
||||
"KC_INT1",
|
||||
"KC_LNG1",
|
||||
];
|
||||
for (const code of editable) expect(isEditableKeycode(code), code).toBe(true);
|
||||
|
||||
const formalNames = qmkBasicKeycodes.filter((entry) => entry.group !== "internal");
|
||||
expect(formalNames.length).toBeGreaterThanOrEqual(100);
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const keyId = p.idx.order[0]!;
|
||||
for (const code of editable) {
|
||||
p.map = assignKey(p.map, "Base", keyId, code);
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||
}
|
||||
p.map = assignKey(p.map, "Base", keyId, "LCTL(KC_ENT)");
|
||||
expect(isEditableKeycode("LCTL(KC_ENT)")).toBe(true);
|
||||
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||
p.map = assignKey(p.map, "Base", keyId, "LT(Nav, KC_LNG1)");
|
||||
expect(isEditableKeycode("LT(Nav, KC_LNG1)")).toBe(true);
|
||||
p.map = assignKey(p.map, "Base", keyId, "KC_P0");
|
||||
const roundTrip = await loadProject((await saveProject(p)).json!);
|
||||
expect(roundTrip.project!.map.layers[0]!.keymap[0]!.keycode).toBe("KC_P0");
|
||||
|
||||
for (const code of ["KC_NO", "XXXXXXX", "KC_TRANSPARENT", "KC_TRNS", "_______"]) {
|
||||
expect(isEditableKeycode(code), code).toBe(true);
|
||||
p.map = assignKey(p.map, "Base", keyId, code);
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||
}
|
||||
|
||||
for (const code of ["KC_FOO", "QK_BOOT"]) {
|
||||
expect(parseKeycode(code).kind).toBe("unknown");
|
||||
expect(isEditableKeycode(code)).toBe(false);
|
||||
expect(() => assignKey(p.map, "Base", keyId, code)).toThrow();
|
||||
}
|
||||
p.map.layers[0]!.keymap[0]!.keycode = "KC_FOO";
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe("KC_FOO");
|
||||
|
||||
expect(getKeycodeLabel("KC_A")).toBe("A");
|
||||
expect(getKeycodeLabel("KC_ENTER")).toBe("Enter");
|
||||
expect(getKeycodeLabel("KC_ENT")).toBe("Enter");
|
||||
expect(getKeycodeLabel("KC_ESCAPE")).toBe("Escape");
|
||||
expect(getKeycodeLabel("KC_ESC")).toBe("Escape");
|
||||
});
|
||||
it("imports KLE, saves, reloads and validates schemas", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const saved = await saveProject(p);
|
||||
expect(saved.errors.some((issue) => issue.severity === "blocksSave")).toBe(false);
|
||||
const loaded = await loadProject(saved.json!);
|
||||
expect(loaded.errors.filter((x) => x.severity === "blocksSave")).toEqual([]);
|
||||
expect(JSON.stringify(loaded.project)).toBe(JSON.stringify(p));
|
||||
for (const l of ["phy", "idx", "elc", "map"] as const) expect(validateSchema(l, p[l])).toEqual([]);
|
||||
});
|
||||
it("preserves unknown data and IDX order", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
p.extra = { x: 1 };
|
||||
(p.phy as typeof p.phy & { extraPhy: boolean }).extraPhy = true;
|
||||
p.idx.extraIdx = true;
|
||||
p.elc.extraElc = true;
|
||||
p.map.extraMap = true;
|
||||
const q = (await loadProject((await saveProject(p)).json!)).project!;
|
||||
expect(q.extra).toEqual({ x: 1 });
|
||||
expect((q.phy as typeof q.phy & { extraPhy: boolean }).extraPhy).toBe(true);
|
||||
expect(q.idx.extraIdx).toBe(true);
|
||||
expect(q.elc.extraElc).toBe(true);
|
||||
expect(q.map.extraMap).toBe(true);
|
||||
expect(unregisteredKeys(q.phy, appendKeys(q.idx, []))).toEqual([]);
|
||||
});
|
||||
it("supports MAP layer reference changes, duplicate checks and safe parsing", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||
p.map = assignKey(p.map, "Base", p.idx.order[0]!, "MO(Nav)");
|
||||
p.map = assignKey(p.map, "Base", p.idx.order[1]!, "LT(Nav, KC_SPC)");
|
||||
p.map = renameLayer(p.map, "Nav", "Fn");
|
||||
expect(p.map.layers[0]!.keymap.map((x) => x.keycode)).toEqual(["MO(Fn)", "LT(Fn, KC_SPC)"]);
|
||||
expect(parseKeycode("MO(Fn)").layer).toBe("Fn");
|
||||
expect(() => deleteLayer(p.map, "Fn")).toThrow();
|
||||
p.map.layers[0]!.keymap.push({ ...p.map.layers[0]!.keymap[0]! });
|
||||
expect(validateProject(p).some((x) => x.code === "duplicate-map-key")).toBe(true);
|
||||
});
|
||||
it("generates ordered matrix/keymap previews and cascades deletion", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order;
|
||||
p.output.matrix = {
|
||||
rowPins: ["m.GP0", "m.GP1", "m.GP2", "m.GP3"],
|
||||
colPins: ["m.GP4", "m.GP5", "m.GP6", "m.GP7"],
|
||||
};
|
||||
p.elc.providers.push({ id: "m", kind: "mcu", part: "RP2040" });
|
||||
p.elc.ledChains = [{ id: "chain", data: "m.GP8", order: [ids[0]!, ids[1]!] }];
|
||||
p.map.layers[0]!.keymap = ids.map((keyId, i) => ({ keyId, keycode: `KC_${i % 10}` }));
|
||||
p.elc.keys = ids.map((key, i) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: `m.GP${Math.floor(i / 4)}`, b: `m.GP${4 + (i % 4)}` },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
const out = generateMockTarget(p);
|
||||
expect(out.errors).toEqual([]);
|
||||
expect(out.target?.idx).toEqual(ids);
|
||||
expect(out.target?.matrix.rows.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
|
||||
expect(out.target?.matrix.cols.slice(0, 5)).toEqual([0, 1, 2, 3, 0]);
|
||||
expect(out.target?.keymaps[0]!.keycodes).toHaveLength(16);
|
||||
const q = deleteNodeCascade(p, ids[0]!);
|
||||
expect(q.idx.order).not.toContain(ids[0]);
|
||||
expect(q.elc.keys.some((x) => x.key === ids[0])).toBe(false);
|
||||
expect(q.elc.ledChains?.[0]?.order).not.toContain(ids[0]);
|
||||
expect(q.map.layers[0]!.keymap.some((x) => x.keyId === ids[0])).toBe(false);
|
||||
});
|
||||
it("groups history edits", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
||||
h = new ProjectHistory(p);
|
||||
const q = structuredClone(p);
|
||||
q.phy.metadata.name = "drag";
|
||||
h.begin();
|
||||
h.push(q);
|
||||
h.replaceTop(q);
|
||||
h.commit(q);
|
||||
expect(h.canUndo).toBe(true);
|
||||
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||
expect(h.redo().phy.metadata.name).toBe("drag");
|
||||
});
|
||||
it("imports the VIA geometry sample", async () => {
|
||||
const via = JSON.parse(
|
||||
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
||||
) as unknown;
|
||||
const p = await createProjectFromKle(via, { idGenerator: gen });
|
||||
expect(validateSchema("phy", p.phy)).toEqual([]);
|
||||
});
|
||||
it("detects T-25 collisions and permits reversed diode pairs and exclusive variants", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order.slice(0, 2);
|
||||
p.phy.root = p.phy.root.filter((n) => n.type === "key" && ids.includes(n.id));
|
||||
p.idx.order = ids;
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "b-to-a" },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" }, diode: "b-to-a" },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "a-to-b" },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
const a = p.phy.root[0]!,
|
||||
b = p.phy.root[1]!;
|
||||
p.phy.root = [
|
||||
{
|
||||
type: "variantGroup",
|
||||
id: "vg",
|
||||
name: "Choice",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: "va",
|
||||
children: [
|
||||
{ type: "variant", id: "va", name: "A", children: [a] },
|
||||
{ type: "variant", id: "vb", name: "B", children: [b] },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||
expect(mcuPinCandidates(p.elc.providers[0] as { id: string; kind: "mcu"; part: string })).toHaveLength(
|
||||
30,
|
||||
);
|
||||
});
|
||||
it("filters variant output without rewriting stored IDX order", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order.slice(0, 2);
|
||||
p.phy.root = [
|
||||
{
|
||||
type: "variantGroup",
|
||||
id: "vgx",
|
||||
name: "Choice",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: "vax",
|
||||
children: [
|
||||
{
|
||||
type: "variant",
|
||||
id: "vax",
|
||||
name: "A",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: ids[0]!,
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "variant",
|
||||
id: "vbx",
|
||||
name: "B",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: ids[1]!,
|
||||
profile: "default",
|
||||
transform: { x: 1, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
p.output.variantSelection = { vgx: "vax" };
|
||||
p.idx.order = ids;
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.output.matrix = { rowPins: ["m.GP0"], colPins: ["m.GP1"] };
|
||||
p.elc.keys = ids.map((key) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP0", b: "m.GP1" },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
p.map.layers[0]!.keymap = ids.map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
let g = generateMockTarget(p);
|
||||
expect(g.target?.idx).toEqual([ids[0]]);
|
||||
p.output.variantSelection = { vgx: "vbx" };
|
||||
g = generateMockTarget(p);
|
||||
expect(g.target?.idx).toEqual([ids[1]]);
|
||||
expect(p.idx.order).toEqual(ids);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath";
|
||||
|
||||
describe("PHY drag math", () => {
|
||||
it("snaps from the operation start in quarter-unit increments", () => {
|
||||
expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25);
|
||||
expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5);
|
||||
});
|
||||
it("returns snapped local deltas", () => {
|
||||
expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElc } from "../src/domain/elc";
|
||||
import { wireMatrixKeys } from "../src/domain/elcMatrix";
|
||||
|
||||
describe("wireMatrixKeys", () => {
|
||||
it("assigns row-major pins and diode without mutating source", () => {
|
||||
const source = createElc({ source: "test", checksum: "" });
|
||||
const next = wireMatrixKeys(
|
||||
source,
|
||||
["k1", "k2", "k3"],
|
||||
["MCU.GP0", "MCU.GP1"],
|
||||
["MCU.GP2", "MCU.GP3"],
|
||||
"a-to-b",
|
||||
);
|
||||
expect(next.keys.map((w) => w.detect === "contact" && [w.terminals.a, w.terminals.b, w.diode])).toEqual([
|
||||
["MCU.GP0", "MCU.GP2", "a-to-b"],
|
||||
["MCU.GP0", "MCU.GP3", "a-to-b"],
|
||||
["MCU.GP1", "MCU.GP2", "a-to-b"],
|
||||
]);
|
||||
expect(source.keys).toEqual([]);
|
||||
});
|
||||
it("rejects an undersized matrix", () => {
|
||||
expect(() =>
|
||||
wireMatrixKeys(createElc({ source: "test", checksum: "" }), ["a", "b"], ["R"], ["C"]),
|
||||
).toThrow();
|
||||
});
|
||||
it("preserves extension fields when updating existing wiring", () => {
|
||||
const source = {
|
||||
...createElc({ source: "test", checksum: "" }),
|
||||
keys: [
|
||||
{
|
||||
key: "k1",
|
||||
detect: "contact" as const,
|
||||
terminals: { a: "OLD.R", b: "OLD.C" },
|
||||
extension: "keep",
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = wireMatrixKeys(source, ["k1"], ["R"], ["C"]);
|
||||
expect(next.keys[0]).toMatchObject({ extension: "keep", terminals: { a: "R", b: "C" } });
|
||||
});
|
||||
});
|
||||
Vendored
+5967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { KeyNode, PhyFile } from "../src/domain/phy";
|
||||
import { flattenKeys } from "../src/domain/geometry";
|
||||
import { clusterNodes, reframe, reparent } from "../src/domain/phyCommands";
|
||||
import { importKle } from "../src/io/kleImport";
|
||||
import { exportKle } from "../src/io/kleExport";
|
||||
import { createIdx } from "../src/domain/idx";
|
||||
|
||||
type OracleKey = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
width2: number;
|
||||
height2: number;
|
||||
rotation_angle: number;
|
||||
rotation_x: number;
|
||||
rotation_y: number;
|
||||
labels: (string | null)[];
|
||||
textColor: (string | null)[];
|
||||
textSize: (number | null)[];
|
||||
default: { textColor: string; textSize: number };
|
||||
color: string;
|
||||
profile: string;
|
||||
nub: boolean;
|
||||
stepped: boolean;
|
||||
decal: boolean;
|
||||
ghost: boolean;
|
||||
sm: string;
|
||||
sb: string;
|
||||
st: string;
|
||||
};
|
||||
type Case = { input: unknown[]; meta: Record<string, unknown>; keys: OracleKey[] };
|
||||
const oracle = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"),
|
||||
) as Record<string, Case>;
|
||||
// Independent expected lookup derived from the frozen serial.js output fixture.
|
||||
const labelMap: readonly (readonly (number | null)[])[] = [
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
|
||||
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
|
||||
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
|
||||
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
|
||||
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
|
||||
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
|
||||
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
|
||||
[null, null, null, null, 0, null, null, null, null, null, 4, null],
|
||||
];
|
||||
const uuid = (() => {
|
||||
let i = 0;
|
||||
return () => `12345678-90ab-4cde-8f01-${String(++i).padStart(12, "0")}`;
|
||||
})();
|
||||
const angleRad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const near = (actual: number, expected: number, tolerance = 1e-9) =>
|
||||
expect(Math.abs(actual - expected), `${actual} differs from ${expected}`).toBeLessThanOrEqual(tolerance);
|
||||
function canonicalLabels(labels: (string | null)[]): string[] {
|
||||
const result = labels.map((label) => label ?? "");
|
||||
while (result.at(-1) === "") result.pop();
|
||||
return result;
|
||||
}
|
||||
function oracleOrigin(k: OracleKey): { x: number; y: number } {
|
||||
const r = angleRad(k.rotation_angle),
|
||||
dx = k.x - k.rotation_x,
|
||||
dy = k.y - k.rotation_y;
|
||||
return {
|
||||
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
|
||||
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
|
||||
};
|
||||
}
|
||||
function expectedCorners(k: OracleKey, second = false): { x: number; y: number }[] {
|
||||
const r = angleRad(k.rotation_angle),
|
||||
ox = k.x + (second ? k.x2 : 0),
|
||||
oy = k.y + (second ? k.y2 : 0);
|
||||
const w = second ? k.width2 : k.width,
|
||||
h = second ? k.height2 : k.height;
|
||||
return [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
].map(([x, y]) => {
|
||||
const px = ox + x!,
|
||||
py = oy + y!,
|
||||
dx = px - k.rotation_x,
|
||||
dy = py - k.rotation_y;
|
||||
return {
|
||||
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
|
||||
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
|
||||
};
|
||||
});
|
||||
}
|
||||
function keyCorners(k: KeyNode, second = false): { x: number; y: number }[] {
|
||||
const r = angleRad(k.transform.rotation),
|
||||
ox = second ? (k.size.x2 ?? 0) : 0,
|
||||
oy = second ? (k.size.y2 ?? 0) : 0;
|
||||
const w = second ? (k.size.w2 ?? k.size.w) : k.size.w,
|
||||
h = second ? (k.size.h2 ?? k.size.h) : k.size.h;
|
||||
return [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
].map(([x, y]) => ({
|
||||
x: k.transform.x + Math.cos(r) * (ox + x!) - Math.sin(r) * (oy + y!),
|
||||
y: k.transform.y + Math.sin(r) * (ox + x!) + Math.cos(r) * (oy + y!),
|
||||
}));
|
||||
}
|
||||
function expectedStyleArrays(k: OracleKey, key: KeyNode): void {
|
||||
const compat = key.kleCompatibility ?? {},
|
||||
a = typeof compat.a === "number" ? compat.a : 4;
|
||||
const slots = labelMap[a] ?? labelMap[4]!;
|
||||
const t = typeof compat.t === "string" ? compat.t.split("\n") : [];
|
||||
expect(compat.c).toBe(k.color);
|
||||
expect(compat.p).toBe(k.profile);
|
||||
expect(compat.sm).toBe(k.sm);
|
||||
expect(compat.sb).toBe(k.sb);
|
||||
expect(compat.st).toBe(k.st);
|
||||
expect(compat.l === true).toBe(k.stepped);
|
||||
expect(compat.n === true).toBe(k.nub);
|
||||
expect(key.decal === true).toBe(k.decal);
|
||||
expect(key.ghost === true).toBe(k.ghost);
|
||||
expect(compat.f).toBe(k.default.textSize);
|
||||
const defaultTextColor = t[0] ?? "#000000";
|
||||
expect(k.default.textColor).toBe(defaultTextColor);
|
||||
const expectedColors = Array<string | null>(12).fill(null),
|
||||
expectedSizes = Array<number | null>(12).fill(null);
|
||||
slots.forEach((source, slot) => {
|
||||
const hasLabel = (key.labels?.[slot] ?? "") !== "";
|
||||
if (hasLabel && source !== null && source > 0 && source < t.length) expectedColors[slot] = t[source]!;
|
||||
if (hasLabel && source !== null && source >= 0) {
|
||||
if (Array.isArray(compat.fa) && source < compat.fa.length && typeof compat.fa[source] === "number")
|
||||
expectedSizes[slot] = compat.fa[source] as number;
|
||||
else if (source > 0 && typeof compat.f2 === "number") expectedSizes[slot] = compat.f2;
|
||||
}
|
||||
});
|
||||
const oracleColors = Array.from({ length: 12 }, (_, i) => k.textColor[i] ?? null);
|
||||
const oracleSizes = Array.from({ length: 12 }, (_, i) => k.textSize[i] ?? null);
|
||||
expect(expectedColors).toEqual(oracleColors);
|
||||
expect(expectedSizes).toEqual(oracleSizes);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
expect(expectedColors[i]).toBe(oracleColors[i]);
|
||||
expect(expectedSizes[i]).toBe(oracleSizes[i]);
|
||||
}
|
||||
}
|
||||
function verify(phy: PhyFile, expected: Case, tolerance = 1e-9): void {
|
||||
const keys = flattenKeys(phy).map((x) => x.key);
|
||||
expect(keys).toHaveLength(expected.keys.length);
|
||||
expect(phy.kleCompatibility).toEqual(expected.meta);
|
||||
const unmatched = [...keys];
|
||||
for (const want of expected.keys) {
|
||||
const origin = oracleOrigin(want),
|
||||
labels = canonicalLabels(want.labels);
|
||||
const index = unmatched.findIndex(
|
||||
(candidate) =>
|
||||
JSON.stringify(candidate.labels ?? []) === JSON.stringify(labels) &&
|
||||
Math.abs(candidate.transform.x - origin.x) < 1e-6 &&
|
||||
Math.abs(candidate.transform.y - origin.y) < 1e-6,
|
||||
);
|
||||
expect(
|
||||
index,
|
||||
`missing oracle key labels=${JSON.stringify(labels)} at ${origin.x},${origin.y}`,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
const actual = unmatched.splice(index, 1)[0]!;
|
||||
near(actual.transform.x, origin.x, tolerance);
|
||||
near(actual.transform.y, origin.y, tolerance);
|
||||
near(actual.transform.rotation, want.rotation_angle, tolerance);
|
||||
expect(actual.labels ?? []).toEqual(labels);
|
||||
expect(actual.size.w).toBe(want.width);
|
||||
expect(actual.size.h).toBe(want.height);
|
||||
expect(actual.size.x2 ?? 0).toBe(want.x2);
|
||||
expect(actual.size.y2 ?? 0).toBe(want.y2);
|
||||
expect(actual.size.w2 ?? actual.size.w).toBe(want.width2);
|
||||
expect(actual.size.h2 ?? actual.size.h).toBe(want.height2);
|
||||
const actualMain = keyCorners(actual),
|
||||
expectedMain = expectedCorners(want);
|
||||
const actualSecond = keyCorners(actual, true),
|
||||
expectedSecond = expectedCorners(want, true);
|
||||
[...actualMain, ...actualSecond].forEach((point, i) => {
|
||||
const target = [...expectedMain, ...expectedSecond][i]!;
|
||||
near(point.x, target.x, tolerance);
|
||||
near(point.y, target.y, tolerance);
|
||||
});
|
||||
expectedStyleArrays(want, actual);
|
||||
}
|
||||
}
|
||||
|
||||
describe("KLE importer against frozen serial.js oracle", () => {
|
||||
for (const [name, testCase] of Object.entries(oracle)) {
|
||||
it(`${name}: import and export/import match oracle`, () => {
|
||||
const first = importKle(testCase.input, { idGenerator: uuid });
|
||||
verify(first.phy, testCase);
|
||||
const eligible = first.phy.root.filter((node): node is KeyNode => node.type === "key" && !node.decal);
|
||||
expect(first.keyOrder).toEqual(eligible.map((key) => key.id));
|
||||
const second = importKle(exportKle(first.phy), { idGenerator: uuid });
|
||||
verify(second.phy, testCase, 1e-6);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("PHY regression cases from Sol review", () => {
|
||||
const base = (root: PhyFile["root"]): PhyFile => ({
|
||||
version: "0.1.0",
|
||||
metadata: {},
|
||||
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||
root,
|
||||
});
|
||||
it("reparent rejects self and descendants regardless of moved node kind", () => {
|
||||
const phy = base([
|
||||
{
|
||||
type: "cluster",
|
||||
id: "parent",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
children: [
|
||||
{
|
||||
type: "cluster",
|
||||
id: "child",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
children: [
|
||||
{ type: "cluster", id: "grandchild", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "variantGroup",
|
||||
id: "group",
|
||||
name: "G",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: "v",
|
||||
children: [
|
||||
{
|
||||
type: "variant",
|
||||
id: "v",
|
||||
name: "V",
|
||||
children: [
|
||||
{ type: "cluster", id: "inside", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
|
||||
],
|
||||
},
|
||||
{ type: "variant", id: "v2", name: "V2", children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(() => reparent(phy, "parent", "parent")).toThrow();
|
||||
expect(() => reparent(phy, "parent", "child")).toThrow();
|
||||
expect(() => reparent(phy, "child", "child")).toThrow();
|
||||
expect(() => reparent(phy, "child", "grandchild")).toThrow();
|
||||
expect(() => reparent(phy, "group", "inside")).toThrow();
|
||||
});
|
||||
it("cluster frame starts at the rotated shape bounds, including second rectangles", () => {
|
||||
const phy = base([
|
||||
{
|
||||
type: "key",
|
||||
id: "wide",
|
||||
profile: "default",
|
||||
transform: { x: 2, y: 2, rotation: 90 },
|
||||
size: { w: 2, h: 1, x2: -1, y2: -0.5, w2: 1, h2: 1 },
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
id: "other",
|
||||
profile: "default",
|
||||
transform: { x: 5, y: 2, rotation: 90 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
]);
|
||||
const grouped = clusterNodes(
|
||||
phy,
|
||||
["wide", "other"],
|
||||
"block",
|
||||
() => "12345678-90ab-4cde-8f01-000000000001",
|
||||
);
|
||||
const cluster = grouped.root[0]!;
|
||||
expect(cluster.type).toBe("cluster");
|
||||
if (cluster.type !== "cluster") throw new Error("Expected cluster");
|
||||
// In the cluster's 90-degree coordinate frame the second rectangle extends left/up.
|
||||
near(cluster.transform.rotation, 90);
|
||||
near(cluster.transform.x, 5);
|
||||
near(cluster.transform.y, 1);
|
||||
});
|
||||
it("flattening propagates raw ancestor rotations without normalizing", () => {
|
||||
const phy = base([
|
||||
{
|
||||
type: "cluster",
|
||||
id: "a",
|
||||
transform: { x: 0, y: 0, rotation: 450 },
|
||||
children: [
|
||||
{
|
||||
type: "cluster",
|
||||
id: "b",
|
||||
transform: { x: 0, y: 0, rotation: 400 },
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: "k",
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: -20 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(flattenKeys(phy)[0]!.rotation).toBe(830);
|
||||
});
|
||||
it("cluster, uncluster, reparent, and reframe retain raw world angles", () => {
|
||||
let phy = base([
|
||||
{
|
||||
type: "cluster",
|
||||
id: "outer",
|
||||
transform: { x: 1, y: 2, rotation: 400 },
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: "a",
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 50 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
id: "b",
|
||||
profile: "default",
|
||||
transform: { x: 2, y: 0, rotation: 50 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "cluster", id: "target", transform: { x: -1, y: 4, rotation: 720 }, children: [] },
|
||||
]);
|
||||
const rotations = () =>
|
||||
Object.fromEntries(flattenKeys(phy).map(({ key, rotation }) => [key.id, rotation]));
|
||||
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||
phy = clusterNodes(phy, ["a", "b"], "nested", uuid);
|
||||
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||
const outer = phy.root[0]!;
|
||||
if (outer.type !== "cluster") throw new Error("Expected outer cluster");
|
||||
const nested = outer.children[0]!;
|
||||
if (nested.type !== "cluster") throw new Error("Expected nested cluster");
|
||||
phy = reframe(phy, nested.id, { x: 3, y: -2, rotation: 810 });
|
||||
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||
phy = reparent(phy, "a", "target");
|
||||
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||
phy = clusterNodes(phy, ["b"], "single", uuid);
|
||||
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||
});
|
||||
it("T-18 initial order filters decal and retains ghost", () => {
|
||||
const phy = base([
|
||||
{
|
||||
type: "key",
|
||||
id: "decal",
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
decal: true,
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
id: "ghost",
|
||||
profile: "default",
|
||||
transform: { x: 1, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
ghost: true,
|
||||
},
|
||||
]);
|
||||
const imported = importKle([[{ d: true }, "D", { g: true }, "G"]], { idGenerator: uuid });
|
||||
expect(imported.keyOrder).toHaveLength(1);
|
||||
const idx = createIdx(phy, ["decal", "ghost"], { source: "x", checksum: "sha256-jcs:x" });
|
||||
expect(idx.order).toEqual(["ghost"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { PhyFile, KeyNode } from "../src/domain/phy";
|
||||
import { flattenKeys } from "../src/domain/geometry";
|
||||
import { importKle } from "../src/io/kleImport";
|
||||
import { exportKle } from "../src/io/kleExport";
|
||||
import { canonicalize, sha256Jcs } from "../src/io/json";
|
||||
import {
|
||||
addVariant,
|
||||
clusterNodes,
|
||||
createVariantGroup,
|
||||
deleteVariant,
|
||||
reframe,
|
||||
reparent,
|
||||
uncluster,
|
||||
} from "../src/domain/phyCommands";
|
||||
|
||||
const gen = (() => {
|
||||
let i = 0;
|
||||
return () => {
|
||||
const s = String(++i).padStart(12, "0");
|
||||
return `${s.slice(0, 8)}-${s.slice(8)}-4000-8000-000000000000`;
|
||||
};
|
||||
})();
|
||||
function sample(path: string): unknown {
|
||||
return JSON.parse(readFileSync(resolve(process.cwd(), "../samples/kle", path), "utf8")) as unknown;
|
||||
}
|
||||
function byShape(p: PhyFile) {
|
||||
return flattenKeys(p)
|
||||
.map((k) => ({
|
||||
x: k.x,
|
||||
y: k.y,
|
||||
r: k.rotation,
|
||||
w: k.key.size.w,
|
||||
h: k.key.size.h,
|
||||
labels: k.key.labels,
|
||||
compat: k.key.kleCompatibility,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.round(a.r * 1e6) - Math.round(b.r * 1e6) ||
|
||||
Math.round(a.y * 1e6) - Math.round(b.y * 1e6) ||
|
||||
Math.round(a.x * 1e6) - Math.round(b.x * 1e6),
|
||||
);
|
||||
}
|
||||
describe("KLE round trip", () => {
|
||||
for (const file of ["macropad-4x4.json", "cr0wn60.json"])
|
||||
it(file, () => {
|
||||
const a = importKle(sample(file), { idGenerator: gen }).phy;
|
||||
const b = importKle(exportKle(a), { idGenerator: gen }).phy;
|
||||
const aa = byShape(a),
|
||||
bb = byShape(b);
|
||||
expect(bb).toHaveLength(aa.length);
|
||||
for (let i = 0; i < aa.length; i++) {
|
||||
const x = aa[i]!,
|
||||
y = bb[i]!;
|
||||
expect(
|
||||
Math.abs(x.x - y.x),
|
||||
`${file} item ${i}: x ${x.x} vs ${y.x}; y=${x.y}/${y.y}; rot=${x.r}/${y.r}; labels=${JSON.stringify(x.labels)}/${JSON.stringify(y.labels)}`,
|
||||
).toBeLessThanOrEqual(1e-6);
|
||||
expect(Math.abs(x.y - y.y), `${file} item ${i}: y ${x.y} vs ${y.y}`).toBeLessThanOrEqual(1e-6);
|
||||
expect(Math.abs(x.r - y.r)).toBeLessThanOrEqual(1e-6);
|
||||
expect(x.w).toBe(y.w);
|
||||
expect(x.h).toBe(y.h);
|
||||
expect(x.labels).toEqual(y.labels);
|
||||
expect(x.compat).toEqual(y.compat);
|
||||
}
|
||||
});
|
||||
it("rotated KLE cursor, mid-row rotation and pivot changes", () => {
|
||||
const { phy } = importKle([[{ r: 90, rx: 1, ry: 1 }, "A", "B", { r: 180, rx: 2, ry: 1 }, "C"]], {
|
||||
idGenerator: gen,
|
||||
});
|
||||
const k = phy.root as KeyNode[];
|
||||
expect(k.map((x) => [x.transform.x, x.transform.y, x.transform.rotation])).toEqual([
|
||||
[1, 1, 90],
|
||||
[1, 2, 90],
|
||||
[2, 1, 180],
|
||||
]);
|
||||
const corners = (n: KeyNode) =>
|
||||
[
|
||||
[0, 0],
|
||||
[n.size.w, 0],
|
||||
[n.size.w, n.size.h],
|
||||
[0, n.size.h],
|
||||
].map(([x, y]) => {
|
||||
const a = (n.transform.rotation * Math.PI) / 180;
|
||||
return [
|
||||
n.transform.x + Math.cos(a) * x! - Math.sin(a) * y!,
|
||||
n.transform.y + Math.sin(a) * x! + Math.cos(a) * y!,
|
||||
];
|
||||
});
|
||||
const check = (actual: number[][], expected: number[][]) =>
|
||||
actual.forEach((p, i) =>
|
||||
p.forEach((v, j) => expect(Math.abs(v - expected[i]![j]!)).toBeLessThanOrEqual(1e-12)),
|
||||
);
|
||||
check(corners(k[0]!), [
|
||||
[1, 1],
|
||||
[1, 2],
|
||||
[0, 2],
|
||||
[0, 1],
|
||||
]);
|
||||
check(corners(k[2]!), [
|
||||
[2, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
function fixture(): PhyFile {
|
||||
return {
|
||||
version: "0.1.0",
|
||||
metadata: {},
|
||||
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||
root: [
|
||||
{
|
||||
type: "cluster",
|
||||
id: "outer",
|
||||
name: "Outer",
|
||||
transform: { x: 4, y: 2, rotation: 30 },
|
||||
children: [
|
||||
{
|
||||
type: "cluster",
|
||||
id: "inner",
|
||||
name: "Inner",
|
||||
transform: { x: 1, y: 2, rotation: 20 },
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: "a",
|
||||
profile: "default",
|
||||
transform: { x: 0.2, y: 0.4, rotation: 15 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
{
|
||||
type: "key",
|
||||
id: "b",
|
||||
profile: "default",
|
||||
transform: { x: 1.4, y: -0.2, rotation: -5 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "cluster",
|
||||
id: "other",
|
||||
name: "Other",
|
||||
transform: { x: -3, y: 6, rotation: -12 },
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const pose = (p: PhyFile) =>
|
||||
Object.fromEntries(flattenKeys(p).map((k) => [k.key.id, [k.x, k.y, k.rotation]]));
|
||||
const closePose = (a: Record<string, number[]>, b: Record<string, number[]>) => {
|
||||
for (const id of Object.keys(a)) {
|
||||
a[id]!.forEach((v, i) =>
|
||||
expect(Math.abs(v - b[id]![i]!), `pose ${id} component ${i}: ${v} vs ${b[id]![i]}`).toBeLessThanOrEqual(
|
||||
1e-9,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
describe("PHY commands and variants", () => {
|
||||
it("preserves nested rotated key world placement through cluster, uncluster, reparent, reframe", () => {
|
||||
let p = fixture();
|
||||
const before = pose(p);
|
||||
p = clusterNodes(p, ["a", "b"], "picked", gen);
|
||||
closePose(before, pose(p));
|
||||
const outer = p.root[0]!;
|
||||
if (outer.type !== "cluster") throw Error();
|
||||
const inner = outer.children[0]!;
|
||||
if (inner.type !== "cluster") throw Error();
|
||||
const created = inner.children[0]!;
|
||||
if (created.type !== "cluster") throw Error();
|
||||
p = uncluster(p, created.id);
|
||||
closePose(before, pose(p));
|
||||
p = reparent(p, "a", "other");
|
||||
closePose(before, pose(p));
|
||||
p = reframe(p, "outer", { x: 8, y: -4, rotation: -20 });
|
||||
closePose(before, pose(p));
|
||||
});
|
||||
it("flattens variant selection, exports only the selected branch, and applies deletion rules", () => {
|
||||
let p: PhyFile = {
|
||||
version: "0.1.0",
|
||||
metadata: {},
|
||||
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||
root: [],
|
||||
};
|
||||
p = createVariantGroup(
|
||||
p,
|
||||
"Layout",
|
||||
{ x: 2, y: 0, rotation: 0 },
|
||||
[
|
||||
{
|
||||
name: "ANSI",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: "ansi",
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
labels: ["Enter"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ISO",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: "iso",
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
labels: ["Enter"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
gen,
|
||||
);
|
||||
const group = p.root[0]!;
|
||||
if (group.type !== "variantGroup") throw Error();
|
||||
expect(flattenKeys(p).map((x) => x.key.id)).toEqual(["ansi"]);
|
||||
const choice = group.children[1]!.id;
|
||||
expect(flattenKeys(p, { [group.id]: choice }).map((x) => x.key.id)).toEqual(["iso"]);
|
||||
const out = JSON.stringify(exportKle(p, { [group.id]: choice }));
|
||||
expect(out).not.toContain('"ansi"');
|
||||
expect(() => deleteVariant(p, group.id, group.children[0]!.id)).toThrow();
|
||||
p = addVariant(p, group.id, "JIS", gen);
|
||||
const grown = p.root[0]!;
|
||||
if (grown.type !== "variantGroup") throw Error();
|
||||
p = deleteVariant(p, group.id, grown.default);
|
||||
const reduced = p.root[0]!;
|
||||
if (reduced.type !== "variantGroup") throw Error();
|
||||
expect(reduced.default).toBe(reduced.children[0]!.id);
|
||||
});
|
||||
});
|
||||
describe("JCS", () => {
|
||||
it("sorts keys and uses ECMAScript number formatting", () => {
|
||||
expect(canonicalize({ z: 1, a: 1e-6, b: 1e21, c: -0 })).toBe('{"a":0.000001,"b":1e+21,"c":0,"z":1}');
|
||||
expect(canonicalize([333333333.33333329, 1e30, 4.5, 2e-3, 1e-27])).toBe(
|
||||
"[333333333.3333333,1e+30,4.5,0.002,1e-27]",
|
||||
);
|
||||
});
|
||||
it("hashes canonical UTF-8 with SHA-256", async () => {
|
||||
expect(await sha256Jcs({ a: 1 })).toBe(
|
||||
"sha256-jcs:015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createProject } from "../src/domain/project";
|
||||
import type { PhyFile, KeyNode, VariantGroupNode } from "../src/domain/phy";
|
||||
import { generateMockTarget } from "../src/domain/generator";
|
||||
import { validateProject } from "../src/domain/validate";
|
||||
import { assignKey, type MapFile } from "../src/domain/map";
|
||||
import { setKeyWiring, setProvider, type ContactWiring, type ElcFile } from "../src/domain/elc";
|
||||
import { listDeleteReferences, deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||
import { loadProject, saveProject } from "../src/io/projectFile";
|
||||
import { makePhyReference } from "../src/domain/phyReference";
|
||||
import { ProjectHistory } from "../src/domain/history";
|
||||
|
||||
const emptyPhy = (): PhyFile => ({
|
||||
version: "0.1.0",
|
||||
metadata: {},
|
||||
profiles: [{ id: "d", name: "D", unitSize: { x: 1, y: 1 } }],
|
||||
root: [],
|
||||
});
|
||||
const key = (id: string, decal = false): KeyNode => ({
|
||||
type: "key",
|
||||
id,
|
||||
profile: "d",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
...(decal ? { decal: true } : {}),
|
||||
});
|
||||
const variantGroup = (
|
||||
id: string,
|
||||
defaultId: string,
|
||||
variants: Array<[string, string, (KeyNode | VariantGroupNode)[]]>,
|
||||
): VariantGroupNode => ({
|
||||
type: "variantGroup",
|
||||
id,
|
||||
name: id,
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: defaultId,
|
||||
children: variants.map(([variantId, name, children]) => ({
|
||||
type: "variant",
|
||||
id: variantId,
|
||||
name,
|
||||
children,
|
||||
})),
|
||||
});
|
||||
|
||||
describe("review regressions", () => {
|
||||
it("keeps sibling Variant references when deleting a Variant and moves the selection", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [
|
||||
variantGroup("vg", "va", [
|
||||
["va", "A", [key("ka")]],
|
||||
["vb", "B", [key("kb")]],
|
||||
["vc", "C", [key("kc")]],
|
||||
]),
|
||||
];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["ka", "kb", "kc"];
|
||||
p.output.variantSelection = { vg: "va" };
|
||||
p.elc.keys = ["ka", "kb", "kc"].map((id) => ({
|
||||
key: id,
|
||||
detect: "contact",
|
||||
terminals: { a: "VCC", b: "GND" },
|
||||
}));
|
||||
p.elc.ledChains = [{ id: "chain", data: "VCC", order: ["ka", "kb", "kc"] }];
|
||||
p.map.layers[0]!.keymap = ["ka", "kb", "kc"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
expect(listDeleteReferences(p, "va").map((ref) => ref.id)).toEqual(["ka", "ka", "ka", "ka"]);
|
||||
const result = deleteNodeCascade(p, "va");
|
||||
expect(
|
||||
result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.children.map((v) => v.id),
|
||||
).toEqual(["vb", "vc"]);
|
||||
expect(result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.default).toBe("vb");
|
||||
expect(result.output.variantSelection.vg).toBe("vb");
|
||||
expect(result.idx.order).toEqual(["kb", "kc"]);
|
||||
expect(result.elc.keys.map((w) => w.key)).toEqual(["kb", "kc"]);
|
||||
expect(result.elc.ledChains?.[0]?.order).toEqual(["kb", "kc"]);
|
||||
expect(result.map.layers[0]!.keymap.map((assignment) => assignment.keyId)).toEqual(["kb", "kc"]);
|
||||
});
|
||||
|
||||
it("returns schema issues without running semantic validation on malformed input", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const badOutput = await loadProject(JSON.stringify({ ...p, output: {} }));
|
||||
expect(badOutput.errors.some((issue) => issue.path === "/output/variantSelection")).toBe(true);
|
||||
expect(badOutput.errors.some((issue) => issue.code === "positive-unit-size")).toBe(false);
|
||||
const badPhy = await loadProject(
|
||||
JSON.stringify({ ...p, phy: { version: "0.1.0", profiles: null, root: [] } }),
|
||||
);
|
||||
expect(badPhy.errors.every((issue) => issue.code === "schema")).toBe(true);
|
||||
expect(badPhy.errors.length).toBeGreaterThan(0);
|
||||
const blockedSave = await saveProject({ ...p, phy: { ...p.phy, profiles: [] } });
|
||||
expect(blockedSave.json).toBeUndefined();
|
||||
expect(blockedSave.errors.some((issue) => issue.severity === "blocksSave")).toBe(true);
|
||||
const semanticBlock = await saveProject({
|
||||
...p,
|
||||
phy: { ...p.phy, profiles: [{ ...p.phy.profiles[0]!, unitSize: { x: 0, y: 1 } }] },
|
||||
});
|
||||
expect(semanticBlock.json).toBeUndefined();
|
||||
expect(semanticBlock.errors.some((issue) => issue.code === "positive-unit-size")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported project versions and allows other blocksSave projects to load", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const unsupported = await loadProject(JSON.stringify({ ...p, version: "0.2.0" }));
|
||||
expect(unsupported.project).toBeUndefined();
|
||||
expect(unsupported.errors.some((x) => x.code === "version")).toBe(true);
|
||||
p.idx.order = ["missing"];
|
||||
const blocked = await loadProject(JSON.stringify(p));
|
||||
expect(blocked.project).toBeDefined();
|
||||
expect(blocked.errors.some((x) => x.severity === "blocksSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks saving duplicate IDX keys", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("k")];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["k", "k"];
|
||||
expect(validateProject(p).some((x) => x.code === "schema" && x.severity === "blocksSave")).toBe(true);
|
||||
const result = await saveProject(p);
|
||||
expect(result.json).toBeUndefined();
|
||||
expect(result.errors.some((x) => x.code === "schema")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retain a no-op grouped history entry", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const history = new ProjectHistory(p);
|
||||
history.begin();
|
||||
history.commit(p);
|
||||
expect(history.canUndo).toBe(false);
|
||||
const changed = { ...p, projectId: "changed" };
|
||||
history.begin();
|
||||
history.commit(changed);
|
||||
expect(history.canUndo).toBe(true);
|
||||
expect(history.undo().projectId).toBe(p.projectId);
|
||||
});
|
||||
|
||||
it("ignores decals when requiring index registration, wiring, and keymap assignments", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("decal", true)];
|
||||
const p = await createProject(phy);
|
||||
const relevant = validateProject(p).filter((issue) => issue.ids?.includes("decal"));
|
||||
expect(
|
||||
relevant.filter((issue) => ["idx-unregistered", "unwired-key", "unassigned-key"].includes(issue.code)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves extension fields while editing existing MAP and ELC entries", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const map = p.map as MapFile;
|
||||
map.layers[0]!.keymap = [{ keyId: "k", keycode: "KC_A", vendorAssignment: { keep: true } }];
|
||||
const editedMap = assignKey(map, "Base", "k", "KC_B");
|
||||
expect(editedMap.layers[0]!.keymap[0]).toEqual({
|
||||
keyId: "k",
|
||||
keycode: "KC_B",
|
||||
vendorAssignment: { keep: true },
|
||||
});
|
||||
expect(() => assignKey(map, "Base", "k", "VENDOR_ACTION")).toThrow();
|
||||
expect(() => assignKey(map, "Base", "k", "MO(Missing)")).toThrow();
|
||||
|
||||
const elc = p.elc as ElcFile;
|
||||
elc.providers = [{ id: "m", kind: "mcu", part: "RP2040", vendorProvider: 9 }];
|
||||
const editedProvider = setProvider(elc, { id: "m", kind: "mcu", part: "RP2040B" });
|
||||
expect(editedProvider.providers[0]).toMatchObject({ part: "RP2040B", vendorProvider: 9 });
|
||||
elc.keys = [
|
||||
{
|
||||
key: "k",
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP0", b: "m.GP1", vendorTerminal: "x" },
|
||||
vendorWiring: true,
|
||||
} as ContactWiring,
|
||||
];
|
||||
const editedElc = setKeyWiring(elc, {
|
||||
key: "k",
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP2", b: "m.GP3" },
|
||||
});
|
||||
expect(editedElc.keys[0]).toMatchObject({
|
||||
vendorWiring: true,
|
||||
terminals: { a: "m.GP2", b: "m.GP3", vendorTerminal: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates pin ownership, provider pin ranges, duplicate ELC IDs and MAP layer references", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("k")];
|
||||
const p = await createProject(phy);
|
||||
p.elc.providers = [
|
||||
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||
{ id: "mux", kind: "analog-mux", channels: 2, select: ["missing.X"], common: "m.GP30" },
|
||||
];
|
||||
p.elc.keys = [
|
||||
{ key: "k", detect: "contact", terminals: { a: "mux.2", b: "missing.P" } },
|
||||
{ key: "k", detect: "contact", terminals: { a: "VCC", b: "GND" } },
|
||||
];
|
||||
p.elc.ledChains = [
|
||||
{ id: "chain", data: "m.GP0", order: [] },
|
||||
{ id: "chain", data: "m.GP1", order: [] },
|
||||
];
|
||||
p.map.layers[0]!.keymap = [{ keyId: "k", keycode: "MO(Missing)" }];
|
||||
const codes = new Set(validateProject(p).map((issue) => issue.code));
|
||||
for (const code of [
|
||||
"duplicate-provider-id",
|
||||
"duplicate-key-wiring",
|
||||
"duplicate-led-chain-id",
|
||||
"missing-pin-provider",
|
||||
"unknown-provider-pin",
|
||||
"missing-map-layer-reference",
|
||||
])
|
||||
expect(codes.has(code), code).toBe(true);
|
||||
const unknownPart = validateProject({
|
||||
...p,
|
||||
elc: {
|
||||
...p.elc,
|
||||
providers: [{ id: "custom", kind: "mcu", part: "UnknownPart" }],
|
||||
keys: [{ key: "k", detect: "contact", terminals: { a: "custom.any_pin", b: "VCC" } }],
|
||||
ledChains: [],
|
||||
},
|
||||
});
|
||||
expect(unknownPart.some((issue) => issue.code === "unknown-provider-pin")).toBe(false);
|
||||
expect(() => setProvider(p.elc, { id: "bad.id", kind: "mcu", part: "RP2040" })).toThrow();
|
||||
});
|
||||
|
||||
it("selects nested variants and treats sibling alternatives as exclusive", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [
|
||||
variantGroup("outer", "outer-a", [
|
||||
[
|
||||
"outer-a",
|
||||
"A",
|
||||
[
|
||||
key("common"),
|
||||
variantGroup("inner", "inner-a", [
|
||||
["inner-a", "IA", [key("ia")]],
|
||||
["inner-b", "IB", [key("ib")]],
|
||||
]),
|
||||
],
|
||||
],
|
||||
["outer-b", "B", [key("other")]],
|
||||
]),
|
||||
];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["common", "ia", "ib", "other"];
|
||||
p.output.variantSelection = { outer: "outer-a", inner: "inner-b" };
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.output.matrix = { rowPins: ["m.GP0", "m.GP2"], colPins: ["m.GP1", "m.GP3"] };
|
||||
p.elc.keys = ["common", "ia", "ib", "other"].map((id) => ({
|
||||
key: id,
|
||||
detect: "contact",
|
||||
terminals: id === "common" || id === "other" ? { a: "m.GP2", b: "m.GP3" } : { a: "m.GP0", b: "m.GP1" },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
p.map.layers[0]!.keymap = ["common", "ia", "ib", "other"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
expect(validateProject(p).some((issue) => issue.code === "wiring-collision")).toBe(false);
|
||||
const generated = generateMockTarget(p);
|
||||
expect(generated.target?.idx).toEqual(["common", "ib"]);
|
||||
expect(p.idx.order).toEqual(["common", "ia", "ib", "other"]);
|
||||
});
|
||||
|
||||
it("uses the independently calculated JCS checksum and reports stale references", async () => {
|
||||
const phy = emptyPhy();
|
||||
expect(await makePhyReference(phy)).toEqual({
|
||||
source: "project:phy",
|
||||
checksum: "sha256-jcs:277e45f2cae4b3883480599c5569e73fbeec9e1a22bfb9bc323aecc4cb91d843",
|
||||
});
|
||||
const p = await createProject(phy);
|
||||
p.phy.metadata.name = "changed after reference creation";
|
||||
const loaded = await loadProject(JSON.stringify(p));
|
||||
expect(
|
||||
loaded.errors.filter((issue) => issue.code === "phy-reference-mismatch").map((issue) => issue.path),
|
||||
).toEqual(["/idx/phyReference", "/elc/phyReference", "/map/phyReference"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "e2e/**/*.ts", "*.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: { include: ["tests/**/*.test.ts"] },
|
||||
} as never);
|
||||
@@ -0,0 +1,25 @@
|
||||
# 仮決定(モック用)
|
||||
|
||||
モックの仮実装のために、仕様上の未決事項を仮に1つへ固定したもの。**仕様ではない。**
|
||||
|
||||
- メインドキュメント(リポジトリ直下の `*.md`)・backlog・journal の運用ルールの対象外。
|
||||
メインドキュメントやbacklogからこのフォルダへリンクしない。
|
||||
- 仮決定は本決定を意味しない。推測の検証済み扱いにもしない(`[推測:P-n]` のタグと未確認事項はそのまま残る)。
|
||||
- 本決定にする場合は、通常どおり `/decide` でメインドキュメントとjournalに記録し、ここの該当項目を消す。
|
||||
- モックを終えたら、このフォルダごと削除する。経緯は履歴で追える。
|
||||
- モックのコードからは仮決定のID(`T-12` など)をコメントで参照してよい。覆すときの影響範囲をgrepで追うため。
|
||||
|
||||
## ファイル
|
||||
|
||||
| ファイル | 内容 |
|
||||
| --- | --- |
|
||||
| [decisions.md](decisions.md) | 仮決定の本体。範囲と技術 / 明示的な未決事項 / 暗黙の未決事項 |
|
||||
| [discrepancies.md](discrepancies.md) | 作業中に見つけたドキュメント間の食い違い。メインドキュメントは直していない |
|
||||
| [phy-editor-feedback.md](phy-editor-feedback.md) | PHY Editorモックの所感。画面仕様を作り直すときの入力 |
|
||||
| [phy-editor-keybindings.md](phy-editor-keybindings.md) | PHY Editorのキー割り当ての叩き台と、モディファイアを振る基準 |
|
||||
| [idx-editor-feedback.md](idx-editor-feedback.md) | IDX Editorモックの所感。画面仕様を作り直すときの入力 |
|
||||
| [elc-editor-feedback.md](elc-editor-feedback.md) | ELC Editorモックの所感。画面仕様を作り直すときの入力 |
|
||||
| [map-editor-feedback.md](map-editor-feedback.md) | MAP Editorモックの所感とRemapの現状。画面仕様を作り直すときの入力 |
|
||||
| `consultation/` | Codex(Sol / Astra)への相談の原文。仮決定はこれを突き合わせて決めた |
|
||||
|
||||
作成: 2026-09-25、ブランチ `mock/provisional-decisions`。
|
||||
@@ -0,0 +1,161 @@
|
||||
指定の直下11本のMarkdownと `backlog/README.md` を読みました。ファイルは編集していません。
|
||||
|
||||
前提を2点補足します。
|
||||
|
||||
- **未確認事項には、設計判断と事実検証が混在しています。** 仮決定で決められるのは採用方針・対応範囲であり、VIAの実機動作などを「確認済み」にはできません。
|
||||
- **backlogには当面の扱いが本文で決定済みの項目があります。** そこは既存方針を使えます。また、以前の失敗がNuxt・Piniaに起因したとは、今回の資料からは判断できません。
|
||||
|
||||
以下、**「事実」は文書で確認した内容、「提案」は今回の仮決定案**です。外部実装に関する文書内の記述は、今回すべてを原典で再検証したわけではありません。
|
||||
|
||||
判定の基準となるモックは、**PHY・IDX・ELC・MAPの編集、保存往復、出力対応表、疑似デバイスとの読み書き**まで。実機VIA、ファームウェアのビルド・書き込み、HST逆引きは対象外とします。
|
||||
「しない」は、この範囲なら未決の答えに依存せず作れる、という意味です。
|
||||
|
||||
**1. 明示的な未決事項**
|
||||
|
||||
仕様書の未確認事項9件、backlogの16件をすべて挙げます。
|
||||
[全体方針](../../keyboard-design-layers.md)、[実装計画](../../implementation-plan.md)、[MAP仕様](../../MAPレイヤー.md)、[HST仕様](../../HSTレイヤー.md)を根拠とします。
|
||||
|
||||
| 出典・項目 | モックへの影響 | 仮決定案/範囲と理由 |
|
||||
|---|---|---|
|
||||
| 全体 P-1 アナログ値の解釈の所属 | しない | アナログの解釈・キャリブレーションを対象外にする。ELCへ仮置きする必要はない。 |
|
||||
| 実装 P-1 kle-serial2のserializeの由来 | **影響する** | **KLE入出力を独立した自前アダプターにする。** 出自の判断を実装の前提にせず、後からライブラリへ置換できる。旧エディタのコードは取り込まない。 |
|
||||
| 実装 P-2 技術選定 | **影響する** | **TypeScript+Vue 3+Vite+SVG**。状態更新をUIから分離する。詳細は後述。 |
|
||||
| 実装 P-3 リポジトリ構成 | **影響する** | **既存リポジトリ内に独立した`mock/`、当初は単一パッケージ。** 分割・公開の仕組みを先に増やさず、内部の依存境界だけ固定する。 |
|
||||
| MAP P-1 動作定義・キーごとの設定の保存形式 | しない | マクロ、タップダンス、コンボ、キー別設定を対象外にする。基本キーコードと名前によるレイヤー参照だけで編集・往復を検証できる。 |
|
||||
| MAP P-2 VIAコマンドの実機動作 | しない | **`[推測:P-2]`は回避する。** 疑似デバイスはアプリ側の読み書き操作だけを模擬し、VIAの実装・検証済みとは扱わない。 |
|
||||
| MAP P-3 プロジェクトと実機の対応・状態管理 | **影響する** | **生成時の対応表をプロジェクトの出力記録に保存し、疑似デバイスと明示的に対応付ける。** 保存・読み出し・反映を分離し、自動同期しない。詳細は次表。 |
|
||||
| HST P-1 順方向表から逆引き候補を判断できる | しない | **`[推測:P-1]`は回避する。** 文字からの逆引き、Key Override候補生成、実現可否判定を作らない。 |
|
||||
| HST P-2 対象ホストを複数持つか | しない | 対象ホストの設定・検証を作らない。MAPは本文の当面方針どおりUS表示とし、ホストとの適合を保証する表示にはしない。 |
|
||||
|
||||
PHY・ELC・IDX・Schema・CLAUDEの実際の「未確認事項」は「なし」です。AGENTSと制作フローには該当項目がありません。CLAUDE内のP-1は**書式例**なので数えていません。
|
||||
|
||||
MAP P-3は複数の判断を含むため、仮決定を分解します。
|
||||
|
||||
| 未決部分 | 提案 | 理由 |
|
||||
|---|---|---|
|
||||
| 対応情報の置き場所・照合 | `mock-target`出力記録に、対象構成、キーID↔出力番号↔row/col、レイヤー名↔番号、生成条件を保存。疑似デバイスはその記録IDを持つ。 | レイヤー正本に通信アドレスを混ぜず、対応を再現できる。将来の実機識別方法とは分離する。 |
|
||||
| 排他キーの書き込み | 出力時に選んだバリアントのキーだけを書き込む。選択外のMAPは保持する。 | 同じアドレスへ複数の割り当てを同時に反映しない。 |
|
||||
| 読み出し先 | 対応表に記録された選択構成のキーだけへ戻す。選択外へ複製しない。 | 通信アドレスだけからPHYキーを推定しない。 |
|
||||
| layout option | 疑似デバイス側では変更不可。バリアント変更は新しい出力記録・対応付けとして扱う。 | VIA固有の選択符号化を決めずに往復を検証できる。 |
|
||||
| 設計変更後 | 配線・対象キー・選択構成・IDX順・MAPレイヤー順の変更で対応を失効。座標・ラベルだけの変更では失効させない。 | アドレス解釈が変わる編集と、表示だけの編集を区別できる。 |
|
||||
| 差分・保存・反映 | 「現在のMAP」「生成時の初期MAP」「最後に読んだデバイス値」を分離。読み出しは差分プレビュー後に取り込み、書き込み後は再読出しで確認。 | デバイス側の変更を無条件に上書きせず、部分失敗も表現できる。 |
|
||||
|
||||
次は[backlog全項目](../../backlog/README.md)です。
|
||||
|
||||
| 項目 | モックへの影響 | 仮決定案/既存方針の利用 |
|
||||
|---|---|---|
|
||||
| PHY-P-2 KLE再取り込み時の照合 | しない | 本文どおり新規PHYへの取り込みだけ。既存IDへの照合・更新は提供しない。 |
|
||||
| PHY-P-6 見た目情報の所属層 | しない | 本文どおり`kleCompatibility`に保持・書き戻し。所属層の決定と見た目編集は不要。 |
|
||||
| PHY-P-8 ラベルのMAP適用 | しない | 本文どおり自動適用しない。新規MAPは未割り当て。 |
|
||||
| PHY-P-12 部品の表裏描画 | しない | 本文どおり描き分けない。実装面を意味するデータも追加しない。 |
|
||||
| PHY-P-13 reframeの操作系 | **影響する** | **通常の移動とは別の「座標枠を変更」ダイアログで、原点・角度を数値入力。** 本文の暫定方針を具体化でき、ハンドル操作へ後から交換しやすい。 |
|
||||
| PHY-P-14 原点の吸着先 | しない | 本文どおり数値入力のみ。吸着は付けない。 |
|
||||
| ELC-P-1 光学スイッチ | しない | 本文どおり未対応。接点へ読み替えない。 |
|
||||
| ELC-P-2 複数muxの制御 | しない | muxの編集・走査出力をモック対象外とする。 |
|
||||
| ELC-P-3 方式ごとの走査設定 | **影響する** | **出力プレビューは単純な接点マトリクスに限定し、出力設定でrow/colのピン群・順番と走査方向を明示する。** 配線だけで全方式を推定せず、実機で動く走査設定との検証は後工程に残す。 |
|
||||
| IDX-P-1 既存レイアウトとの互換範囲 | しない | 独自の`order`を編集・保存する。既存コミュニティレイアウトとの互換出力は保証しない。 |
|
||||
| MAP-P-4 ON/OFF以外の入力 | しない | エンコーダはPHY上の部品配置まで。回転への割り当ては作らない。 |
|
||||
| MAP-P-5 表示用OS配列 | しない | 本文にある当面のUS表示を利用。US/JIS切り替えやHSTプロファイル選択は後回し。 |
|
||||
| MAP-P-6 develop・Community Modules | しない | 実ファームウェアへの出力・通信を今回作らないため、対応範囲を拡張しない。 |
|
||||
| MAP-P-7 再ビルド時の調整保存 | しない | ビルド・書き込みを対象外とする。疑似デバイスからMAPへ取り込む経路だけ検証する。 |
|
||||
| MAP-P-8 QMK以外のキーコード表記 | しない | QMK表記を維持。他ファームウェアへの変換層を作らない。 |
|
||||
| MAP-P-9 C生成・ユーザーC組み込み | しない | C生成と対象動作をモック対象外とし、フック方式などを先に固定しない。 |
|
||||
|
||||
**2. 暗黙の未決事項と仮決定案**
|
||||
|
||||
以下は、仕様の記載だけでは実装手順が一意にならない箇所です。**すべて提案**であり、既存仕様への追加・制限となるものも含みます。
|
||||
|
||||
| 項目 | 仮決定案 | 理由・変更時の影響 |
|
||||
|---|---|---|
|
||||
| ID生成・名前空間 | 新規ノード・プロファイル・provider等に種別接頭辞+UUID。PHYの全ノード種別でIDを一意にする。既存IDは保持する。 | 座標・順序・表示名の変更とIDを切り離せる。生成器は注入可能にする。 |
|
||||
| 複製時の参照 | 配下を含め新IDを発行。ELC・MAPは自動複製しない。IDX追加は明示操作にする。 | 配線の衝突や意図しない動作の複製を避け、後から複製オプションを追加できる。 |
|
||||
| プロジェクトファイル | 単一の`*.bkle-mock.json`に`format`、`version`、`projectId`、PHY/ELC/IDX/MAP、出力設定・記録を内包。各層の単独入出力も用意する。 | ファイル間の保存ずれを避けつつ、将来の分割形式へ変換できる。 |
|
||||
| `phyReference.source` | バンドル内では`project://<projectId>/phy`。単独ファイル読込では利用者がPHYを選択し、自動取得しない。 | ファイル移動やネットワークに依存させない。 |
|
||||
| `phyReference.checksum` | PHY全体をJCSで正規化し、UTF-8のSHA-256を`sha256-jcs:<hex>`として保存。意味的同一性ではなく内容一致の判定とする。 | 空白・オブジェクトのプロパティ順に左右されない。[JCSの定義](https://www.rfc-editor.org/rfc/rfc8785)を利用できる。 |
|
||||
| checksum不一致 | IDの付け替えはしない。参照と制約を再検証し、単独ファイルなら再対応付けを明示する。統合編集では検証後の保存時に更新する。 | ハッシュ一致を設計互換性の保証と混同しない。出力記録の失効判定は別にする。 |
|
||||
| version運用 | モック形式・各層は`0.1.0`から開始し、受け入れる版を明示列挙。未知版は編集不可。破壊変更は変換関数経由で読み込む。 | 文書の例にある`1.0.0`を、確立済みの互換契約とは扱わない。 |
|
||||
| Schemaと内部モデル | 保存DTOをSchemaへ合わせ、参照解決済み内部モデルとは変換関数で分離。仮拡張はプロジェクト側の`mockExtensions`へ隔離する。 | 仮決定を覆しても、レイヤー正本全体を変えずに済む。 |
|
||||
| 未知フィールド | 読み込んだ未知データは保持し、対応外機能を明示。新規出力には勝手に追加しない。 | 編集していない情報を保存時に消さない。 |
|
||||
| 保存とUI状態 | 選択・フォーカス・ズーム・表示中バリアントはレイヤーへ保存しない。再開用状態は別の`uiState`へ置く。 | 表示状態と出力構成を混同しない。 |
|
||||
| Undo/Redo | プロジェクト全体を単位にしたコマンド履歴。ドラッグ1回を1操作とし、当初はスナップショット方式。 | PHY削除と他層参照の削除を一緒に戻せる。履歴方式は交換可能。 |
|
||||
| ノード削除 | 影響するIDX・ELC・MAP・LED参照を一覧化し、同一操作で削除。Variantの既定値変更も同時に行う。 | 孤立参照を通常操作で作らない。 |
|
||||
| 検証の段階 | 構造検証と意味検証を分離。未配線・未割り当ての作業途中は保存可能、出力条件不足は出力を止める。 | 編集途中の状態を「不正ファイル」として締め出さない。 |
|
||||
| 意味検証の範囲 | ID重複、参照先の存在・種別、Variantの既定値、名前重複、provider循環、接続衝突などを検査する。 | JSON Schemaだけでは表せない制約がある。 |
|
||||
| MAPの未割り当て | `keymap`に該当キーの要素がない状態。`KC_NO`・`KC_TRNS`とは別扱い。選択中のIDX対象キーが未割り当てなら出力を止める。 | データ不足と明示的な無動作・透過を区別できる。 |
|
||||
| MAPの重複・空レイヤー | 同一レイヤーの`keyId`重複は禁止。新規作成時は`Base`を1枚作る。レイヤー名は大文字小文字を区別し、一意とする。 | 初期状態と重複時の挙動を一意にする。 |
|
||||
| 名前による動作参照 | 保存文字列は仮に`MO("Nav")`などの構文に固定。限定パーサーで解析し、`eval`は使わない。 | 現行Schemaの文字列型を維持しつつ、出力番号から独立できる。 |
|
||||
| 名前変更・削除 | レイヤー名変更は解析済み参照を同一操作で更新。参照中のレイヤー削除は参照の解消まで止める。 | 並べ替えだけでなく改名でも参照切れを防ぐ。 |
|
||||
| キーコード対応範囲 | 明示リストの基本コード・修飾キー・無動作・透過・限定したレイヤー動作のみ編集可能。未知コードは保持し、対象外出力は止める。 | 任意のQMK式を解釈できるかのように扱わない。 |
|
||||
| IDXの初期生成 | 初期値は空。クリック順で登録し、KLE取込時だけ入力列挙順を一度きりの候補として提示して確定する。 | **PHYの現在の木・座標から再生成しない。** 保存後はIDXだけが順番の正本になる。 |
|
||||
| IDXの対象と追加 | 全バリアントの通常キーを登録候補にする。新規キーは「未登録」と表示し、追加位置を明示させる。 | 新規キー作成で既存の出力番号を暗黙に動かさない。 |
|
||||
| ghostの扱い | `ghost`は表示属性とし、IDX・ELC・MAPの対象にできる。`decal`は番号・配線・割り当て対象から除く。 | IDXが明示的に除外するのは部品とdecal。ghostの意味を推定してキーを落とさない。 |
|
||||
| バリアント選択 | 出力設定に`VariantGroup ID → Variant ID`を保存。到達可能なグループだけ解決し、未指定はPHYの既定値を使う。 | 表示中のバリアントと出力構成を独立させる。 |
|
||||
| 未配線・部分配線 | ELC要素がないキーは未配線。片端子だけの入力はUIの編集途中状態に置き、完成後にELCへ確定する。 | 必須端子のSchemaを変えずに入力途中を扱える。 |
|
||||
| ピン名・提供者 | 当初は選定した1種類のMCUのピンカタログを使用。ピン名は完全一致、provider IDに`.`を使わない。 | 部品データベース全般を作らず、誤記を検出できる。 |
|
||||
| row/col割り当て | 出力設定の順序付き`rowPins`・`colPins`の添字を座標にする。各接点の両端が両群に1本ずつ属することを検査する。 | `a/b`の順番、キー位置、IDX順からrow/colを推定しない。 |
|
||||
| 走査方向・ダイオード | モック出力対象は両ピン群が分離し、ダイオード方向が一貫する構成だけ。走査方向は出力設定に保持する。 | ELCへrow/colや走査方式を逆流させず、複雑な回路判定を局所化できる。 |
|
||||
| LEDチェーン | キーは`perKeyLED=true`、部品は利用者がLEDとして明示したものを候補にする。同じノードの重複登録を禁止。バリアントで番号を詰めない。 | 部品種別をPHYの自由なグループ名から推定しない。LEDファームウェア出力は対象外。 |
|
||||
| 混在プロファイルの座標尺度 | モック編集対象は全プロファイルで共通の`unitSize={19.05,19.05}`に制限。異なる尺度は保持して対応外表示。 | 親クラスターのUをどのプロファイルでmmへ変換するか未定のため、暗黙の換算を避ける。 |
|
||||
| スナップ・数値入力 | ドラッグと増減操作は開始値から0.25U・15度刻み。直接数値入力は非グリッド値も許容。内部演算は丸めない。 | 外部から得た値や組み替え結果を維持し、スナップ方針をUI側で交換できる。 |
|
||||
| KLE入出力の契約 | 厳密JSONを入力とし、新規PHYへ変換。VIA定義の意味的取り込みは別アダプター扱いで対象外。 | マトリクスラベルから実ピンやバリアント構造を推定しない。 |
|
||||
| KLEラベル・互換属性 | ラベル欄の位置と空欄を保持し、属性の持続・リセットを入出力アダプター内で解決する。参照情報をMAPへ転用しない。 | 不透明な保存だけではラベルと`a`等の往復整合性を保証できない。 |
|
||||
| KLE出力の精度・並び | 同じ絶対角度では共通の回転中心を使い、出力順を固定。出力座標のみ小数6桁へ丸める。既知の属性リセット制約は出力時に表示する。 | 再現可能な差分を得つつ、PHY正本を丸めない。 |
|
||||
| 部品・変形キーの描画 | 部品は共通マーカー+名前。キー外形は2矩形の和集合。ラベルはテキスト描画し、保持したCSS等は適用しない。 | 未定の予約名一覧や見た目編集へ依存せず、配置操作を試せる。 |
|
||||
| HSTの保存形式 | 今回は定義しない。US表示用の限定辞書をUI側へ置き、未知コードはコード名を表示する。 | 表示辞書をホストの完全な順方向モデルとして固定しない。 |
|
||||
|
||||
**仕様どうしの差・食い違いは、暗黙の未決事項と分けて扱います。**
|
||||
|
||||
根拠は[JSON Schema](../../phase1-json-schema.md)、[PHY仕様](../../PHYレイヤー.md)、[ELC仕様](../../ELCレイヤー.md)、[IDX仕様](../../IDXレイヤー.md)です。
|
||||
|
||||
| 文書で確認できた差 | 判定 | モックでの扱いの提案 |
|
||||
|---|---|---|
|
||||
| PHYのTS型には各ノードの`type`、`Cluster.name`、キー・ファイルの`kleCompatibility`がない。Schemaにはある。 | **型定義の不一致**。特に`type`はSchemaで必須。 | 保存型はSchemaへ合わせる。本文TS型をそのまま実装にコピーしない。 |
|
||||
| ELC・IDXのTS型には`version`・`phyReference`がない。Schemaでは必須。 | **型定義の不一致**。本体型とファイル型の区別が明記されていない。 | 本体を包む保存DTOを用意する。 |
|
||||
| MAP本文は名前によるレイヤー・動作参照を要求するが、Schemaは任意文字列の`keycode`のみ。 | **表現規則の不足**。文字列なので直接の矛盾ではない。 | 限定構文と意味検証を追加し、動作定義自体は対象外にする。 |
|
||||
| Schemaは`unitSize`やキー幅・高さに0を許す。IDやレイヤー名の一意性・参照整合性も十分には検証しない。 | **保存可能条件と意味制約の差**。 | 意味検証で寸法を正値に限定し、参照・重複を検査する。 |
|
||||
| PHY概要はmatrix・MAP/HST情報を付加できるとするが、最上位方針では別レイヤーとしてPHYを参照する。 | **所有・格納場所の曖昧さ**。 | PHY内へ埋めず、プロジェクトの別セクションで束ねる。 |
|
||||
| HST概要は「MAPが出力するのはHIDキーコード」、MAP仕様はQMK表記・レイヤー動作等を扱う。 | **抽象度の食い違い**。QMKの動作すべてが単一HIDコードになるわけではない。 | MAPはファームウェア動作、HSTはホストへ届く入力の解釈として境界を置く。 |
|
||||
| ELCは非排他キーの「同じ接続先の組」をエラーとする一方、実装計画はcharlieplexingを検証対象にする。 | **衝突条件の曖昧さ**。無順序のピン対だけで比較すると、ダイオード方向で区別する構成を拒否し得る。 | 単純マトリクスに限定。charlieplexing対応時に極性を含む接続同一性を確定する。 |
|
||||
| reparent式の`M_i`が元のローカル変換か、共通座標系での変換か明示されていない。 | **式の記号の曖昧さ**。任意階層間では解釈が重要。 | `新local = inverse(新親world) × 旧world`として実装・検証する。 |
|
||||
|
||||
ELC本文の「LEDドライバIC方式は未定」、デジタル系providerの詳細未定も確認しました。これは暗黙ではなく、**管理セクション外に明記された保留**です。モックでは対象外にします。
|
||||
|
||||
**3. モックの対象範囲と技術選定**
|
||||
|
||||
狙いは、**配置を編集しても割り当てがずれず、全バリアントを保持したまま選択構成を出力・往復できること**の確認です。
|
||||
|
||||
| ツール・経路 | 作る範囲 |
|
||||
|---|---|
|
||||
| PHY Editor | KLE新規取込、キー・部品の追加、移動・回転・サイズ編集、クラスター化・解除・reparent・数値reframe、バリアント編集・切替、KLE出力。 |
|
||||
| Key Indexer | 全バリアントのキー一覧、登録・除外・並べ替え、選択構成の出力番号プレビュー。 |
|
||||
| Wiring Editor | 接点・MCUピンのフォーム編集、配線表示、未配線・衝突表示。LEDチェーンは順序編集・確認まで。 |
|
||||
| Keymap Designer | 基本コード・限定したレイヤー動作の割り当て、レイヤー追加・改名・並べ替え、未割り当て表示。 |
|
||||
| Generator | 選択構成の平坦配置、IDX順、row/col、MAP配列、対応表を`mock-target.json`へ出力。**QMKへ投入可能な生成物とは扱わない。** |
|
||||
| Dynamicの疑似経路 | 疑似デバイスへ反映→外部変更を模擬→読み出し→差分確認→MAPへ取り込み→再生成。実機通信は行わない。 |
|
||||
| 統合環境 | 1画面のタブ切替、プロジェクト保存・読込、Undo/Redo、検証一覧。Git統合・ウィザード・デスクトップ配布は後回し。 |
|
||||
|
||||
作る順番は、①回転クラスター内ドラッグ+保存往復、②IDX・MAP連携、③ELC・出力表、④疑似デバイス往復とします。①で座標処理を確認してから機能を増やします。
|
||||
|
||||
| 技術項目 | 仮決定案・理由 |
|
||||
|---|---|
|
||||
| 言語 | **TypeScript、strict有効**。判別可能なノード型と、UIに依存しない変換処理を同じ言語で書く。 |
|
||||
| UI・開発環境 | **Vue 3 Composition API+Vite**。既存のVue経験を利用し、単画面エディタとして構成する。Viteには公式の`vue-ts`テンプレートがある。[公式資料](https://vite.dev/guide/) |
|
||||
| 描画 | **素のSVG**。PHYの階層を`<g>`へ対応させ、ドラッグは親要素の`getScreenCTM()`の逆行列でローカル座標へ変換する。APIは確認できるが、今回の操作性能は未検証。[MDN](https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement/getScreenCTM) |
|
||||
| 状態管理 | **純粋な更新関数+Vueの`shallowRef`で保持する1つの文書状態**。UI状態は別に置く。Piniaは初期導入しない。VueはReactivity APIによる共有状態管理を扱っている。[公式資料](https://vuejs.org/guide/scaling-up/state-management) |
|
||||
| データ更新 | コンポーネントから木を直接変更せず、操作コマンドへ集約。描画用の絶対座標は導出し、正本へ書き戻さない。 |
|
||||
| Schema検証 | **Ajvのdraft 2020-12対応を使用**し、意味検証は独立関数にする。既存Schemaの方言に合わせられる。[公式資料](https://ajv.js.org/json-schema.html) |
|
||||
| テスト | **Vitestで変換・参照整合性、PlaywrightでSVG操作・保存往復**。DOMの座標処理は実ブラウザで確認する。[Vitest](https://vitest.dev/guide/)、[Playwright](https://playwright.dev/docs/intro) |
|
||||
| パッケージ構成 | `mock/`内の単一npmパッケージ。`domain/`、`application/`、`adapters/`、`ui/`へ分け、domainからVue・DOMを参照しない。CLI検証も同じdomainを呼ぶ。 |
|
||||
| 依存版 | 実装開始時に動作確認した組み合わせをlockfileで固定。QMK対応版とフロントエンド依存版は別管理する。 |
|
||||
|
||||
完了条件は、次の少数の検証へ絞ります。
|
||||
|
||||
- 回転・ズーム下のドラッグが正しく、reparent・reframe前後で絶対配置が許容誤差内で一致する。
|
||||
- PHYの移動・クラスター変更・IDX並べ替え後も、MAPとELCが同じキーIDを指す。
|
||||
- 入れ子・空のバリアントを選択しても、正本の`order`・MAP・ELC・LED番号が書き換わらない。
|
||||
- プロジェクト保存・再読込で、ID、割り当て、互換属性が保持される。
|
||||
- 疑似デバイスの変更を選択構成のキーへ戻せ、選択外の割り当ては保持される。
|
||||
- 4×4、Cr0wn60の**幾何**、自作の回転・入れ子バリアントfixtureで確認する。実配線・VIAの適合検証とは区別する。
|
||||
|
||||
仮決定の退避先は、提案として`mock-decisions/`を使います。各項目に「ID・仮決定・理由・適用範囲・差し替え箇所・再検討条件」を記録し、試作用Schemaもそこへ置きます。実装・検証から決定IDを参照すれば、メインドキュメントを変更せず、覆すときの影響を追えます。
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 相談の問い(Sol・Astraに同じ問いを渡した)
|
||||
|
||||
キーボード設計ツール「Beyond KLE」の設計リポジトリ。まず AGENTS.md(→CLAUDE.md)を読み、
|
||||
リポジトリ直下の *.md と backlog/README.md をすべて読んで。コードはまだ無い。
|
||||
|
||||
これから「モックの仮実装」(動くプロトタイプ)に着手する。そのために、仕様上まだ決まっていない事項を
|
||||
「仮決定」として決めておきたい。仮決定はメインドキュメントには書かず、別フォルダに退避する予定。
|
||||
仮決定は本決定ではなく、モックを作るために進路を1つに固定するもの。後で覆しやすいこと、覆したときの影響が小さいことを重視する。
|
||||
|
||||
問い:
|
||||
1. 明示的な未決事項(各仕様書の「## 未確認事項」、implementation-plan.md の P-1〜P-3、backlog/README.md の各項目)それぞれについて、
|
||||
モックに影響するか(しない/影響する)を判定し、影響するものは仮決定案を1つ、理由1〜2行とともに示す。
|
||||
推測タグ([推測:P-n])に依存する箇所は、「推測が成り立つ前提で進める/回避する」のどちらにするかを示す。
|
||||
2. 明示的に未決とされていないが、実装しようとすると決まっていないと分かる事項(暗黙の未決事項)を洗い出し、
|
||||
それぞれ仮決定案を示す。例: ID生成規則、ファイルの束ね方(プロジェクトファイル)、phyReference.checksum の計算方法、
|
||||
MAPの未割り当ての表現、IDX order の初期生成、row/col 割り当てアルゴリズム、スキーマの version 運用、など。
|
||||
仕様どうしの矛盾・食い違いを見つけたら別に挙げる(例: phase1-json-schema.md とレイヤー仕様の型定義の差)。
|
||||
3. モックの対象範囲の提案(どのツール・どの経路までを作るか)。技術選定(言語・UIフレームワーク・描画・状態管理・テスト・パッケージ構成)も仮決定案を出す。
|
||||
参考: ユーザーは以前 Nuxt3+Pinia で類似エディタ(C:/Users/ewder_71l5bct/Documents/Dev/kbd-editor)を作りかけてうまくいかなかった。そのコードは取り込まない方針。
|
||||
描画はSVGが有力と書かれている。
|
||||
|
||||
ファイルは編集しないこと。日本語で。表や箇条書きで簡潔に、全体で最大250行。
|
||||
事実(ファイルに書いてあること・原典で確認したこと)と推測・提案を区別して書くこと。
|
||||
問いの前提が誤っていると思ったら、答える前にそう指摘すること。
|
||||
@@ -0,0 +1,88 @@
|
||||
## 前提とモックの範囲
|
||||
|
||||
指定されたリポジトリ直下の `*.md` と [backlog/README.md](<../../backlog/README.md>) をすべて読みました。問いの前提に大きな誤りはありません。ただし、[CLAUDE.md](<../../CLAUDE.md:54>) は未検証の推測に基づく実装を避けるよう求めています。**仮決定は仕様の確定や推測の検証完了とは扱わず**、モックで必要な経路だけを固定するのがよいと考えます。
|
||||
|
||||
**提案する対象範囲**は、ブラウザで動く一つのプロトタイプです。KLE JSONを新規PHYとして読み込み、PHYの配置・クラスター・バリアントを編集し、IDXの順序、ELCの接点配線、MAPの基本的なキーコードを編集する。選択したバリアントについて「PHYキーID → 出力番号 → 接続先 → キーコード」の対応をプレビューし、4層を保存・再読込できるところまで作ります。KLEへの書き出しも含めます。実機VIA通信、完成品としてのQMK出力、HST、アナログ入力は対象外とします。
|
||||
|
||||
以下の「影響する」は、この範囲を作る際に扱いを一つ決める必要がある、という意味です。「しない」は未決事項を解決した意味ではありません。
|
||||
|
||||
## 1. 明示的な未決事項
|
||||
|
||||
### 仕様書と実装計画の未確認事項
|
||||
|
||||
| 項目 | 影響 | 仮決定案・理由 |
|
||||
|---|---|---|
|
||||
| [全体 P-1](<../../keyboard-design-layers.md:197>) アナログ値の解釈 | しない | アナログを対象外にする。 |
|
||||
| [MAP P-1](<../../MAPレイヤー.md:140>) 動作定義・キー別設定 | しない | 現行Schemaの単純な割り当てだけを扱う。 |
|
||||
| [MAP P-2](<../../MAPレイヤー.md:148>) VIAコマンドの実機動作 **〔推測〕** | しない | **推測を回避する**。実機通信を作らない。 |
|
||||
| [MAP P-3](<../../MAPレイヤー.md:153>) プロジェクトと実機の対応 | しない | 実機への書き込み・読み出しを作らない。 |
|
||||
| [HST P-1](<../../HSTレイヤー.md:87>) 逆引き候補の判断 **〔推測〕** | しない | **推測を回避する**。文字からキーコードへの逆引きを作らない。 |
|
||||
| [HST P-2](<../../HSTレイヤー.md:93>) 複数ホスト | しない | HSTプロファイルを扱わず、MAPにはQMK表記をそのまま表示する。 |
|
||||
| [実装 P-1](<../../implementation-plan.md:95>) kle-serial2の出自 | **影響する** | **kle-serial2を採用せず、必要なKLE読み書きを自作する。** 出自の推測に依存せず、モックが使う構文を明示できる。未対応の属性は黙って捨てずに警告する。 |
|
||||
| [実装 P-2](<../../implementation-plan.md:101>) 技術選定 | **影響する** | 後述のVue 3・TypeScript・SVG構成を仮採用する。回転クラスター内のドラッグを最初に試し、描画方式を確認する。 |
|
||||
| [実装 P-3](<../../implementation-plan.md:109>) リポジトリ構成 | **影響する** | 最初は単一アプリ・単一パッケージとする。純粋な変換処理をUIから分離し、必要になった時だけパッケージ化する。 |
|
||||
|
||||
### backlogの全項目
|
||||
|
||||
| 項目 | 影響 | 仮決定案、または今回不要な理由 |
|
||||
|---|---|---|
|
||||
| [PHY-P-2](<../../backlog/README.md:9>) KLE再取込時の照合 | しない | 新規PHYとしての読込のみ。 |
|
||||
| [PHY-P-6](<../../backlog/README.md:18>) 見た目情報の所属 | **影響する** | **既定の`kleCompatibility`へ不透明なまま退避・再出力する。** 編集UIと正式な所属層は決めず、往復時の情報損失を抑える。 |
|
||||
| [PHY-P-8](<../../backlog/README.md:28>) ラベルのMAP適用 | しない | 自動適用せず参考表示のみ。 |
|
||||
| [PHY-P-12](<../../backlog/README.md:36>) 部品の表裏 | しない | 仕様どおり描き分けない。 |
|
||||
| [PHY-P-13](<../../backlog/README.md:44>) reframeの操作系 | **影響する** | **通常の移動・回転と別のコマンドにし、原点・角度を数値入力する。** 操作の取り違えを防ぎ、後でハンドルUIへ置き換えやすい。 |
|
||||
| [PHY-P-14](<../../backlog/README.md:52>) 原点の吸着 | しない | 数値入力で成立する。 |
|
||||
| [ELC-P-1](<../../backlog/README.md:62>) 光学スイッチ | しない | 対象外。 |
|
||||
| [ELC-P-2](<../../backlog/README.md:70>) 複数mux | しない | 対象外。 |
|
||||
| [ELC-P-3](<../../backlog/README.md:79>) 走査設定 | しない | 配線は表示するが、実際の走査・ファームウェアは生成しない。 |
|
||||
| [IDX-P-1](<../../backlog/README.md:91>) 既存レイアウトとの順序互換 | しない | 既存コミュニティレイアウト向け互換出力をしない。 |
|
||||
| [MAP-P-4](<../../backlog/README.md:104>) エンコーダ等への割当 | しない | キーへの割当だけを扱う。 |
|
||||
| [MAP-P-5](<../../backlog/README.md:115>) 表示用OS配列 | しない | 文字への翻訳をせずQMK表記を表示する。 |
|
||||
| [MAP-P-6](<../../backlog/README.md:124>) develop・Community Modules | しない | QMK生成自体を対象外にする。 |
|
||||
| [MAP-P-7](<../../backlog/README.md:135>) 再ビルド前の実機調整保存 | しない | ビルド・実機通信をしない。 |
|
||||
| [MAP-P-8](<../../backlog/README.md:144>) QMK以外のキーコード | しない | QMK表記だけを保存する。 |
|
||||
| [MAP-P-9](<../../backlog/README.md:152>) CコードとユーザーCの統合 | しない | Cコードを生成しない。 |
|
||||
|
||||
PHY-P-6、P-13の案は、[PHY仕様の当面の扱い](<../../PHYレイヤー.md:318>)をモックの具体的な実装方法に落としたものです。
|
||||
|
||||
## 2. 暗黙の未決事項と仮決定案
|
||||
|
||||
以下は**仕様から導かれる確定事項ではなく、モック用の提案**です。
|
||||
|
||||
| 事項 | 仮決定案 |
|
||||
|---|---|
|
||||
| プロジェクトファイル | `*.bkle.json`一つに`phy`・`elc`・`idx`・`map`と画面状態を束ねる。各層のJSONは単独でも入出力可能にする。 |
|
||||
| ID生成 | 新規ノードに種別接頭辞+`crypto.randomUUID()`。移動・並べ替えで再生成しない。KLE新規取込は毎回新しいID群とする。 |
|
||||
| `phyReference` | 束ねたファイル内の`source`は`project:phy`。`checksum`はPHYだけを対象にした`sha256:<hex>`。オブジェクトのキーを辞書順に直列化し、配列順は維持してハッシュする。保存時に更新する。 |
|
||||
| `version` | 各層と束ねファイルをモックでは`1.0.0`で開始。読込は当面この版だけ受け付け、変換が必要な版を推測で読み替えない。 |
|
||||
| MAP未割当 | `keymap`配列に項目がない状態で表す。`KC_NO`や`KC_TRNS`は明示的に入力された割当として区別する。 |
|
||||
| MAP初期レイヤー | 名前`Base`の空レイヤーを一つ作る。名前の変更・追加・並べ替えは許す。 |
|
||||
| IDX初期順序 | KLE取込時のキー列挙順、手作成なら作成順で一度だけ生成する。以後は自動再生成せず、画面で手動修正する。幾何に基づく「正しい順」とは表示しない。 |
|
||||
| IDX対象 | 部品と`decal`を除く全バリアントのキーIDを候補にする。`order`から漏れたキーは診断表示する。 |
|
||||
| row/col割当 | **プレビュー限定**で、接点配線が二つのピン群に明確に分かれる場合だけ、辞書順で群と番号を固定する。charlieplexing等、方向や走査設定が要る構成は「未導出」と表示する。ELCの正本には書かない。 |
|
||||
| バリアント選択 | 編集中の選択は画面状態として束ねファイルに保存し、PHYには書かない。書き出し時の選択は明示する。 |
|
||||
| KLEの数値精度 | PHYの数値は丸めず保存。KLE書き出しの座標だけ小数6桁に丸め、丸めによる衝突や属性の再現不能を警告する。 |
|
||||
| 未対応KLE属性 | 分かる属性は`kleCompatibility`へ保持する。解釈不能な構文・属性を検出したら、読込を止めるか損失を画面に列挙し、黙って成功扱いにしない。 |
|
||||
| 検証の境界 | Schemaの形に加え、ノードID一意性、参照先の存在、VariantGroupの`default`、IDX重複、MAP内の重複割当をアプリ側で検査する。 |
|
||||
| undo/redo | 編集操作単位でプロジェクト状態の履歴を持ち、ドラッグ中の連続移動を一操作にまとめる。 |
|
||||
| 仮決定の置き場 | 将来`mock-decisions/`のような独立フォルダに、対象の未決ID・仮決定・採用理由・覆す際の影響を記録する。メイン仕様の確定事項には混ぜない。 |
|
||||
|
||||
### 文書間の食い違い・不足〔確認した事実〕
|
||||
|
||||
- [PHYのTypeScript風定義](<../../PHYレイヤー.md:207>)にはノードの`type`がなく、[JSON Schema](<../../phase1-json-schema.md:16>)では必須です。[実装計画のPHY例](<../../implementation-plan.md:62>)も`type`を欠き、そのままではSchemaに適合しません。モックの保存形式はSchema側に合わせる案です。
|
||||
- [ELC・IDXの保存形式](<../../ELCレイヤー.md:100>)には`version`と`phyReference`がなく、[両Schema](<../../phase1-json-schema.md:423>)では必須です。内部の型と保存用の包みを分けるか、型を揃える必要があります。
|
||||
- [PHYの概要](<../../PHYレイヤー.md:3>)はmatrixやMAP/HST情報を付加可能と書く一方、[最上位の層分離方針](<../../keyboard-design-layers.md:17>)は各層がPHYを参照する構造です。モックでは層分離を採り、PHYへ他層の正本を埋め込まない案です。
|
||||
- [phase1-json-schema.md](<../../phase1-json-schema.md:16>)に載るSchemaはPHY・IDX・ELC・MAPの4層で、HSTのSchemaはありません。これは今回の範囲外ですが、5層全体の保存形式はまだ揃っていません。またSchemaはMarkdown内のコードブロックであり、実際に検証器へ渡すJSONファイルはまだありません。
|
||||
|
||||
## 3. 技術選定と確認する経路〔提案〕
|
||||
|
||||
| 項目 | 仮決定案 |
|
||||
|---|---|
|
||||
| 言語・UI | TypeScript+Vue 3+Vite。以前のNuxt 3+Piniaの失敗だけからVueやPiniaの良否は断定しない。今回必要な単一画面にはVue公式のVite構成を使う。[Vue公式](https://vuejs.org/guide/quick-start) |
|
||||
| 描画 | SVGの入れ子`<g>`でPHYの変換を表す。ドラッグ位置は`getScreenCTM()`の逆変換で親のローカル座標へ戻す。最初に回転クラスターで操作感を検証する。[MDN](https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement/getScreenCTM) |
|
||||
| 状態管理 | 一つの`shallowRef`にプロジェクト状態を置き、純粋な編集コマンドで状態を置き換える。Piniaはこの規模では追加しない。Vue公式も`shallowRef`で内部を不変データとして扱う方法を説明している。[Vue公式](https://vuejs.org/api/reactivity-advanced) |
|
||||
| テスト | VitestでKLE変換、座標変換、reparent/reframe、バリアント選択、IDX絞込、保存・再読込を検証。Playwrightでは回転クラスターのドラッグと保存の一往復を確認する。[Vitest](https://vitest.dev/guide/index.html)、[Playwright](https://playwright.dev/docs/writing-tests) |
|
||||
| 構成 | 単一パッケージ内を`src/core`(層と変換)、`src/io`(KLE・保存)、`src/ui`に分ける。既存エディタのコードは取り込まない。 |
|
||||
|
||||
最初の動作確認は、リポジトリにあると[実装計画が記す](<../../implementation-plan.md:12>)4×4とCr0wn60を使い、別途小さな手作りバリアント例を足すのがよいです。成功条件は、保存・再読込後もPHYのID、IDXの順序、ELCとMAPの参照、選択別の対応表が変わらないことです。ファイルは編集していません。
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# 仮決定
|
||||
|
||||
各項目: 仮決定 / 理由(短く) / 覆すときに影響する箇所。
|
||||
「モック対象外」は、未決のまま進めてもモックが作れるという意味で、問いが解決したことを意味しない。
|
||||
|
||||
## A. モックの範囲と技術
|
||||
|
||||
### T-1 モックの目的
|
||||
配置を編集しても割り当てがずれないこと、全バリアントを保持したまま選択した構成を出力・往復できることを、動くもので確かめる。
|
||||
完成品のツールにはしない。
|
||||
|
||||
### T-2 対象範囲
|
||||
ブラウザで動く1つのアプリ。1画面でタブを切り替える。
|
||||
|
||||
| ツール | 作る範囲 |
|
||||
|---|---|
|
||||
| PHY Editor | KLE JSONの新規取り込み、キー・部品の追加、移動・回転・サイズ編集、クラスター化・解除・reparent・数値入力のreframe、バリアントの編集・表示切替、KLE JSON出力 |
|
||||
| Key Indexer | 全バリアントのキーの一覧、`order` への登録・除外・並べ替え、選択構成での出力番号のプレビュー |
|
||||
| Wiring Editor | MCUの提供者と接点キーの端子のフォーム編集、配線の表示、未配線・衝突の表示、LEDチェーンの順序編集 |
|
||||
| Keymap Designer | 基本キーコードと名前によるレイヤー動作の割り当て、レイヤーの追加・改名・並べ替え、未割り当ての表示 |
|
||||
| Generator(プレビュー) | 選択構成の平坦な配置、IDXの並び、row/col、MAPの配列、対応表を `mock-target.json` に出力。QMKにそのまま渡せる生成物とは扱わない |
|
||||
| 共通 | プロジェクトの保存・読み込み、Undo/Redo、検証結果の一覧 |
|
||||
|
||||
対象外: 実機との通信(VIA/WebHID)、QMKのビルド・書き込み、HST、アナログ入力、マルチプレクサ、光学スイッチ、エンコーダ等への割り当て、マクロ・タップダンス等の動作定義。
|
||||
|
||||
後段(任意): 疑似デバイスとの往復(書き込み → 外部での変更を模擬 → 読み出し → 差分確認 → MAPへ取り込み)。着手する場合は T-24 に従う。
|
||||
|
||||
### T-3 作る順番
|
||||
1. 回転したクラスター内のキーのドラッグと、保存・再読み込みの往復(描画方式の確認を兼ねる)
|
||||
2. IDX・MAPとの連携
|
||||
3. ELCと出力の対応表
|
||||
4. (任意)疑似デバイスとの往復
|
||||
|
||||
### T-4 検証に使うデータ
|
||||
`samples/kle/macropad-4x4.json`、`samples/kle/cr0wn60.json`(幾何のみ)、手作りの回転・入れ子バリアントのfixture。
|
||||
実在の配線とVIAの適合は検証の対象にしない。
|
||||
|
||||
### T-5 技術選定 (implementation-plan P-2)
|
||||
| 項目 | 仮決定 |
|
||||
|---|---|
|
||||
| 言語 | TypeScript(strict) |
|
||||
| UI | Vue 3(Composition API) + Vite。Nuxtは使わない(SSR・ルーティングが不要) |
|
||||
| 描画 | 素のSVG。PHYの階層を入れ子の `<g>` に対応させ、ドラッグは親要素の `getScreenCTM()` の逆行列でローカル座標へ戻す |
|
||||
| 状態管理 | 文書全体を1つの `shallowRef` に置き、純粋な更新関数(コマンド)で置き換える。UIの状態は別に持つ。Piniaは入れない |
|
||||
| Schema検証 | Ajv(draft 2020-12)。参照の整合性などの意味的な検証は別の関数にする |
|
||||
| テスト | Vitest(変換・参照整合性)、Playwright(SVG上の操作・保存の往復) |
|
||||
| パッケージ管理 | pnpm。依存の版はlockfileで固定 |
|
||||
|
||||
理由: 描画の変換がPHYの `Translate · Rotate` と同じ定義で、座標の逆変換をブラウザに任せられる。Vueは既存の経験を使える。
|
||||
|
||||
### T-6 リポジトリ構成 (implementation-plan P-3)
|
||||
このリポジトリの `mock/` に単一のパッケージとして置く。
|
||||
中は `domain/`(層のモデル・変換。Vue・DOMを参照しない)、`io/`(KLE・プロジェクトファイル)、`ui/` に分ける。
|
||||
モノレポ化とパッケージの分割はモックの後に決める。
|
||||
|
||||
### T-7 KLE JSONの読み書き (implementation-plan P-1)
|
||||
kle-serial2 を使わず、読み書きともに自作する。ijprest/keyboard-layout-editor・kle-serial・過去のkbd-editorのコードも取り込まない。
|
||||
KLE本家の挙動の確認は、既存の検証ハーネスで本家の `serial.js` を動かして比べる(コードは写さない)。
|
||||
理由: 出自の確認を待たずに進められ、後からライブラリへ差し替えられる。
|
||||
|
||||
## B. 明示的な未決事項
|
||||
|
||||
### 未確認事項
|
||||
|
||||
| 出典 | 仮決定 |
|
||||
|---|---|
|
||||
| 全体 P-1 アナログ値の解釈の所属 | モック対象外。ELCにも仮置きしない |
|
||||
| MAP P-1 動作定義とキーごとの設定の保存形式 | モック対象外。MAPは現行Schemaの `layers[].keymap[]` だけを扱う。モック後の叩き台として: マクロ・Staticの動作は `map.definitions[]`(`name` で識別、`kind` と種別ごとの内容)に置き、キーごとの設定はPHYのキーIDに付ける(`map.keySettings[keyId]`) |
|
||||
| MAP P-2 VIAのコマンドは0.34.4のソースどおり[推測] | 推測に依存しない。実機通信を作らない。疑似デバイスはアプリ側の操作を模擬するだけで、VIAの動作とは扱わない |
|
||||
| MAP P-3 プロジェクトと実機の対応・状態管理 | 本線のモックでは対象外。疑似デバイスの段階で T-24 を使う |
|
||||
| HST P-1 使う側が逆引きを判断できる[推測] | 推測に依存しない。逆引き・Key Overrideの候補生成を作らない |
|
||||
| HST P-2 対象ホストを複数持つか | モックではホストを持たない。MAPはQMKの表記のまま表示し、よく使うコードだけUS配列の文字を添える(T-23) |
|
||||
| implementation-plan P-1〜P-3 | T-7、T-5、T-6 |
|
||||
|
||||
### backlog
|
||||
|
||||
| 項目 | 仮決定 |
|
||||
|---|---|
|
||||
| PHY-P-2 KLE取り込みの照合基準 | 新規PHYとしての取り込みだけ。照合・更新は作らない |
|
||||
| PHY-P-6 見た目情報の所属層 | 現行Schemaどおり、キー単位とファイル単位の `kleCompatibility` に退避し書き戻す。編集UIは作らない。ラベルと `a` の往復はT-20 |
|
||||
| PHY-P-8 ラベルのMAP適用 | 自動適用しない。ラベルは表示だけ |
|
||||
| PHY-P-12 部品の表裏 | 描き分けない。予約グループ名は `@led` `@encoder` `@screw` `@oled` の4つを仮に置き、形だけ変えて描く。それ以外は共通のマーカーと名前 |
|
||||
| PHY-P-13 reframeの操作系 | 通常の移動・回転とは別の「座標枠の変更」コマンドにし、原点と基準角度を数値で入れる。ハンドル操作は作らない |
|
||||
| PHY-P-14 原点の吸着 | 作らない |
|
||||
| ELC-P-1 光学スイッチ | 対象外。接点に読み替えない |
|
||||
| ELC-P-2 複数マルチプレクサ | 対象外。analog-muxの提供者はデータとして読み書きだけする |
|
||||
| ELC-P-3 走査設定 | Generatorのプレビューは単純な接点マトリクスに限る(T-19)。走査方向は出力設定で明示させ、配線から推定しない |
|
||||
| IDX-P-1 既存コミュニティレイアウトとの互換 | 保証しない。独自の `order` を編集・保存するだけ |
|
||||
| MAP-P-4 ON/OFF以外の入力 | 対象外。エンコーダはPHYの部品ノードとして置くまで |
|
||||
| MAP-P-5 表示するOS配列 | T-23 |
|
||||
| MAP-P-6 developブランチ・Community Modules | 対象外(QMKへの実出力をしない) |
|
||||
| MAP-P-7 再ビルド時の調整の保全 | 対象外 |
|
||||
| MAP-P-8 QMK以外のキーコード表記 | QMK表記だけを保存する |
|
||||
| MAP-P-9 Cコードの生成 | 対象外 |
|
||||
|
||||
ELC本文にある「LEDのドライバIC方式」「デジタル側の提供者の種別ごとの記述」も対象外とする。
|
||||
|
||||
## C. 暗黙の未決事項
|
||||
|
||||
仕様だけでは実装が一意にならない箇所。
|
||||
|
||||
### T-10 プロジェクトファイル
|
||||
1ファイル `*.bkle.json` に束ねる。
|
||||
|
||||
```ts
|
||||
type ProjectFile = {
|
||||
format: "beyond-kle-mock-project",
|
||||
version: "0.1.0",
|
||||
projectId: string,
|
||||
phy: PhyFile, elc: ElcFile, idx: IdxFile, map: MapFile, // 各層はSchemaどおりのファイル形
|
||||
output: { variantSelection: Record<string, string>, matrix?: MatrixOutputSettings },
|
||||
ui?: unknown // 表示中のバリアント、ズーム等。仕様の層には入れない
|
||||
}
|
||||
```
|
||||
|
||||
各層を単独のJSONとして書き出す・読み込む機能も持つ。モック独自の拡張は各層の中に入れず、`ProjectFile` の直下に置く。
|
||||
|
||||
### T-11 ID
|
||||
新規ノードは種別の接頭辞 + ランダム文字列(`key-`, `cl-`, `cmp-`, `vg-`, `v-` + `crypto.randomUUID()` の先頭12桁)。
|
||||
プロファイル・提供者・LEDチェーンも同じ規則。PHY内の全ノードでIDを一意にする。
|
||||
移動・並べ替え・reparentではIDを変えない。複製は配下も含めて新しいIDにし、ELC・MAP・IDXへの参照は複製しない。
|
||||
KLEの取り込みは毎回新しいIDを振る。
|
||||
|
||||
### T-12 phyReference
|
||||
- `source`: プロジェクト内では `project:phy`。単独ファイルでは読み込んだPHYのファイル名(利用者がPHYを選び直せる)
|
||||
- `checksum`: PHY全体をJCS(RFC 8785)で正規化し、UTF-8のSHA-256を `sha256-jcs:<hex>` とする
|
||||
- 不一致は警告だけ。IDを付け替えず、参照の検証をやり直す。保存時に更新する
|
||||
|
||||
### T-13 version
|
||||
モックが出力する各層とプロジェクトファイルは `0.1.0`。読み込みは `0.1.0` だけを受け付ける(文書の例にある `1.0.0` は互換の約束とは扱わない)。
|
||||
|
||||
### T-14 読み込んだ未知のフィールド
|
||||
保持して書き戻す。新しく書き出すデータには追加しない。
|
||||
|
||||
### T-15 ノードの削除
|
||||
IDX・ELC・MAP・LEDチェーンにある参照を一覧で示し、同じ操作(1回のUndo単位)で削除する。
|
||||
Variantを削除したときは、VariantGroupの `default` を先頭のVariantへ移す。子が1つになるVariantGroupの削除は、確認して止める。
|
||||
|
||||
### T-16 検証の段階
|
||||
- 保存を止めるもの: Schema違反、ID重複、参照先がない
|
||||
- 出力(`mock-target.json`)を止めるもの: IDXに未登録のキー、選択構成のキーの未配線・未割り当て、非排他キーの接続先の衝突
|
||||
- どちらでも警告として一覧に出す。編集途中の状態は保存できる
|
||||
|
||||
### T-17 Undo/Redo
|
||||
プロジェクト全体のスナップショットで持つ。ドラッグ1回を1操作にまとめる。
|
||||
|
||||
### T-18 IDXの初期の並び
|
||||
KLE取り込み時は、取り込んだファイルでのキーの出現順で `order` を1回だけ作る。手で置いたキーは作った順に末尾へ足す。
|
||||
その後はPHYから作り直さない。IDXに未登録のキーは「未登録」と表示する。
|
||||
`decal` と部品ノードは対象外、`ghost` は対象にする。
|
||||
|
||||
### T-19 row/col の割り当て(Generatorのプレビュー)
|
||||
出力設定に順序付きの `rowPins` と `colPins` を利用者が指定し、その添字をrow/colとする。
|
||||
各接点キーの両端子が、片方は `rowPins`、もう片方は `colPins` に属することを検査する。満たさない構成(charlieplexing、direct)は「未対応」と表示して出力しない。
|
||||
端子の `a`/`b` の順、キーの位置、IDXの順からは推定しない。
|
||||
|
||||
### T-20 KLEの入出力の細部
|
||||
- 入力は厳密なJSON(KLEの緩いJSONは読まない)。VIA定義の `layouts.keymap` は、KLE JSONとして幾何だけを取り込む
|
||||
- ラベルは欄の位置と空欄を保って保持する。`a` 等の属性の引き継ぎとリセットは入出力の処理で解決する
|
||||
- 書き出しは、同じ絶対角度のキーに共通の回転中心を使い、キーの順を固定する。座標は小数6桁に丸める(PHYの値は丸めない)
|
||||
- 書き出し時のバリアント選択は `output.variantSelection` を使い、指定のないものはPHYの `default`
|
||||
|
||||
### T-21 プロファイルと座標の単位
|
||||
モックで編集できるのは、全プロファイルの `unitSize` が同じ場合だけ。違うものは読み込んで保持するが、編集不可と表示する。
|
||||
理由: 親の座標系のUを、どのプロファイルでmmへ換算するかが決まっていない([discrepancies.md](discrepancies.md) D-6)。
|
||||
|
||||
### T-22 MAPの割り当て
|
||||
- 未割り当ては `keymap` に要素がない状態で表す。`KC_NO`・`KC_TRNS` は明示の割り当てとして区別する
|
||||
- 同じレイヤーでの `keyId` の重複は禁止
|
||||
- 新規MAPは名前 `Base` の空のレイヤー1枚から始める。レイヤー名は大文字小文字を区別し、MAP内で一意
|
||||
- レイヤーへの参照は `MO(Nav)` のように括弧内にレイヤー名を書く。対象は `MO` `TG` `TO` `TT` `DF` `OSL` `LT(名前, kc)` に限る。専用の解析器で読み、`eval` は使わない
|
||||
- レイヤー名の変更は、参照を同じ操作で書き換える。参照されているレイヤーの削除は止める
|
||||
- 編集できるキーコードは、基本キー・修飾キー・`KC_NO`・`KC_TRNS`・上のレイヤー動作・修飾キー付き(`LCTL(kc)` 等)に限る。知らないコードは保持し、表示だけする
|
||||
|
||||
### T-23 キーコードの表示
|
||||
QMKの表記をそのまま出し、基本キーだけUS配列の文字を添える。表示用の小さな対応表はUI側に置き、HSTのデータ形式としては扱わない。
|
||||
|
||||
### T-24 疑似デバイスとの対応(後段・任意)
|
||||
MAP P-3 の問いを、モック用に次のように固定する。
|
||||
- 出力のたびに「出力記録」を作り、選択構成・キーID↔出力番号↔row/col・レイヤー名↔番号を保存する。疑似デバイスは記録のIDを持つ
|
||||
- 書き込みは選択構成のキーだけ。選択外のバリアントのキーの割り当てはMAPに残す
|
||||
- 読み出した値は、記録にある選択構成のキーだけへ戻す。通信上のアドレスからPHYキーを推定しない
|
||||
- layout optionは疑似デバイス側で変えられないものとする。バリアントを変えたら新しい出力記録にする
|
||||
- 配線・IDXの対象キー・バリアント選択・IDXの順・レイヤーの順が変わったら記録を失効させる。座標やラベルの変更では失効させない
|
||||
- 「現在のMAP」「出力時のMAP」「最後に読んだ値」を分けて持つ。読み出しは差分を見せてから取り込む。自動同期はしない
|
||||
|
||||
### T-25 非排他キーの接続先の衝突判定
|
||||
ダイオードがあれば、接続先を(アノード側のピン, カソード側のピン)の順序付きの組として比べる。ダイオードがなければ順序のない組として比べる。
|
||||
理由: 順序のない組で比べると、Cr0wn60のようなcharlieplexingが常にエラーになる([discrepancies.md](discrepancies.md) D-5)。
|
||||
|
||||
### T-26 MCUのピン名
|
||||
提供者の `part` は1種類(RP2040)だけをモックに組み込み、ピン名(`GP0`〜`GP29`)を候補として出す。知らない `part` は自由入力にする。
|
||||
提供者のIDに `.` を含めない(`PinRef` の区切りと衝突するため)。
|
||||
|
||||
### T-27 reparentの計算
|
||||
ワールド変換を使って `新しいローカル変換 = (新しい親のワールド変換)⁻¹ · 旧ワールド変換` とする。
|
||||
仕様の式を、親が兄弟でない場合にも当てはまる形で実装する([discrepancies.md](discrepancies.md) D-7)。
|
||||
reparent・reframeの前後で、絶対位置の差が1e-9U以下であることをテストで確かめる。
|
||||
|
||||
### T-28 スナップ
|
||||
ドラッグと増減ボタンは、操作開始時の値から0.25U・15度刻み。数値の直接入力はグリッド外の値も受け付ける。計算結果は丸めない。
|
||||
@@ -0,0 +1,16 @@
|
||||
# ドキュメント間の食い違い
|
||||
|
||||
仮決定の作業中に見つけたもの。メインドキュメントは直していない(CLAUDE.md「食い違いは指摘する」)。
|
||||
直すかどうかは別途決める。モックでの扱いは decisions.md の該当項目。
|
||||
|
||||
| ID | 食い違い | 根拠 | モックでの扱い |
|
||||
|---|---|---|---|
|
||||
| D-1 | PHYの保存形式(TS風の型)に、ノードの `type`、`Cluster.name`、キー・ファイル単位の `kleCompatibility` がない。JSON Schemaにはあり、`type` は必須 | `PHYレイヤー.md`「保存形式」、`phase1-json-schema.md` §1 | 保存形式はSchemaに合わせる |
|
||||
| D-2 | ELC・IDXの保存形式に `version` と `phyReference` がない。Schemaでは必須 | `ELCレイヤー.md`・`IDXレイヤー.md`「保存形式」、`phase1-json-schema.md` §2, §3 | 層の本体と、それを包むファイルの形を分ける(T-10) |
|
||||
| D-3 | PHYの概要は「matrix情報や入力キー情報を含ませることができる」とするが、全体方針ではELC・MAPが別の層としてPHYを参照する | `PHYレイヤー.md`「概要」、`keyboard-design-layers.md`「レイヤー構造」 | PHYへ他層の情報を入れない |
|
||||
| D-4 | MAPはレイヤーとStaticの動作を名前で参照すると定めるが、Schemaの `keycode` は任意の文字列で、名前での参照の書き方がない | `MAPレイヤー.md`「MAPデータと他レイヤーの関係」、`phase1-json-schema.md` §4 | 書き方を仮に決める(T-22) |
|
||||
| D-5 | ELCは「排他でないキー同士が同じ接続先の組を持つのは設計エラー」とする。順序のない組と読むと、Cr0wn60(行と列に同じ9本のピン。`1,0` と `0,1` が両方ある)は、行と列のピンの並びが同じなら同じ2本のピンの組になってエラーになり(ピンの並びはファームウェアで未確認)、charlieplexingを検証用サンプルにしていることと両立しない | `ELCレイヤー.md`「レイアウトバリアント」、`implementation-plan.md`「テストケース」、`samples/via/cr0wn60.json` | ダイオードの向きを含めて比べる(T-25) |
|
||||
| D-6 | 座標の単位はUで「1Uのサイズはプロファイルで指定する」が、プロファイルはキーごとに参照する。クラスターの変換や、異なる `unitSize` のキーが混在する木で、どのUを使うかが決まっていない | `PHYレイヤー.md`「座標と回転」「プロファイル設定」 | `unitSize` が揃ったものだけ編集可(T-21) |
|
||||
| D-7 | reparentの式 `M_i' = M_C⁻¹ · M_i` は、`M_i` と `M_C` が同じ座標系にある場合(新しい親が元の兄弟)にしか成り立たない。クラスターから出す操作や、別の階層への移動での形が書かれていない | `PHYレイヤー.md`「クラスターの組み替え」 | ワールド変換で計算する(T-27) |
|
||||
| D-8 | HSTは「MAPが出力するのはHIDのキーコード」とするが、MAPはQMKの表記でレイヤー動作等も扱い、すべてが1つのHIDキーコードになるわけではない | `HSTレイヤー.md`「概要」、`MAPレイヤー.md` | 影響なし(HSTを作らない) |
|
||||
| D-9 | Schemaは `unitSize`、キーの `w`/`h` に0を許し、IDの一意性・参照先の存在は表せない | `phase1-json-schema.md` | 意味的な検証の関数で正の値・一意性・参照を検査する(T-16) |
|
||||
@@ -0,0 +1,49 @@
|
||||
# ELC Editor モックの所感(画面仕様の入力)
|
||||
|
||||
モックを触った所感と、それを受けた議論の整理。**仕様ではない。**
|
||||
画面仕様を一から作るときの入力にし、仕様へ反映したら消す。
|
||||
|
||||
## 方向が合意できたもの
|
||||
|
||||
### 一括配線
|
||||
|
||||
- キーの上を順になぞって配線する。ピンの指定は、なぞる前でも後でもできる。
|
||||
- 通常のマトリクスは、rowだけ・colだけを分けてなぞれるようにする(rowモード / colモード)。
|
||||
- 編集中は、片方の端子だけ配線した状態を保存できる。ELCの端子を省略可能にし、片側だけのキーは検証で拾う。
|
||||
ELCの保存形式の変更になるので、本決定は別途行う。
|
||||
- 倍マトリクスでは、同じrow/colの組が編集中に重なってよい。ダイオードの向きで区別するので衝突にはならない(T-25)。
|
||||
重なった場合も保存は止めない(T-16)。
|
||||
|
||||
### ピン提供者
|
||||
|
||||
- ボード(Pro Micro互換など、MCUを載せた開発基板)も提供者として扱いたい。多くの利用者はチップではなくボードを使う。
|
||||
現行の `mcu` 提供者はチップの品番だけを持つ。
|
||||
|
||||
### LEDチェーン
|
||||
|
||||
- per-key LEDとアンダーグローを別チェーンにできること。仕様上はできる(チェーンに入れるLEDは設計者が選ぶ)。
|
||||
モックでできないのは下記「モックの実装上の問題」による。
|
||||
- チェーン内の順番を指定できること。配線しやすい向きがあるため。キーの配線と同じく、なぞって指定する。
|
||||
|
||||
## 要検討
|
||||
|
||||
- 倍マトリクスの向きの指定方法(選択範囲だけダイオードの向きを逆にする、など)。
|
||||
- 二乗マトリクス(同じピンがrowにもcolにも現れ、向きはダイオードだけで区別する)の操作。通常のマトリクスと同じ操作で足りるか。
|
||||
row/colで表せるかの議論は後で行う。Generatorのプレビューは今のところ「未対応」になる(T-19)。
|
||||
- directの操作。row/colの指定がなく、片方の端子は電源レールになる。
|
||||
- mux・シフトレジスタを使う場合の操作。rowかcolの一方を親の提供者の論理ピンにする形になるか。
|
||||
マトリクスとの階層化はモデル上は表せるが、デジタル側の提供者の記述は未定義。
|
||||
- ボードを提供者にしたときの形。ボードのピン名とMCUのピンの対応、外に出ていないピンの扱い、分割キーボードでの使い方。
|
||||
- 対応している提供者を図付きで見せる。ボードのピン配置図と一緒に考える。
|
||||
- ピン名の候補の一覧が「今使えるピン」と誤解される問題。ダイアログに分けるなど。ボードを提供者にすれば、外に出ていないピンは候補から消える。
|
||||
- GPIOに直結したLED(インジケータなど)。仕様にないので `backlog/README.md` のELC-P-4とした。
|
||||
|
||||
## モックの実装上の問題
|
||||
|
||||
作り直しで解消する前提で、原因の記録だけ残す。
|
||||
|
||||
- 選ばれていないバリアントのキーを配線できない: 出力用の選択(`output.variantSelection`)で絞ったキーだけを表示し、
|
||||
ELC画面に選択を変えるUIがない(IDX・Generatorのタブでしか変えられない)。仕様では全バリアントのキーを配線する。
|
||||
- per-key LEDが意図しないチェーンに入る: 配線用のキーボード表示のクリック(`clickKey`)が、編集中のチェーンにそのキーを追加する。
|
||||
1つのLEDが複数のチェーンに入っても検査しない。
|
||||
- 一括配線はキーをクリックで選ぶ方式で、配線の順はクリック順ではなくPHYの表示順になる。
|
||||
@@ -0,0 +1,34 @@
|
||||
# IDX Editor モックの所感(画面仕様の入力)
|
||||
|
||||
モックを触った所感と、それを受けた議論の整理。**仕様ではない。**
|
||||
画面仕様を一から作るときの入力にし、仕様へ反映したら消す。
|
||||
|
||||
## 方向が合意できたもの
|
||||
|
||||
### 並べ替え
|
||||
|
||||
- リストを主な操作の場にしない。並べ替えはキャンバス上のキーをドラッグ&ドロップして行う。
|
||||
- ドロップで「挿入」と「入れ替え」を区別できるようにする。
|
||||
- 挿入: 位置nのキーを、位置iとi+1の間にドロップする。
|
||||
- n > i+1 のとき、キーは位置i+1に入り、元のi+1〜n-1は1つずつ後ろへずれる。
|
||||
- n < i のとき、キーは位置iに入り、元のn+1〜iは1つずつ前へずれる(通常のリストの移動と同じ)。
|
||||
- 入れ替え: 位置iのキーを位置nのキーの上にドロップすると、2つを入れ替える。他のキーは動かない。
|
||||
|
||||
## 要検討
|
||||
|
||||
- プリセット(一括の並べ替え)の種類。候補: 分割キーボード用(左手側を先に並べる)、行と列の入れ替え。
|
||||
- なぞって連番を振る操作。なぞり始めたキーの位置から連番にする。複雑な配置で役立ちそうだが、実装は重い。
|
||||
- バリアント選択のドロップダウンの位置。左上に置く案。
|
||||
- 欠番(番号の飛び)を作れないこと。現行仕様では、出力番号は `order` 内の位置で表し、飛ばない(`IDXレイヤー.md`「保存形式」)。
|
||||
- 場面: 4x4の右下(11, 15)をoption A、縦2UキーをBにする。2Uを `order` の末尾に置くと、Bでは12〜14が1つずつ前へずれる。
|
||||
「2Uを末尾にする」と「バリアント外のキーの番号をずらさない」を両立するには、11を欠番にするしかない。
|
||||
- 番号を詰める方式は2026-09-22に決めた(`journal/2026-09-22-idx-variant-output.md`)。番号の互換性はバリアントを選んだ構成ごとに扱い、
|
||||
別の構成で同じ番号が同じキーを指すことは保証しない。ただし、そのときの例は1対1の排他キー(ANSI/ISO)だけだった。
|
||||
- 今の仕様のままなら、2Uを11の位置に置けば12〜14はずれない(QMKのテンキーの+キーも上端の位置に置く慣習)。
|
||||
- 構成をまたいで番号が揃っていてほしい利用場面(手書きのkeymap.cを共有する、など)があるかを確認してから扱いを決める。
|
||||
|
||||
## モックの実装上の問題
|
||||
|
||||
作り直しで解消する前提で、原因の記録だけ残す。
|
||||
|
||||
- バリアント選択が見つけにくい: 右ペインの「出力構成」見出しの下、キーボード表示の上にあり、並びを編集するリストから離れている。
|
||||
@@ -0,0 +1,67 @@
|
||||
# MAP Editor モックの所感(画面仕様の入力)
|
||||
|
||||
モックを触った所感と、それを受けた議論の整理。**仕様ではない。**
|
||||
画面仕様を一から作るときの入力にし、仕様へ反映したら消す。
|
||||
|
||||
Remapの編集機能をほぼ正解とみなし、Remapから何を引いて何を足すかで考える。
|
||||
Remapの現状は末尾の「参考: Remapのキーマップ編集」にまとめた。
|
||||
|
||||
## 方向が合意できたもの
|
||||
|
||||
### 割り当て操作
|
||||
|
||||
- 主な操作は、キーコード一覧からのD&D。一覧は分かりやすい粒度でまとめる。入力欄への手入力は補助の手段とする。
|
||||
- Tabによる入力ループ: Tabでキーを選ぶ → 何かのキーで入力欄を開く → `KC_Q` などのキーコードを入れる → Enterで確定 → 次のキーへ。
|
||||
- 入れるのはキーコード。PCの物理キーを押して割り当てる方式(Remapの方式)ではない。
|
||||
- 検索は今の入力欄と同等でよい。
|
||||
|
||||
### 引数付きのキーコード(`LT()` など)
|
||||
|
||||
- QMK前提で、できれば全部に対応する。
|
||||
- 引数を展開した値を並べず(モックの入力欄、Remapの `TG(0)`〜`TG(3)`)、パラメータごとに入力欄を設ける。
|
||||
- 引数にはキーコード以外のもの(レイヤー、修飾キー、Staticの定義名など)も入る。検索対象の一覧はそれに合わせて作る。
|
||||
|
||||
### 未割り当て
|
||||
|
||||
- 未割り当て(`KC_NO` とは区別する)はキーの枠などのハイライトで示す。Remapの変更済み表示の代わりに置く。
|
||||
- モック下部の未割り当て用ボタンは要らない。
|
||||
|
||||
### StaticとDynamic
|
||||
|
||||
- ここでのStaticは、Firmware Generatorへ渡すMAPを編集すること。Dynamicは実機の割り当てを読み書きすること。
|
||||
- 用途によって必要な機能が変わるかもしれない。例: ハイライトは、Staticなら未割り当て、DynamicならRemapと同じ変更済み表示でよい。
|
||||
|
||||
## 要検討
|
||||
|
||||
- StaticとDynamicでモードを分けるか、色の違いだけにするか。
|
||||
- Claudeの案: モードは分けない。印の種類を変えて両方出す(未割り当ては枠、実機との差分は点)。色だけで分けると、両方が当てはまるキーで表示がぶつかる。
|
||||
- 機能面で違いが出るのは候補一覧。実機に定義がないもの(`TD()` などStaticで定義する動作)は、Dynamicでは候補から外すか警告する。
|
||||
- 入力ループで入力欄を開くキー(Enter、Space、文字キーでそのまま入力を始めるなど)。
|
||||
- Remapの「物理キーを押して割り当て」を残すか。残すなら、Tabでの移動とぶつからない起動方法が要る。
|
||||
- レイヤー遷移のシミュレーション: 「有効なレイヤー」を与えると、どのレイヤーのどのキーが入力されるかを表示する。QMKでは `KC_TRNS` などが、その下にある有効なレイヤーのキーを送出するため。要否も含めて未定。
|
||||
|
||||
## 参考: Remapのキーマップ編集(2026-10-01時点)
|
||||
|
||||
ローカルのクローン(2023-02、`ccbaefd`)のソースと、実機(Corne)につないだ本家(Remap for QMK 0.18)の操作で確認した。
|
||||
|
||||
- 画面:
|
||||
- 上段: キーボード図とレイヤー番号
|
||||
- 下段: キーコード一覧(BASIC / FUNCTIONS / LAYER / DEVICE / SPECIAL / MIDI)と検索欄
|
||||
- 割り当て方法:
|
||||
- 一覧からD&D
|
||||
- キーをクリックしてポップオーバーで編集。タブは次の3つ。
|
||||
- キー: 補完入力と修飾キーのチェックボックス
|
||||
- ホールド・タップ: Hold欄とTap欄でLT・MTを組む
|
||||
- カスタム: 16進で直接入力
|
||||
- キーを選択した状態でPCの物理キーを押す。離すと次のキーへ進む。
|
||||
- 検索は表示名と説明文に当たる。`KC_Q` や `TG(` では候補が出ない。
|
||||
- Tabは物理キーでの入力として拾われるので、キー間の移動には使えない。
|
||||
- レイヤー系(`TO/TG/MO/OSL/TT/DF`、`LM()`)は、レイヤー数の分だけ展開した候補を並べる。
|
||||
- 一覧にあるもの(BASIC・LAYER以外の主なもの):
|
||||
- FUNCTIONS: Combo On/Off、Auto Shift、VIA User Key、Programmable Button、
|
||||
User、Leader、Dynamic Macro、Macro 0〜15
|
||||
- SPECIAL: One Shot、Key Override、Tapping Term、Auto Correct、Repeat Key、
|
||||
Key Lock、Caps Word、Steno、Unicode Mode
|
||||
- Tap Dance(`TD()`)は一覧にも検索にも見つからなかった。
|
||||
- 変更済みの表示: キーとレイヤー番号に点が付く。上部に「元 > 新」の差分とキャンセルが出る。書き込みボタンで変更をまとめて実機へ反映する。
|
||||
- そのほか: キーマップの保存・共有(Mine / Shared / History)、PDFのキーマップ表、Layout Option、Test Matrix、マクロエディタ、ライティング設定。
|
||||
@@ -0,0 +1,100 @@
|
||||
# PHY Editor モックの所感(画面仕様の入力)
|
||||
|
||||
モックを触った所感と、それを受けた議論の整理。**仕様ではない。**
|
||||
画面仕様を一から作るときの入力にし、仕様へ反映したら消す。
|
||||
|
||||
## 方向が合意できたもの
|
||||
|
||||
### 選択
|
||||
|
||||
- 何を選択しているかが一目で分かること(モックは選択枠がほぼ見えない。下記「モックの実装上の問題」)。
|
||||
- クリックでの追加選択は1件ずつのトグルのみ(連続選択の「順番」は作らない)。
|
||||
- 複数選択はキャンバス上のドラッグ(範囲選択)でもできる。
|
||||
- キャンバスでクリックすると、それまでの選択によらずキーが選ばれる。
|
||||
- クラスターは長押しで選ぶ。押している間、一定間隔で選択先が直近の親から順に上がり、
|
||||
一番上の親の次はキーに戻ってループする。離したときの状態で選択が確定する。途中の選択先はインジケータで示す。
|
||||
- Shift+長押しで、クラスターを選択に足せる。
|
||||
- 親とその配下は同時に選択しない(変換が二重にかかるだけで、使い道がない)。
|
||||
- クラスターは長押しのほか、階層パネルとキー操作(親へ上がる・子へ降りる)でも選べる。長押しはヘルプで案内する。
|
||||
- タッチ操作(長押しが標準のメニューと重なる)は当面考えない。
|
||||
- 何もない場所のクリック・Escで選択を外せる。
|
||||
|
||||
### クラスター
|
||||
|
||||
- クラスターの当たり判定・選択表示は子を囲む矩形。クラスターのローカル座標で取る(回転に追従するOBB)。
|
||||
- 階層(ツリー)パネルはクラスター・VariantGroupの選択と構造の確認に必要。キーの一覧としては不要なので、キーは畳むなど目立たせない。
|
||||
- VariantGroupはクラスターの一種という認識。移動・回転の「親」にVariantGroupも含む。
|
||||
|
||||
### フォーカスモード
|
||||
|
||||
- 特定のクラスター内だけを編集対象にし、他を薄く表示するモードを作る。
|
||||
- 入る: クラスターを選んだ状態でダブルクリック。ダブルクリックを始めた時点で選ばれていたクラスターに入る
|
||||
(1回目のクリックで選択がキーに移っても、入る先は変わらない)。キーボードではEnter。
|
||||
- 出る: Esc。選択があるときのEscはまず選択を外し、選択がない状態のEscでフォーカスを抜ける。
|
||||
|
||||
### 移動・回転・サイズ
|
||||
|
||||
- 移動・サイズ変更・削除をキーボードで操作できる。削除は参照の確認(T-15)を省かない。確認は操作した場所の近くに出す。
|
||||
- 矢印キーでの移動方向は、直近の親(クラスター・VariantGroup)の軸方向。ドラッグと同じ。
|
||||
- 移動の刻みはグリッドの状態として持ち、矢印キーとドラッグが同じ刻みに従う。修飾キーで刻みを変えない。
|
||||
- 複数選択に対して回転以外の操作(移動など)もできる。
|
||||
- 複数キーを共通の中心で回したいなら、それはクラスターをなす。選択ノードを個別に回転する操作はそれとして残す。
|
||||
|
||||
### 表示
|
||||
|
||||
- キャンバスは画面サイズに追従する(モックは1152×896px固定)。
|
||||
- 読み込み時は全体が収まり中央に来るようにズームする。同じ操作をボタン・キーでも呼べる(全体表示)。
|
||||
- ボタンはアイコン化したい。ツールチップを付ける。
|
||||
|
||||
### 呼び方と見出し
|
||||
|
||||
- PHYの要素は、キー・クラスター・部品・バリアントグループ・バリアントと呼ぶ(仕様書の呼び方に揃える)。
|
||||
「レイアウト」は配置全体を指す語(QMKの `LAYOUT_*`、KLE)とぶつかるので、バリアントの呼び名に使わない。
|
||||
- 階層の最上位は root と呼ぶ(フォーカス中のパンくずなど)。
|
||||
- 別の親へ移す操作(reparent)は「移し先…」とし、行き先は候補の一覧(root・クラスター・バリアント)で見せる。
|
||||
「移動」は座標の移動と紛れるので使わない。
|
||||
- 見出しを付けすぎない。操作ボタンはキャンバスの上に置き、階層パネルは木だけにして見出しを付けない
|
||||
(開閉ボタンのツールチップは「階層」)。キャンバスにも見出しを付けない。
|
||||
- プロパティ欄の項目名はモックのままでよい。
|
||||
|
||||
### バリアントの切り替え
|
||||
|
||||
- キャンバス上で、バリアントグループの右上に小さなバッジを常に出し、クリックで開くポップオーバーからバリアントを選ぶ。
|
||||
バリアントがどこにあるかを一目で分かるようにするため、選択中だけにしない。
|
||||
- バッジはグループの座標系(OBB)の右上に置く。文字は正立させ、大きさは画面上で固定する。フォーカス外では薄く表示する。
|
||||
- キーでは V / Shift+V で次・前のバリアントへ送る。バリアントグループを1つ選んでいるときだけ効く。
|
||||
- 切り替えで隠れたノードの選択は、そのバリアントグループに移す。
|
||||
- ポップオーバーでは既定のバリアントを印で示すだけにする。既定の変更とバリアントの追加・削除はデータを変えるので、
|
||||
表示の切り替えと分けてプロパティ欄や操作ボタンで行う。
|
||||
|
||||
## 要検討
|
||||
|
||||
### モックで試して決める
|
||||
|
||||
触って比べないと決まらないもの。
|
||||
|
||||
- 長押しの詳細: 選択先が上がる間隔、インジケータの見た目、長押しとドラッグの切り分け(長押しで階層を選んでからドラッグに移れるか)。
|
||||
- 個別回転とクラスター化してからの回転のどちらを主な操作にするか。
|
||||
- キー割り当て。叩き台をモックに入れて試す。叩き台と残っている問いは [phy-editor-keybindings.md](phy-editor-keybindings.md)。
|
||||
- グリッドの刻みの候補値。0.25U刻みはズーム1倍で16pxずつ飛び、ドラッグでの細かい調整がしづらい。
|
||||
- どこまでアイコンのみのボタンにするか。
|
||||
- ホイールでのカーソル中心ズーム、ホバー時の強調(モックに無い。未議論)。
|
||||
- バリアントのバッジが隣のバッジやキーと重ならないか。V をグループ内のキーを選んでいるときにも(いちばん近いグループに)効かせるか。
|
||||
|
||||
### 画面仕様までに決める
|
||||
|
||||
試さなくても決められる、または試す前に決めておく必要があるもの。
|
||||
|
||||
- タブ名と検証結果欄の呼び方。PHY・ELCなどのレイヤー名は設計上の仮称で、画面に出すかどうかは別途決める。
|
||||
全エディタに共通するので、PHY Editorとは別に扱う。
|
||||
- コピー&ペースト(モックに無い。未議論)。新しいIDの振り方と他の層からの参照の扱いはデータモデルの問いで、
|
||||
メインドキュメント側に置くべきものかもしれない。
|
||||
|
||||
## モックの実装上の問題
|
||||
|
||||
作り直しで解消する前提で、原因の記録だけ残す。
|
||||
|
||||
- 選択枠が見えない: `.key-face` に `vector-effect: non-scaling-stroke` があり、`stroke-width: 0.055` が画面上0.055pxになる。
|
||||
- クラスターをキャンバスでつかめない: クラスターの `<g>` に面がなく、子キーの `pointerdown.stop` が先に取る。
|
||||
- プロパティ欄の「±.25U」「±15°」は+方向しか呼ばない。画面下部に同じ操作の −/+ ボタンが重複している。
|
||||
- 複数選択の回転は各ノードの原点まわりに個別に回る。ドラッグで動くのはつかんだ1件だけ。
|
||||
@@ -0,0 +1,128 @@
|
||||
# PHY Editor のキー操作(検討用)
|
||||
|
||||
キー割り当ての叩き台と、モディファイアを振るときの基準。**仕様ではない。**
|
||||
[phy-editor-feedback.md](phy-editor-feedback.md) の要検討を詰めるための材料。
|
||||
|
||||
## 決めた方針
|
||||
|
||||
- KLE の割り当てを土台にする。主な利用者は KLE から移ってくるため。KLE の割り当ては末尾の「参考」。
|
||||
- Shift+矢印はサイズ変更(KLE と同じ)。キーのサイズは 0.25U 刻みで足りるので、サイズ変更に刻みの切り替えは要らない。
|
||||
- 移動の刻みはグリッドの状態として持ち、矢印キーとドラッグが同じ刻みに従う。修飾キーで刻みを変えない。
|
||||
- 画面上で隣にあるキーへ4方向で移る操作は当面作らない。Tab の順送りで足りるか試す。
|
||||
|
||||
## 割り当て案
|
||||
|
||||
Mac では Ctrl を Cmd に読み替える。1文字のキー(R・G・V など)は、キャンバスにフォーカスがあり入力欄にいないときだけ効く。
|
||||
|
||||
### A. 選択を動かす(データは変わらない)
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| 選択を外す | Esc | 合意済み |
|
||||
| 親へ上がる / 子へ降りる | Alt+↑ / Alt+↓ | エクスプローラーの Alt+↑ と揃う。降りる先は、上がる前に選んでいた子 |
|
||||
| 兄弟へ順に移る | Tab / Shift+Tab | 親のローカル座標で行ごとに並べた順。MAP Editor の入力ループと揃う |
|
||||
| 同じ親を持つノードをすべて選ぶ | Ctrl+A | 選択がなければ、フォーカス先(フォーカス中でなければ最上位)の直下をすべて選ぶ。全選択を兼ねる |
|
||||
|
||||
### B. 配置を変える(データが変わる。方向があり、繰り返し押す)
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| 移動 | ←↑↓→ | グリッドの刻み。方向は直近の親の軸方向(合意済み) |
|
||||
| サイズ(w/h) | Shift+←↑↓→ | 0.25U 刻み。右と下が伸びる向き |
|
||||
| 回転 | PgUp / PgDn | ±15°。PgUp が正方向。細かい角度は数値で入れる |
|
||||
| スイッチの向き | R / Shift+R | 90° ずつ。KiCad の R(回転)と揃う |
|
||||
| スイッチ位置のオフセット | なし | 数値入力で足りる |
|
||||
|
||||
### C. 構造を変える(データが変わる。1回で完結する)
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| クラスターにする / 解除する | Ctrl+G / Ctrl+Shift+G | 慣例 |
|
||||
| 1つ上の親へ出す | なし | 階層パネル・メニューから |
|
||||
| VariantGroup にする / Variant を足す | なし | 頻度が低い |
|
||||
|
||||
### D. 作る・消す(データが変わる。1回で完結する)
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| 削除 | Delete / Backspace | Mac のキーボードに Delete キーがないため両方。参照の確認は省かない(合意済み) |
|
||||
| 複製 | Ctrl+D | |
|
||||
| コピー・カット・ペースト | Ctrl+C / Ctrl+X / Ctrl+V | キーだけ確保する。新しい ID と他の層からの参照の扱いは未議論 |
|
||||
| キーを足す | Ins | KLE と同じ |
|
||||
|
||||
### E. 表示(データは変わらず、保存もしない)
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| 全体表示 / 選択範囲にズーム | Shift+1 / Shift+2 | Figma と同じ。`code` で判定する |
|
||||
| ズームイン・アウト | `+` / `-` | Ctrl+± はブラウザのズームなので取らない |
|
||||
| フォーカスモードに入る・出る | Enter / Esc | 合意済み |
|
||||
| グリッドの刻みを切り替える | G / Shift+G | |
|
||||
| 表示するバリアントを切り替える | V / Shift+V | 選んでいる VariantGroup の次・前の Variant へ送る |
|
||||
| 表示のオン・オフ、階層パネルの開閉 | なし | 頻度が低い |
|
||||
|
||||
### F. 文書全体・その他
|
||||
|
||||
| 操作 | キー | メモ |
|
||||
| --- | --- | --- |
|
||||
| 元に戻す / やり直す | Ctrl+Z / Ctrl+Shift+Z、Ctrl+Y | |
|
||||
| 保存 | Ctrl+S | |
|
||||
| プロパティの入力欄へ移る | F2 | KLE と同じ。Enter はフォーカスモードに使う |
|
||||
| ショートカットの一覧を出す | `?` | |
|
||||
|
||||
### 割り当てていないもの
|
||||
|
||||
- KLE の Ctrl+矢印(回転中心の移動): 回転の中心はクラスターの原点なので、対応する操作がない。
|
||||
|
||||
### 残っている問い
|
||||
|
||||
- グリッドの刻みの候補値(1U・0.25U と、それより細かい値をいくつ用意するか)。モックで試すか、他者のフィードバックで決める。
|
||||
- Ins は Mac のキーボードにない。Mac での代わり。
|
||||
- Ctrl+D(Chrome のブックマーク)をページ側で横取りできるか。
|
||||
|
||||
## モディファイアを振るときの基準
|
||||
|
||||
1. **データを変えるか**: A・E(変えない)と、B・C・D(変える)に分かれる。
|
||||
表示や選択を動かすつもりで配置を変えてしまわないよう、両者は別のモディファイアにしたい。
|
||||
割り当て案では、修飾なしと Shift の矢印が配置を変え、Alt と Tab が選択を動かす。
|
||||
2. **方向があって繰り返し押すか**: A(一部)と B は方向を持つので、矢印キーを取り合う。
|
||||
どの値を動かすかをモディファイアで切り替えることになる。
|
||||
1回で完結する C・D・F は、Ctrl+文字キーの慣例に乗せられる。
|
||||
3. **刻みの粗さ**: B の中だけで使う軸。「どの値か」と「刻み」をどちらもモディファイアで表すと、
|
||||
組み合わせの数が掛け算で増える。割り当て案では刻みをグリッドの状態に移した。
|
||||
4. **効く場所**: どこでも効くもの(元に戻す・保存)、キャンバスやパネルにフォーカスがあるときだけ効くもの、
|
||||
入力欄では文字入力を優先するもの、に分かれる。
|
||||
5. **キーの判定方法**: JIS 配列や独自のキー配置を使う人が多い前提で、記号・文字のキー(`?` `+` `R` など)は
|
||||
`event.key` で判定し、刻印に従わせる。Shift+数字は記号が配列によって違う(Shift+2 は US で `@`、JIS で `"`)ので
|
||||
`event.code` で判定する。
|
||||
|
||||
回転とスイッチの向きは対になったキーや1文字のキーに移したので、矢印キーを取り合うのは移動・サイズ・選択の移動の3つ。
|
||||
|
||||
## 避けたい組み合わせ
|
||||
|
||||
Web アプリとして動かす場合、OS やブラウザが先に取る組み合わせがある。
|
||||
|
||||
- Ctrl+Alt+矢印: Windows の一部の Intel グラフィックドライバで画面回転のホットキー
|
||||
- Ctrl+矢印: macOS の Mission Control(デスクトップの切り替え)
|
||||
- Alt+←/→: ブラウザの戻る/進む
|
||||
- Ctrl+F: ブラウザのページ内検索。ELC・IDX のエディタでキー検索に使いたくなる可能性もある
|
||||
- Ctrl+± / Ctrl+0: ブラウザのズーム。取れたとしても、ページを拡大したい人の手段を奪う
|
||||
|
||||
## 参考: KLE の割り当て
|
||||
|
||||
KLE のヘルプダイアログ(`kb.html`)より。
|
||||
|
||||
| キー | 操作 |
|
||||
| --- | --- |
|
||||
| ←↑↓→ | 選択したキーを移動 |
|
||||
| Shift+←↑↓→ | 選択したキーのサイズを変える |
|
||||
| Ctrl+←↑↓→ | 選択したキーの回転中心を移動 |
|
||||
| PgUp / PgDn | 選択したキーの回転角を変える |
|
||||
| Ins / Del | キーを足す / 選択したキーを消す |
|
||||
| F2 | 選択したキーのテキストを編集 |
|
||||
| Ctrl+A / Esc | 全選択 / 選択解除 |
|
||||
| J / K | 前 / 次のキーを選ぶ |
|
||||
| Ctrl+Z / Ctrl+Shift+Z、Ctrl+Y | 元に戻す / やり直す |
|
||||
| Ctrl+X、Shift+Del / Ctrl+C、Ctrl+Ins / Ctrl+V、Shift+Ins | カット / コピー / ペースト |
|
||||
| Ctrl+S | 保存(サーバー上) |
|
||||
| F1、`?` / F7 | ヘルプ / オプション |
|
||||
Reference in New Issue
Block a user