Files
Beyond-KLE/mock/src/io/kleImport.ts
T
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
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
2026-09-25 22:42:30 +09:00

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");
}