Files
Beyond-KLE/mock/src/ui/panels/GeneratorPanel.vue
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

276 lines
8.9 KiB
Vue

<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>