provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。 T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。 - KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7) - 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した - 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build - TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した - 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
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");
|
|
}
|