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:
@@ -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;
|
||||
Reference in New Issue
Block a user