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
This commit is contained in:
2026-09-25 22:42:30 +09:00
co-authored by Claude Opus 5.5
parent 937fc27dbd
commit fccbc28bee
59 changed files with 15509 additions and 0 deletions
+564
View File
@@ -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>
+275
View File
@@ -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>
+254
View File
@@ -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>
+337
View File
@@ -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>